From 3a3113cfa31ec51971e1f272fe9820b0e570a1f6 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 4 Sep 2026 13:00:53 -0500 Subject: [PATCH 1/5] fix(explore): name the propose workflow at every handoff Explore mode refuses to implement, but nowhere named the workflow that turns the discussion into a change. The refusal, the "flow into a proposal" ending, the closing summary, and the do-not-implement guardrail all described the next step as prose. With no named exit, agents answered the discovery questions and then started writing code (#869). All four handoff points now point at `/opsx:propose`, written in the canonical `/opsx:` form so each tool renders the invocation it actually registers. Skill and command bodies are patched together, the skills.sh mirror is regenerated, and parity hashes are refreshed. Closes #869 Co-Authored-By: Claude Opus 5 --- .../explore-names-the-propose-handoff.md | 5 ++ skills/openspec-explore/SKILL.md | 8 +-- src/core/templates/workflows/explore.ts | 14 ++--- test/core/templates/explore.test.ts | 52 +++++++++++++++++++ .../templates/skill-templates-parity.test.ts | 6 +-- 5 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 .changeset/explore-names-the-propose-handoff.md diff --git a/.changeset/explore-names-the-propose-handoff.md b/.changeset/explore-names-the-propose-handoff.md new file mode 100644 index 0000000000..3de448d71c --- /dev/null +++ b/.changeset/explore-names-the-propose-handoff.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Name the propose workflow where explore hands off. Explore mode refuses to implement, but every place it said what to do instead — the refusal itself, the "flow into a proposal" ending, the closing summary, and the do-not-implement guardrail — described the next step as prose ("create a change proposal") without naming the workflow that does it. With no named exit, agents finished the discovery questions and started writing code, which is the failure reported through GitHub Copilot in #869. The explore skill and command now point at `/opsx:propose` at all four handoff points, written in the canonical `/opsx:` form so each tool renders the invocation it actually registers (`/openspec-propose` for skills-only delivery, `/opsx-propose` or `/opsx:propose` for command surfaces). Fixes #869. diff --git a/skills/openspec-explore/SKILL.md b/skills/openspec-explore/SKILL.md index b597796fb1..9d2418a78d 100644 --- a/skills/openspec-explore/SKILL.md +++ b/skills/openspec-explore/SKILL.md @@ -11,7 +11,7 @@ metadata: Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. -**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and point them at `/openspec-propose`, which turns the discussion into a change. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. @@ -296,7 +296,7 @@ You: That changes everything. There's no required ending. Discovery might: -- **Flow into a proposal**: "Ready to start? I can create a change proposal." +- **Flow into a proposal**: "Ready to start? Run `/openspec-propose` and this becomes a change." - **Result in artifact updates**: "Updated design.md with these decisions" - **Just provide clarity**: User has what they need, moves on - **Continue later**: "We can pick this up anytime" @@ -313,7 +313,7 @@ When it feels like things are crystallizing, you might summarize: **Open questions**: [if any remain] **Next steps** (if ready): -- Create a change proposal +- Turn this into a change: `/openspec-propose` - Keep exploring: just keep talking ``` @@ -323,7 +323,7 @@ But this summary is optional. Sometimes the thinking IS the value. ## Guardrails -- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or `openspec/config.yaml` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. +- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or `openspec/config.yaml` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. When the user is ready to build, name the handoff rather than starting: `/openspec-propose` turns the discussion into a change, and the work happens there. - **Don't fake understanding** - If something is unclear, dig deeper - **Don't rush** - Discovery is thinking time, not task time - **Don't force structure** - Let patterns emerge naturally diff --git a/src/core/templates/workflows/explore.ts b/src/core/templates/workflows/explore.ts index 8954a2cce4..950acd4518 100644 --- a/src/core/templates/workflows/explore.ts +++ b/src/core/templates/workflows/explore.ts @@ -35,7 +35,7 @@ export function getExploreSkillTemplate(): SkillTemplate { description: 'Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.', instructions: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. -**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and point them at \`/opsx:propose\`, which turns the discussion into a change. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. @@ -300,7 +300,7 @@ You: That changes everything. There's no required ending. Discovery might: -- **Flow into a proposal**: "Ready to start? I can create a change proposal." +- **Flow into a proposal**: "Ready to start? Run \`/opsx:propose\` and this becomes a change." - **Result in artifact updates**: "Updated design.md with these decisions" - **Just provide clarity**: User has what they need, moves on - **Continue later**: "We can pick this up anytime" @@ -317,7 +317,7 @@ When it feels like things are crystallizing, you might summarize: **Open questions**: [if any remain] **Next steps** (if ready): -- Create a change proposal +- Turn this into a change: \`/opsx:propose\` - Keep exploring: just keep talking \`\`\` @@ -327,7 +327,7 @@ But this summary is optional. Sometimes the thinking IS the value. ## Guardrails -- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or \`openspec/config.yaml\` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. +- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or \`openspec/config.yaml\` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. When the user is ready to build, name the handoff rather than starting: \`/opsx:propose\` turns the discussion into a change, and the work happens there. - **Don't fake understanding** - If something is unclear, dig deeper - **Don't rush** - Discovery is thinking time, not task time - **Don't force structure** - Let patterns emerge naturally @@ -350,7 +350,7 @@ export function getOpsxExploreCommandTemplate(): CommandTemplate { tags: ['workflow', 'explore', 'experimental', 'thinking'], content: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. -**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and point them at \`/opsx:propose\`, which turns the discussion into a change. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. @@ -520,7 +520,7 @@ If the user mentions a change or you detect one is relevant: There's no required ending. Discovery might: -- **Flow into a proposal**: "Ready to start? I can create a change proposal." +- **Flow into a proposal**: "Ready to start? Run \`/opsx:propose\` and this becomes a change." - **Result in artifact updates**: "Updated design.md with these decisions" - **Just provide clarity**: User has what they need, moves on - **Continue later**: "We can pick this up anytime" @@ -531,7 +531,7 @@ When things crystallize, you might offer a summary - but it's optional. Sometime ## Guardrails -- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or \`openspec/config.yaml\` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. +- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or \`openspec/config.yaml\` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. When the user is ready to build, name the handoff rather than starting: \`/opsx:propose\` turns the discussion into a change, and the work happens there. - **Don't fake understanding** - If something is unclear, dig deeper - **Don't rush** - Discovery is thinking time, not task time - **Don't force structure** - Let patterns emerge naturally diff --git a/test/core/templates/explore.test.ts b/test/core/templates/explore.test.ts index b6ec059403..d970f0ea48 100644 --- a/test/core/templates/explore.test.ts +++ b/test/core/templates/explore.test.ts @@ -4,6 +4,7 @@ import { getExploreSkillTemplate, getOpsxExploreCommandTemplate, } from '../../../src/core/templates/skill-templates.js'; +import { transformToSkillReferences } from '../../../src/utils/command-references.js'; const skill = getExploreSkillTemplate(); const command = getOpsxExploreCommandTemplate(); @@ -406,3 +407,54 @@ describe('explore templates', () => { } }); }); + +// Regression for #869: explore refused to implement and told the agent to +// "create a change proposal" without ever naming the workflow that does it. +// With no named exit, agents answered the discovery questions and then went +// straight to writing code - the failure two reporters hit through Copilot. +describe('explore handoff to the propose workflow (#869)', () => { + it('names the propose workflow when the user asks for implementation', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain( + 'point them at `/opsx:propose`, which turns the discussion into a change' + ); + expect(body, label).toContain('The work happens from that change, never from explore mode'); + expect(body, label).not.toContain( + 'remind them to exit explore mode first and create a change proposal' + ); + } + }); + + it('names the propose workflow where discovery ends', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain( + '**Flow into a proposal**: "Ready to start? Run `/opsx:propose` and this becomes a change."' + ); + expect(body, label).not.toContain('I can create a change proposal'); + } + }); + + it('pairs the do-not-implement guardrail with the handoff', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain( + 'When the user is ready to build, name the handoff rather than starting: `/opsx:propose` turns the discussion into a change, and the work happens there' + ); + } + }); + + it('offers the handoff as a next step in the closing summary', () => { + expect(skill.instructions).toContain('- Turn this into a change: `/opsx:propose`'); + expect(skill.instructions).not.toContain('- Create a change proposal'); + }); + + // The reference has to be the canonical `/opsx:` form of a known + // command id, or the per-tool transformers leave it as written and the + // skill advertises an invocation no tool registers (#727, #1307). + it('writes the reference so per-tool rendering rewrites it', () => { + for (const [label, body] of bodies) { + const rendered = transformToSkillReferences(body); + expect(rendered, label).toContain('/openspec-propose'); + expect(rendered, label).not.toContain('/opsx:propose'); + } + }); +}); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 3f309bdaa3..8d5ff4103f 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -38,14 +38,14 @@ import { import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; const EXPECTED_FUNCTION_HASHES: Record = { - getExploreSkillTemplate: '6315fcc5c2eb848963bc8bca4c23e657412a99608e610daee59fb4e58cd21fd4', + getExploreSkillTemplate: '18d307634e92c092cb91491f6886fe940e623c19c55e9108b16b780947b635d0', getNewChangeSkillTemplate: 'eabd1e895c5881dcb17dcbaa3fb26098dd59e8eacb318e400820b4dc811ef781', getContinueChangeSkillTemplate: '012136f6411a99c8fa228e2f9444cb64b0a89e0f56fdeac2fe03b2f5bee0c5d7', getApplyChangeSkillTemplate: 'd1e7d5ceb85193c0964057dbb88e9651526754bd33f84020e2440ff0621d5dbb', getFfChangeSkillTemplate: 'efa6a70c111b18b61a7720250b9622afa9a212fb64edf609cf80e2182a9bdf8c', getSyncSpecsSkillTemplate: 'b099e2ff31859c9b10d928066e662524f9aad9ecf2be12fceacb732d718c4146', getOnboardSkillTemplate: '3a836faae463d88c289a1c129cb7ee556a563b7e53e1a52a4711ff152a3b51f7', - getOpsxExploreCommandTemplate: 'b4706a5b8fd280f7929eea610ecc9d41676b2d2dd6653d259cbbc2bfe01813d9', + getOpsxExploreCommandTemplate: 'dd1bea1baacc544c7ac417ec09fdeeb33916d112a853232d66b662174246296f', getOpsxNewCommandTemplate: 'f2d30e569798a4c92ba932859d6ba4e0ad10e18feccbade1cfee0957597b3463', getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0', @@ -66,7 +66,7 @@ const EXPECTED_FUNCTION_HASHES: Record = { }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': 'dd84af68d3c93b40659dcdd8d383423b25b443cacdc4b514cd70614ae10c5cac', + 'openspec-explore': 'f5db987ddfa576b7b1e5da4da6d74e0ba5bcfc215c22e2506b2048ed92cbd4ed', 'openspec-new-change': 'ec4529beef978e34634a6f7286fab55d68fad8fb374dceb45691d52caab33fbb', 'openspec-continue-change': 'bb6194a16c54891cdb253678e8f70ce53b2af86735243980f366ce551d37e42e', 'openspec-apply-change': '81ea96d9fa6ec8536cd23c1fe561ed28e1cc1cad0a8ceb700588e08974cc0e49', From 7310533002140b57e31a06597539562da63453c8 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 4 Sep 2026 13:18:45 -0500 Subject: [PATCH 2/5] fix(explore): name the continuation after a seamless capture The capture path let explore scaffold a change and write artifacts, then said nothing about what came next. An agent holding a fresh proposal inside explore mode has an obvious wrong next move, and it is the one #869 reported. The capture now ends by naming `/opsx:propose` for the remaining planning artifacts and `/opsx:apply` for implementation, and says plainly that capturing artifacts is not permission to implement them. Widen the rendering guard to walk the real registries instead of a hand-picked few: every registered command adapter and every entry in AI_TOOLS must rewrite every canonical reference in both bodies, with no `/opsx:` form surviving on any skills surface. A new adapter or a changed invocation shape now fails here rather than shipping a command nobody answers to. Co-Authored-By: Claude Opus 5 --- skills/openspec-explore/SKILL.md | 2 +- src/core/templates/workflows/explore.ts | 4 +- test/core/templates/explore.test.ts | 126 +++++++++++++++++- .../templates/skill-templates-parity.test.ts | 6 +- 4 files changed, 131 insertions(+), 7 deletions(-) diff --git a/skills/openspec-explore/SKILL.md b/skills/openspec-explore/SKILL.md index 9d2418a78d..aa5e0d64f3 100644 --- a/skills/openspec-explore/SKILL.md +++ b/skills/openspec-explore/SKILL.md @@ -140,7 +140,7 @@ If the user asks you to capture the exploration as a new change, transition seam 3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to `resolvedOutputPath`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists. 4. After creating each artifact, re-run `openspec status --change "" --json` (append the confirmed `--store ""` only for a registered standalone store) and continue until every requested artifact is `done`, `skipped`, or was deliberately skipped because its own `instruction` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still `blocked` only because you deliberately skipped a conditional prerequisite, run `openspec instructions "" --change "" --json` (append the confirmed `--store ""` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture. -Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. +Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: `/openspec-propose` writes the remaining planning artifacts, and `/openspec-apply-change` implements the change once tasks exist. Capturing artifacts is never permission to implement them. ### When a change exists diff --git a/src/core/templates/workflows/explore.ts b/src/core/templates/workflows/explore.ts index 950acd4518..8cbe8d65a7 100644 --- a/src/core/templates/workflows/explore.ts +++ b/src/core/templates/workflows/explore.ts @@ -144,7 +144,7 @@ If the user asks you to capture the exploration as a new change, transition seam 3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to \`resolvedOutputPath\`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists. 4. After creating each artifact, re-run \`openspec status --change "" --json\` (append the confirmed \`--store ""\` only for a registered standalone store) and continue until every requested artifact is \`done\`, \`skipped\`, or was deliberately skipped because its own \`instruction\` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still \`blocked\` only because you deliberately skipped a conditional prerequisite, run \`openspec instructions "" --change "" --json\` (append the confirmed \`--store ""\` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture. -Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. +Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: \`/opsx:propose\` writes the remaining planning artifacts, and \`/opsx:apply\` implements the change once tasks exist. Capturing artifacts is never permission to implement them. ### When a change exists @@ -468,7 +468,7 @@ If the user asks you to capture the exploration as a new change, transition seam 3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to \`resolvedOutputPath\`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists. 4. After creating each artifact, re-run \`openspec status --change "" --json\` (append the confirmed \`--store ""\` only for a registered standalone store) and continue until every requested artifact is \`done\`, \`skipped\`, or was deliberately skipped because its own \`instruction\` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still \`blocked\` only because you deliberately skipped a conditional prerequisite, run \`openspec instructions "" --change "" --json\` (append the confirmed \`--store ""\` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture. -Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. +Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: \`/opsx:propose\` writes the remaining planning artifacts, and \`/opsx:apply\` implements the change once tasks exist. Capturing artifacts is never permission to implement them. ### When a change exists diff --git a/test/core/templates/explore.test.ts b/test/core/templates/explore.test.ts index d970f0ea48..541ba754c1 100644 --- a/test/core/templates/explore.test.ts +++ b/test/core/templates/explore.test.ts @@ -4,7 +4,18 @@ import { getExploreSkillTemplate, getOpsxExploreCommandTemplate, } from '../../../src/core/templates/skill-templates.js'; -import { transformToSkillReferences } from '../../../src/utils/command-references.js'; +import { + getSkillReferenceTransformer, + transformCommandInvocations, + transformToCodexCompatibleSkillReferences, + transformToSkillReferences, +} from '../../../src/utils/command-references.js'; +import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; +import { + formatCommandInvocation, + getInvocationForAdapter, +} from '../../../src/core/command-generation/invocation.js'; +import { AI_TOOLS } from '../../../src/core/config.js'; const skill = getExploreSkillTemplate(); const command = getOpsxExploreCommandTemplate(); @@ -458,3 +469,116 @@ describe('explore handoff to the propose workflow (#869)', () => { } }); }); + +// The handoff is only useful if every tool renders it as an invocation that +// tool actually registers. These assertions walk the real registries rather +// than a hand-picked few, so a new adapter or a changed invocation shape +// cannot quietly leave explore advertising a command nobody answers to +// (the #727 / #1307 failure mode). +describe('explore handoff renders for every delivery surface (#869)', () => { + // Both workflows explore hands off to. Each is a `CORE_WORKFLOWS` member, + // so naming them does not advertise anything the default profile omits. + const HANDOFF_IDS = ['propose', 'apply'] as const; + + function canonicalCount(body: string, commandId: string): number { + return occurrenceCount(body, `/opsx:${commandId}`); + } + + it('names both handoff workflows in both bodies before any rendering', () => { + for (const [label, body] of bodies) { + for (const commandId of HANDOFF_IDS) { + expect(canonicalCount(body, commandId), `${label} ${commandId}`).toBeGreaterThan(0); + } + } + }); + + it('rewrites every reference for each registered command adapter', () => { + const adapters = CommandAdapterRegistry.getAll(); + expect(adapters.length).toBeGreaterThan(0); + + for (const adapter of adapters) { + const invocation = getInvocationForAdapter(adapter); + + for (const [label, body] of bodies) { + const rendered = transformCommandInvocations(body, invocation); + + for (const commandId of HANDOFF_IDS) { + const expected = formatCommandInvocation(invocation, commandId); + const where = `${adapter.toolId} ${label} ${commandId}`; + + // Every canonical reference became this tool's spelling. Counting + // rather than substring-matching catches a partial rewrite, and it + // holds for the namespaced tools whose spelling is the canonical one. + expect(occurrenceCount(rendered, expected), where).toBe( + canonicalCount(body, commandId) + ); + } + } + } + }); + + it('rewrites every reference for each skills-only tool', () => { + for (const tool of AI_TOOLS) { + const transform = getSkillReferenceTransformer(tool.value); + + for (const [label, body] of bodies) { + const rendered = transform(body); + + expect(rendered, `${tool.value} ${label}`).not.toContain('/opsx:'); + expect(occurrenceCount(rendered, 'openspec-propose'), `${tool.value} ${label}`).toBe( + canonicalCount(body, 'propose') + ); + expect( + occurrenceCount(rendered, 'openspec-apply-change'), + `${tool.value} ${label}` + ).toBe(canonicalCount(body, 'apply')); + } + } + }); + + it('keeps the handoff readable on the shared .agents tree Codex writes', () => { + for (const [label, body] of bodies) { + const rendered = transformToCodexCompatibleSkillReferences(body); + + expect(rendered, label).not.toContain('/opsx:'); + expect( + occurrenceCount(rendered, '$openspec-propose (Codex) or /openspec-propose (other agents)'), + label + ).toBe(canonicalCount(body, 'propose')); + expect( + occurrenceCount( + rendered, + '$openspec-apply-change (Codex) or /openspec-apply-change (other agents)' + ), + label + ).toBe(canonicalCount(body, 'apply')); + } + }); +}); + +// Regression for #869: the seamless capture path let explore scaffold a +// change and write artifacts, then said nothing about what came next. An +// agent holding a fresh proposal inside explore mode has an obvious wrong +// next move, which is the one the issue reported. +describe('explore capture path names where the work continues (#869)', () => { + it('ends the capture by naming propose and apply', () => { + for (const [label, body] of bodies) { + const transition = newChangeTransition(body, label); + + expect(transition, label).toContain( + 'When the requested capture is done, stop there and name where the work continues' + ); + expect(transition, label).toContain('`/opsx:propose` writes the remaining planning artifacts'); + expect(transition, label).toContain('`/opsx:apply` implements the change once tasks exist'); + } + }); + + it('says that capturing artifacts is not permission to implement them', () => { + for (const [label, body] of bodies) { + const transition = newChangeTransition(body, label); + expect(transition, label).toContain( + 'Capturing artifacts is never permission to implement them' + ); + } + }); +}); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 8d5ff4103f..298e57e2e0 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -38,14 +38,14 @@ import { import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; const EXPECTED_FUNCTION_HASHES: Record = { - getExploreSkillTemplate: '18d307634e92c092cb91491f6886fe940e623c19c55e9108b16b780947b635d0', + getExploreSkillTemplate: 'f58f10c711cd6e87e4c6d79666fc89bdaa54cf8847de08b0c24dd90943074bf9', getNewChangeSkillTemplate: 'eabd1e895c5881dcb17dcbaa3fb26098dd59e8eacb318e400820b4dc811ef781', getContinueChangeSkillTemplate: '012136f6411a99c8fa228e2f9444cb64b0a89e0f56fdeac2fe03b2f5bee0c5d7', getApplyChangeSkillTemplate: 'd1e7d5ceb85193c0964057dbb88e9651526754bd33f84020e2440ff0621d5dbb', getFfChangeSkillTemplate: 'efa6a70c111b18b61a7720250b9622afa9a212fb64edf609cf80e2182a9bdf8c', getSyncSpecsSkillTemplate: 'b099e2ff31859c9b10d928066e662524f9aad9ecf2be12fceacb732d718c4146', getOnboardSkillTemplate: '3a836faae463d88c289a1c129cb7ee556a563b7e53e1a52a4711ff152a3b51f7', - getOpsxExploreCommandTemplate: 'dd1bea1baacc544c7ac417ec09fdeeb33916d112a853232d66b662174246296f', + getOpsxExploreCommandTemplate: '7951297289a89da6a049a2e84c297d12d745a68093fe8014fb8f54477b79c252', getOpsxNewCommandTemplate: 'f2d30e569798a4c92ba932859d6ba4e0ad10e18feccbade1cfee0957597b3463', getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0', @@ -66,7 +66,7 @@ const EXPECTED_FUNCTION_HASHES: Record = { }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': 'f5db987ddfa576b7b1e5da4da6d74e0ba5bcfc215c22e2506b2048ed92cbd4ed', + 'openspec-explore': 'c0fcccd4c6111a3e1fac1a88142c2491ab36cbe99637fe254a37d598a32a69e4', 'openspec-new-change': 'ec4529beef978e34634a6f7286fab55d68fad8fb374dceb45691d52caab33fbb', 'openspec-continue-change': 'bb6194a16c54891cdb253678e8f70ce53b2af86735243980f366ce551d37e42e', 'openspec-apply-change': '81ea96d9fa6ec8536cd23c1fe561ed28e1cc1cad0a8ceb700588e08974cc0e49', From a3e49930092b2b188aa765cd087f02911654dae0 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 4 Sep 2026 13:35:37 -0500 Subject: [PATCH 3/5] docs(changeset): cover the capture-path handoff Co-Authored-By: Claude Opus 5 --- .changeset/explore-names-the-propose-handoff.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/explore-names-the-propose-handoff.md b/.changeset/explore-names-the-propose-handoff.md index 3de448d71c..42fc14239d 100644 --- a/.changeset/explore-names-the-propose-handoff.md +++ b/.changeset/explore-names-the-propose-handoff.md @@ -2,4 +2,6 @@ "@fission-ai/openspec": patch --- -Name the propose workflow where explore hands off. Explore mode refuses to implement, but every place it said what to do instead — the refusal itself, the "flow into a proposal" ending, the closing summary, and the do-not-implement guardrail — described the next step as prose ("create a change proposal") without naming the workflow that does it. With no named exit, agents finished the discovery questions and started writing code, which is the failure reported through GitHub Copilot in #869. The explore skill and command now point at `/opsx:propose` at all four handoff points, written in the canonical `/opsx:` form so each tool renders the invocation it actually registers (`/openspec-propose` for skills-only delivery, `/opsx-propose` or `/opsx:propose` for command surfaces). Fixes #869. +Name the workflow where explore hands off. Explore mode refuses to implement, but every place it said what to do instead described the next step as prose ("create a change proposal") without naming the workflow that does it — the refusal itself, the "flow into a proposal" ending, the closing summary, and the do-not-implement guardrail. Its seamless capture path was worse: it scaffolded a change, wrote artifacts, and then said nothing at all about what came next. With no named exit, agents finished the discovery questions and started writing code, which is the failure reported through GitHub Copilot in #869 — and which the docs already promised would not happen ("when the picture is clear, it hands off to `/opsx:propose`"). + +The explore skill and command now name `/opsx:propose` at all four prose handoffs, and the capture path ends by naming `/opsx:propose` for the remaining planning artifacts and `/opsx:apply` for implementation, with an explicit note that capturing artifacts is not permission to implement them. Both workflows are `CORE_WORKFLOWS` members, and the references are written in the canonical `/opsx:` form so each tool renders the invocation it actually registers (`/openspec-propose` for skills-only delivery, `/opsx-propose`, `/opsx:propose`, or `@opsx-propose` for command surfaces). Fixes #869. From 0d3132a2b5abfa72a3771d1d49d7449d3a80471d Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 15 Sep 2026 07:58:32 -0500 Subject: [PATCH 4/5] fix(explore): resolve handoffs against the installed workflow set A custom profile can install explore without propose or apply, and the explore skill and command still named both. Handoffs are now authored with optionalWorkflow() and resolved in getSkillTemplates()/getCommandTemplates() against the workflow filter every init/update path already passes. Missing workflows fall back to explore's own capture path and the openspec instructions apply CLI. Output with every workflow installed is unchanged. Uses the same API and marker syntax as #1775 so the two compose. Co-Authored-By: Claude Opus 5 --- .../explore-names-the-propose-handoff.md | 2 +- src/core/shared/skill-generation.ts | 44 +++++++-- src/core/templates/optional-workflow.ts | 61 ++++++++++++ src/core/templates/workflows/explore.ts | 61 ++++++++++-- test/core/templates/explore.test.ts | 99 +++++++++++++++++-- .../templates/skill-templates-parity.test.ts | 24 ++++- 6 files changed, 264 insertions(+), 27 deletions(-) create mode 100644 src/core/templates/optional-workflow.ts diff --git a/.changeset/explore-names-the-propose-handoff.md b/.changeset/explore-names-the-propose-handoff.md index 42fc14239d..2fc7685e23 100644 --- a/.changeset/explore-names-the-propose-handoff.md +++ b/.changeset/explore-names-the-propose-handoff.md @@ -4,4 +4,4 @@ Name the workflow where explore hands off. Explore mode refuses to implement, but every place it said what to do instead described the next step as prose ("create a change proposal") without naming the workflow that does it — the refusal itself, the "flow into a proposal" ending, the closing summary, and the do-not-implement guardrail. Its seamless capture path was worse: it scaffolded a change, wrote artifacts, and then said nothing at all about what came next. With no named exit, agents finished the discovery questions and started writing code, which is the failure reported through GitHub Copilot in #869 — and which the docs already promised would not happen ("when the picture is clear, it hands off to `/opsx:propose`"). -The explore skill and command now name `/opsx:propose` at all four prose handoffs, and the capture path ends by naming `/opsx:propose` for the remaining planning artifacts and `/opsx:apply` for implementation, with an explicit note that capturing artifacts is not permission to implement them. Both workflows are `CORE_WORKFLOWS` members, and the references are written in the canonical `/opsx:` form so each tool renders the invocation it actually registers (`/openspec-propose` for skills-only delivery, `/opsx-propose`, `/opsx:propose`, or `@opsx-propose` for command surfaces). Fixes #869. +The explore skill and command now name `/opsx:propose` at all four prose handoffs, and the capture path ends by naming `/opsx:propose` for the remaining planning artifacts and `/opsx:apply` for implementation, with an explicit note that capturing artifacts is not permission to implement them. The references are written in the canonical `/opsx:` form so each tool renders the invocation it actually registers (`/openspec-propose` for skills-only delivery, `/opsx-propose`, `/opsx:propose`, or `@opsx-propose` for command surfaces). The handoffs follow the installed workflow set: a custom profile without `propose` or `apply` gets explore's own capture path and the `openspec instructions apply` CLI instead of a command it never installed. Fixes #869. diff --git a/src/core/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index f671b4de73..2c7a6fe4d0 100644 --- a/src/core/shared/skill-generation.ts +++ b/src/core/shared/skill-generation.ts @@ -32,8 +32,28 @@ import { type SkillTemplate, } from '../templates/skill-templates.js'; import type { CommandContent } from '../command-generation/index.js'; +import { resolveOptionalWorkflows } from '../templates/optional-workflow.js'; +import { ALL_WORKFLOWS } from '../profiles.js'; import { OPENSPEC_CLI_ALLOWED_TOOLS } from './allowed-tools.js'; +/** + * The workflow set a template body is rendered against. + * + * `workflowFilter` is both the list of workflows to install and the set a + * template may refer to, so resolving optional-workflow conditionals here — + * the one place every generation path (init, update, migration, the skills.sh + * distribution) already funnels through — keeps a reference to an uninstalled + * workflow out of every generated file. + * + * With no filter, every workflow is installed (that is what an unfiltered call + * means), so the installed branch is kept. + */ +function resolveInstalledWorkflows( + workflowFilter?: readonly string[] +): ReadonlySet { + return new Set(workflowFilter ?? ALL_WORKFLOWS); +} + /** * Skill template with directory name and workflow ID mapping. */ @@ -72,10 +92,16 @@ export function getSkillTemplates(workflowFilter?: readonly string[]): SkillTemp { template: getOpsxProposeSkillTemplate(), dirName: 'openspec-propose', workflowId: 'propose' }, ]; - if (!workflowFilter) return all; + const installed = resolveInstalledWorkflows(workflowFilter); + const selected = workflowFilter ? all.filter(entry => installed.has(entry.workflowId)) : all; - const filterSet = new Set(workflowFilter); - return all.filter(entry => filterSet.has(entry.workflowId)); + return selected.map(entry => ({ + ...entry, + template: { + ...entry.template, + instructions: resolveOptionalWorkflows(entry.template.instructions, installed), + }, + })); } /** @@ -99,10 +125,16 @@ export function getCommandTemplates(workflowFilter?: readonly string[]): Command { template: getOpsxProposeCommandTemplate(), id: 'propose' }, ]; - if (!workflowFilter) return all; + const installed = resolveInstalledWorkflows(workflowFilter); + const selected = workflowFilter ? all.filter(entry => installed.has(entry.id)) : all; - const filterSet = new Set(workflowFilter); - return all.filter(entry => filterSet.has(entry.id)); + return selected.map(entry => ({ + ...entry, + template: { + ...entry.template, + content: resolveOptionalWorkflows(entry.template.content, installed), + }, + })); } /** diff --git a/src/core/templates/optional-workflow.ts b/src/core/templates/optional-workflow.ts new file mode 100644 index 0000000000..2a6ea7c19a --- /dev/null +++ b/src/core/templates/optional-workflow.ts @@ -0,0 +1,61 @@ +/** + * Optional-Workflow Conditionals + * + * Not every workflow is installed. A `custom` profile can ship explore without + * propose or apply, so a template that always names `/opsx:propose` writes a + * dead reference for that install. + * + * `command-references.ts` rewrites how a reference is spelled; this module + * decides whether it is emitted at all. Templates author both branches with + * `optionalWorkflow()`, and `resolveOptionalWorkflows()` keeps one of them at + * generation time against the installed workflow set. + */ + +const CONDITIONAL_PATTERN = + /\[\[opsx:if-workflow ([a-z-]+)\]\]([\s\S]*?)\[\[opsx:else\]\]([\s\S]*?)\[\[opsx:end\]\]/g; + +const RESIDUAL_MARKER_PATTERN = /\[\[opsx:(if-workflow|else|end)/; + +/** + * Authors a passage whose wording depends on whether `workflowId` is installed. + * Both branches must read correctly on their own. + * + * @param workflowId - Workflow id as it appears in ALL_WORKFLOWS (e.g. 'propose') + * @param whenInstalled - Text to emit when the workflow is part of the install + * @param whenMissing - Text to emit otherwise + */ +export function optionalWorkflow( + workflowId: string, + whenInstalled: string, + whenMissing: string +): string { + return `[[opsx:if-workflow ${workflowId}]]${whenInstalled}[[opsx:else]]${whenMissing}[[opsx:end]]`; +} + +/** + * Resolves every `optionalWorkflow()` passage in `text` against the workflows + * that will actually be installed. Runs before the command-reference + * transformers, so a reference in a dropped branch never reaches them. + * + * @throws If a malformed conditional leaves a marker in the output + */ +export function resolveOptionalWorkflows( + text: string, + installedWorkflows: ReadonlySet +): string { + const resolved = text.replace( + CONDITIONAL_PATTERN, + (_match, workflowId: string, whenInstalled: string, whenMissing: string) => + installedWorkflows.has(workflowId) ? whenInstalled : whenMissing + ); + + const residual = RESIDUAL_MARKER_PATTERN.exec(resolved); + if (residual) { + throw new Error( + `Malformed optional-workflow conditional: '${residual[0]}' is unresolved. Each block needs the full ` + + '[[opsx:if-workflow ]] ... [[opsx:else]] ... [[opsx:end]] form.' + ); + } + + return resolved; +} diff --git a/src/core/templates/workflows/explore.ts b/src/core/templates/workflows/explore.ts index 3eead3bdca..5bc327258d 100644 --- a/src/core/templates/workflows/explore.ts +++ b/src/core/templates/workflows/explore.ts @@ -5,6 +5,7 @@ * templates file into workflow-focused modules. */ import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { optionalWorkflow } from '../optional-workflow.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; const PLANNING_GUIDANCE = `## Planning a Change @@ -29,13 +30,55 @@ If this stays a single-device tool, I recommend keeping SQLite to avoid adding a service to operate; shared state would need a separate sync design. \`\`\``; +/** + * Explore's handoffs. A custom profile can install explore without propose or + * apply, so each reference is resolved at generation time (see + * optional-workflow.ts) instead of naming a workflow that may not exist. The + * fallbacks point at explore's own capture path and the always-present CLI. + */ +const IMPLEMENT_REQUEST_HANDOFF = optionalWorkflow( + 'propose', + 'point them at `/opsx:propose`, which turns the discussion into a change', + 'offer to capture the discussion as a change, as described below' +); + +const CAPTURE_PLANNING_HANDOFF = optionalWorkflow( + 'propose', + '`/opsx:propose` writes the remaining planning artifacts', + 'any remaining planning artifacts can be captured here the same way' +); + +const CAPTURE_APPLY_HANDOFF = optionalWorkflow( + 'apply', + '`/opsx:apply` implements the change once tasks exist', + 'implementation works from the change\'s tasks (`openspec instructions apply --change "" --json`), outside explore mode' +); + +const DISCOVERY_END_HANDOFF = optionalWorkflow( + 'propose', + 'Ready to start? Run `/opsx:propose` and this becomes a change.', + 'Ready to start? I can capture this as a change.' +); + +const SUMMARY_NEXT_STEP = optionalWorkflow( + 'propose', + '- Turn this into a change: `/opsx:propose`', + '- Capture this as a change: ask me to' +); + +const GUARDRAIL_HANDOFF = optionalWorkflow( + 'propose', + '`/opsx:propose` turns the discussion into a change, and the work happens there', + 'offer to capture the discussion as a change, and the work happens from that change' +); + export function getExploreSkillTemplate(): SkillTemplate { return { name: 'openspec-explore', description: 'Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.', instructions: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. -**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and point them at \`/opsx:propose\`, which turns the discussion into a change. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and ${IMPLEMENT_REQUEST_HANDOFF}. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. @@ -152,7 +195,7 @@ If the user asks you to capture the exploration as a new change, transition seam 3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to \`resolvedOutputPath\`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists. 4. After creating each artifact, re-run \`openspec status --change "" --json\` (append the confirmed \`--store ""\` only for a registered standalone store) and continue until every requested artifact is \`done\`, \`skipped\`, or was deliberately skipped because its own \`instruction\` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still \`blocked\` only because you deliberately skipped a conditional prerequisite, run \`openspec instructions "" --change "" --json\` (append the confirmed \`--store ""\` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture. -Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: \`/opsx:propose\` writes the remaining planning artifacts, and \`/opsx:apply\` implements the change once tasks exist. Capturing artifacts is never permission to implement them. +Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: ${CAPTURE_PLANNING_HANDOFF}, and ${CAPTURE_APPLY_HANDOFF}. Capturing artifacts is never permission to implement them. ### When a change exists @@ -308,7 +351,7 @@ You: That changes everything. There's no required ending. Discovery might: -- **Flow into a proposal**: "Ready to start? Run \`/opsx:propose\` and this becomes a change." +- **Flow into a proposal**: "${DISCOVERY_END_HANDOFF}" - **Result in artifact updates**: "Updated design.md with these decisions" - **Just provide clarity**: User has what they need, moves on - **Continue later**: "We can pick this up anytime" @@ -325,7 +368,7 @@ When it feels like things are crystallizing, you might summarize: **Open questions**: [if any remain] **Next steps** (if ready): -- Turn this into a change: \`/opsx:propose\` +${SUMMARY_NEXT_STEP} - Keep exploring: just keep talking \`\`\` @@ -335,7 +378,7 @@ But this summary is optional. Sometimes the thinking IS the value. ## Guardrails -- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or \`openspec/config.yaml\` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. When the user is ready to build, name the handoff rather than starting: \`/opsx:propose\` turns the discussion into a change, and the work happens there. +- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or \`openspec/config.yaml\` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. When the user is ready to build, name the handoff rather than starting: ${GUARDRAIL_HANDOFF}. - **Don't fake understanding** - If something is unclear, dig deeper - **Don't rush** - Discovery is thinking time, not task time - **Don't force structure** - Let patterns emerge naturally @@ -358,7 +401,7 @@ export function getOpsxExploreCommandTemplate(): CommandTemplate { tags: ['workflow', 'explore', 'experimental', 'thinking'], content: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. -**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and point them at \`/opsx:propose\`, which turns the discussion into a change. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and ${IMPLEMENT_REQUEST_HANDOFF}. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. @@ -484,7 +527,7 @@ If the user asks you to capture the exploration as a new change, transition seam 3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to \`resolvedOutputPath\`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists. 4. After creating each artifact, re-run \`openspec status --change "" --json\` (append the confirmed \`--store ""\` only for a registered standalone store) and continue until every requested artifact is \`done\`, \`skipped\`, or was deliberately skipped because its own \`instruction\` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still \`blocked\` only because you deliberately skipped a conditional prerequisite, run \`openspec instructions "" --change "" --json\` (append the confirmed \`--store ""\` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture. -Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: \`/opsx:propose\` writes the remaining planning artifacts, and \`/opsx:apply\` implements the change once tasks exist. Capturing artifacts is never permission to implement them. +Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: ${CAPTURE_PLANNING_HANDOFF}, and ${CAPTURE_APPLY_HANDOFF}. Capturing artifacts is never permission to implement them. ### When a change exists @@ -536,7 +579,7 @@ If the user mentions a change or you detect one is relevant: There's no required ending. Discovery might: -- **Flow into a proposal**: "Ready to start? Run \`/opsx:propose\` and this becomes a change." +- **Flow into a proposal**: "${DISCOVERY_END_HANDOFF}" - **Result in artifact updates**: "Updated design.md with these decisions" - **Just provide clarity**: User has what they need, moves on - **Continue later**: "We can pick this up anytime" @@ -547,7 +590,7 @@ When things crystallize, you might offer a summary - but it's optional. Sometime ## Guardrails -- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or \`openspec/config.yaml\` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. When the user is ready to build, name the handoff rather than starting: \`/opsx:propose\` turns the discussion into a change, and the work happens there. +- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or \`openspec/config.yaml\` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. When the user is ready to build, name the handoff rather than starting: ${GUARDRAIL_HANDOFF}. - **Don't fake understanding** - If something is unclear, dig deeper - **Don't rush** - Discovery is thinking time, not task time - **Don't force structure** - Let patterns emerge naturally diff --git a/test/core/templates/explore.test.ts b/test/core/templates/explore.test.ts index 541ba754c1..8106982755 100644 --- a/test/core/templates/explore.test.ts +++ b/test/core/templates/explore.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - getExploreSkillTemplate, - getOpsxExploreCommandTemplate, -} from '../../../src/core/templates/skill-templates.js'; import { getSkillReferenceTransformer, transformCommandInvocations, @@ -16,9 +12,19 @@ import { getInvocationForAdapter, } from '../../../src/core/command-generation/invocation.js'; import { AI_TOOLS } from '../../../src/core/config.js'; - -const skill = getExploreSkillTemplate(); -const command = getOpsxExploreCommandTemplate(); +import { + generateSkillContent, + getCommandContents, + getCommandTemplates, + getSkillTemplates, +} from '../../../src/core/shared/skill-generation.js'; +import { generateCommands } from '../../../src/core/command-generation/generator.js'; +import { getProfileWorkflows } from '../../../src/core/profiles.js'; + +// Bodies as generated with every workflow installed. Profile-dependent +// handoffs are covered separately below. +const skill = getSkillTemplates().find(e => e.workflowId === 'explore')!.template; +const command = getCommandTemplates().find(e => e.id === 'explore')!.template; // Both delivery surfaces must carry the same contract; every behavioral // assertion below runs against each body. @@ -582,3 +588,82 @@ describe('explore capture path names where the work continues (#869)', () => { } }); }); + +// A custom profile can install explore without propose or apply. Explore must +// then not name a handoff to a workflow that was never generated; the agent +// would be sent to a command nobody answers to. Checked through the same +// registries init and update call, on both delivery surfaces. +describe('explore handoffs follow the installed workflow set (#869)', () => { + const PROFILES: Array<[string, string[], Array<'propose' | 'apply'>]> = [ + ['explore only', ['explore'], ['propose', 'apply']], + ['explore + propose without apply', ['explore', 'propose'], ['apply']], + ]; + + function exploreSkillBody(workflows: string[]): string { + const entry = getSkillTemplates(workflows).find(e => e.workflowId === 'explore'); + expect(entry).toBeDefined(); + return entry!.template.instructions; + } + + function exploreCommandBody(workflows: string[]): string { + const entry = getCommandContents(workflows).find(e => e.id === 'explore'); + expect(entry).toBeDefined(); + return entry!.body; + } + + it.each(PROFILES)('%s: generated skills never name a missing workflow', (_name, workflows, missing) => { + const body = exploreSkillBody(workflows); + for (const tool of AI_TOOLS) { + const content = generateSkillContent( + getSkillTemplates(workflows).find(e => e.workflowId === 'explore')!.template, + 'TEST', + getSkillReferenceTransformer(tool.value) + ); + for (const id of missing) { + const skillName = id === 'propose' ? 'openspec-propose' : 'openspec-apply-change'; + expect(content, `${tool.value} ${id}`).not.toContain(skillName); + } + } + for (const id of missing) { + expect(body).not.toContain(`/opsx:${id}`); + expect(transformToCodexCompatibleSkillReferences(body)).not.toMatch( + new RegExp(`openspec-${id}`) + ); + } + expect(body).not.toContain('[[opsx:'); + }); + + it.each(PROFILES)('%s: generated commands never name a missing workflow', (_name, workflows, missing) => { + const contents = getCommandContents(workflows); + for (const adapter of CommandAdapterRegistry.getAll()) { + const invocation = getInvocationForAdapter(adapter); + const explore = generateCommands(contents, adapter).find(c => + c.fileContent.includes('Enter explore mode') + ); + expect(explore, adapter.toolId).toBeDefined(); + for (const id of missing) { + expect(explore!.fileContent, `${adapter.toolId} ${id}`).not.toContain( + formatCommandInvocation(invocation, id) + ); + expect(explore!.fileContent, `${adapter.toolId} ${id}`).not.toContain(`/opsx:${id}`); + } + expect(explore!.fileContent, adapter.toolId).not.toContain('[[opsx:'); + } + expect(exploreCommandBody(workflows)).not.toContain('[[opsx:'); + }); + + it.each(PROFILES)('%s: explore still names a way forward', (_name, workflows) => { + for (const body of [exploreSkillBody(workflows), exploreCommandBody(workflows)]) { + expect(body).toContain('Capturing artifacts is never permission to implement them'); + expect(body).toContain('The work happens from that change, never from explore mode'); + } + }); + + it('keeps both named handoffs when propose and apply are installed (core profile)', () => { + const core = getProfileWorkflows('core'); + for (const body of [exploreSkillBody([...core]), exploreCommandBody([...core])]) { + expect(body).toContain('point them at `/opsx:propose`'); + expect(body).toContain('`/opsx:apply` implements the change once tasks exist'); + } + }); +}); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index d6124c607b..2d217c0e0c 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -36,16 +36,32 @@ import { getSkillTemplates, } from '../../../src/core/shared/skill-generation.js'; import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; +import { resolveOptionalWorkflows } from '../../../src/core/templates/optional-workflow.js'; +import { ALL_WORKFLOWS } from '../../../src/core/profiles.js'; + +/** + * Templates carry optional-workflow conditionals that the production registry + * resolves against the installed workflow set. Pin what generation emits, not + * the unresolved authoring form: with every workflow installed this is byte + * for byte what `getSkillTemplates()` returns. + */ +const asDeployed = (template: SkillTemplate): SkillTemplate => ({ + ...template, + instructions: resolveOptionalWorkflows( + template.instructions, + new Set(ALL_WORKFLOWS) + ), +}); const EXPECTED_FUNCTION_HASHES: Record = { - getExploreSkillTemplate: '9af3d4363cf2cb97e0367baafc0e77a8bac975196b91249dc32cf79b1f8e9f5d', + getExploreSkillTemplate: '647555fb5cc36b615ce77d27cddc0df34889810da925ecf6e83e8268f2729210', getNewChangeSkillTemplate: 'eabd1e895c5881dcb17dcbaa3fb26098dd59e8eacb318e400820b4dc811ef781', getContinueChangeSkillTemplate: '012136f6411a99c8fa228e2f9444cb64b0a89e0f56fdeac2fe03b2f5bee0c5d7', getApplyChangeSkillTemplate: 'd1e7d5ceb85193c0964057dbb88e9651526754bd33f84020e2440ff0621d5dbb', getFfChangeSkillTemplate: 'efa6a70c111b18b61a7720250b9622afa9a212fb64edf609cf80e2182a9bdf8c', getSyncSpecsSkillTemplate: 'b099e2ff31859c9b10d928066e662524f9aad9ecf2be12fceacb732d718c4146', getOnboardSkillTemplate: '3a836faae463d88c289a1c129cb7ee556a563b7e53e1a52a4711ff152a3b51f7', - getOpsxExploreCommandTemplate: '90346aff7843a1d9d11db9b3168b6b1ccfb8b74d0eaf03226023c03771fccc4c', + getOpsxExploreCommandTemplate: '43fa0c1717d37baa0cb5c5d09d40a69a7570d98407c7e0458434e8b7e611243a', getOpsxNewCommandTemplate: 'f2d30e569798a4c92ba932859d6ba4e0ad10e18feccbade1cfee0957597b3463', getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0', @@ -158,7 +174,7 @@ describe('skill templates split parity', () => { const actualHashes = Object.fromEntries( GENERATED_SKILL_FACTORIES.map(([dirName, createTemplate]) => [ dirName, - hash(generateSkillContent(createTemplate(), 'PARITY-BASELINE')), + hash(generateSkillContent(asDeployed(createTemplate()), 'PARITY-BASELINE')), ]) ); @@ -259,7 +275,7 @@ describe('skill templates split parity', () => { ], [ 'explore skill', - generateSkillContent(getExploreSkillTemplate(), 'PARITY-BASELINE'), + generateSkillContent(asDeployed(getExploreSkillTemplate()), 'PARITY-BASELINE'), 'specs//spec.md', "Preserve an existing capability's full path", ], From 43269e4cf03a52d00c56f8fc655e3c186bc793f3 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 15 Sep 2026 08:22:12 -0500 Subject: [PATCH 5/5] docs(changeset): drop em dashes from the release note Co-Authored-By: Claude Opus 5 --- .changeset/explore-names-the-propose-handoff.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/explore-names-the-propose-handoff.md b/.changeset/explore-names-the-propose-handoff.md index 2fc7685e23..8bbcd4aa09 100644 --- a/.changeset/explore-names-the-propose-handoff.md +++ b/.changeset/explore-names-the-propose-handoff.md @@ -2,6 +2,6 @@ "@fission-ai/openspec": patch --- -Name the workflow where explore hands off. Explore mode refuses to implement, but every place it said what to do instead described the next step as prose ("create a change proposal") without naming the workflow that does it — the refusal itself, the "flow into a proposal" ending, the closing summary, and the do-not-implement guardrail. Its seamless capture path was worse: it scaffolded a change, wrote artifacts, and then said nothing at all about what came next. With no named exit, agents finished the discovery questions and started writing code, which is the failure reported through GitHub Copilot in #869 — and which the docs already promised would not happen ("when the picture is clear, it hands off to `/opsx:propose`"). +Name the workflow where explore hands off. Explore mode refuses to implement, but every place it said what to do instead described the next step as prose ("create a change proposal") without naming the workflow that does it: the refusal itself, the "flow into a proposal" ending, the closing summary, and the do-not-implement guardrail. Its seamless capture path was worse: it scaffolded a change, wrote artifacts, and then said nothing at all about what came next. With no named exit, agents finished the discovery questions and started writing code, which is the failure reported through GitHub Copilot in #869, and which the docs already promised would not happen ("when the picture is clear, it hands off to `/opsx:propose`"). The explore skill and command now name `/opsx:propose` at all four prose handoffs, and the capture path ends by naming `/opsx:propose` for the remaining planning artifacts and `/opsx:apply` for implementation, with an explicit note that capturing artifacts is not permission to implement them. The references are written in the canonical `/opsx:` form so each tool renders the invocation it actually registers (`/openspec-propose` for skills-only delivery, `/opsx-propose`, `/opsx:propose`, or `@opsx-propose` for command surfaces). The handoffs follow the installed workflow set: a custom profile without `propose` or `apply` gets explore's own capture path and the `openspec instructions apply` CLI instead of a command it never installed. Fixes #869.