From edfd989c90dae7ee3a9c2457584dda06d9d5c75b Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 3 Sep 2026 12:37:09 -0500 Subject: [PATCH 01/10] fix(templates): stop generated skills naming workflows the profile omits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `core` profile installs six of the twelve workflows, but the update and apply templates named `/opsx:continue` (6 times) and `/opsx:new` (twice) regardless. On a default install those became `/openspec-continue-change` and `/openspec-new-change` — skills that were never written — so `update-change` refused to create a missing artifact and handed off to a dead end. The only guard was a sentence asking the model to check availability at runtime, 70 lines above the two places it hits the wall. `command-references.ts` decides how a reference is spelled; nothing decided whether it should be emitted at all. Add that: templates author both wordings with `optionalWorkflow()`, and `getSkillTemplates()` / `getCommandTemplates()` — the one place every generation path already funnels the resolved workflow set through — pick a branch before the reference transformers run. A profile that omits a workflow now gets a concrete `openspec status` / `openspec instructions` fallback instead of a reference to a skill that does not exist. Closes #1734 Co-Authored-By: Claude Opus 5 --- skills/openspec-apply-change/SKILL.md | 2 +- skills/openspec-update-change/SKILL.md | 4 +- src/core/shared/skill-generation.ts | 44 ++++++++-- src/core/templates/optional-workflow.ts | 81 +++++++++++++++++++ src/core/templates/workflows/apply-change.ts | 14 +++- src/core/templates/workflows/update-change.ts | 67 ++++++++++++--- .../profile-workflow-references.test.ts | 62 ++++++++++++++ test/core/templates/optional-workflow.test.ts | 54 +++++++++++++ .../templates/skill-templates-parity.test.ts | 30 +++++-- test/core/templates/update-change.test.ts | 71 ++++++++++------ 10 files changed, 374 insertions(+), 55 deletions(-) create mode 100644 src/core/templates/optional-workflow.ts create mode 100644 test/core/shared/profile-workflow-references.test.ts create mode 100644 test/core/templates/optional-workflow.test.ts diff --git a/skills/openspec-apply-change/SKILL.md b/skills/openspec-apply-change/SKILL.md index 098f63fecb..6a5b9003a7 100644 --- a/skills/openspec-apply-change/SKILL.md +++ b/skills/openspec-apply-change/SKILL.md @@ -50,7 +50,7 @@ Implement tasks from an OpenSpec change. - Optional `operationGuidance`: current advisory guidance for apply **Handle states:** - - If `state: "blocked"` (missing artifacts): show message, suggest using `/openspec-continue-change` (if it is not installed, run `openspec status --change "" --json` to see the next artifact and `openspec instructions --change "" --json` for how to create it) + - If `state: "blocked"` (missing artifacts): show message, then suggest using `/openspec-continue-change` to create them - If `state: "all_done"`: congratulate, suggest archive - Otherwise: proceed to implementation diff --git a/skills/openspec-update-change/SKILL.md b/skills/openspec-update-change/SKILL.md index 24c9f88367..81d408199b 100644 --- a/skills/openspec-update-change/SKILL.md +++ b/skills/openspec-update-change/SKILL.md @@ -15,7 +15,7 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. -`/openspec-continue-change` is an optional workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, `openspec status --change "" --json` shows the next artifact and `openspec instructions "" --change "" --json` explains how to create it. +This workflow revises artifacts that already exist; `/openspec-continue-change` is what creates the ones that do not. **Steps** @@ -87,4 +87,4 @@ After each invocation, show: - Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`. - Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/openspec-continue-change`'s job. - Confirm every edit with the user before writing. -- If the request changes the change's *intent* rather than refining it, first verify whether the optional `/openspec-new-change` workflow is available. If it is, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend `openspec new change ""` instead. +- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic). diff --git a/src/core/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index f671b4de73..28274e5312 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 (#1734, umbrella #919). + * + * 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..cb0019a3fe --- /dev/null +++ b/src/core/templates/optional-workflow.ts @@ -0,0 +1,81 @@ +/** + * Optional-Workflow Conditionals + * + * Not every workflow is installed. The `core` profile ships six of the twelve + * (`propose`, `explore`, `apply`, `update`, `sync`, `archive`), and a `custom` + * profile can ship any subset. A template that names `/opsx:continue` is + * therefore writing a dead reference for anyone whose profile omits it — the + * agent is told to hand off to a workflow that was never generated (#1734, + * umbrella #919). + * + * `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()` picks one at + * generation time against the resolved workflow set, so the generated file + * states one path instead of asking the model to check availability at runtime. + */ + +const OPEN = '[[opsx:if-workflow '; +const OPEN_END = ']]'; +const ELSE = '[[opsx:else]]'; +const END = '[[opsx:end]]'; + +const CONDITIONAL_PATTERN = + /\[\[opsx:if-workflow ([a-z-]+)\]\]([\s\S]*?)\[\[opsx:else\]\]([\s\S]*?)\[\[opsx:end\]\]/g; + +/** Any leftover marker, used to fail loudly on malformed authoring. */ +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: the generated file contains + * exactly one of them, with no trace of the other. + * + * @param workflowId - Workflow id as it appears in ALL_WORKFLOWS (e.g. 'continue') + * @param whenInstalled - Text to emit when the workflow is part of the profile + * @param whenMissing - Text to emit otherwise, typically a CLI fallback + * + * @example + * optionalWorkflow('continue', 'suggest `/opsx:continue`', 'run `openspec status`') + */ +export function optionalWorkflow( + workflowId: string, + whenInstalled: string, + whenMissing: string +): string { + return `${OPEN}${workflowId}${OPEN_END}${whenInstalled}${ELSE}${whenMissing}${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 branch + * that was dropped never reaches them. + * + * @param text - Template body, possibly containing conditionals + * @param installedWorkflows - The resolved workflow set for this installation + * @returns The body with one branch of each conditional kept + * @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]}' survived resolution. ` + + 'Each block needs the full [[opsx:if-workflow ]] ... [[opsx:else]] ... [[opsx:end]] form.' + ); + } + + return resolved; +} diff --git a/src/core/templates/workflows/apply-change.ts b/src/core/templates/workflows/apply-change.ts index d7ebe2f4eb..dc10d494de 100644 --- a/src/core/templates/workflows/apply-change.ts +++ b/src/core/templates/workflows/apply-change.ts @@ -5,8 +5,20 @@ * 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'; +/** + * `/opsx:continue` is not in the `core` profile, so the blocked-state handoff + * is authored with a CLI fallback and resolved at generation time (see + * optional-workflow.ts). + */ +const BLOCKED_STATE_HANDOFF = optionalWorkflow( + 'continue', + 'suggest using `/opsx:continue` to create them', + 'run `openspec status --change "" --json` to see the next artifact and `openspec instructions --change "" --json` for how to create it' +); + /** * The apply workflow instructions, authored once and rendered by both the * skill and command surfaces. The surfaces are intentionally distinct, but @@ -58,7 +70,7 @@ ${STORE_SELECTION_GUIDANCE} - Optional \`operationGuidance\`: current advisory guidance for apply **Handle states:** - - If \`state: "blocked"\` (missing artifacts): show message, suggest using \`/opsx:continue\` (if it is not installed, run \`openspec status --change "" --json\` to see the next artifact and \`openspec instructions --change "" --json\` for how to create it) + - If \`state: "blocked"\` (missing artifacts): show message, then ${BLOCKED_STATE_HANDOFF} - If \`state: "all_done"\`: congratulate, suggest archive - Otherwise: proceed to implementation diff --git a/src/core/templates/workflows/update-change.ts b/src/core/templates/workflows/update-change.ts index 7700cd8d7e..1cebfc4e1a 100644 --- a/src/core/templates/workflows/update-change.ts +++ b/src/core/templates/workflows/update-change.ts @@ -5,8 +5,51 @@ * 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'; +/** + * Passages that hand off to `/opsx:continue` or `/opsx:new`. Neither workflow + * is in the `core` profile, so each is authored with a CLI fallback and + * resolved at generation time (see optional-workflow.ts). Shared by both + * surfaces below so the skill and the command cannot drift apart. + */ +const CONTINUE_SCOPE_NOTE = optionalWorkflow( + 'continue', + 'This workflow revises artifacts that already exist; `/opsx:continue` is what creates the ones that do not.', + 'This workflow revises artifacts that already exist; it never creates missing ones. When an artifact is missing, `openspec status --change "" --json` names the next one and `openspec instructions "" --change "" --json` explains how to write it.' +); + +const CONTINUE_CREATE_THEM = optionalWorkflow( + 'continue', + 'point the user to `/opsx:continue` to create them', + 'point the user to `openspec instructions "" --change "" --json` for how to create them' +); + +const CONTINUE_NEXT_STEP = optionalWorkflow( + 'continue', + 'suggest `/opsx:continue` to create them', + 'run `openspec status --change "" --json` for the next artifact and point the user to `openspec instructions "" --change "" --json` for how to create it' +); + +const CONTINUE_DEFERRED = optionalWorkflow( + 'continue', + 'Anything deferred to `/opsx:continue` (not-yet-created artifacts or files)', + 'Anything deferred because it does not exist yet (not-yet-created artifacts or files)' +); + +const CONTINUE_FRONTIER = optionalWorkflow( + 'continue', + "that is `/opsx:continue`'s job", + 'creating them is a separate step, outside this workflow' +); + +const INTENT_CHANGE_GUARDRAIL = optionalWorkflow( + 'new', + 'recommend starting fresh with `/opsx:new` (the "Update vs. Start Fresh" heuristic)', + 'ask for a distinct unused change name and recommend `openspec new change ""` instead (the "Update vs. Start Fresh" heuristic)' +); + export function getUpdateChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-update-change', @@ -17,7 +60,7 @@ ${STORE_SELECTION_GUIDANCE} **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. -\`/opsx:continue\` is an optional workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, \`openspec status --change "" --json\` shows the next artifact and \`openspec instructions "" --change "" --json\` explains how to create it. +${CONTINUE_SCOPE_NOTE} **Steps** @@ -60,7 +103,7 @@ ${STORE_SELECTION_GUIDANCE} - Read the artifact(s) the request touches and the change's other existing artifacts. - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. - Note everything that is now inconsistent, missing, or contradictory. - - Revise only files that already exist (\`existingOutputPaths\`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to \`/opsx:continue\` to create them. + - Revise only files that already exist (\`existingOutputPaths\`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and ${CONTINUE_CREATE_THEM}. - If the change is already coherent, say so and make no edits. 5. **Confirm and apply, one artifact at a time** @@ -72,7 +115,7 @@ ${STORE_SELECTION_GUIDANCE} \`\`\` 6. **Point to the next step (guidance only - NEVER act on it)** - - Artifacts still missing -> suggest \`/opsx:continue\` to create them. + - Artifacts still missing -> ${CONTINUE_NEXT_STEP}. - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest \`/opsx:apply\` to carry the delta into code. - Everything done and implemented -> suggest \`/opsx:archive\`. @@ -80,16 +123,16 @@ ${STORE_SELECTION_GUIDANCE} After each invocation, show: - Which artifacts were revised (and which proposed revisions were rejected) -- Anything deferred to \`/opsx:continue\` (not-yet-created artifacts or files) +- ${CONTINUE_DEFERRED} - Where the change stands and the recommended next command **Guardrails** - Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to \`/opsx:apply\`. - Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names. - Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`. -- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is \`/opsx:continue\`'s job. +- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - ${CONTINUE_FRONTIER}. - Confirm every edit with the user before writing. -- If the request changes the change's *intent* rather than refining it, first verify whether the optional \`/opsx:new\` workflow is available. If it is, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend \`openspec new change ""\` instead.`, +- If the request changes the change's *intent* rather than refining it, ${INTENT_CHANGE_GUARDRAIL}.`, license: 'MIT', compatibility: 'Requires openspec CLI.', metadata: { author: 'openspec', version: '1.0' }, @@ -108,7 +151,7 @@ ${STORE_SELECTION_GUIDANCE} **Input**: Optionally specify a change name after \`/opsx:update\` (e.g., \`/opsx:update add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. -\`/opsx:continue\` is an optional workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, \`openspec status --change "" --json\` shows the next artifact and \`openspec instructions "" --change "" --json\` explains how to create it. +${CONTINUE_SCOPE_NOTE} **Steps** @@ -151,7 +194,7 @@ ${STORE_SELECTION_GUIDANCE} - Read the artifact(s) the request touches and the change's other existing artifacts. - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. - Note everything that is now inconsistent, missing, or contradictory. - - Revise only files that already exist (\`existingOutputPaths\`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to \`/opsx:continue\` to create them. + - Revise only files that already exist (\`existingOutputPaths\`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and ${CONTINUE_CREATE_THEM}. - If the change is already coherent, say so and make no edits. 5. **Confirm and apply, one artifact at a time** @@ -163,7 +206,7 @@ ${STORE_SELECTION_GUIDANCE} \`\`\` 6. **Point to the next step (guidance only - NEVER act on it)** - - Artifacts still missing -> suggest \`/opsx:continue\` to create them. + - Artifacts still missing -> ${CONTINUE_NEXT_STEP}. - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest \`/opsx:apply\` to carry the delta into code. - Everything done and implemented -> suggest \`/opsx:archive\`. @@ -171,15 +214,15 @@ ${STORE_SELECTION_GUIDANCE} After each invocation, show: - Which artifacts were revised (and which proposed revisions were rejected) -- Anything deferred to \`/opsx:continue\` (not-yet-created artifacts or files) +- ${CONTINUE_DEFERRED} - Where the change stands and the recommended next command **Guardrails** - Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to \`/opsx:apply\`. - Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names. - Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`. -- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is \`/opsx:continue\`'s job. +- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - ${CONTINUE_FRONTIER}. - Confirm every edit with the user before writing. -- If the request changes the change's *intent* rather than refining it, first verify whether the optional \`/opsx:new\` workflow is available. If it is, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend \`openspec new change ""\` instead.` +- If the request changes the change's *intent* rather than refining it, ${INTENT_CHANGE_GUARDRAIL}.` }; } diff --git a/test/core/shared/profile-workflow-references.test.ts b/test/core/shared/profile-workflow-references.test.ts new file mode 100644 index 0000000000..5259ff8c2f --- /dev/null +++ b/test/core/shared/profile-workflow-references.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { + getSkillTemplates, + getCommandContents, + generateSkillContent, +} from '../../../src/core/shared/skill-generation.js'; +import { + ALL_WORKFLOWS, + CORE_WORKFLOWS, + getProfileWorkflows, +} from '../../../src/core/profiles.js'; +import { transformToSkillReferences } from '../../../src/utils/command-references.js'; + +/** + * The default `core` profile installs six of the twelve workflows. Anything it + * generates that names one of the other six sends the agent to a skill that was + * never written — a dead end the agent cannot recover from (#1734, umbrella + * #919). Assert it on the generated bytes, both spellings: the canonical + * `/opsx:` a command file carries and the `/openspec-` a skills-only + * install carries. + */ +const coreWorkflows = getProfileWorkflows('core'); +const uninstalled = ALL_WORKFLOWS.filter((id) => !coreWorkflows.includes(id)); + +const skillNameFor = (id: string) => transformToSkillReferences(`/opsx:${id}`); + +describe('core profile generates no reference to an uninstalled workflow', () => { + it('resolves to exactly the six core workflows', () => { + expect([...coreWorkflows].sort()).toEqual([...CORE_WORKFLOWS].sort()); + expect(uninstalled).toContain('continue'); + expect(uninstalled).toContain('new'); + }); + + it('holds for every generated skill body', () => { + for (const { template, dirName } of getSkillTemplates(coreWorkflows)) { + const content = generateSkillContent(template, 'TEST'); + const skillsOnly = transformToSkillReferences(content); + + for (const id of uninstalled) { + expect(content, `${dirName} -> /opsx:${id}`).not.toContain(`/opsx:${id}`); + expect(skillsOnly, `${dirName} -> ${skillNameFor(id)}`).not.toContain(skillNameFor(id)); + } + } + }); + + it('holds for every generated command body', () => { + for (const { id: commandId, body } of getCommandContents(coreWorkflows)) { + for (const id of uninstalled) { + expect(body, `${commandId} -> /opsx:${id}`).not.toContain(`/opsx:${id}`); + } + } + }); + + it('still names the optional workflows when the profile installs them', () => { + const expanded = getSkillTemplates(ALL_WORKFLOWS); + const update = expanded.find(({ workflowId }) => workflowId === 'update'); + + expect(update?.template.instructions).toContain('/opsx:continue'); + expect(update?.template.instructions).toContain('/opsx:new'); + }); +}); diff --git a/test/core/templates/optional-workflow.test.ts b/test/core/templates/optional-workflow.test.ts new file mode 100644 index 0000000000..660450086a --- /dev/null +++ b/test/core/templates/optional-workflow.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + optionalWorkflow, + resolveOptionalWorkflows, +} from '../../../src/core/templates/optional-workflow.js'; + +const installed = (...ids: string[]) => new Set(ids); + +describe('optionalWorkflow / resolveOptionalWorkflows', () => { + it('keeps the installed branch and drops the other', () => { + const text = `Next: ${optionalWorkflow('continue', 'run `/opsx:continue`', 'run `openspec status`')}.`; + + expect(resolveOptionalWorkflows(text, installed('continue'))).toBe( + 'Next: run `/opsx:continue`.' + ); + }); + + it('keeps the fallback branch when the workflow is not installed', () => { + const text = `Next: ${optionalWorkflow('continue', 'run `/opsx:continue`', 'run `openspec status`')}.`; + + expect(resolveOptionalWorkflows(text, installed('apply'))).toBe( + 'Next: run `openspec status`.' + ); + }); + + it('resolves every block independently, including multiline branches', () => { + const text = [ + optionalWorkflow('continue', 'A-yes', 'A-no'), + optionalWorkflow('new', 'B-yes\nsecond line', 'B-no'), + optionalWorkflow('continue', 'C-yes', 'C-no'), + ].join('\n'); + + expect(resolveOptionalWorkflows(text, installed('new'))).toBe( + 'A-no\nB-yes\nsecond line\nC-no' + ); + }); + + it('leaves text without conditionals untouched', () => { + const text = 'Plain body naming `/opsx:apply` only.'; + + expect(resolveOptionalWorkflows(text, installed())).toBe(text); + }); + + // A branch that is dropped must leave nothing behind: a surviving marker + // would ship as literal noise in a generated SKILL.md. + it('throws on a malformed block rather than emitting a marker', () => { + const truncated = '[[opsx:if-workflow continue]]yes'; + + expect(() => resolveOptionalWorkflows(truncated, installed('continue'))).toThrow( + /Malformed optional-workflow conditional/ + ); + }); +}); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 3f309bdaa3..8ad8bf611e 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -36,19 +36,35 @@ 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: '6315fcc5c2eb848963bc8bca4c23e657412a99608e610daee59fb4e58cd21fd4', getNewChangeSkillTemplate: 'eabd1e895c5881dcb17dcbaa3fb26098dd59e8eacb318e400820b4dc811ef781', getContinueChangeSkillTemplate: '012136f6411a99c8fa228e2f9444cb64b0a89e0f56fdeac2fe03b2f5bee0c5d7', - getApplyChangeSkillTemplate: 'd1e7d5ceb85193c0964057dbb88e9651526754bd33f84020e2440ff0621d5dbb', + getApplyChangeSkillTemplate: '2479f540b86025fe33d1e7b4350e2af5ae63d82cdd919f26b840b6cacb6aa243', getFfChangeSkillTemplate: 'efa6a70c111b18b61a7720250b9622afa9a212fb64edf609cf80e2182a9bdf8c', getSyncSpecsSkillTemplate: 'b099e2ff31859c9b10d928066e662524f9aad9ecf2be12fceacb732d718c4146', getOnboardSkillTemplate: '3a836faae463d88c289a1c129cb7ee556a563b7e53e1a52a4711ff152a3b51f7', getOpsxExploreCommandTemplate: 'b4706a5b8fd280f7929eea610ecc9d41676b2d2dd6653d259cbbc2bfe01813d9', getOpsxNewCommandTemplate: 'f2d30e569798a4c92ba932859d6ba4e0ad10e18feccbade1cfee0957597b3463', getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', - getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0', + getOpsxApplyCommandTemplate: 'e146555c8e1104f00fbbd04d308b82a26395d83503b2d7f2f7c0c10402534a84', getOpsxFfCommandTemplate: '21132fc9c6d3b3ab2d2295d6bbd72d1e0052eb35ea1be0258c8b1ab3e200c4db', getArchiveChangeSkillTemplate: '56bfada1a5f35a127791b70de9d428a75b5aedd1584d6c9803a1ecb1fd1b4a23', getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d', @@ -61,15 +77,15 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxProposeSkillTemplate: '9c0fbf0137151bd03ec30c45180f83daec96e8976ceaf517c63147f84b803446', getOpsxProposeCommandTemplate: 'b3c145f541dcc13d9859eae8f7bedbe4553371477ed2c5ac07a4a80f82c46f52', getFeedbackSkillTemplate: 'dabeb5e825b9349abc8156c3e7b8608f27987912a6d9bf47ef29addde6138133', - getUpdateChangeSkillTemplate: '7dc8abc6f64c58bf34d7581ed4ab095a3b7a53cb372349bee2d840db58622819', - getOpsxUpdateCommandTemplate: 'e2388521b22f92f74561df9a0c2f98e1fa4d265af93b5ba26f42fb47a6c5bfed', + getUpdateChangeSkillTemplate: '7d9c532d6cdfe0b755e8109adc6e0b98fe89cff9cc6c5e4927459116be1ab392', + getOpsxUpdateCommandTemplate: '2f60a7d452b53688558aab7bf7f9912f361220880cf97fb48fb6be0ac8b29874', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-explore': 'dd84af68d3c93b40659dcdd8d383423b25b443cacdc4b514cd70614ae10c5cac', 'openspec-new-change': 'ec4529beef978e34634a6f7286fab55d68fad8fb374dceb45691d52caab33fbb', 'openspec-continue-change': 'bb6194a16c54891cdb253678e8f70ce53b2af86735243980f366ce551d37e42e', - 'openspec-apply-change': '81ea96d9fa6ec8536cd23c1fe561ed28e1cc1cad0a8ceb700588e08974cc0e49', + 'openspec-apply-change': '145793072fbea6b888929c8cd09599fb6ad065bb27926231bbf93061395e051c', 'openspec-ff-change': '31355250514bce51b16ff37ee2b833bc9d475cd0dbd4b1f68fe2041694575623', 'openspec-sync-specs': 'd933d8856584d6c1253de91e652e7aee9e85c77ad4d3531f6476f79d84e6e5e8', 'openspec-archive-change': '7c65053d674ba4e1e20e2bf73ba7e5a7f94baef2eaa9b33cee48d4cadea51b7a', @@ -77,7 +93,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-verify-change': 'af9be013dcbe8c6d8f6d9ab10c893fbd03f4c62933c384d82f63894dd0ceb84f', 'openspec-onboard': 'f6f59476acaf5e4d65dbb180da4cef62432612f3cecf207d471a951295e2003a', 'openspec-propose': 'e358b45102a88082cf20f5c4441cba02533724ad6eef8ed15ba174e3496cb6ed', - 'openspec-update-change': '586547406aca94422dfeb3ffedce6c01049429b743f57ce829baa79ebc714d51', + 'openspec-update-change': 'ae394c6a873a2f482e5c6434bbd43c2ac57a56702edbaf959a764f2093c11123', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates @@ -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')), ]) ); diff --git a/test/core/templates/update-change.test.ts b/test/core/templates/update-change.test.ts index cf68f234f5..c495ff1ba7 100644 --- a/test/core/templates/update-change.test.ts +++ b/test/core/templates/update-change.test.ts @@ -5,16 +5,26 @@ import { getOpsxUpdateCommandTemplate, } from '../../../src/core/templates/skill-templates.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, CORE_WORKFLOWS } from '../../../src/core/profiles.js'; const skill = getUpdateChangeSkillTemplate(); const command = getOpsxUpdateCommandTemplate(); +const render = (workflows: readonly string[]): Array<[string, string]> => { + const installed = new Set(workflows); + return [ + ['skill', resolveOptionalWorkflows(skill.instructions, installed)], + ['command', resolveOptionalWorkflows(command.content, installed)], + ]; +}; + // Both delivery surfaces must carry the same contract; every behavioral -// assertion below runs against each body. -const bodies: Array<[string, string]> = [ - ['skill', skill.instructions], - ['command', command.content], -]; +// assertion below runs against each body. Templates carry optional-workflow +// conditionals, so a body is only meaningful once resolved against a workflow +// set — these are the bodies a profile with every workflow installed receives. +const bodies = render(ALL_WORKFLOWS); +const coreBodies = render(CORE_WORKFLOWS); describe('update-change templates', () => { it('generates the expected skill and command shape (3.1)', () => { @@ -77,45 +87,54 @@ describe('update-change templates', () => { } }); - it('explains the optional continue workflow before suggesting it', () => { + it('hands off to /opsx:continue when that workflow is installed', () => { for (const [label, body] of bodies) { - const availabilityGuidance = body.indexOf( - '`/opsx:continue` is an optional workflow and may not be installed' - ); - const firstSuggestion = body.indexOf( - '`/opsx:continue`', - availabilityGuidance + '`/opsx:continue`'.length + expect(body, label).toContain( + '`/opsx:continue` is what creates the ones that do not' ); + expect(body, label).toContain('suggest `/opsx:continue` to create them'); + expect(body, label).toContain("that is `/opsx:continue`'s job"); + // The handoff is stated outright, not deferred to a runtime availability + // check the model has to perform (#1734). + expect(body, label).not.toContain('may not be installed'); + expect(body, label).not.toContain('verify that it is available'); + } + }); - expect(availabilityGuidance, label).toBeGreaterThanOrEqual(0); - expect(body.indexOf('`/opsx:continue`'), label).toBe(availabilityGuidance); - expect(firstSuggestion, label).toBeGreaterThan(availabilityGuidance); + it('never names /opsx:continue on a profile that does not install it', () => { + for (const [label, body] of coreBodies) { + expect(body, label).not.toContain('/opsx:continue'); + expect(body, label).toContain('it never creates missing ones'); + expect(body, label).toContain( + 'run `openspec status --change "" --json` for the next artifact' + ); expect(body, label).toContain( - 'If it is unavailable, `openspec status --change "" --json` shows the next artifact' + '`openspec instructions "" --change "" --json` for how to create them' ); expect(body, label).toContain( - '`openspec instructions "" --change "" --json` explains how to create it' + 'Anything deferred because it does not exist yet' ); + expect(body, label).toContain('creating them is a separate step, outside this workflow'); } }); - it('confirms every edit and redirects intent changes to /opsx:new', () => { + it('confirms every edit and redirects intent changes to /opsx:new when installed', () => { for (const [label, body] of bodies) { expect(body, label).toContain('Write only after the user confirms'); expect(body, label).toContain('If the user rejects a revision, do not write it'); expect(body, label).toContain('recommend starting fresh with `/opsx:new`'); expect(body, label).toContain('Update vs. Start Fresh'); + expect(body, label).not.toContain('first verify whether the optional'); + } + }); + + it('routes intent changes to the CLI when /opsx:new is not installed', () => { + for (const [label, body] of coreBodies) { + expect(body, label).not.toContain('/opsx:new'); expect(body, label).toContain('ask for a distinct unused change name'); expect(body, label).toContain('openspec new change ""'); expect(body, label).not.toContain('openspec new change ""'); - - const newAvailabilityCheck = body.indexOf( - 'first verify whether the optional `/opsx:new` workflow is available' - ); - const newRecommendation = body.indexOf('recommend starting fresh with `/opsx:new`'); - expect(newAvailabilityCheck, label).toBeGreaterThanOrEqual(0); - expect(body.slice(0, newAvailabilityCheck), label).not.toContain('`/opsx:new`'); - expect(newRecommendation, label).toBeGreaterThan(newAvailabilityCheck); + expect(body, label).toContain('Update vs. Start Fresh'); } }); }); From 89d7753164197463665dffbdcf447903a495896d Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 3 Sep 2026 12:39:41 -0500 Subject: [PATCH 02/10] chore(changeset): describe profile-aware workflow references Co-Authored-By: Claude Opus 5 --- .changeset/profile-aware-workflow-references.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/profile-aware-workflow-references.md diff --git a/.changeset/profile-aware-workflow-references.md b/.changeset/profile-aware-workflow-references.md new file mode 100644 index 0000000000..c1517c5528 --- /dev/null +++ b/.changeset/profile-aware-workflow-references.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Generated skills and commands no longer point at workflows the active profile does not install. On the default `core` profile, the update workflow told agents to hand off to `/opsx:continue` for missing artifacts and to `/opsx:new` for a change of intent — neither of which `core` generates. Both now render a concrete `openspec status` / `openspec instructions` fallback instead, decided at generation time rather than by a runtime availability check the agent had to perform. From 24bf90ea5620c9199750ceeeb1c1f4e4fae2523e Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 3 Sep 2026 12:45:36 -0500 Subject: [PATCH 03/10] test(init): assert the core profile names no uninstalled workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The end-to-end init test pinned the runtime availability hedging that #1734 is about, and asserted `/opsx:continue` appears in the default profile's generated update workflow — the bug itself. Assert the fixed behavior instead: neither `/opsx:continue` nor `/opsx:new` appears, and the CLI fallback is stated outright, for both the update and apply surfaces. Co-Authored-By: Claude Opus 5 --- test/core/init.test.ts | 74 ++++++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 3a65055bc4..fab27e3a7e 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -517,45 +517,55 @@ describe('InitCommand', () => { ); } - const updateVariants: Array<[string, string]> = [ - [ - await fs.readFile( - path.join( - testDir, - '.claude', - 'skills', - 'openspec-update-change', - 'SKILL.md' - ), - 'utf-8' + // The default profile installs six workflows; `continue` and `new` are + // not among them. Nothing it generates may name them (#1734) - it would + // send the agent to a skill that was never written. The CLI fallback is + // stated outright instead of behind a runtime availability check. + const updateVariants = [ + await fs.readFile( + path.join( + testDir, + '.claude', + 'skills', + 'openspec-update-change', + 'SKILL.md' ), - '`/opsx:continue`', - ], - [ - await fs.readFile( - path.join(testDir, '.claude', 'commands', 'opsx', 'update.md'), - 'utf-8' - ), - '`/opsx:continue`', - ], + 'utf-8' + ), + await fs.readFile( + path.join(testDir, '.claude', 'commands', 'opsx', 'update.md'), + 'utf-8' + ), ]; - for (const [content, continueReference] of updateVariants) { - const availabilityGuidance = content.indexOf( - `${continueReference} is an optional workflow and may not be installed` - ); - const nextReference = content.indexOf( - continueReference, - availabilityGuidance + continueReference.length - ); - - expect(availabilityGuidance).toBeGreaterThanOrEqual(0); - expect(content.indexOf(continueReference)).toBe(availabilityGuidance); - expect(nextReference).toBeGreaterThan(availabilityGuidance); + for (const content of updateVariants) { + expect(content).not.toContain('/opsx:continue'); + expect(content).not.toContain('/opsx:new'); + expect(content).not.toContain('is an optional workflow and may not be installed'); + expect(content).toContain('it never creates missing ones'); expect(content).toContain('openspec status --change "" --json'); expect(content).toContain( 'openspec instructions "" --change "" --json' ); + expect(content).toContain('openspec new change ""'); + } + + const applyVariants = [ + await fs.readFile( + path.join(testDir, '.claude', 'skills', 'openspec-apply-change', 'SKILL.md'), + 'utf-8' + ), + await fs.readFile( + path.join(testDir, '.claude', 'commands', 'opsx', 'apply.md'), + 'utf-8' + ), + ]; + + for (const content of applyVariants) { + expect(content).not.toContain('/opsx:continue'); + expect(content).toContain( + 'run `openspec status --change "" --json` to see the next artifact' + ); } const syncFiles = [ From 725750a4d36f860dbcb213a6194fa27aa4fa6118 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 3 Sep 2026 13:15:10 -0500 Subject: [PATCH 04/10] fix(templates): resolve every cross-workflow reference, not just core's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first commit fixed the two templates the default profile broke on. Every other cross-workflow handoff had the same shape, and arbitrary subsets are reachable: a `custom` profile is whatever the user picked, and `openspec update` re-derives a workflow set from what it finds on disk (legacy tool overrides, inferred Codex workflows) without passing it through getProfileWorkflows. So resolve all of them: - `apply` -> archive; `continue` -> apply, archive; `ff` -> apply; `new` -> continue; `propose` -> apply; `update` -> apply, archive; `archive` and `bulk-archive` -> sync. - `onboard`'s two command-reference tables are built from the installed set rather than printed in full with an "only if installed" caveat, and its explore, resume and next-step prompts are resolved the same way. Two supporting changes: - `onlyWithWorkflow()` plus a whole-line rule in the resolver: a conditional that owns its line takes the line with it when it resolves to empty, so a dropped table row cannot leave a blank line that ends the table in markdown. - `generateSkillContent()` and `generateCommand()` now throw on an unresolved marker. A generation path that skips the choke point fails loudly instead of writing `[[opsx:...]]` into a user's SKILL.md. The propose and ff surfaces keep their deliberate wording difference (#258): the command surface never invites "ask me to implement", so its missing-`apply` fallback names the CLI rather than a conversation. The guard test now runs the property over every subset that could expose a reference — each workflow alone, everything but one, the empty set, and the two shipped profiles — for skills and commands, in both spellings. Twenty-plus of those cases fail against the previous commit. Only `openspec-onboard` changes in the skills/ mirror: with every workflow installed, all other templates render byte for byte as before. Co-Authored-By: Claude Opus 5 --- skills/openspec-onboard/SKILL.md | 46 +++----- src/core/command-generation/generator.ts | 6 + src/core/shared/skill-generation.ts | 10 +- src/core/templates/optional-workflow.ts | 71 +++++++++++- src/core/templates/workflows/apply-change.ts | 9 +- .../templates/workflows/archive-change.ts | 24 +++- .../workflows/bulk-archive-change.ts | 24 +++- .../templates/workflows/continue-change.ts | 18 ++- src/core/templates/workflows/ff-change.ts | 25 +++- src/core/templates/workflows/new-change.ts | 21 +++- src/core/templates/workflows/onboard.ts | 109 ++++++++++++------ src/core/templates/workflows/propose.ts | 26 ++++- src/core/templates/workflows/update-change.ts | 34 +++++- .../profile-workflow-references.test.ts | 107 +++++++++++++---- test/core/templates/optional-workflow.test.ts | 43 +++++++ test/core/templates/propose.test.ts | 64 ++++++++-- .../templates/skill-templates-parity.test.ts | 70 +++++------ 17 files changed, 551 insertions(+), 156 deletions(-) diff --git a/skills/openspec-onboard/SKILL.md b/skills/openspec-onboard/SKILL.md index fb3f13bec7..06792a98d5 100644 --- a/skills/openspec-onboard/SKILL.md +++ b/skills/openspec-onboard/SKILL.md @@ -472,23 +472,18 @@ This same rhythm works for any size change—a small fix or a major feature. ## Command Reference -**Core workflow:** +**The commands you have installed:** - | Command | What it does | - |-------------------|--------------------------------------------| + | Command | What it does | + |------------------|--------------------------------------------| | `/openspec-propose` | Create a change and generate all artifacts | | `/openspec-explore` | Think through problems before/during work | | `/openspec-apply-change` | Implement tasks from a change | | `/openspec-archive-change` | Archive a completed change | - -**Additional commands** (only if installed - availability depends on your profile): - - | Command | What it does | - |--------------------|----------------------------------------------------------| - | `/openspec-new-change` | Start a new change, step through artifacts one at a time | - | `/openspec-continue-change` | Continue working on an existing change | - | `/openspec-ff-change` | Fast-forward: create all artifacts at once | - | `/openspec-verify-change` | Verify implementation matches artifacts | + | `/openspec-new-change` | Start a new change, one artifact at a time | + | `/openspec-continue-change` | Continue working on an existing change | + | `/openspec-ff-change` | Fast-forward: create all artifacts at once | + | `/openspec-verify-change` | Verify implementation matches artifacts | --- @@ -508,8 +503,8 @@ If the user says they need to stop, want to pause, or seem disengaged: ``` No problem! Your change is saved at the `changeRoot` reported by `openspec status --change "" --json`. -To pick up where we left off later: -- `/openspec-continue-change ` - Resume artifact creation (if installed; otherwise `openspec status --change "" --json` shows the next artifact) +To pick up where we left off later, `openspec status --change "" --json` shows exactly where the change stands. +- `/openspec-continue-change ` - Resume artifact creation - `/openspec-apply-change ` - Jump to implementation (if tasks exist) The work won't be lost. Come back whenever you're ready. @@ -524,23 +519,18 @@ If the user says they just want to see the commands or skip the tutorial: ``` ## OpenSpec Quick Reference -**Core workflow:** +**The commands you have installed:** | Command | What it does | |--------------------------|--------------------------------------------| - | `/openspec-propose ` | Create a change and generate all artifacts | - | `/openspec-explore` | Think through problems (no code changes) | - | `/openspec-apply-change ` | Implement tasks | - | `/openspec-archive-change ` | Archive when done | - -**Additional commands** (only if installed - availability depends on your profile): - - | Command | What it does | - |---------------------------|-------------------------------------| - | `/openspec-new-change ` | Start a new change, step by step | - | `/openspec-continue-change ` | Continue an existing change | - | `/openspec-ff-change ` | Fast-forward: all artifacts at once | - | `/openspec-verify-change ` | Verify implementation | + | `/openspec-propose ` | Create a change and generate all artifacts | + | `/openspec-explore` | Think through problems (no code changes) | + | `/openspec-apply-change ` | Implement tasks | + | `/openspec-archive-change ` | Archive when done | + | `/openspec-new-change ` | Start a new change, step by step | + | `/openspec-continue-change ` | Continue an existing change | + | `/openspec-ff-change ` | Fast-forward: all artifacts at once | + | `/openspec-verify-change ` | Verify implementation | Try `/openspec-propose` to start your first change. ``` diff --git a/src/core/command-generation/generator.ts b/src/core/command-generation/generator.ts index ec445085a4..cedd95463c 100644 --- a/src/core/command-generation/generator.ts +++ b/src/core/command-generation/generator.ts @@ -7,6 +7,7 @@ import type { CommandContent, ToolCommandAdapter, GeneratedCommand } from './types.js'; import { getInvocationForAdapter, needsInvocationRewrite } from './invocation.js'; import { transformCommandInvocations } from '../../utils/command-references.js'; +import { assertWorkflowConditionalsResolved } from '../templates/optional-workflow.js'; /** * Generate a single command file using the provided adapter. @@ -26,6 +27,11 @@ export function generateCommand( content: CommandContent, adapter: ToolCommandAdapter ): GeneratedCommand { + assertWorkflowConditionalsResolved( + content.body, + `Command '${content.id}' was generated without resolving its optional-workflow blocks` + ); + const invocation = getInvocationForAdapter(adapter); const formatted = needsInvocationRewrite(invocation) ? { ...content, body: transformCommandInvocations(content.body, invocation) } diff --git a/src/core/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index 28274e5312..bf3d307ca4 100644 --- a/src/core/shared/skill-generation.ts +++ b/src/core/shared/skill-generation.ts @@ -32,7 +32,10 @@ import { type SkillTemplate, } from '../templates/skill-templates.js'; import type { CommandContent } from '../command-generation/index.js'; -import { resolveOptionalWorkflows } from '../templates/optional-workflow.js'; +import { + assertWorkflowConditionalsResolved, + resolveOptionalWorkflows, +} from '../templates/optional-workflow.js'; import { ALL_WORKFLOWS } from '../profiles.js'; import { OPENSPEC_CLI_ALLOWED_TOOLS } from './allowed-tools.js'; @@ -170,6 +173,11 @@ export function generateSkillContent( ? transformInstructions(template.instructions) : template.instructions; + assertWorkflowConditionalsResolved( + instructions, + `Skill '${template.name}' was generated without resolving its optional-workflow blocks` + ); + return `--- name: ${template.name} description: ${template.description} diff --git a/src/core/templates/optional-workflow.ts b/src/core/templates/optional-workflow.ts index cb0019a3fe..f799f5c333 100644 --- a/src/core/templates/optional-workflow.ts +++ b/src/core/templates/optional-workflow.ts @@ -20,6 +20,15 @@ const OPEN_END = ']]'; const ELSE = '[[opsx:else]]'; const END = '[[opsx:end]]'; +/** + * A conditional that occupies a whole line on its own. Matched first so a + * branch that resolves to empty takes its line with it — otherwise dropping a + * table row or a bullet would leave a blank line behind, which markdown reads + * as the end of the table or list. + */ +const WHOLE_LINE_PATTERN = + /^([ \t]*)\[\[opsx:if-workflow ([a-z-]+)\]\]([^\n]*?)\[\[opsx:else\]\]([^\n]*?)\[\[opsx:end\]\][ \t]*\r?\n/gm; + const CONDITIONAL_PATTERN = /\[\[opsx:if-workflow ([a-z-]+)\]\]([\s\S]*?)\[\[opsx:else\]\]([\s\S]*?)\[\[opsx:end\]\]/g; @@ -47,6 +56,21 @@ export function optionalWorkflow( return `${OPEN}${workflowId}${OPEN_END}${whenInstalled}${ELSE}${whenMissing}${END}`; } +/** + * A passage that is dropped entirely when `workflowId` is not installed. + * + * Use for a line that only makes sense alongside the workflow it names — a + * command-reference table row, a bullet listing one workflow. When the + * conditional is the whole line, the line goes with it rather than leaving a + * blank one behind. + * + * @param workflowId - Workflow id as it appears in ALL_WORKFLOWS + * @param whenInstalled - Text to emit when the workflow is part of the profile + */ +export function onlyWithWorkflow(workflowId: string, whenInstalled: string): string { + return optionalWorkflow(workflowId, whenInstalled, ''); +} + /** * Resolves every `optionalWorkflow()` passage in `text` against the workflows * that will actually be installed. @@ -63,19 +87,54 @@ export function resolveOptionalWorkflows( text: string, installedWorkflows: ReadonlySet ): string { - const resolved = text.replace( + const wholeLinesResolved = text.replace( + WHOLE_LINE_PATTERN, + ( + _match, + indent: string, + workflowId: string, + whenInstalled: string, + whenMissing: string + ) => { + const chosen = installedWorkflows.has(workflowId) ? whenInstalled : whenMissing; + return chosen === '' ? '' : `${indent}${chosen}\n`; + } + ); + + const resolved = wholeLinesResolved.replace( CONDITIONAL_PATTERN, (_match, workflowId: string, whenInstalled: string, whenMissing: string) => installedWorkflows.has(workflowId) ? whenInstalled : whenMissing ); - const residual = RESIDUAL_MARKER_PATTERN.exec(resolved); + assertWorkflowConditionalsResolved( + resolved, + 'Malformed optional-workflow conditional' + ); + + return resolved; +} + +/** + * Fails loudly if `text` still carries a conditional marker. + * + * Called at the end of resolution to catch a malformed block, and again at the + * points that write a generated file — so a body that skipped resolution + * altogether (a generation path that bypassed getSkillTemplates / + * getCommandTemplates) throws instead of shipping literal markers to a user. + * + * @param text - Text about to be written, or just resolved + * @param reason - What went wrong, used as the message prefix + * @throws If any `[[opsx:...]]` marker remains + */ +export function assertWorkflowConditionalsResolved(text: string, reason: string): void { + const residual = RESIDUAL_MARKER_PATTERN.exec(text); if (residual) { throw new Error( - `Malformed optional-workflow conditional: '${residual[0]}' survived resolution. ` + - 'Each block needs the full [[opsx:if-workflow ]] ... [[opsx:else]] ... [[opsx:end]] form.' + `${reason}: '${residual[0]}' is unresolved. Optional-workflow blocks are ` + + 'resolved by getSkillTemplates()/getCommandTemplates() against the installed ' + + 'workflow set, and each needs the full [[opsx:if-workflow ]] ... ' + + '[[opsx:else]] ... [[opsx:end]] form.' ); } - - return resolved; } diff --git a/src/core/templates/workflows/apply-change.ts b/src/core/templates/workflows/apply-change.ts index dc10d494de..3cffefe8aa 100644 --- a/src/core/templates/workflows/apply-change.ts +++ b/src/core/templates/workflows/apply-change.ts @@ -19,6 +19,13 @@ const BLOCKED_STATE_HANDOFF = optionalWorkflow( 'run `openspec status --change "" --json` to see the next artifact and `openspec instructions --change "" --json` for how to create it' ); +/** The archive handoff shown once every task is done. */ +const ARCHIVE_HANDOFF = optionalWorkflow( + 'archive', + 'You can archive this change with `/opsx:archive`.', + 'You can archive this change by running `openspec archive ""`.' +); + /** * The apply workflow instructions, authored once and rendered by both the * skill and command surfaces. The surfaces are intentionally distinct, but @@ -159,7 +166,7 @@ Working on task 4/7: - [x] Task 2 ... -All tasks complete! You can archive this change with \`/opsx:archive\`. +All tasks complete! ${ARCHIVE_HANDOFF} \`\`\` **Output On Pause (Issue Encountered)** diff --git a/src/core/templates/workflows/archive-change.ts b/src/core/templates/workflows/archive-change.ts index 2dae74d436..3f4b496725 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -5,8 +5,28 @@ * 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'; +/** + * Archiving must merge delta specs into the main specs; the `sync` workflow is + * how it normally does that. A profile that selects `archive` gets `sync` + * injected (see getProfileWorkflows), but an install whose workflow set was + * read back off disk can still be missing it — in which case the merge has to + * happen inline rather than be handed to a workflow that is not there. + */ +const SYNC_INLINE_HANDOFF = optionalWorkflow( + 'sync', + 'run the `/opsx:sync` workflow inline (agent-driven intelligent merge)', + 'perform the delta-to-main-spec merge inline yourself (agent-driven intelligent merge)' +); + +const SYNC_GUARDRAIL = optionalWorkflow( + 'sync', + 'run the `/opsx:sync` workflow inline (agent-driven)', + 'perform the delta-to-main-spec merge inline (agent-driven)' +); + export function getArchiveChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-archive-change', @@ -300,7 +320,7 @@ ${STORE_SELECTION_GUIDANCE} form of main specs produced by this merge; do not use them as archive guidance, change CLI behavior, or copy the rule text into any output file. - Then run the \`/opsx:sync\` workflow inline (agent-driven intelligent merge) for change '', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result. + Then ${SYNC_INLINE_HANDOFF} for change '', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result. Then re-run the comparison from the top of this step against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: - ADDED requirements present @@ -402,7 +422,7 @@ Target archive directory already exists. - Don't block archive on warnings - just inform and confirm - Preserve .openspec.yaml when moving to archive (it moves with the directory) - Show clear summary of what happened -- If sync is requested, run the \`/opsx:sync\` workflow inline (agent-driven) +- If sync is requested, ${SYNC_GUARDRAIL} - Never archive while a spec sync is still in flight — run the sync inline and verify the main specs before moving \`changeRoot\` - If delta specs exist, always run the sync assessment and show the combined summary before prompting - Apply relevant runtime context and report conflicts; operation guidance remains advisory diff --git a/src/core/templates/workflows/bulk-archive-change.ts b/src/core/templates/workflows/bulk-archive-change.ts index cacede2543..48bc1b9403 100644 --- a/src/core/templates/workflows/bulk-archive-change.ts +++ b/src/core/templates/workflows/bulk-archive-change.ts @@ -5,8 +5,28 @@ * 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'; +/** + * Archiving must merge delta specs into the main specs; the `sync` workflow is + * how it normally does that. A profile that selects `archive` gets `sync` + * injected (see getProfileWorkflows), but an install whose workflow set was + * read back off disk can still be missing it — in which case the merge has to + * happen inline rather than be handed to a workflow that is not there. + */ +const SYNC_INLINE_HANDOFF = optionalWorkflow( + 'sync', + 'Run the `/opsx:sync` workflow inline (agent-driven intelligent merge)', + 'Perform the delta-to-main-spec merge inline yourself (agent-driven intelligent merge)' +); + +const SYNC_GUARDRAIL = optionalWorkflow( + 'sync', + 'run the `/opsx:sync` workflow inline (agent-driven)', + 'perform the delta-to-main-spec merge inline (agent-driven)' +); + export function getBulkArchiveChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-bulk-archive-change', @@ -519,7 +539,7 @@ ${STORE_SELECTION_GUIDANCE} Process changes in the determined order (respecting conflict resolution): a. **Sync included delta specs**: - - Run the \`/opsx:sync\` workflow inline (agent-driven intelligent merge) only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish. + - ${SYNC_INLINE_HANDOFF} only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish. - For conflicts, apply in resolved order. - Pass that change's fetched specs-rule snapshot into inline sync; inline sync must reuse it without fetching instructions again @@ -664,7 +684,7 @@ No active changes found. Create a new change to get started. - Preserve .openspec.yaml when moving to archive - Archive directory target uses current date: YYYY-MM-DD-; a name that already starts with a \`YYYY-MM-DD-\` prefix is used as-is (never stack a second date) - If archive target exists, fail that change but continue with others -- If sync is requested, run the \`/opsx:sync\` workflow inline (agent-driven) for each change with included delta specs +- If sync is requested, ${SYNC_GUARDRAIL} for each change with included delta specs - Carry the per-delta \`includedDeltas\` and \`excludedDeltas\` decisions into execution; sync and verify only included deltas - Report every excluded delta as \`sync skipped\` without treating the archive itself as skipped - Never archive a change while a spec sync is still in flight — run the sync inline and verify main specs at \`/openspec/specs//spec.md\` before moving \`changeRoot\` diff --git a/src/core/templates/workflows/continue-change.ts b/src/core/templates/workflows/continue-change.ts index 14b3109e43..9b19b3ed32 100644 --- a/src/core/templates/workflows/continue-change.ts +++ b/src/core/templates/workflows/continue-change.ts @@ -5,8 +5,24 @@ * 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'; +/** + * The planning-complete handoff. Neither `apply` nor `archive` is guaranteed + * to be installed, so each half is resolved at generation time (see + * optional-workflow.ts). + */ +const PLANNING_COMPLETE_HANDOFF = optionalWorkflow( + 'apply', + 'You can now implement this change with `/opsx:apply`.', + 'You can now implement this change - `openspec instructions apply --change "" --json` returns the tasks and how to work them.' +) + ' ' + optionalWorkflow( + 'archive', + 'Once implementation and any tracked work are complete, archive it with `/opsx:archive`.', + 'Once implementation and any tracked work are complete, archive it with `openspec archive ""`.' +); + export function getContinueChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-continue-change', @@ -171,7 +187,7 @@ ${STORE_SELECTION_GUIDANCE} **If all planning artifacts are complete (\`isPlanningComplete: true\`, or legacy \`isComplete: true\`)**: - Congratulate the user - Show final status including the schema used - - Suggest: "Planning is complete! You can now implement this change with \`/opsx:apply\`. Once implementation and any tracked work are complete, archive it with \`/opsx:archive\`." + - Suggest: "Planning is complete! ${PLANNING_COMPLETE_HANDOFF}" - STOP --- diff --git a/src/core/templates/workflows/ff-change.ts b/src/core/templates/workflows/ff-change.ts index 843fb79c87..c7c179be72 100644 --- a/src/core/templates/workflows/ff-change.ts +++ b/src/core/templates/workflows/ff-change.ts @@ -5,8 +5,29 @@ * 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'; +/** + * The implementation handoff, resolved at generation time so a profile + * without `apply` is not told to run it (see optional-workflow.ts). + * + * The two surfaces word this differently on purpose (#258): a command-only + * tool has no conversational agent to ask, so its prompt names a command or + * the CLI and never invites "ask me to implement". + */ +const SKILL_APPLY_HANDOFF = optionalWorkflow( + 'apply', + 'Run `/opsx:apply` or ask me to implement to start working on the tasks.', + 'Ask me to implement to start working on the tasks.' +); + +const COMMAND_APPLY_HANDOFF = optionalWorkflow( + 'apply', + 'Run `/opsx:apply` to start implementing.', + 'Run `openspec instructions apply --change "" --json` to get the task list and start implementing.' +); + export function getFfChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-ff-change', @@ -97,7 +118,7 @@ After completing all artifacts, summarize: - Change name and location - List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why - What's ready: "All artifacts needed for implementation are ready." -- Prompt: "Run \`/opsx:apply\` or ask me to implement to start working on the tasks." +- Prompt: "${SKILL_APPLY_HANDOFF}" **Artifact Creation Guidelines** @@ -214,7 +235,7 @@ After completing all artifacts, summarize: - Change name and location - List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why - What's ready: "All artifacts needed for implementation are ready." -- Prompt: "Run \`/opsx:apply\` to start implementing." +- Prompt: "${COMMAND_APPLY_HANDOFF}" **Artifact Creation Guidelines** diff --git a/src/core/templates/workflows/new-change.ts b/src/core/templates/workflows/new-change.ts index e45858abbc..502b3a289a 100644 --- a/src/core/templates/workflows/new-change.ts +++ b/src/core/templates/workflows/new-change.ts @@ -5,8 +5,25 @@ * 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'; +/** + * Handoffs to `continue`, which is not guaranteed to be installed alongside + * `new`; resolved at generation time (see optional-workflow.ts). + */ +const FIRST_ARTIFACT_PROMPT = optionalWorkflow( + 'continue', + 'Run `/opsx:continue` or just describe what this change is about and I\'ll draft it.', + 'Just describe what this change is about and I\'ll draft it.' +); + +const EXISTING_CHANGE_HINT = optionalWorkflow( + 'continue', + 'suggest using `/opsx:continue` instead', + 'say so and ask whether to resume that change or pick a different name' +); + export function getNewChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-new-change', @@ -144,13 +161,13 @@ After completing the steps, summarize: - Schema/workflow being used and its artifact sequence - Current status (0/N artifacts complete) - The template for the first artifact -- Prompt: "Ready to create the first artifact? Run \`/opsx:continue\` or just describe what this change is about and I'll draft it." +- Prompt: "Ready to create the first artifact? ${FIRST_ARTIFACT_PROMPT}" **Guardrails** - Do NOT create any artifacts yet - just show the instructions - Do NOT advance beyond showing the first artifact template - If the name is invalid (not kebab-case), ask for a valid name -- If a change with that name already exists, suggest using \`/opsx:continue\` instead +- If a change with that name already exists, ${EXISTING_CHANGE_HINT} - Pass --schema if using a non-default workflow` }; } diff --git a/src/core/templates/workflows/onboard.ts b/src/core/templates/workflows/onboard.ts index 414c6e18b5..1e74170d81 100644 --- a/src/core/templates/workflows/onboard.ts +++ b/src/core/templates/workflows/onboard.ts @@ -5,8 +5,70 @@ * templates file into workflow-focused modules. */ import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { onlyWithWorkflow, optionalWorkflow } from '../optional-workflow.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +/** + * The tutorial names other workflows throughout. Which of them exist depends + * on the profile, so each mention is resolved at generation time (see + * optional-workflow.ts) instead of being listed with an "if installed" caveat + * the reader has to check for themselves. + */ +const EXPLORE_MODE_NOTE = optionalWorkflow( + 'explore', + 'Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.', + 'Investigating before implementing is worth doing whenever a problem needs thinking through.' +); + +/** + * The command-reference tables. Every row is dropped along with its line when + * the profile does not install that workflow, so the table lists exactly the + * commands the reader can run — and stays a valid table either way. + */ +const COMMAND_REFERENCE_ROWS = [ + onlyWithWorkflow('propose', ' | `/opsx:propose` | Create a change and generate all artifacts |'), + onlyWithWorkflow('explore', ' | `/opsx:explore` | Think through problems before/during work |'), + onlyWithWorkflow('apply', ' | `/opsx:apply` | Implement tasks from a change |'), + onlyWithWorkflow('archive', ' | `/opsx:archive` | Archive a completed change |'), + onlyWithWorkflow('new', ' | `/opsx:new` | Start a new change, one artifact at a time |'), + onlyWithWorkflow('continue', ' | `/opsx:continue` | Continue working on an existing change |'), + onlyWithWorkflow('ff', ' | `/opsx:ff` | Fast-forward: create all artifacts at once |'), + onlyWithWorkflow('verify', ' | `/opsx:verify` | Verify implementation matches artifacts |'), +].join('\n'); + +const QUICK_REFERENCE_ROWS = [ + onlyWithWorkflow('propose', ' | `/opsx:propose ` | Create a change and generate all artifacts |'), + onlyWithWorkflow('explore', ' | `/opsx:explore` | Think through problems (no code changes) |'), + onlyWithWorkflow('apply', ' | `/opsx:apply ` | Implement tasks |'), + onlyWithWorkflow('archive', ' | `/opsx:archive ` | Archive when done |'), + onlyWithWorkflow('new', ' | `/opsx:new ` | Start a new change, step by step |'), + onlyWithWorkflow('continue', ' | `/opsx:continue ` | Continue an existing change |'), + onlyWithWorkflow('ff', ' | `/opsx:ff ` | Fast-forward: all artifacts at once |'), + onlyWithWorkflow('verify', ' | `/opsx:verify ` | Verify implementation |'), +].join('\n'); + +/** + * Resume hints for a user stopping mid-tutorial. Both are optional, so the + * sentence that introduces them stands on its own without either. + */ +const RESUME_HINTS = [ + onlyWithWorkflow('continue', '- `/opsx:continue ` - Resume artifact creation'), + onlyWithWorkflow('apply', '- `/opsx:apply ` - Jump to implementation (if tasks exist)'), +].join('\n'); + +/** Where the tutorial points once it is over. */ +const NEXT_STEP_INVITE = optionalWorkflow( + 'propose', + 'Try `/opsx:propose` on something you actually want to build. You\'ve got the rhythm now!', + 'Try this on something you actually want to build. You\'ve got the rhythm now!' +); + +const QUICK_REFERENCE_INVITE = optionalWorkflow( + 'propose', + 'Try `/opsx:propose` to start your first change.', + 'Ask me to start your first change whenever you are ready.' +); + export function getOnboardSkillTemplate(): SkillTemplate { return { name: 'openspec-onboard', @@ -164,7 +226,7 @@ Spend 1-2 minutes investigating the relevant code: │ [Optional: ASCII diagram if helpful] │ └─────────────────────────────────────────┘ -Explore mode (\`/opsx:explore\`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem. +${EXPLORE_MODE_NOTE} Now let's create a change to hold our work. \`\`\` @@ -482,29 +544,17 @@ This same rhythm works for any size change—a small fix or a major feature. ## Command Reference -**Core workflow:** - - | Command | What it does | - |-------------------|--------------------------------------------| - | \`/opsx:propose\` | Create a change and generate all artifacts | - | \`/opsx:explore\` | Think through problems before/during work | - | \`/opsx:apply\` | Implement tasks from a change | - | \`/opsx:archive\` | Archive a completed change | +**The commands you have installed:** -**Additional commands** (only if installed - availability depends on your profile): - - | Command | What it does | - |--------------------|----------------------------------------------------------| - | \`/opsx:new\` | Start a new change, step through artifacts one at a time | - | \`/opsx:continue\` | Continue working on an existing change | - | \`/opsx:ff\` | Fast-forward: create all artifacts at once | - | \`/opsx:verify\` | Verify implementation matches artifacts | + | Command | What it does | + |------------------|--------------------------------------------| +${COMMAND_REFERENCE_ROWS} --- ## What's Next? -Try \`/opsx:propose\` on something you actually want to build. You've got the rhythm now! +${NEXT_STEP_INVITE} \`\`\` --- @@ -518,9 +568,8 @@ If the user says they need to stop, want to pause, or seem disengaged: \`\`\` No problem! Your change is saved at the \`changeRoot\` reported by \`openspec status --change "" --json\`. -To pick up where we left off later: -- \`/opsx:continue \` - Resume artifact creation (if installed; otherwise \`openspec status --change "" --json\` shows the next artifact) -- \`/opsx:apply \` - Jump to implementation (if tasks exist) +To pick up where we left off later, \`openspec status --change "" --json\` shows exactly where the change stands. +${RESUME_HINTS} The work won't be lost. Come back whenever you're ready. \`\`\` @@ -534,25 +583,13 @@ If the user says they just want to see the commands or skip the tutorial: \`\`\` ## OpenSpec Quick Reference -**Core workflow:** +**The commands you have installed:** | Command | What it does | |--------------------------|--------------------------------------------| - | \`/opsx:propose \` | Create a change and generate all artifacts | - | \`/opsx:explore\` | Think through problems (no code changes) | - | \`/opsx:apply \` | Implement tasks | - | \`/opsx:archive \` | Archive when done | - -**Additional commands** (only if installed - availability depends on your profile): - - | Command | What it does | - |---------------------------|-------------------------------------| - | \`/opsx:new \` | Start a new change, step by step | - | \`/opsx:continue \` | Continue an existing change | - | \`/opsx:ff \` | Fast-forward: all artifacts at once | - | \`/opsx:verify \` | Verify implementation | +${QUICK_REFERENCE_ROWS} -Try \`/opsx:propose\` to start your first change. +${QUICK_REFERENCE_INVITE} \`\`\` Exit gracefully. diff --git a/src/core/templates/workflows/propose.ts b/src/core/templates/workflows/propose.ts index 54ceda16c7..e09e4c774e 100644 --- a/src/core/templates/workflows/propose.ts +++ b/src/core/templates/workflows/propose.ts @@ -5,8 +5,30 @@ * 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'; +/** + * The implementation handoff. `apply` is not guaranteed to be installed, so + * the prompt is resolved at generation time (see optional-workflow.ts) rather + * than naming a workflow that may not exist. + * + * The two surfaces word this differently on purpose (#258): a command-only + * tool has no conversational agent to ask, so its prompt names a command or + * the CLI and never invites "ask me to implement". + */ +const SKILL_APPLY_HANDOFF = optionalWorkflow( + 'apply', + 'run `/opsx:apply` or ask me to apply this change', + 'ask me to apply this change' +); + +const COMMAND_APPLY_HANDOFF = optionalWorkflow( + 'apply', + 'run `/opsx:apply`', + 'run `openspec instructions apply --change "" --json` to get the tasks' +); + export function getOpsxProposeSkillTemplate(): SkillTemplate { return { name: 'openspec-propose', @@ -132,7 +154,7 @@ After completing all artifacts, summarize: - Change name and location - List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why - What's ready: "All artifacts needed for implementation are ready." -- Prompt: "The artifacts are ready for review. When you are ready, run \`/opsx:apply\` or ask me to apply this change." +- Prompt: "The artifacts are ready for review. When you are ready, ${SKILL_APPLY_HANDOFF}." **Artifact Creation Guidelines** @@ -285,7 +307,7 @@ After completing all artifacts, summarize: - Change name and location - List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why - What's ready: "All artifacts needed for implementation are ready." -- Prompt: "The artifacts are ready for review. When you are ready, run \`/opsx:apply\`." +- Prompt: "The artifacts are ready for review. When you are ready, ${COMMAND_APPLY_HANDOFF}." **Artifact Creation Guidelines** diff --git a/src/core/templates/workflows/update-change.ts b/src/core/templates/workflows/update-change.ts index 1cebfc4e1a..2a3c02ba73 100644 --- a/src/core/templates/workflows/update-change.ts +++ b/src/core/templates/workflows/update-change.ts @@ -44,6 +44,28 @@ const CONTINUE_FRONTIER = optionalWorkflow( 'creating them is a separate step, outside this workflow' ); +/** + * `apply` and `archive` are in the `core` profile but not guaranteed in a + * custom one, so their handoffs are resolved the same way. + */ +const APPLY_DELTA_HANDOFF = optionalWorkflow( + 'apply', + 'suggest `/opsx:apply` to carry the delta into code', + 'say that the code may need updating and offer to carry the delta into it' +); + +const APPLY_GUARDRAIL = optionalWorkflow( + 'apply', + 'stop and point to `/opsx:apply`', + 'stop and say that the revised plan now implies code changes; implementing them is a separate step' +); + +const ARCHIVE_HANDOFF = optionalWorkflow( + 'archive', + 'suggest `/opsx:archive`', + 'suggest archiving with `openspec archive ""`' +); + const INTENT_CHANGE_GUARDRAIL = optionalWorkflow( 'new', 'recommend starting fresh with `/opsx:new` (the "Update vs. Start Fresh" heuristic)', @@ -116,8 +138,8 @@ ${CONTINUE_SCOPE_NOTE} 6. **Point to the next step (guidance only - NEVER act on it)** - Artifacts still missing -> ${CONTINUE_NEXT_STEP}. - - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest \`/opsx:apply\` to carry the delta into code. - - Everything done and implemented -> suggest \`/opsx:archive\`. + - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; ${APPLY_DELTA_HANDOFF}. + - Everything done and implemented -> ${ARCHIVE_HANDOFF}. **Output** @@ -127,7 +149,7 @@ After each invocation, show: - Where the change stands and the recommended next command **Guardrails** -- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to \`/opsx:apply\`. +- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, ${APPLY_GUARDRAIL}. - Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names. - Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`. - Do not advance the build frontier: no new artifacts, no new files under glob artifacts - ${CONTINUE_FRONTIER}. @@ -207,8 +229,8 @@ ${CONTINUE_SCOPE_NOTE} 6. **Point to the next step (guidance only - NEVER act on it)** - Artifacts still missing -> ${CONTINUE_NEXT_STEP}. - - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest \`/opsx:apply\` to carry the delta into code. - - Everything done and implemented -> suggest \`/opsx:archive\`. + - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; ${APPLY_DELTA_HANDOFF}. + - Everything done and implemented -> ${ARCHIVE_HANDOFF}. **Output** @@ -218,7 +240,7 @@ After each invocation, show: - Where the change stands and the recommended next command **Guardrails** -- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to \`/opsx:apply\`. +- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, ${APPLY_GUARDRAIL}. - Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names. - Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`. - Do not advance the build frontier: no new artifacts, no new files under glob artifacts - ${CONTINUE_FRONTIER}. diff --git a/test/core/shared/profile-workflow-references.test.ts b/test/core/shared/profile-workflow-references.test.ts index 5259ff8c2f..3ef2c50b65 100644 --- a/test/core/shared/profile-workflow-references.test.ts +++ b/test/core/shared/profile-workflow-references.test.ts @@ -13,50 +13,109 @@ import { import { transformToSkillReferences } from '../../../src/utils/command-references.js'; /** - * The default `core` profile installs six of the twelve workflows. Anything it - * generates that names one of the other six sends the agent to a skill that was - * never written — a dead end the agent cannot recover from (#1734, umbrella - * #919). Assert it on the generated bytes, both spellings: the canonical + * Nothing OpenSpec generates may name a workflow the installation does not + * have. The reference resolves to a skill that was never written, so the agent + * is sent somewhere it cannot go and the flow dead-ends (#1734, umbrella #919). + * + * The default `core` profile is the case that shipped broken, but arbitrary + * subsets are reachable too: a `custom` profile is whatever workflows the user + * picked, and `openspec update` re-derives a set from what it finds on disk + * (legacy tool overrides, inferred Codex workflows) without passing it through + * getProfileWorkflows. So assert the property over a family of subsets rather + * than over one profile. + * + * Assertions run on the generated bytes, in both spellings: the canonical * `/opsx:` a command file carries and the `/openspec-` a skills-only * install carries. */ -const coreWorkflows = getProfileWorkflows('core'); -const uninstalled = ALL_WORKFLOWS.filter((id) => !coreWorkflows.includes(id)); - const skillNameFor = (id: string) => transformToSkillReferences(`/opsx:${id}`); -describe('core profile generates no reference to an uninstalled workflow', () => { - it('resolves to exactly the six core workflows', () => { - expect([...coreWorkflows].sort()).toEqual([...CORE_WORKFLOWS].sort()); - expect(uninstalled).toContain('continue'); - expect(uninstalled).toContain('new'); +/** + * Every subset that could expose a reference: + * - each workflow alone: nothing may name anything but itself; + * - everything but one: catches a reference hidden inside another workflow's + * installed branch; + * - the empty set: catches a reference hidden in a *fallback* branch; + * - the two shipped profiles. + */ +const SUBSETS: Array<[string, readonly string[]]> = [ + ['core profile', getProfileWorkflows('core')], + ['every workflow', ALL_WORKFLOWS], + ['no workflows', []], + ...ALL_WORKFLOWS.map( + (id) => [`only ${id}`, [id]] as [string, readonly string[]] + ), + ...ALL_WORKFLOWS.map( + (id) => + [`every workflow but ${id}`, ALL_WORKFLOWS.filter((w) => w !== id)] as [ + string, + readonly string[], + ] + ), +]; + +describe('generated files never name an uninstalled workflow', () => { + it('covers the core profile and every single-workflow-difference subset', () => { + expect([...getProfileWorkflows('core')].sort()).toEqual([...CORE_WORKFLOWS].sort()); + expect(SUBSETS).toHaveLength(3 + ALL_WORKFLOWS.length * 2); }); - it('holds for every generated skill body', () => { - for (const { template, dirName } of getSkillTemplates(coreWorkflows)) { + it.each(SUBSETS)('holds for the skills generated with %s', (_label, workflows) => { + const installed = new Set(workflows); + const absent = ALL_WORKFLOWS.filter((id) => !installed.has(id)); + + for (const { template, dirName } of getSkillTemplates(workflows)) { const content = generateSkillContent(template, 'TEST'); const skillsOnly = transformToSkillReferences(content); - for (const id of uninstalled) { + for (const id of absent) { expect(content, `${dirName} -> /opsx:${id}`).not.toContain(`/opsx:${id}`); - expect(skillsOnly, `${dirName} -> ${skillNameFor(id)}`).not.toContain(skillNameFor(id)); + expect(skillsOnly, `${dirName} -> ${skillNameFor(id)}`).not.toContain( + skillNameFor(id) + ); } } }); - it('holds for every generated command body', () => { - for (const { id: commandId, body } of getCommandContents(coreWorkflows)) { - for (const id of uninstalled) { + it.each(SUBSETS)('holds for the commands generated with %s', (_label, workflows) => { + const installed = new Set(workflows); + const absent = ALL_WORKFLOWS.filter((id) => !installed.has(id)); + + for (const { id: commandId, body } of getCommandContents(workflows)) { + for (const id of absent) { expect(body, `${commandId} -> /opsx:${id}`).not.toContain(`/opsx:${id}`); } } }); - it('still names the optional workflows when the profile installs them', () => { - const expanded = getSkillTemplates(ALL_WORKFLOWS); - const update = expanded.find(({ workflowId }) => workflowId === 'update'); + // The mirror image: dropping a reference must not drop the handoff itself. + // With every workflow installed, every reference that exists is emitted. + // `bulk-archive` is the one workflow nothing points at - it is reached from + // the CLI, not from another workflow. + it('still names every referenced workflow when the profile installs them', () => { + const bodies = [ + ...getSkillTemplates(ALL_WORKFLOWS).map( + ({ dirName, template }) => [dirName, template.instructions] as const + ), + ...getCommandContents(ALL_WORKFLOWS).map(({ id, body }) => [id, body] as const), + ]; + const named = new Set(); + + for (const [, body] of bodies) { + for (const id of ALL_WORKFLOWS) { + if (body.includes(`/opsx:${id}`)) named.add(id); + } + } + + expect([...named].sort()).toEqual( + [...ALL_WORKFLOWS].filter((id) => id !== 'bulk-archive').sort() + ); + }); - expect(update?.template.instructions).toContain('/opsx:continue'); - expect(update?.template.instructions).toContain('/opsx:new'); + // Archiving merges delta specs into main specs, which is the sync workflow's + // job. Selecting archive in a custom profile pulls sync in with it. + it('injects sync for a custom profile that selects archive', () => { + expect(getProfileWorkflows('custom', ['archive'])).toContain('sync'); + expect(getProfileWorkflows('custom', ['bulk-archive'])).toContain('sync'); }); }); diff --git a/test/core/templates/optional-workflow.test.ts b/test/core/templates/optional-workflow.test.ts index 660450086a..42adfb52f8 100644 --- a/test/core/templates/optional-workflow.test.ts +++ b/test/core/templates/optional-workflow.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + onlyWithWorkflow, optionalWorkflow, resolveOptionalWorkflows, } from '../../../src/core/templates/optional-workflow.js'; @@ -42,6 +43,48 @@ describe('optionalWorkflow / resolveOptionalWorkflows', () => { expect(resolveOptionalWorkflows(text, installed())).toBe(text); }); + // A dropped table row must take its line with it. A blank line left behind + // ends the table in markdown, so the rows after it stop rendering as a table. + it('removes the whole line when a line-level conditional resolves to empty', () => { + const table = [ + '| Command | What it does |', + '|---------|--------------|', + onlyWithWorkflow('propose', '| `/opsx:propose` | Start a change |'), + onlyWithWorkflow('ff', '| `/opsx:ff` | Fast-forward |'), + onlyWithWorkflow('apply', '| `/opsx:apply` | Implement tasks |'), + '', + 'Done.', + ].join('\n'); + + expect(resolveOptionalWorkflows(table, installed('propose', 'apply'))).toBe( + [ + '| Command | What it does |', + '|---------|--------------|', + '| `/opsx:propose` | Start a change |', + '| `/opsx:apply` | Implement tasks |', + '', + 'Done.', + ].join('\n') + ); + }); + + it('keeps the indentation of a line-level conditional it keeps', () => { + const text = `intro\n ${onlyWithWorkflow('apply', '- run `/opsx:apply`')}\nouttro`; + + expect(resolveOptionalWorkflows(text, installed('apply'))).toBe( + 'intro\n - run `/opsx:apply`\nouttro' + ); + expect(resolveOptionalWorkflows(text, installed())).toBe('intro\nouttro'); + }); + + // Only a conditional that owns its whole line takes the line with it; one + // that sits inside a sentence must not swallow the text around it. + it('leaves the surrounding line intact for an inline conditional', () => { + const text = `Next: ${onlyWithWorkflow('apply', 'run `/opsx:apply`')}.`; + + expect(resolveOptionalWorkflows(text, installed())).toBe('Next: .'); + }); + // A branch that is dropped must leave nothing behind: a surviving marker // would ship as literal noise in a generated SKILL.md. it('throws on a malformed block rather than emitting a marker', () => { diff --git a/test/core/templates/propose.test.ts b/test/core/templates/propose.test.ts index 8f8e77a49a..4bf65d289b 100644 --- a/test/core/templates/propose.test.ts +++ b/test/core/templates/propose.test.ts @@ -17,19 +17,37 @@ import { getInvocationForAdapter, } from '../../../src/core/command-generation/invocation.js'; import { getCommandContents } from '../../../src/core/shared/skill-generation.js'; +import { resolveOptionalWorkflows } from '../../../src/core/templates/optional-workflow.js'; +import { ALL_WORKFLOWS } from '../../../src/core/profiles.js'; + +// Templates carry optional-workflow conditionals; a body only means anything +// once resolved against a workflow set. Unless a test says otherwise, these are +// the bodies a profile with every workflow installed receives. +const withAll = (body: string) => + resolveOptionalWorkflows(body, new Set(ALL_WORKFLOWS)); +const withoutApply = (body: string) => + resolveOptionalWorkflows( + body, + new Set(ALL_WORKFLOWS.filter((id) => id !== 'apply')) + ); + +const proposeSkillBody = withAll(getOpsxProposeSkillTemplate().instructions); +const proposeCommandBody = withAll(getOpsxProposeCommandTemplate().content); +const asDeployed = (template: T): T => ({ + ...template, + instructions: withAll(template.instructions), +}); -const proposeSkillBody = getOpsxProposeSkillTemplate().instructions; -const proposeCommandBody = getOpsxProposeCommandTemplate().content; const proposeBodies: Array<[string, string]> = [ - ['propose skill', generateSkillContent(getOpsxProposeSkillTemplate(), 'TEST')], - ['propose command', getOpsxProposeCommandTemplate().content], + ['propose skill', generateSkillContent(asDeployed(getOpsxProposeSkillTemplate()), 'TEST')], + ['propose command', proposeCommandBody], ]; // ff runs the byte-identical artifact loop, so it carries the identical guards. const loopBodies: Array<[string, string]> = [ ...proposeBodies, - ['ff skill', getFfChangeSkillTemplate().instructions], - ['ff command', getOpsxFfCommandTemplate().content], + ['ff skill', withAll(getFfChangeSkillTemplate().instructions)], + ['ff command', withAll(getOpsxFfCommandTemplate().content)], ]; const repoRoot = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../../..'); @@ -122,7 +140,9 @@ describe('planning code inspection (#339)', () => { }); it('preserves inspection guidance through every command adapter', () => { - for (const command of getCommandContents(['propose', 'ff'])) { + for (const command of getCommandContents(ALL_WORKFLOWS).filter(({ id }) => + ['propose', 'ff'].includes(id) + )) { for (const adapter of CommandAdapterRegistry.getAll()) { const generated = generateCommand(command, adapter).fileContent; const inspection = generated.indexOf('**Inspect the relevant project before drafting**'); @@ -198,8 +218,36 @@ describe('propose implementation boundary', () => { expect(proposeSkillBody).not.toContain('ask me to implement'); }); + // The same boundary has to hold when `apply` is not installed: the command + // surface may name the CLI, never a conversational handoff (#1734). + it('keeps command-only tools off direct coding when apply is not installed', () => { + const command = withoutApply(getOpsxProposeCommandTemplate().content); + const skill = withoutApply(getOpsxProposeSkillTemplate().instructions); + const ffCommand = withoutApply(getOpsxFfCommandTemplate().content); + + for (const body of [command, skill, ffCommand]) { + expect(body).not.toContain('/opsx:apply'); + } + + expect(command).toContain( + 'run `openspec instructions apply --change "" --json` to get the tasks' + ); + expect(command).not.toContain('ask me to implement'); + expect(command).not.toContain('ask me to apply this change'); + + expect(ffCommand).toContain( + 'Run `openspec instructions apply --change "" --json` to get the task list' + ); + expect(ffCommand).not.toContain('ask me to implement'); + + expect(skill).toContain('ask me to apply this change'); + expect(skill).not.toContain('ask me to implement'); + }); + it('preserves both boundaries through every command adapter', () => { - const propose = getCommandContents(['propose'])[0]; + // Resolve against every workflow: this asserts the apply handoff, which + // is only emitted when `apply` is installed. + const propose = getCommandContents(ALL_WORKFLOWS).find(({ id }) => id === 'propose'); expect(propose?.id).toBe('propose'); for (const adapter of CommandAdapterRegistry.getAll()) { diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 8ad8bf611e..e2d67503a0 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -57,28 +57,28 @@ const EXPECTED_FUNCTION_HASHES: Record = { getExploreSkillTemplate: '6315fcc5c2eb848963bc8bca4c23e657412a99608e610daee59fb4e58cd21fd4', getNewChangeSkillTemplate: 'eabd1e895c5881dcb17dcbaa3fb26098dd59e8eacb318e400820b4dc811ef781', getContinueChangeSkillTemplate: '012136f6411a99c8fa228e2f9444cb64b0a89e0f56fdeac2fe03b2f5bee0c5d7', - getApplyChangeSkillTemplate: '2479f540b86025fe33d1e7b4350e2af5ae63d82cdd919f26b840b6cacb6aa243', - getFfChangeSkillTemplate: 'efa6a70c111b18b61a7720250b9622afa9a212fb64edf609cf80e2182a9bdf8c', + getApplyChangeSkillTemplate: 'a609be94a08110cbf5ce66fb60d038afa848efafdf9118f242106e2c89cf361f', + getFfChangeSkillTemplate: 'dbf062f7018309bfd89993d215017964cc82b7eab413e8318051cd9421589da4', getSyncSpecsSkillTemplate: 'b099e2ff31859c9b10d928066e662524f9aad9ecf2be12fceacb732d718c4146', - getOnboardSkillTemplate: '3a836faae463d88c289a1c129cb7ee556a563b7e53e1a52a4711ff152a3b51f7', + getOnboardSkillTemplate: '3549e6a34a59ff5a11cdabf0edfdac2e9171158dd78b83045505f5ec7c7b83bf', getOpsxExploreCommandTemplate: 'b4706a5b8fd280f7929eea610ecc9d41676b2d2dd6653d259cbbc2bfe01813d9', - getOpsxNewCommandTemplate: 'f2d30e569798a4c92ba932859d6ba4e0ad10e18feccbade1cfee0957597b3463', - getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', - getOpsxApplyCommandTemplate: 'e146555c8e1104f00fbbd04d308b82a26395d83503b2d7f2f7c0c10402534a84', - getOpsxFfCommandTemplate: '21132fc9c6d3b3ab2d2295d6bbd72d1e0052eb35ea1be0258c8b1ab3e200c4db', + getOpsxNewCommandTemplate: '00a1077bd71ad84ceaa149e35479b3041598fc4c0219b75b7bb74feac80624f4', + getOpsxContinueCommandTemplate: '1cfb9527dc2cdb267d56c1804a97346b0055861954fbe75a86949e962a5040a5', + getOpsxApplyCommandTemplate: '1df8a23b8e96f7f872139acb202dd0b5ee8c6f1d4b6b147268ee22d57af7593c', + getOpsxFfCommandTemplate: '52e3abb57200b026b53c3735037b7aa0d53532aa9d9c010a364fba8732b11401', getArchiveChangeSkillTemplate: '56bfada1a5f35a127791b70de9d428a75b5aedd1584d6c9803a1ecb1fd1b4a23', getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d', getOpsxSyncCommandTemplate: '0d2427efb79986e8fff3f96bd075a739c80d45eb29159fae717e950030da8202', getVerifyChangeSkillTemplate: '223b7ffd99299a7d430e13092b9a0a3421b39f0d3217232f46c39d79b5f619ff', - getOpsxArchiveCommandTemplate: '9f973c819b11620985b03322945f0e0a92a02a2ef455b94e74482f5e6292ac5d', - getOpsxOnboardCommandTemplate: 'ee99aa99252c602720fbb8c63fb3ac438a5bd4e952fd961ddf1ae956cbfc2c8f', - getOpsxBulkArchiveCommandTemplate: '9fa8cdebe2f5667ebfc37bdc023396762c59d5b038c771dac2d8fd2c19e2627b', + getOpsxArchiveCommandTemplate: '7fda84c09ee6a39cd1837a88aff137740bcf42d1d39201b2d0e7e2e7f2f94df1', + getOpsxOnboardCommandTemplate: '68c99792652c60a5e59381b47f9a7bc7f7836be0e7fe3276ac15a16a6e16a0c6', + getOpsxBulkArchiveCommandTemplate: '3027375407447fed26b573ce3f96386e23dfecc2ea3804ff354a7692b3ecb393', getOpsxVerifyCommandTemplate: '1efcf7eff0671f48e9d9420f50865c563dd3079ee60f8c380bb7a90dd0102696', - getOpsxProposeSkillTemplate: '9c0fbf0137151bd03ec30c45180f83daec96e8976ceaf517c63147f84b803446', - getOpsxProposeCommandTemplate: 'b3c145f541dcc13d9859eae8f7bedbe4553371477ed2c5ac07a4a80f82c46f52', + getOpsxProposeSkillTemplate: '48572c8673f79a364086bf1398bc71bd049fd3c9b4112478a34f90d8bfd85b82', + getOpsxProposeCommandTemplate: 'd38f07719993f13edc72d25eb2f239d84d62b66a59c1953e22e6c6048ecbf3ef', getFeedbackSkillTemplate: 'dabeb5e825b9349abc8156c3e7b8608f27987912a6d9bf47ef29addde6138133', - getUpdateChangeSkillTemplate: '7d9c532d6cdfe0b755e8109adc6e0b98fe89cff9cc6c5e4927459116be1ab392', - getOpsxUpdateCommandTemplate: '2f60a7d452b53688558aab7bf7f9912f361220880cf97fb48fb6be0ac8b29874', + getUpdateChangeSkillTemplate: '34ea0d9b3dfa053795060651cd0f540afbf02066391175faff82919eb02f7773', + getOpsxUpdateCommandTemplate: '6ed7477af41a69c158a4f21e7854b0eed6b798e01693d632b180a2f4e266832c', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { @@ -91,7 +91,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-archive-change': '7c65053d674ba4e1e20e2bf73ba7e5a7f94baef2eaa9b33cee48d4cadea51b7a', 'openspec-bulk-archive-change': '2039b9ecf6e64339dffe0e16272507a386d9fe326f419ff758315aa736fdd96c', 'openspec-verify-change': 'af9be013dcbe8c6d8f6d9ab10c893fbd03f4c62933c384d82f63894dd0ceb84f', - 'openspec-onboard': 'f6f59476acaf5e4d65dbb180da4cef62432612f3cecf207d471a951295e2003a', + 'openspec-onboard': 'f1aa5f1601643cf8047ab0252813d469fcf313ca31d098e0410c89e1fe550fcf', 'openspec-propose': 'e358b45102a88082cf20f5c4441cba02533724ad6eef8ed15ba174e3496cb6ed', 'openspec-update-change': 'ae394c6a873a2f482e5c6434bbd43c2ac57a56702edbaf959a764f2093c11123', }; @@ -263,7 +263,7 @@ describe('skill templates split parity', () => { const pathAwareTemplates: Array<[string, string, string, string]> = [ [ 'propose skill', - generateSkillContent(getOpsxProposeSkillTemplate(), 'PARITY-BASELINE'), + generateSkillContent(asDeployed(getOpsxProposeSkillTemplate()), 'PARITY-BASELINE'), 'specs//spec.md', "Preserve an existing capability's full path", ], @@ -275,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", ], @@ -287,7 +287,7 @@ describe('skill templates split parity', () => { ], [ 'onboard skill', - generateSkillContent(getOnboardSkillTemplate(), 'PARITY-BASELINE'), + generateSkillContent(asDeployed(getOnboardSkillTemplate()), 'PARITY-BASELINE'), '', 'Use the exact existing path for modified', ], @@ -299,7 +299,7 @@ describe('skill templates split parity', () => { ], [ 'sync skill', - generateSkillContent(getSyncSpecsSkillTemplate(), 'PARITY-BASELINE'), + generateSkillContent(asDeployed(getSyncSpecsSkillTemplate()), 'PARITY-BASELINE'), '/openspec/specs//spec.md', 'Preserve the full path from each delta spec', ], @@ -311,7 +311,7 @@ describe('skill templates split parity', () => { ], [ 'archive skill', - generateSkillContent(getArchiveChangeSkillTemplate(), 'PARITY-BASELINE'), + generateSkillContent(asDeployed(getArchiveChangeSkillTemplate()), 'PARITY-BASELINE'), '/openspec/specs//spec.md', 'Preserve the full path from each delta spec', ], @@ -323,7 +323,7 @@ describe('skill templates split parity', () => { ], [ 'bulk archive skill', - generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE'), + generateSkillContent(asDeployed(getBulkArchiveChangeSkillTemplate()), 'PARITY-BASELINE'), '/openspec/specs//spec.md', 'Preserve the full path from each delta spec', ], @@ -345,7 +345,7 @@ describe('skill templates split parity', () => { const onboardVariants: Array<[string, string]> = [ [ 'onboard skill', - generateSkillContent(getOnboardSkillTemplate(), 'PARITY-BASELINE'), + generateSkillContent(asDeployed(getOnboardSkillTemplate()), 'PARITY-BASELINE'), ], ['onboard command', getOpsxOnboardCommandTemplate().content], ]; @@ -360,7 +360,7 @@ describe('skill templates split parity', () => { const bulkArchiveVariants: Array<[string, string]> = [ [ 'bulk archive skill', - generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE'), + generateSkillContent(asDeployed(getBulkArchiveChangeSkillTemplate()), 'PARITY-BASELINE'), ], ['bulk archive command', getOpsxBulkArchiveCommandTemplate().content], ]; @@ -400,7 +400,7 @@ describe('skill templates split parity', () => { it('keeps onboarding task examples aligned with concrete verification guidance (#345)', () => { const variants: Array<[string, string]> = [ - ['onboard skill', generateSkillContent(getOnboardSkillTemplate(), 'PARITY-BASELINE')], + ['onboard skill', generateSkillContent(asDeployed(getOnboardSkillTemplate()), 'PARITY-BASELINE')], ['onboard command', getOpsxOnboardCommandTemplate().content], ]; @@ -442,7 +442,7 @@ describe('skill templates split parity', () => { ]; for (const [dirName, createTemplate] of allSkills) { - const content = generateSkillContent(createTemplate(), 'PARITY-BASELINE'); + const content = generateSkillContent(asDeployed(createTemplate()), 'PARITY-BASELINE'); expect(content, dirName).not.toContain('workspace-planning'); expect(content, dirName).not.toContain('Workspace guard'); } @@ -452,7 +452,7 @@ describe('skill templates split parity', () => { const variants: Array<[string, string]> = [ [ 'skill', - generateSkillContent(getContinueChangeSkillTemplate(), 'PARITY-BASELINE'), + generateSkillContent(asDeployed(getContinueChangeSkillTemplate()), 'PARITY-BASELINE'), ], ['opsx command', getOpsxContinueCommandTemplate().content], ]; @@ -468,7 +468,7 @@ describe('skill templates split parity', () => { }); it('gates the archive on a completed spec sync (#1393)', () => { - const generatedSkill = generateSkillContent(getArchiveChangeSkillTemplate(), 'PARITY-BASELINE'); + const generatedSkill = generateSkillContent(asDeployed(getArchiveChangeSkillTemplate()), 'PARITY-BASELINE'); const commandContent = getOpsxArchiveCommandTemplate().content; // The single archive skill references openspec-sync-specs; opsx command references /opsx:sync. @@ -498,7 +498,7 @@ describe('skill templates split parity', () => { }); it('gates bulk archive on inline synchronous spec sync and verification before moving change root', () => { - const generatedSkill = generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE'); + const generatedSkill = generateSkillContent(asDeployed(getBulkArchiveChangeSkillTemplate()), 'PARITY-BASELINE'); const commandContent = getOpsxBulkArchiveCommandTemplate().content; // The bulk archive skill references openspec-sync-specs; opsx command references /opsx:sync. @@ -529,7 +529,7 @@ describe('skill templates split parity', () => { const variants: Array<[string, string]> = [ [ 'bulk skill', - generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE'), + generateSkillContent(asDeployed(getBulkArchiveChangeSkillTemplate()), 'PARITY-BASELINE'), ], ['bulk opsx command', getOpsxBulkArchiveCommandTemplate().content], ]; @@ -903,9 +903,9 @@ describe('skill templates split parity', () => { // bug #1381 actually reported. it('honors Cancel at every archive confirmation (#1381)', () => { const variants: Array<[string, string]> = [ - ['bulk skill', generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE')], + ['bulk skill', generateSkillContent(asDeployed(getBulkArchiveChangeSkillTemplate()), 'PARITY-BASELINE')], ['bulk opsx command', getOpsxBulkArchiveCommandTemplate().content], - ['single skill', generateSkillContent(getArchiveChangeSkillTemplate(), 'PARITY-BASELINE')], + ['single skill', generateSkillContent(asDeployed(getArchiveChangeSkillTemplate()), 'PARITY-BASELINE')], ['single opsx command', getOpsxArchiveCommandTemplate().content], ]; @@ -924,7 +924,7 @@ describe('skill templates split parity', () => { // would send every legitimate answer down the "ask again" path forever. it('routes the bulk archive confirmation by intent, not by literal label (#1381)', () => { const variants: Array<[string, string]> = [ - ['bulk skill', generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE')], + ['bulk skill', generateSkillContent(asDeployed(getBulkArchiveChangeSkillTemplate()), 'PARITY-BASELINE')], ['bulk opsx command', getOpsxBulkArchiveCommandTemplate().content], ]; @@ -944,11 +944,11 @@ describe('skill templates split parity', () => { it('makes the schema instruction field authoritative for artifact creation (#777)', () => { const variants: Array<[string, string]> = [ - ['propose skill', generateSkillContent(getOpsxProposeSkillTemplate(), 'PARITY-BASELINE')], + ['propose skill', generateSkillContent(asDeployed(getOpsxProposeSkillTemplate()), 'PARITY-BASELINE')], ['propose command', getOpsxProposeCommandTemplate().content], - ['continue skill', generateSkillContent(getContinueChangeSkillTemplate(), 'PARITY-BASELINE')], + ['continue skill', generateSkillContent(asDeployed(getContinueChangeSkillTemplate()), 'PARITY-BASELINE')], ['continue command', getOpsxContinueCommandTemplate().content], - ['ff skill', generateSkillContent(getFfChangeSkillTemplate(), 'PARITY-BASELINE')], + ['ff skill', generateSkillContent(asDeployed(getFfChangeSkillTemplate()), 'PARITY-BASELINE')], ['ff command', getOpsxFfCommandTemplate().content], ]; From cc21dc87b83544ba446444d0b7ec91221dafe320 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 3 Sep 2026 13:15:18 -0500 Subject: [PATCH 05/10] chore(changeset): cover the full cross-workflow reference fix Co-Authored-By: Claude Opus 5 --- .changeset/profile-aware-workflow-references.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/profile-aware-workflow-references.md b/.changeset/profile-aware-workflow-references.md index c1517c5528..9291ac662b 100644 --- a/.changeset/profile-aware-workflow-references.md +++ b/.changeset/profile-aware-workflow-references.md @@ -2,4 +2,4 @@ "@fission-ai/openspec": patch --- -Generated skills and commands no longer point at workflows the active profile does not install. On the default `core` profile, the update workflow told agents to hand off to `/opsx:continue` for missing artifacts and to `/opsx:new` for a change of intent — neither of which `core` generates. Both now render a concrete `openspec status` / `openspec instructions` fallback instead, decided at generation time rather than by a runtime availability check the agent had to perform. +Generated skills and commands no longer point at workflows the active profile does not install. On the default `core` profile, the update workflow told agents to hand off to `/opsx:continue` for missing artifacts and to `/opsx:new` for a change of intent — neither of which `core` generates. Every cross-workflow handoff is now decided at generation time against the installed workflow set, and renders a concrete CLI fallback (`openspec status`, `openspec instructions`, `openspec archive`) when the workflow it would name is absent, rather than relying on a runtime availability check the agent had to perform. The onboarding tutorial's command tables are likewise built from the workflows you actually have. From 26d1cff09b2b72b03163768768312be6f0499066 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 3 Sep 2026 13:23:01 -0500 Subject: [PATCH 06/10] fix(templates): validate conditionals before choosing a branch Resolution discarded the unselected branch and only then checked for residual markers, so a truncated block inside the *missing* branch was accepted for a profile that installs the workflow and rejected for one that does not. Profile-dependent authoring errors are exactly what this module exists to remove. Validate the authored text up front instead: every marker must be one of the three recognized forms, and they must appear as a flat sequence of if / else / end. A malformed block now throws identically for every profile. The post-resolution check stays as a backstop. Caught by CodeRabbit on #1775. Co-Authored-By: Claude Opus 5 --- src/core/templates/optional-workflow.ts | 47 +++++++++++++++++++ test/core/templates/optional-workflow.test.ts | 31 ++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/core/templates/optional-workflow.ts b/src/core/templates/optional-workflow.ts index f799f5c333..d40bc66ddd 100644 --- a/src/core/templates/optional-workflow.ts +++ b/src/core/templates/optional-workflow.ts @@ -35,6 +35,51 @@ const CONDITIONAL_PATTERN = /** Any leftover marker, used to fail loudly on malformed authoring. */ const RESIDUAL_MARKER_PATTERN = /\[\[opsx:(if-workflow|else|end)/; +/** A single well-formed marker, in any position. */ +const MARKER_PATTERN = /\[\[opsx:(?:if-workflow [a-z-]+|else|end)\]\]/g; + +/** Anything that opens like a marker, well-formed or not. */ +const MARKER_LIKE_PATTERN = /\[\[opsx:/; + +/** + * Rejects a malformed conditional before any branch is chosen. + * + * Checking after resolution is not enough: the unselected branch is discarded + * first, so a truncated block inside it would pass for one profile and throw + * for another — the exact profile-dependent behavior this module exists to + * remove. Authoring is either valid for every profile or valid for none. + * + * @param text - Template body as authored + * @throws If a marker is unrecognized, or the blocks are not a flat sequence + * of if / else / end + */ +function assertConditionalsWellFormed(text: string): void { + const kinds: Array<'if' | 'else' | 'end'> = []; + const withoutMarkers = text.replace(MARKER_PATTERN, (marker) => { + kinds.push(marker.startsWith(OPEN) ? 'if' : marker === ELSE ? 'else' : 'end'); + return ''; + }); + + const unrecognized = MARKER_LIKE_PATTERN.exec(withoutMarkers); + if (unrecognized) { + throw new Error( + `Malformed optional-workflow conditional: unrecognized marker at '${withoutMarkers + .slice(unrecognized.index, unrecognized.index + 40) + .split('\n')[0]}'. Markers are [[opsx:if-workflow ]], [[opsx:else]] and [[opsx:end]].` + ); + } + + for (let i = 0; i < kinds.length; i += 3) { + if (kinds[i] !== 'if' || kinds[i + 1] !== 'else' || kinds[i + 2] !== 'end') { + throw new Error( + 'Malformed optional-workflow conditional: markers are out of order or a ' + + 'block is incomplete. Each block needs the full [[opsx:if-workflow ]] ' + + '... [[opsx:else]] ... [[opsx:end]] form, and blocks cannot nest.' + ); + } + } +} + /** * Authors a passage whose wording depends on whether `workflowId` is installed. * @@ -87,6 +132,8 @@ export function resolveOptionalWorkflows( text: string, installedWorkflows: ReadonlySet ): string { + assertConditionalsWellFormed(text); + const wholeLinesResolved = text.replace( WHOLE_LINE_PATTERN, ( diff --git a/test/core/templates/optional-workflow.test.ts b/test/core/templates/optional-workflow.test.ts index 42adfb52f8..ccfca20214 100644 --- a/test/core/templates/optional-workflow.test.ts +++ b/test/core/templates/optional-workflow.test.ts @@ -94,4 +94,35 @@ describe('optionalWorkflow / resolveOptionalWorkflows', () => { /Malformed optional-workflow conditional/ ); }); + + // Validation runs before a branch is chosen. Checking only the output would + // let a broken block inside the *discarded* branch through for one profile + // and throw for another — profile-dependent authoring errors are the thing + // this module exists to remove. + it('throws for every profile, including ones that discard the broken branch', () => { + const brokenMissingBranch = + '[[opsx:if-workflow continue]]ok[[opsx:else]]oops [[opsx:if-workflow new]][[opsx:end]]'; + + for (const set of [installed('continue'), installed(), installed('continue', 'new')]) { + expect(() => resolveOptionalWorkflows(brokenMissingBranch, set)).toThrow( + /Malformed optional-workflow conditional/ + ); + } + }); + + it('rejects a marker it does not recognize', () => { + const typo = '[[opsx:if-workflow continue]]a[[opsx:otherwise]]b[[opsx:end]]'; + + expect(() => resolveOptionalWorkflows(typo, installed('continue'))).toThrow( + /unrecognized marker/ + ); + }); + + it('rejects markers that are out of order', () => { + const swapped = '[[opsx:else]]a[[opsx:if-workflow continue]]b[[opsx:end]]'; + + expect(() => resolveOptionalWorkflows(swapped, installed('continue'))).toThrow( + /out of order or a block is incomplete/ + ); + }); }); From 5cf2df9aed389c69d7fd01eeeda378dadba74a32 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 7 Sep 2026 11:40:07 -0500 Subject: [PATCH 07/10] docs: state the profile-aware handoff rule in the skills reference alfred-openspec on #1775: docs-lab/reference/skills.md described several handoffs as unconditional while this change deliberately emits a CLI or conversational fallback when the profile omits the target. Stated once, above the entries, rather than as a caveat on each of the eleven affected Response and Creates rows: the page's recipe is one fact per row, and repeating the same conditional eleven times would bury the contracts it exists to state. The rows keep naming the skill that owns the next step, which is the fact a reader looks up; the rule above them says what happens when that skill is not installed. Co-Authored-By: Claude Opus 5 --- docs-lab/reference/skills.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs-lab/reference/skills.md b/docs-lab/reference/skills.md index 74437a0dd6..c18670c92c 100644 --- a/docs-lab/reference/skills.md +++ b/docs-lab/reference/skills.md @@ -54,6 +54,8 @@ The skills come in two sets: | [openspec-bulk-archive-change](#openspec-bulk-archive-change) | Archive several change proposals at once | Optional | | [openspec-onboard](#openspec-onboard) | Learn the workflow by doing one real change proposal end to end | Optional | +Each entry below names the skill that owns the next step. When your profile leaves that skill out, the installed files never name it: the handoff becomes the equivalent `openspec` command, or a plain request to you, and a line that exists only to point at a missing skill is not written at all. So the skills you have always hand off to skills you have. Which set you get is [Profiles](../customize/profiles.md). + ## openspec-explore Think through an idea before it becomes a change proposal. From 8ae4763ce227438f84bdf85f0efc17f90e9f69fb Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 7 Sep 2026 12:48:54 -0500 Subject: [PATCH 08/10] fix(workflows): fold #1735 into the profile-aware references #1735 fixes the same issue (#1734) by removing the optional handoffs outright. This PR resolves them at generation time instead, which is strictly better for the template layer: an install that has `continue` still gets told about it. So the mechanism here wins and #1735's content is folded in, rather than the two competing for the same lines. What #1735 had that this did not: - src/commands/workflow/instructions.ts. The CLI's own runtime strings named the openspec-continue-change skill. Those are chosen at run time, so optionalWorkflow() cannot reach them; taken from #1735 verbatim. - The blocked-state fallback. It was a one-line pointer; it now carries #1735's full CLI recovery (select the next `ready` artifact, not `skipped` or `blocked`, read its rules with `openspec instructions`, keep the selected `--store` on both commands) plus the tracking-file repair path and the `missingArtifacts` field it branches on. The installed branch still names `/opsx:continue`, so neither audience loses. #1735's update-change.ts rewrite is not carried over: this PR already covers all six of those sites conditionally, which is the better answer. Both of #1735's test suites come across, and they are worth more here than there. test/core/templates/profile-handoffs.test.ts asserts that no generated file names an uninstalled workflow across every tool and all three delivery modes, which is the property this PR's mechanism exists to provide, and it passes against it. test/commands/profile-handoffs.test.ts covers the runtime CLI strings. The two guards are complementary: that one is broad on tools and deliveries, this PR's own profile-workflow-references.test.ts is broad on workflow subsets. #1735's command-references.test.ts assertions could not be carried as written, since they assume the reference is gone unconditionally. Replaced with a case that resolves the template against a set without `continue` and asserts the fallback carries the whole recovery. Verified it fails when the fallback is shortened. Co-Authored-By: Claude Opus 5 --- .../profile-aware-workflow-references.md | 2 + skills/openspec-apply-change/SKILL.md | 5 +- src/commands/workflow/instructions.ts | 10 +- src/core/templates/workflows/apply-change.ts | 9 +- test/commands/profile-handoffs.test.ts | 200 ++++++++++++++++++ test/core/init.test.ts | 7 +- test/core/templates/profile-handoffs.test.ts | 74 +++++++ .../templates/skill-templates-parity.test.ts | 6 +- test/utils/command-references.test.ts | 31 +++ 9 files changed, 330 insertions(+), 14 deletions(-) create mode 100644 test/commands/profile-handoffs.test.ts create mode 100644 test/core/templates/profile-handoffs.test.ts diff --git a/.changeset/profile-aware-workflow-references.md b/.changeset/profile-aware-workflow-references.md index 9291ac662b..7829f8adcf 100644 --- a/.changeset/profile-aware-workflow-references.md +++ b/.changeset/profile-aware-workflow-references.md @@ -3,3 +3,5 @@ --- Generated skills and commands no longer point at workflows the active profile does not install. On the default `core` profile, the update workflow told agents to hand off to `/opsx:continue` for missing artifacts and to `/opsx:new` for a change of intent — neither of which `core` generates. Every cross-workflow handoff is now decided at generation time against the installed workflow set, and renders a concrete CLI fallback (`openspec status`, `openspec instructions`, `openspec archive`) when the workflow it would name is absent, rather than relying on a runtime availability check the agent had to perform. The onboarding tutorial's command tables are likewise built from the workflows you actually have. + +Also folds in #1735, which fixed the same issue (#1734) by removing the optional handoffs outright. The CLI's own runtime instructions no longer name the `openspec-continue-change` skill either, since those strings are chosen at run time and cannot be resolved against a profile; and the blocked-state fallback now carries the full CLI recovery (select the next `ready` artifact from `openspec status`, read its rules with `openspec instructions`, keep the selected `--store`) rather than a one-line pointer. diff --git a/skills/openspec-apply-change/SKILL.md b/skills/openspec-apply-change/SKILL.md index 6a5b9003a7..2e7eb9ed42 100644 --- a/skills/openspec-apply-change/SKILL.md +++ b/skills/openspec-apply-change/SKILL.md @@ -48,9 +48,12 @@ Implement tasks from an OpenSpec change. - Dynamic instruction based on current state - Optional `context`: current required project instruction input from the selected root - Optional `operationGuidance`: current advisory guidance for apply + - `missingArtifacts` (when present): required artifact ids with no output **Handle states:** - - If `state: "blocked"` (missing artifacts): show message, then suggest using `/openspec-continue-change` to create them + - If `state: "blocked"`: show the message and pause implementation. + - If `missingArtifacts` is non-empty: suggest using `/openspec-continue-change` to create them. + - Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked. - If `state: "all_done"`: congratulate, suggest archive - Otherwise: proceed to implementation diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 1ae6fac7c0..1059aadeda 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -437,18 +437,16 @@ export async function generateApplyInstructions( if (missingArtifacts.length > 0) { state = 'blocked'; - instruction = `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.\nUse the openspec-continue-change skill to create the missing artifacts first.`; + instruction = `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.\nComplete the missing planning artifacts before starting implementation.`; } else if (tracksFile && !tracksFileExists) { // Tracking file configured but doesn't exist yet - const tracksFilename = path.basename(tracksFile); state = 'blocked'; - instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`; + instruction = `The ${tracksFile} tracking file is missing and must be created.\nCreate actionable task checkboxes from the existing planning artifacts before starting implementation.`; } else if (tracksFile && tracksFileExists && tasks.length === 0) { // Tracking file exists but lists nothing an agent can work on: either no // checkboxes at all, or only checkboxes with no text after them. - const tracksFilename = path.basename(tracksFile); state = 'blocked'; - instruction = `The ${tracksFilename} file exists but contains no tasks to work on.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`; + instruction = `The ${tracksFile} tracking file exists but contains no tasks to work on.\nAdd actionable task checkboxes from the existing planning artifacts before starting implementation.`; } else if (tracksFile && remaining === 0 && total > 0) { state = 'all_done'; instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.'; @@ -540,7 +538,7 @@ export function printApplyInstructionsText(instructions: ApplyInstructions): voi console.log('### ⚠️ Blocked'); console.log(); console.log(`Missing artifacts: ${missingArtifacts.join(', ')}`); - console.log('Use the openspec-continue-change skill to create these first.'); + console.log('Complete the missing planning artifacts before starting implementation.'); console.log(); } diff --git a/src/core/templates/workflows/apply-change.ts b/src/core/templates/workflows/apply-change.ts index 3cffefe8aa..dcb6d606bb 100644 --- a/src/core/templates/workflows/apply-change.ts +++ b/src/core/templates/workflows/apply-change.ts @@ -15,8 +15,8 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; */ const BLOCKED_STATE_HANDOFF = optionalWorkflow( 'continue', - 'suggest using `/opsx:continue` to create them', - 'run `openspec status --change "" --json` to see the next artifact and `openspec instructions --change "" --json` for how to create it' + 'suggest using `/opsx:continue` to create them.', + 'suggest completing the missing artifacts. Run `openspec status --change "" --json`, select the next `ready` artifact (not `skipped` or `blocked`), and use `openspec instructions "" --change "" --json` for its rules and template. Keep the selected `--store ` on both commands.' ); /** The archive handoff shown once every task is done. */ @@ -75,9 +75,12 @@ ${STORE_SELECTION_GUIDANCE} - Dynamic instruction based on current state - Optional \`context\`: current required project instruction input from the selected root - Optional \`operationGuidance\`: current advisory guidance for apply + - \`missingArtifacts\` (when present): required artifact ids with no output **Handle states:** - - If \`state: "blocked"\` (missing artifacts): show message, then ${BLOCKED_STATE_HANDOFF} + - If \`state: "blocked"\`: show the message and pause implementation. + - If \`missingArtifacts\` is non-empty: ${BLOCKED_STATE_HANDOFF} + - Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked. - If \`state: "all_done"\`: congratulate, suggest archive - Otherwise: proceed to implementation diff --git a/test/commands/profile-handoffs.test.ts b/test/commands/profile-handoffs.test.ts new file mode 100644 index 0000000000..94aab3b138 --- /dev/null +++ b/test/commands/profile-handoffs.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; +import { ALL_WORKFLOWS, CORE_WORKFLOWS } from '../../src/core/profiles.js'; +import { getSkillTemplates } from '../../src/core/shared/skill-generation.js'; +import { createOpenSpecRoot } from '../helpers/openspec-fixtures.js'; +import { runCLI } from '../helpers/run-cli.js'; + +describe('profile handoff CLI regressions (#1734)', () => { + let tempDir: string; + let project: string; + let env: NodeJS.ProcessEnv; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-profile-handoffs-')); + project = path.join(tempDir, 'project'); + await fs.mkdir(project); + env = { + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + XDG_DATA_HOME: path.join(tempDir, 'data'), + USERPROFILE: path.join(tempDir, 'user-profile'), // Isolate global skill discovery (MiniMax). + }; + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + async function cli(args: string[]): Promise { + const result = await runCLI(args, { cwd: project, env }); + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + return result.stdout; + } + + it.each(['skills', 'commands', 'both'])('keeps fresh and refreshed core %s free of optional handoffs', async (delivery) => { + const configDir = path.join(env.XDG_CONFIG_HOME!, 'openspec'); + await fs.mkdir(configDir, { recursive: true }); + const configFile = path.join(configDir, 'config.json'); + await fs.writeFile(configFile, JSON.stringify({ profile: 'core', delivery })); + await cli(['init', '--tools', 'claude', '--profile', 'core', '--no-animation', '--force']); + + const files = [ + ...(delivery !== 'commands' ? getSkillTemplates(CORE_WORKFLOWS).map(({ dirName }) => + path.join(project, '.claude', 'skills', dirName, 'SKILL.md')) : []), + ...(delivery !== 'skills' ? CORE_WORKFLOWS.map(id => + path.join(project, '.claude', 'commands', 'opsx', `${id}.md`)) : []), + ]; + async function assertCore(): Promise { + if (delivery !== 'commands') { + expect((await fs.readdir(path.join(project, '.claude', 'skills'))).sort()) + .toEqual(getSkillTemplates(CORE_WORKFLOWS).map(entry => entry.dirName).sort()); + } + if (delivery !== 'skills') { + expect((await fs.readdir(path.join(project, '.claude', 'commands', 'opsx'))).sort()) + .toEqual(CORE_WORKFLOWS.map(id => `${id}.md`).sort()); + } + for (const file of files) { + const content = await fs.readFile(file, 'utf-8'); + expect(content, file).not.toMatch(/\/opsx:(continue|new)\b|openspec-(continue|new)-change/); + } + } + await assertCore(); + + // Simulate files generated by an older release, then use the normal refresh path. + for (const file of files) { + const old = (await fs.readFile(file, 'utf-8')).replace(/generatedBy: "[^"]+"/, 'generatedBy: "0.0.0"'); + await fs.writeFile(file, `${old}\nStale handoff: /opsx:continue\n`); + } + await cli(['update']); + await assertCore(); + + // Explicit opt-in still works; switching back removes the optional files. + await fs.writeFile(configFile, JSON.stringify({ profile: 'custom', delivery, workflows: ALL_WORKFLOWS })); + await cli(['update']); + const continueFile = delivery === 'commands' + ? path.join(project, '.claude', 'commands', 'opsx', 'continue.md') + : path.join(project, '.claude', 'skills', 'openspec-continue-change', 'SKILL.md'); + expect(await fs.readFile(continueFile, 'utf-8')).toContain('Continue working on a change'); + await fs.writeFile(configFile, JSON.stringify({ profile: 'core', delivery })); + await cli(['update']); + await assertCore(); + }, 30_000); + + it.each(['local', 'store'])('supports the CLI recovery with custom artifact ids in a %s root', async (scope) => { + const root = scope === 'store' ? path.join(tempDir, 'planning-store') : project; + createOpenSpecRoot(root); + const flags = scope === 'store' ? ['--store', 'planning'] : []; + if (scope === 'store') { + await registerStore({ id: 'planning', localPath: root, globalDataDir: getGlobalDataDir({ env }) }); + createOpenSpecRoot(project); // A local root must not steal the selected store's change. + } + const schemaDir = path.join(root, 'openspec', 'schemas', 'handoff-test'); + await fs.mkdir(path.join(schemaDir, 'templates'), { recursive: true }); + await fs.writeFile(path.join(schemaDir, 'schema.yaml'), `name: handoff-test +version: 1 +artifacts: + - id: brief + generates: brief.md + description: Planning brief + template: brief.md + instruction: Explain the intended behavior. + requires: [] +apply: + requires: [brief] + instruction: Implement the brief. +`); + await fs.writeFile(path.join(schemaDir, 'templates', 'brief.md'), '# Brief\n'); + await fs.writeFile(path.join(root, 'openspec', 'config.yaml'), 'schema: handoff-test\ncontext: Preserve existing behavior.\nrules:\n brief:\n - Include a verification plan.\n'); + await cli(['new', 'change', 'original', ...flags]); + const changeRoot = path.join(root, 'openspec', 'changes', 'original'); + const before = await fs.readdir(changeRoot); + const blocked = JSON.parse(await cli(['instructions', 'apply', '--change', 'original', '--json', ...flags])); + expect(blocked.state).toBe('blocked'); + expect(blocked.instruction).not.toContain('openspec-continue-change'); + expect(blocked.instruction).toContain('Complete the missing planning artifacts'); + const blockedText = await cli(['instructions', 'apply', '--change', 'original', ...flags]); + expect(blockedText).not.toContain('openspec-continue-change'); + const status = JSON.parse(await cli(['status', '--change', 'original', '--json', ...flags])); + const next = status.artifacts.find((artifact: { status: string }) => artifact.status === 'ready'); + expect(next.id).toBe('brief'); + const instructions = JSON.parse(await cli(['instructions', next.id, '--change', 'original', '--json', ...flags])); + expect(instructions.template).toBe('# Brief\n'); + expect(instructions.instruction).toBe('Explain the intended behavior.'); + expect(instructions.context).toBe('Preserve existing behavior.'); + expect(instructions.rules).toEqual(['Include a verification plan.']); + expect(instructions.resolvedOutputPath).toBe(path.join(await fs.realpath(changeRoot), 'brief.md')); + expect(await fs.readdir(changeRoot)).toEqual(before); // Status/instructions do not create artifacts. + + // A fixture write models the separate creation step, not an agent execution. + await fs.writeFile(instructions.resolvedOutputPath, '# Brief\n\nConfirmed planning artifact.\n'); + const ready = JSON.parse(await cli(['instructions', 'apply', '--change', 'original', '--json', ...flags])); + expect(ready.state).toBe('ready'); + await cli(['new', 'change', 'different-intent', ...flags]); + expect(await fs.readFile(instructions.resolvedOutputPath, 'utf-8')).toBe('# Brief\n\nConfirmed planning artifact.\n'); + if (scope === 'store') { + expect(await fs.readdir(path.join(project, 'openspec', 'changes'))).toEqual(['archive']); + } + }, 30_000); + + it.each([ + { name: 'missing', content: undefined, total: 0 }, + { name: 'empty', content: '# Work\n', total: 0 }, + { name: 'text-less checkboxes', content: '- [ ]\n', total: 1 }, + ])('repairs $name tracking without an optional workflow or a ready artifact', async ({ content, total }) => { + createOpenSpecRoot(project); + const schemaDir = path.join(project, 'openspec', 'schemas', 'tracking-test'); + await fs.mkdir(path.join(schemaDir, 'templates'), { recursive: true }); + await fs.writeFile(path.join(schemaDir, 'schema.yaml'), `name: tracking-test +version: 1 +artifacts: + - id: brief + generates: brief.md + description: Planning brief + template: brief.md + requires: [] +apply: + requires: [brief] + tracks: implementation/checklist.md +`); + await fs.writeFile(path.join(schemaDir, 'templates', 'brief.md'), '# Brief\n'); + await fs.writeFile(path.join(project, 'openspec', 'config.yaml'), 'schema: tracking-test\n'); + await cli(['new', 'change', 'tracking']); + const changeRoot = path.join(project, 'openspec', 'changes', 'tracking'); + const briefPath = path.join(changeRoot, 'brief.md'); + await fs.writeFile(briefPath, '# Brief\n\nKeep this plan unchanged.\n'); + const trackingPath = path.join(changeRoot, 'implementation', 'checklist.md'); + await fs.mkdir(path.dirname(trackingPath)); + if (content !== undefined) await fs.writeFile(trackingPath, content); + + const status = JSON.parse(await cli(['status', '--change', 'tracking', '--json'])); + expect(status.artifacts.map((artifact: { status: string }) => artifact.status)).toEqual(['done']); + const blocked = JSON.parse(await cli(['instructions', 'apply', '--change', 'tracking', '--json'])); + expect(blocked.state).toBe('blocked'); + expect(blocked.missingArtifacts).toBeUndefined(); + expect(blocked.progress).toEqual({ total, complete: 0, remaining: total }); + expect(blocked.tasks).toEqual([]); + expect(blocked.instruction).not.toContain('openspec-continue-change'); + expect(blocked.instruction).toContain('implementation/checklist.md'); + expect(blocked.instruction).toContain('existing planning artifacts'); + expect(await cli(['instructions', 'apply', '--change', 'tracking'])).not.toContain('openspec-continue-change'); + if (content === undefined) { + await expect(fs.stat(trackingPath)).rejects.toMatchObject({ code: 'ENOENT' }); + } else { + expect(await fs.readFile(trackingPath, 'utf-8')).toBe(content); + } + + // The fixture repairs tracking separately; CLI status/progress semantics stay unchanged. + await fs.writeFile(trackingPath, '- [ ] Implement the brief\n'); + const ready = JSON.parse(await cli(['instructions', 'apply', '--change', 'tracking', '--json'])); + expect(ready.state).toBe('ready'); + expect(ready.progress).toEqual({ total: 1, complete: 0, remaining: 1 }); + await fs.writeFile(trackingPath, '- [x] Implement the brief\n'); + const done = JSON.parse(await cli(['instructions', 'apply', '--change', 'tracking', '--json'])); + expect(done.state).toBe('all_done'); + expect(done.progress).toEqual({ total: 1, complete: 1, remaining: 0 }); + expect(await fs.readFile(briefPath, 'utf-8')).toBe('# Brief\n\nKeep this plan unchanged.\n'); + }, 30_000); +}); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index fab27e3a7e..953944cfad 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -562,10 +562,15 @@ describe('InitCommand', () => { ]; for (const content of applyVariants) { + // The core profile has no `continue`, so the blocked-state handoff + // must be the CLI recovery in full, not a workflow this install lacks. expect(content).not.toContain('/opsx:continue'); + expect(content).toContain('openspec status --change "" --json'); + expect(content).toContain('next `ready` artifact (not `skipped` or `blocked`)'); expect(content).toContain( - 'run `openspec status --change "" --json` to see the next artifact' + 'openspec instructions "" --change "" --json' ); + expect(content).toContain('Keep the selected `--store ` on both commands'); } const syncFiles = [ diff --git a/test/core/templates/profile-handoffs.test.ts b/test/core/templates/profile-handoffs.test.ts new file mode 100644 index 0000000000..72aedf3190 --- /dev/null +++ b/test/core/templates/profile-handoffs.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { AI_TOOLS } from '../../../src/core/config.js'; +import { ALL_WORKFLOWS, getProfileWorkflows } from '../../../src/core/profiles.js'; +import { + resolveCommandInvocation, + resolveCommandSurfaceCapability, + shouldGenerateCommandsForTool, + shouldGenerateSkillsForTool, +} from '../../../src/core/command-surface.js'; +import { CommandAdapterRegistry, generateCommands } from '../../../src/core/command-generation/index.js'; +import { + generateSkillContent, + getCommandContents, + getSkillTemplates, +} from '../../../src/core/shared/skill-generation.js'; +import { toolSupportsSkills } from '../../../src/core/shared/skill-paths.js'; +import { getTransformerForTool } from '../../../src/utils/command-references.js'; + +const profiles = [ + { name: 'core', workflows: getProfileWorkflows('core') }, + { name: 'custom expanded', workflows: getProfileWorkflows('custom', [...ALL_WORKFLOWS]) }, + { name: 'custom update only', workflows: getProfileWorkflows('custom', ['update']) }, + { name: 'custom archive with sync dependency', workflows: getProfileWorkflows('custom', ['archive']) }, +]; +const skillWorkflows = new Map(getSkillTemplates().map(({ dirName, workflowId }) => [dirName, workflowId])); + +// Check both invocation spellings and bare skill names (archive's sync handoff). +// Unknown references also fail: a typo must not make the guard silently pass. +function expectInstalledReferences(content: string, workflows: readonly string[], label: string): void { + for (const match of content.matchAll(/[/@]opsx[:-]([\w-]+)|\b(openspec-[\w-]+)/g)) { + const workflow = match[1] ?? skillWorkflows.get(match[2]); + expect(workflows, `${label}: unavailable workflow reference ${match[0]}`).toContain(workflow); + } +} + +describe('workflow reference guard', () => { + it.each(['/opsx:continue', '@opsx-continue', '$openspec-continue-change', 'the openspec-sync-specs skill'])( + 'rejects an unavailable workflow in %s', (reference) => { + expect(() => expectInstalledReferences(reference, ['apply'], 'guard')).toThrow('unavailable workflow reference'); + }, + ); + + it.each(['/opsx:aply', '/opsx:apply2', '/opsx:apply_new', '/skill:openspec-aply-change'])( + 'does not accept a misspelled workflow in %s', (reference) => { + expect(() => expectInstalledReferences(reference, ['apply'], 'guard')).toThrow('unavailable workflow reference'); + }, + ); +}); + +describe.each(profiles)('$name workflow handoffs', ({ workflows }) => { + for (const delivery of ['skills', 'commands', 'both'] as const) { + const tools = AI_TOOLS.filter(tool => toolSupportsSkills(tool) && ( + shouldGenerateSkillsForTool(tool.value, delivery) || shouldGenerateCommandsForTool(tool.value, delivery) + )); + it.each(tools)(`only references installed workflows for $value (${delivery})`, (tool) => { + if (shouldGenerateSkillsForTool(tool.value, delivery)) { + const transformer = getTransformerForTool( + tool.value, delivery, + resolveCommandSurfaceCapability(tool.value), + resolveCommandInvocation(tool.value), + ); + for (const { template, workflowId } of getSkillTemplates(workflows)) { + expectInstalledReferences(generateSkillContent(template, 'TEST', transformer), workflows, workflowId); + } + } + if (shouldGenerateCommandsForTool(tool.value, delivery)) { + const adapter = CommandAdapterRegistry.get(tool.value)!; + for (const command of generateCommands(getCommandContents(workflows), adapter)) { + expectInstalledReferences(command.fileContent, workflows, command.path); + } + } + }); + } +}); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index e2d67503a0..169aacfb68 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -57,14 +57,14 @@ const EXPECTED_FUNCTION_HASHES: Record = { getExploreSkillTemplate: '6315fcc5c2eb848963bc8bca4c23e657412a99608e610daee59fb4e58cd21fd4', getNewChangeSkillTemplate: 'eabd1e895c5881dcb17dcbaa3fb26098dd59e8eacb318e400820b4dc811ef781', getContinueChangeSkillTemplate: '012136f6411a99c8fa228e2f9444cb64b0a89e0f56fdeac2fe03b2f5bee0c5d7', - getApplyChangeSkillTemplate: 'a609be94a08110cbf5ce66fb60d038afa848efafdf9118f242106e2c89cf361f', + getApplyChangeSkillTemplate: '796a56dfd16bd04df0c0adfe10c5378bb0c620307fb81eb28bf4f5378cd78aac', getFfChangeSkillTemplate: 'dbf062f7018309bfd89993d215017964cc82b7eab413e8318051cd9421589da4', getSyncSpecsSkillTemplate: 'b099e2ff31859c9b10d928066e662524f9aad9ecf2be12fceacb732d718c4146', getOnboardSkillTemplate: '3549e6a34a59ff5a11cdabf0edfdac2e9171158dd78b83045505f5ec7c7b83bf', getOpsxExploreCommandTemplate: 'b4706a5b8fd280f7929eea610ecc9d41676b2d2dd6653d259cbbc2bfe01813d9', getOpsxNewCommandTemplate: '00a1077bd71ad84ceaa149e35479b3041598fc4c0219b75b7bb74feac80624f4', getOpsxContinueCommandTemplate: '1cfb9527dc2cdb267d56c1804a97346b0055861954fbe75a86949e962a5040a5', - getOpsxApplyCommandTemplate: '1df8a23b8e96f7f872139acb202dd0b5ee8c6f1d4b6b147268ee22d57af7593c', + getOpsxApplyCommandTemplate: '704da8e513f853ace5567997a29b1bcfd30fc6b4386687bd7d1352e06c0f0ffa', getOpsxFfCommandTemplate: '52e3abb57200b026b53c3735037b7aa0d53532aa9d9c010a364fba8732b11401', getArchiveChangeSkillTemplate: '56bfada1a5f35a127791b70de9d428a75b5aedd1584d6c9803a1ecb1fd1b4a23', getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d', @@ -85,7 +85,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-explore': 'dd84af68d3c93b40659dcdd8d383423b25b443cacdc4b514cd70614ae10c5cac', 'openspec-new-change': 'ec4529beef978e34634a6f7286fab55d68fad8fb374dceb45691d52caab33fbb', 'openspec-continue-change': 'bb6194a16c54891cdb253678e8f70ce53b2af86735243980f366ce551d37e42e', - 'openspec-apply-change': '145793072fbea6b888929c8cd09599fb6ad065bb27926231bbf93061395e051c', + 'openspec-apply-change': '149b1bcd19d867749f8a3e016bd452e8b5ef699f591dfb8793a9d527e98ddec5', 'openspec-ff-change': '31355250514bce51b16ff37ee2b833bc9d475cd0dbd4b1f68fe2041694575623', 'openspec-sync-specs': 'd933d8856584d6c1253de91e652e7aee9e85c77ad4d3531f6476f79d84e6e5e8', 'openspec-archive-change': '7c65053d674ba4e1e20e2bf73ba7e5a7f94baef2eaa9b33cee48d4cadea51b7a', diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index 2525e13ab3..15620b5297 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -7,6 +7,8 @@ import { } from '../../src/utils/command-references.js'; import type { CommandInvocation } from '../../src/core/command-generation/invocation.js'; import { getApplyChangeSkillTemplate } from '../../src/core/templates/workflows/apply-change.js'; +import { resolveOptionalWorkflows } from '../../src/core/templates/optional-workflow.js'; +import { ALL_WORKFLOWS } from '../../src/core/profiles.js'; const FLAT_SLASH: CommandInvocation = { style: 'flat', prefix: '/' }; const FLAT_AT: CommandInvocation = { style: 'flat', prefix: '@' }; @@ -349,6 +351,35 @@ describe('apply skill template generates valid per-target invocations', () => { expect(skill).not.toContain('suggest using openspec-continue-change'); }); + // The blocked-state answer for an installation without `continue` (#1734). + // The conditional's fallback has to stand on its own: the agent gets no + // workflow to hand off to, so it needs the whole CLI recovery, not a + // shortened version of the installed branch. + it('gives the full CLI recovery when continue is not installed', () => { + const withoutContinue = resolveOptionalWorkflows( + skill, + new Set(ALL_WORKFLOWS.filter((id) => id !== 'continue')) + ); + const blocked = withoutContinue.slice( + withoutContinue.indexOf('If `state: "blocked"`'), + withoutContinue.indexOf('If `state: "all_done"`') + ); + + expect(blocked).not.toContain('/opsx:continue'); + expect(blocked).toContain('pause implementation'); + expect(blocked).toContain('If `missingArtifacts` is non-empty'); + expect(blocked).toContain('openspec status --change "" --json'); + expect(blocked).toContain('next `ready` artifact (not `skipped` or `blocked`)'); + expect(blocked).toContain('openspec instructions "" --change "" --json'); + expect(blocked).toContain('Keep the selected `--store ` on both commands'); + expect(blocked).toContain( + 'Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file' + ); + expect(blocked).toContain( + 'Do not assume another artifact is ready or start implementation while blocked' + ); + }); + const cases = [ { tool: 'default (skills.sh)', transform: transformToSkillReferences, cont: '/openspec-continue-change', arch: '/openspec-archive-change', apply: '/openspec-apply-change' }, { tool: 'codex', transform: getSkillReferenceTransformer('codex'), cont: '$openspec-continue-change', arch: '$openspec-archive-change', apply: '$openspec-apply-change' }, From 24432df049bc8f101f7541151b78e20b6e8a1cda Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 15 Sep 2026 07:55:31 -0500 Subject: [PATCH 09/10] docs: name the core-profile fallback on the two rows that hit it The rule above the entries covers every profile, but apply-change and update-change are Core skills whose rows name openspec-continue-change, which the core profile never installs. On the default install those rows now say what the generated skill points to instead: openspec status and openspec instructions. Co-Authored-By: Claude Opus 5 --- docs-lab/reference/skills.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-lab/reference/skills.md b/docs-lab/reference/skills.md index c18670c92c..8482c16f60 100644 --- a/docs-lab/reference/skills.md +++ b/docs-lab/reference/skills.md @@ -84,7 +84,7 @@ Implement a change proposal's tasks, working through the list until done or bloc |---|---| | **Arguments** | A change proposal name (`add-auth`), optional. If the target is ambiguous it lists the active change proposals and asks you to pick. | | **Creates** | Code: the minimal changes each task calls for, in your project files. In the change proposal it touches only the tasks file, checking off each finished task (`- [ ]` to `- [x]`). | -| **Response** | Progress per task, then an overall count (N/M tasks complete). All done: suggests `openspec-archive-change`. Blocked by missing artifacts: points to `openspec-continue-change`. Unclear tasks or errors: pauses and asks. | +| **Response** | Progress per task, then an overall count (N/M tasks complete). All done: suggests `openspec-archive-change`. Blocked by missing artifacts: points to `openspec-continue-change`, or to `openspec status` and `openspec instructions` when that skill is not installed (the core profile leaves it out). Unclear tasks or errors: pauses and asks. | ## openspec-update-change @@ -94,7 +94,7 @@ other. | Contract | Description | |---|---| | **Arguments** | A change proposal name, optional, plus the revision you want. With no revision stated it runs a coherence review: artifacts checked against each other for contradictions, gaps, and duplication. | -| **Creates** | Nothing new. Edits only artifact files that already exist. Missing artifacts are `openspec-continue-change`'s job. Never code. | +| **Creates** | Nothing new. Edits only artifact files that already exist. Missing artifacts are `openspec-continue-change`'s job. Without that skill (the core profile leaves it out), it points to `openspec status` and `openspec instructions` instead. Never code. | | **Response** | Shows each proposed revision and writes it only after you confirm, one artifact at a time. Ends with what was revised and the next step; implementation waits for `openspec-apply-change`. | ## openspec-sync-specs From 74199584cc9d2c60696572ead2c6d016afa9aef0 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 15 Sep 2026 08:22:09 -0500 Subject: [PATCH 10/10] docs(changeset): drop em dashes from the release note Co-Authored-By: Claude Opus 5 --- .changeset/profile-aware-workflow-references.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/profile-aware-workflow-references.md b/.changeset/profile-aware-workflow-references.md index 7829f8adcf..c4261b394a 100644 --- a/.changeset/profile-aware-workflow-references.md +++ b/.changeset/profile-aware-workflow-references.md @@ -2,6 +2,6 @@ "@fission-ai/openspec": patch --- -Generated skills and commands no longer point at workflows the active profile does not install. On the default `core` profile, the update workflow told agents to hand off to `/opsx:continue` for missing artifacts and to `/opsx:new` for a change of intent — neither of which `core` generates. Every cross-workflow handoff is now decided at generation time against the installed workflow set, and renders a concrete CLI fallback (`openspec status`, `openspec instructions`, `openspec archive`) when the workflow it would name is absent, rather than relying on a runtime availability check the agent had to perform. The onboarding tutorial's command tables are likewise built from the workflows you actually have. +Generated skills and commands no longer point at workflows the active profile does not install. On the default `core` profile, the update workflow told agents to hand off to `/opsx:continue` for missing artifacts and to `/opsx:new` for a change of intent, neither of which `core` generates. Every cross-workflow handoff is now decided at generation time against the installed workflow set, and renders a concrete CLI fallback (`openspec status`, `openspec instructions`, `openspec archive`) when the workflow it would name is absent, rather than relying on a runtime availability check the agent had to perform. The onboarding tutorial's command tables are likewise built from the workflows you actually have. Also folds in #1735, which fixed the same issue (#1734) by removing the optional handoffs outright. The CLI's own runtime instructions no longer name the `openspec-continue-change` skill either, since those strings are chosen at run time and cannot be resolved against a profile; and the blocked-state fallback now carries the full CLI recovery (select the next `ready` artifact from `openspec status`, read its rules with `openspec instructions`, keep the selected `--store`) rather than a one-line pointer.