diff --git a/packages/paperclip-runner/src/drivers/acpx/native-skill-prompt.test.ts b/packages/paperclip-runner/src/drivers/acpx/native-skill-prompt.test.ts new file mode 100644 index 00000000000..e605d83428c --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/native-skill-prompt.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { claudeNativeSkillPrompt } from "./native-skill-prompt.js"; + +const envelope = (description: unknown, prompt = "Write the welcome note now.") => JSON.stringify({ + schema: "paperclip.native-model-envelope.v2", + task: { description, prompt }, + interactionResponses: [{ response: { status: "accepted" } }], +}); + +describe("Claude native task skill invocation", () => { + it.each(["Use the `first-task` skill (/first-task).", "$first-task"])("invokes %s before the unchanged current request", (description) => { + const text = envelope(description); + expect(claudeNativeSkillPrompt(text, ["first-task", "paperclip"])).toBe(`/first-task ${text}`); + }); + it.each([ + envelope("Ordinary task", "Please mention /first-task in the output."), + envelope("/not-assigned"), envelope(null), envelope("/first-task-extra"), + JSON.stringify({ schema: "another-envelope", task: { description: "/first-task" } }), + "Use /first-task", "null", "[]", + ])("does not invoke from unrelated or malformed input: %s", (text) => { + expect(claudeNativeSkillPrompt(text, ["first-task"])).toBe(text); + }); + it("does not invent an assignment or pick between multiple requested skills", () => { + const text = envelope("/first-task and /research"); + expect(claudeNativeSkillPrompt(text, [])).toBe(text); + expect(claudeNativeSkillPrompt(text, ["first-task", "research"])).toBe(text); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/native-skill-prompt.ts b/packages/paperclip-runner/src/drivers/acpx/native-skill-prompt.ts new file mode 100644 index 00000000000..a94a556e8c8 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/native-skill-prompt.ts @@ -0,0 +1,22 @@ +import { NATIVE_MODEL_ENVELOPE_SCHEMA } from "../../contracts/native-execution.js"; + +/** Invoke one unambiguously selected assigned skill through Claude's native + * command parser. Keep the complete envelope as its argument, including the + * current request and approval context. Comments never select a command. + * Multiple selections remain available to the Skill tool rather than picking one. + */ +export function claudeNativeSkillPrompt(text: string, assignedNames: readonly string[]): string { + let envelope: unknown; + try { envelope = JSON.parse(text); } catch { return text; } + if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return text; + const value = envelope as Record; + if (value.schema !== NATIVE_MODEL_ENVELOPE_SCHEMA || !value.task || typeof value.task !== "object") return text; + const description = (value.task as Record).description; + if (typeof description !== "string") return text; + const references = new Set(Array.from( + description.matchAll(/(?:^|[\s(`])[$/]([a-zA-Z0-9_-]+)(?=$|[\s)`,.;:!?])/g), + (match) => match[1], + )); + const selected = [...new Set(assignedNames)].filter((name) => references.has(name)); + return selected.length === 1 ? `/${selected[0]} ${text}` : text; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index cd9f8a69a68..2db1cb51c64 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -276,6 +276,7 @@ describe("ACPX runtime host", () => { aggregateDigest: canonicalNativeRuntimeContextDigest(snapshot), }; let skillsHome = ""; + const providerStartTurn = vi.fn(() => runtimeTurn()); let assigned = true; let expectedReference = "ASSIGNED_SKILL_MARKER"; const dependencies = fixture.dependencies({ @@ -290,6 +291,7 @@ describe("ACPX runtime host", () => { expect((await stat(reference)).mode & 0o222).toBe(0); } return runtimePort({ + startTurn: providerStartTurn, getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }), }); }, @@ -307,12 +309,34 @@ describe("ACPX runtime host", () => { { ...options, runtimeContext: context }, dependencies, ); + const message = JSON.stringify({ + schema: "paperclip.native-model-envelope.v2", + task: { description: "Use /assigned", prompt: "A new direct user request" }, + interactionResponses: index ? [{ response: { status: "accepted" } }] : [], + }); + host.startTurn({ text: message, requestId: `skill-turn-${index}` }); + expect(providerStartTurn).toHaveBeenLastCalledWith({ + text: `/assigned ${message}`, requestId: `skill-turn-${index}`, + }); await host.close({ reason: "reopen test" }); // A changed source must replace the prior materialized revision on resume. expectedReference = "UPDATED_ASSIGNED_SKILL_MARKER"; await writeFile(join(skillRoot, "references", "answer.txt"), expectedReference); await writeFile(join(skillRoot, "SKILL.md"), "---\nname: assigned\ndescription: Updated instructions.\n---\nRead references/answer.txt before responding."); } + // The same agent's next ordinary task must not inherit the command. + const ordinary = await AcpxRuntimeHost.open( + { ...options, runtimeContext: context }, dependencies, + ); + const ordinaryMessage = JSON.stringify({ + schema: "paperclip.native-model-envelope.v2", + task: { description: "An ordinary task", prompt: "Mention /assigned in a note" }, + }); + ordinary.startTurn({ text: ordinaryMessage, requestId: "ordinary-task" }); + expect(providerStartTurn).toHaveBeenLastCalledWith({ + text: ordinaryMessage, requestId: "ordinary-task", + }); + await ordinary.close({ reason: "ordinary task verified" }); // No stale assignment survives a later launch without runtime context. assigned = false; const host = await AcpxRuntimeHost.open(options, dependencies); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 072dba41a03..a400499f0db 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { claudeNativeSkillPrompt } from "./native-skill-prompt.js"; import { nativeMcpLaunchBinding } from "../native-mcp.js"; import type { @@ -255,6 +256,7 @@ export class AcpxRuntimeHost { readonly #credential: AcpxProviderLifetimeLease | null; readonly #command: VerifiedAcpxCommandLease; readonly #toolBridge: RunnerToolBridge | null; + readonly #claudeSkillNames: readonly string[]; #activeTurn: AcpxRuntimeTurn | null = null; #closingStarted = false; #closePromise: Promise | null = null; @@ -268,6 +270,7 @@ export class AcpxRuntimeHost { credential: AcpxProviderLifetimeLease | null; command: VerifiedAcpxCommandLease; toolBridge: RunnerToolBridge | null; + claudeSkillNames: readonly string[]; }) { this.#runtime = input.runtime; this.#binding = input.binding; @@ -276,6 +279,7 @@ export class AcpxRuntimeHost { this.#credential = input.credential; this.#command = input.command; this.#toolBridge = input.toolBridge; + this.#claudeSkillNames = [...input.claudeSkillNames]; } static async open( @@ -546,6 +550,9 @@ export class AcpxRuntimeHost { credential, command, toolBridge, + claudeSkillNames: options.agent === "claude" + ? (options.runtimeContext?.skills.map((skill) => skill.runtimeName) ?? []) + : [], }); } catch (error) { const cleanup = cleanupRuntimeResources( @@ -642,7 +649,9 @@ export class AcpxRuntimeHost { throw new Error("ACPX runtime host already has an active turn"); } const requestId = boundedRequestId(input.requestId); - const text = boundedTurnText(input.text); + const text = boundedTurnText(claudeNativeSkillPrompt( + boundedTurnText(input.text), this.#claudeSkillNames, + )); const turn = this.#runtime.startTurn({ text, requestId, diff --git a/server/src/onboarding-assets/first-task/README.md b/server/src/onboarding-assets/first-task/README.md index c85be835e2f..499ed0a34b4 100644 --- a/server/src/onboarding-assets/first-task/README.md +++ b/server/src/onboarding-assets/first-task/README.md @@ -64,9 +64,13 @@ Claude. Ordinary CEOs and other agents do not receive it automatically. The hidden description asks the agent to read and follow the skill before responding, including subsequent wakes on that task. The user does not need to -type a slash command. This is a prompt-level invocation of an installed skill, -not a separate model run. The skill applies only to the onboarding task that -invokes it, not to every task assigned to that agent. The deterministic greeting, +type a slash command. Native Codex sends the selected skill as a structured +skill input. Native ACPX Claude invokes the assigned skill through its native +`/first-task` command, with the complete task/wake envelope as the argument, +on initial and resumed turns. Legacy adapters retain the brief's instruction +to read the installed skill. None of these adds a separate model run. The skill +applies only to the onboarding task that invokes it, not to every task assigned +to that agent. The deterministic greeting, opening card, and initial no-wake behavior are unchanged. ## Updates diff --git a/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md b/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md index dd343cd6c7c..f15ffff4fbd 100644 --- a/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md +++ b/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md @@ -27,8 +27,9 @@ Work in this order. 2. Propose, then wait for acceptance. - - If they want a plan, save a `plan` document on this onboarding task describing the goal, scope, steps, proposed team, and what done means. Ask them to accept it. Proposing a team does not authorize hiring it. + - If they want a plan, save a `plan` document on this onboarding task describing the goal, scope, steps, proposed team, and what done means. Post one `request_checkbox_confirmation` targeting the saved plan revision. A card or thread message alone is not a saved plan. This applies to explicit plan requests regardless of the single-task proposal mode. Proposing a team does not authorize hiring it. - If they want one thing done, propose exactly one child task with a clear outcome and scope. Ask them to accept it before creating the child. Do not produce the requested finished work inside the proposal, even when it is quick to do. + - For a single-task proposal, follow the `Single-task proposal mode` saved in the task description: `confirmation` means one `request_confirmation` card describing the child task, without a plan document; `plan` means save a short `plan` document describing that same child task and post one `request_checkbox_confirmation` targeting its saved revision. - Keep this task `in_review` while waiting. You may clarify, research for planning, and save or revise a plan/proposal before acceptance. Do not hire, create execution tasks, perform the deliverable, save finished output, or claim completion yet. 3. Interpret the next reply against the latest proposal. diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 1436aecc304..7f11fbdadcf 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -11664,7 +11664,7 @@ export function issueRoutes( ? null : await resolveRunIssueWorkspaceInheritanceSource(companyId, actor); // When this is genuinely the onboarding first task, the server owns the task - // description: assemble it from brief.md plus the proposal file the + // description: invoke the first-task skill and save the proposal mode the // enableFirstTaskPlanProposal toggle selects, read once here at creation // time, and ignore any client-supplied description. Flipping the toggle // later does not change an existing first task. Best-effort: a read failure diff --git a/server/src/services/onboarding-first-task-assets.test.ts b/server/src/services/onboarding-first-task-assets.test.ts index 6f0bfbbf942..04edf2ac76a 100644 --- a/server/src/services/onboarding-first-task-assets.test.ts +++ b/server/src/services/onboarding-first-task-assets.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { readFile } from "node:fs/promises"; import { ONBOARDING_FIRST_TASK_OPENING_INTERVIEW_OPTION_ID, ONBOARDING_FIRST_TASK_OPENING_QUESTION_ID, @@ -84,6 +85,16 @@ describe("buildOnboardingFirstTaskBrief", () => { }); }); +describe("first-task proposal mode policy", () => { + it("maps both persisted brief modes to their proposal forms", async () => { + const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8"); + expect(skill).toContain("`confirmation` means one `request_confirmation`"); + expect(skill).toContain("`plan` means save a short `plan` document"); + expect(skill).toContain("`request_checkbox_confirmation` targeting its saved revision"); + expect(skill).toContain("explicit plan requests regardless of the single-task proposal mode"); + }); +}); + describe("chief-of-staff persona", () => { it("renders the persona with placeholders filled", async () => { const persona = await renderChiefOfStaffPersona({