diff --git a/.changeset/workflow-verb-triggers.md b/.changeset/workflow-verb-triggers.md new file mode 100644 index 00000000..229d9daa --- /dev/null +++ b/.changeset/workflow-verb-triggers.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Match the natural "openspec " phrasing to the workflow it names. Users and agents say "openspec propose" or "do an openspec apply", but no workflow skill's description contained that phrasing (and a skill's description is what an agent matches on), so the phrase read as an invitation to hand-build the artifacts with the CLI instead of running the workflow. Every workflow skill's description now names the phrasings a user actually types ("openspec propose", "opsx apply", and so on). Run `openspec update` to pick it up. `openspec update` itself is deliberately left unclaimed: it is a real CLI command that refreshes generated files, unrelated to the update-change workflow, which claims "openspec update change" instead. Commands-only installs write no skills and are unchanged. Fixes #1221. diff --git a/docs-lab/start/quickstart.md b/docs-lab/start/quickstart.md index fa7deae1..09d6a4c2 100644 --- a/docs-lab/start/quickstart.md +++ b/docs-lab/start/quickstart.md @@ -17,7 +17,7 @@ flowchart LR archive -. "next change" .-> explore ``` -Every prompt below goes in your AI chat, the same place you ask for code. Each invokes an OpenSpec skill by name, the same spelling in every tool. A plain ask works too ("propose a change to add rate limiting"). Some tools add shorter command aliases (`/opsx:propose` in Claude Code, [other tools vary](../reference/supported-tools.md)). +Every prompt below goes in your AI chat, the same place you ask for code. Each invokes an OpenSpec skill by name, the same spelling in every tool. A plain ask works too ("propose a change to add rate limiting"), and so does naming the step directly - "openspec propose", "opsx apply" - which runs the workflow instead of hand-building the files. (`openspec update` is a real CLI command that refreshes generated files, so say "openspec update change" for that workflow.) Some tools add shorter command aliases (`/opsx:propose` in Claude Code, [other tools vary](../reference/supported-tools.md)). ## Step 1: Explore diff --git a/skills/openspec-apply-change/SKILL.md b/skills/openspec-apply-change/SKILL.md index 7869fac4..ed033ba9 100644 --- a/skills/openspec-apply-change/SKILL.md +++ b/skills/openspec-apply-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-apply-change -description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. +description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. Also use when the user says "openspec apply", "opsx apply", or "openspec implement". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/skills/openspec-archive-change/SKILL.md b/skills/openspec-archive-change/SKILL.md index d3a077d2..dd98c449 100644 --- a/skills/openspec-archive-change/SKILL.md +++ b/skills/openspec-archive-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-archive-change -description: Archive a completed OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. +description: Archive a completed OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. Also use when the user says "openspec archive" or "opsx archive". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/skills/openspec-bulk-archive-change/SKILL.md b/skills/openspec-bulk-archive-change/SKILL.md index 508740b1..b388f0b8 100644 --- a/skills/openspec-bulk-archive-change/SKILL.md +++ b/skills/openspec-bulk-archive-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-bulk-archive-change -description: Archive multiple completed OpenSpec changes at once. Use when archiving several parallel changes. +description: Archive multiple completed OpenSpec changes at once. Use when archiving several parallel changes. Also use for a plural archive request - "openspec bulk-archive", "opsx bulk-archive", "openspec archive all", or "openspec archive these changes". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/skills/openspec-continue-change/SKILL.md b/skills/openspec-continue-change/SKILL.md index 4a0d5e2f..1693da7f 100644 --- a/skills/openspec-continue-change/SKILL.md +++ b/skills/openspec-continue-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-continue-change -description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow. +description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow. Also use when the user says "openspec continue" or "opsx continue". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/skills/openspec-explore/SKILL.md b/skills/openspec-explore/SKILL.md index f7ae3056..5b4534e6 100644 --- a/skills/openspec-explore/SKILL.md +++ b/skills/openspec-explore/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-explore -description: Enter OpenSpec explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements in a project that uses OpenSpec. Use when the user wants to think through something before or during an OpenSpec change. +description: Enter OpenSpec explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements in a project that uses OpenSpec. Use when the user wants to think through something before or during an OpenSpec change. Also use when the user says "openspec explore" or "opsx explore". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/skills/openspec-ff-change/SKILL.md b/skills/openspec-ff-change/SKILL.md index 5607d7bc..f7b4b696 100644 --- a/skills/openspec-ff-change/SKILL.md +++ b/skills/openspec-ff-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-ff-change -description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually. +description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually. Also use when the user says "openspec ff" or "opsx ff". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/skills/openspec-new-change/SKILL.md b/skills/openspec-new-change/SKILL.md index ddb82238..432cc45d 100644 --- a/skills/openspec-new-change/SKILL.md +++ b/skills/openspec-new-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-new-change -description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach. +description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach. Also use when the user says "openspec new change" or "opsx new". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/skills/openspec-onboard/SKILL.md b/skills/openspec-onboard/SKILL.md index 8e23c838..7beeac03 100644 --- a/skills/openspec-onboard/SKILL.md +++ b/skills/openspec-onboard/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-onboard -description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work. +description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work. Also use when the user says "openspec onboard" or "opsx onboard". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/skills/openspec-propose/SKILL.md b/skills/openspec-propose/SKILL.md index 63322805..7f41c4b2 100644 --- a/skills/openspec-propose/SKILL.md +++ b/skills/openspec-propose/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-propose -description: Propose a new OpenSpec change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. +description: Propose a new OpenSpec change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. Also use when the user says "openspec propose" or "opsx propose". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/skills/openspec-sync-specs/SKILL.md b/skills/openspec-sync-specs/SKILL.md index 5480374e..41654b81 100644 --- a/skills/openspec-sync-specs/SKILL.md +++ b/skills/openspec-sync-specs/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-sync-specs -description: Sync delta specs from an OpenSpec change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. +description: Sync delta specs from an OpenSpec change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. Also use when the user says "openspec sync" or "opsx sync". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/skills/openspec-update-change/SKILL.md b/skills/openspec-update-change/SKILL.md index f369cad3..9aae524b 100644 --- a/skills/openspec-update-change/SKILL.md +++ b/skills/openspec-update-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-update-change -description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code. +description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Also use when the user says "openspec update change" or "opsx update". If the user means the openspec update CLI command, which refreshes generated files, run that command instead. Never edits code. allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/skills/openspec-verify-change/SKILL.md b/skills/openspec-verify-change/SKILL.md index e9065636..355febc9 100644 --- a/skills/openspec-verify-change/SKILL.md +++ b/skills/openspec-verify-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-verify-change -description: Verify implementation matches OpenSpec change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving. +description: Verify implementation matches OpenSpec change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving. Also use when the user says "openspec verify" or "opsx verify". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/src/core/templates/workflows/apply-change.ts b/src/core/templates/workflows/apply-change.ts index de86e545..6c03cba1 100644 --- a/src/core/templates/workflows/apply-change.ts +++ b/src/core/templates/workflows/apply-change.ts @@ -223,7 +223,7 @@ This skill supports the "actions on a change" model: export function getApplyChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-apply-change', - description: 'Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.', + description: 'Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. Also use when the user says "openspec apply", "opsx apply", or "openspec implement".', instructions: getApplyInstructions(), license: 'MIT', compatibility: 'Requires openspec CLI.', diff --git a/src/core/templates/workflows/archive-change.ts b/src/core/templates/workflows/archive-change.ts index 4ec03bab..3c1c18c5 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -31,7 +31,7 @@ const SYNC_GUARDRAIL = optionalWorkflow( export function getArchiveChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-archive-change', - description: 'Archive a completed OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.', + description: 'Archive a completed OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. Also use when the user says "openspec archive" or "opsx archive".', instructions: `Archive a completed change in the experimental workflow. ${STORE_SELECTION_GUIDANCE} diff --git a/src/core/templates/workflows/bulk-archive-change.ts b/src/core/templates/workflows/bulk-archive-change.ts index 1efdbb5c..1235bba8 100644 --- a/src/core/templates/workflows/bulk-archive-change.ts +++ b/src/core/templates/workflows/bulk-archive-change.ts @@ -31,7 +31,7 @@ const SYNC_GUARDRAIL = optionalWorkflow( export function getBulkArchiveChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-bulk-archive-change', - description: 'Archive multiple completed OpenSpec changes at once. Use when archiving several parallel changes.', + description: 'Archive multiple completed OpenSpec changes at once. Use when archiving several parallel changes. Also use for a plural archive request - "openspec bulk-archive", "opsx bulk-archive", "openspec archive all", or "openspec archive these changes".', instructions: `Archive multiple completed changes in a single operation. This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented. diff --git a/src/core/templates/workflows/continue-change.ts b/src/core/templates/workflows/continue-change.ts index a339db9a..56d5b34a 100644 --- a/src/core/templates/workflows/continue-change.ts +++ b/src/core/templates/workflows/continue-change.ts @@ -27,7 +27,7 @@ const PLANNING_COMPLETE_HANDOFF = optionalWorkflow( export function getContinueChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-continue-change', - description: 'Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.', + description: 'Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow. Also use when the user says "openspec continue" or "opsx continue".', instructions: `Continue working on a change by creating the next artifact. ${STORE_SELECTION_GUIDANCE} diff --git a/src/core/templates/workflows/explore.ts b/src/core/templates/workflows/explore.ts index 39bba776..96fe854b 100644 --- a/src/core/templates/workflows/explore.ts +++ b/src/core/templates/workflows/explore.ts @@ -76,7 +76,7 @@ const GUARDRAIL_HANDOFF = optionalWorkflow( export function getExploreSkillTemplate(): SkillTemplate { return { name: 'openspec-explore', - description: 'Enter OpenSpec explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements in a project that uses OpenSpec. Use when the user wants to think through something before or during an OpenSpec change.', + description: 'Enter OpenSpec explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements in a project that uses OpenSpec. Use when the user wants to think through something before or during an OpenSpec change. Also use when the user says "openspec explore" or "opsx explore".', instructions: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. **IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and ${IMPLEMENT_REQUEST_HANDOFF}. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. An explicit request from the user to capture the exploration as a new change is itself that confirmation, covering the change and the change artifacts the request names; scaffold it first as described below. diff --git a/src/core/templates/workflows/ff-change.ts b/src/core/templates/workflows/ff-change.ts index 057218b4..42edd7f9 100644 --- a/src/core/templates/workflows/ff-change.ts +++ b/src/core/templates/workflows/ff-change.ts @@ -32,7 +32,7 @@ const COMMAND_APPLY_HANDOFF = optionalWorkflow( export function getFfChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-ff-change', - description: 'Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually.', + description: 'Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually. Also use when the user says "openspec ff" or "opsx ff".', instructions: `Fast-forward through artifact creation - generate everything needed to start implementation in one go. ${STORE_SELECTION_GUIDANCE} diff --git a/src/core/templates/workflows/new-change.ts b/src/core/templates/workflows/new-change.ts index 97b54738..fee477ea 100644 --- a/src/core/templates/workflows/new-change.ts +++ b/src/core/templates/workflows/new-change.ts @@ -28,7 +28,7 @@ const EXISTING_CHANGE_HINT = optionalWorkflow( export function getNewChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-new-change', - description: 'Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.', + description: 'Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach. Also use when the user says "openspec new change" or "opsx new".', instructions: `Start a new change using the experimental artifact-driven approach. ${STORE_SELECTION_GUIDANCE} diff --git a/src/core/templates/workflows/onboard.ts b/src/core/templates/workflows/onboard.ts index 5db283f0..8b4f3b5f 100644 --- a/src/core/templates/workflows/onboard.ts +++ b/src/core/templates/workflows/onboard.ts @@ -73,7 +73,7 @@ const QUICK_REFERENCE_INVITE = optionalWorkflow( export function getOnboardSkillTemplate(): SkillTemplate { return { name: 'openspec-onboard', - description: 'Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.', + description: 'Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work. Also use when the user says "openspec onboard" or "opsx onboard".', instructions: getOnboardInstructions(), license: 'MIT', compatibility: 'Requires openspec CLI.', diff --git a/src/core/templates/workflows/propose.ts b/src/core/templates/workflows/propose.ts index c3754852..625bbeee 100644 --- a/src/core/templates/workflows/propose.ts +++ b/src/core/templates/workflows/propose.ts @@ -33,7 +33,7 @@ const COMMAND_APPLY_HANDOFF = optionalWorkflow( export function getOpsxProposeSkillTemplate(): SkillTemplate { return { name: 'openspec-propose', - description: 'Propose a new OpenSpec change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.', + description: 'Propose a new OpenSpec change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. Also use when the user says "openspec propose" or "opsx propose".', instructions: `Propose a new change - create the change and generate all artifacts in one step. **Planning boundary**: This workflow creates planning artifacts only. The user request that selected or triggered this workflow authorizes planning only, even if it asks to build or fix something. Do not edit project code. After the planning artifacts are complete, stop. Do not start implementation in the same response, even if the initial request asks for it. Wait for a new user request after the artifacts are presented; then start the apply workflow. diff --git a/src/core/templates/workflows/sync-specs.ts b/src/core/templates/workflows/sync-specs.ts index d06ee6fe..45280dff 100644 --- a/src/core/templates/workflows/sync-specs.ts +++ b/src/core/templates/workflows/sync-specs.ts @@ -11,7 +11,7 @@ import { PROJECT_ROOT_GUARD } from './project-root.js'; export function getSyncSpecsSkillTemplate(): SkillTemplate { return { name: 'openspec-sync-specs', - description: 'Sync delta specs from an OpenSpec change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.', + description: 'Sync delta specs from an OpenSpec change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. Also use when the user says "openspec sync" or "opsx sync".', instructions: `Sync delta specs from a change to main specs. This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement). diff --git a/src/core/templates/workflows/update-change.ts b/src/core/templates/workflows/update-change.ts index e5bdf974..6735d1db 100644 --- a/src/core/templates/workflows/update-change.ts +++ b/src/core/templates/workflows/update-change.ts @@ -76,7 +76,7 @@ const INTENT_CHANGE_GUARDRAIL = optionalWorkflow( export function getUpdateChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-update-change', - description: "Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code.", + description: "Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Also use when the user says \"openspec update change\" or \"opsx update\". If the user means the openspec update CLI command, which refreshes generated files, run that command instead. Never edits code.", instructions: `Revise a change's existing planning artifacts and keep them coherent. Never edit code. ${STORE_SELECTION_GUIDANCE} diff --git a/src/core/templates/workflows/verify-change.ts b/src/core/templates/workflows/verify-change.ts index dab988ba..8bc85edf 100644 --- a/src/core/templates/workflows/verify-change.ts +++ b/src/core/templates/workflows/verify-change.ts @@ -11,7 +11,7 @@ import { PROJECT_ROOT_GUARD } from './project-root.js'; export function getVerifyChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-verify-change', - description: 'Verify implementation matches OpenSpec change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.', + description: 'Verify implementation matches OpenSpec change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving. Also use when the user says "openspec verify" or "opsx verify".', instructions: `Verify that an implementation matches the change artifacts (specs, tasks, design). ${STORE_SELECTION_GUIDANCE} diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 29be2bd5..18e7933b 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -54,46 +54,46 @@ const asDeployed = (template: SkillTemplate): SkillTemplate => ({ }); const EXPECTED_FUNCTION_HASHES: Record = { - getExploreSkillTemplate: '432ca3409df7deff108724116b715752f18352bfbeb0c13e8b67a9e8030aea4e', - getNewChangeSkillTemplate: 'c7baff552ed6adcde8603741311c01aa56e4d548ccc46e4e8139fc3bc751ec23', - getContinueChangeSkillTemplate: '281ffd3876e8d3e4c2f5a2bdd640116abf617b900b57d8144ada7975dd10bf49', - getApplyChangeSkillTemplate: '40f0af2661e8e2340bd1a16316e96d5ac6ddc4d1932d911671cc85232e5ede7d', - getFfChangeSkillTemplate: '35dfbabda840facba023c471377c39f17a8449d84eae44fd1f12a4bc9a0cf1fe', - getSyncSpecsSkillTemplate: '66f3540367fe96fb1598a985f29f494364265e72953a83b69a65556bcd694ba5', - getOnboardSkillTemplate: '9fb82967e8887a051eb9e44eb10cbd65676020b57179ae7e5f9a8a3159cc4646', + getExploreSkillTemplate: 'b17a409b5634b5e48864a87f038f2111a74c2442288e9f4cb7a704f86b6d75e7', + getNewChangeSkillTemplate: '0e5035b7b42198afc430206a1dbc9579096650ef0813d85e837d5a6cd0b98a85', + getContinueChangeSkillTemplate: '550dc22bc8e0921b1ca5cef867379c4f370c5f1902c420bf9fa3bbfa75cea933', + getApplyChangeSkillTemplate: '04ae407c97b5f9cb0cc15199fe877ccc7cd1eff78bfe10ad70c16a112b10a661', + getFfChangeSkillTemplate: '6fb5492e78b9ceec068949080ec9f2e0d2a8baff75a2fe33d07ad33ffe542b65', + getSyncSpecsSkillTemplate: '2ba7107351ed2644542f089b0c224d345aca37142d7dc0db7ee8000d5425ebdf', + getOnboardSkillTemplate: '234e8237286ab7ae426923d058e66e32842211fd2d9180d62a17952f7a22d8ec', getOpsxExploreCommandTemplate: 'f6cf22825643281d653355745623a6c1a4566db46cc2f262d2282243c6d8169a', getOpsxNewCommandTemplate: '6d504fef1e0d4ced7c423f4cc9d9d2cee11b1a6224edf685e06a3f0757e0ebff', getOpsxContinueCommandTemplate: 'ace5c9cc239c12b57dc86fd9a1c02a6ca467cb8e1245127340c07ab1b9d37c11', getOpsxApplyCommandTemplate: 'd70cecce3b7d1dd4dbd5fd1fc2bccb538f5e61f5b43d520e4beca896e3f9e6b3', getOpsxFfCommandTemplate: '04cb49b0bf3ebe364b45268a283564ee4fd50b78b01ec1d3f975bcae68179d2d', - getArchiveChangeSkillTemplate: 'fad151a30728b9bbc67f2f9d9e72781b1923876d0e99c4820dfe204b2f5e4702', - getBulkArchiveChangeSkillTemplate: '7e494b5d96ddfc7d34721b27afe101b7529ad5a597ae9d3a1e5d92c451578b61', + getArchiveChangeSkillTemplate: '8447a2489240bf0c27f863065d61453dd0264842d1dabafe27b577d6bff96eb3', + getBulkArchiveChangeSkillTemplate: 'f17399959921ff98c7798e4591c8888825b7c9a83b0a90f09d98c7e0984ab793', getOpsxSyncCommandTemplate: '1664f4c4d8698ba058d03eaf977ff1de1718f6d86110e7987f681b6d2a6a9b1f', - getVerifyChangeSkillTemplate: '66a70271a3b3a07491784451a7237503807fceec2cb8ff6ec3d9dc35efff1da2', + getVerifyChangeSkillTemplate: '2e069a277dac23818b13bb50b66e806ab405bc3b7f535400e1ebf81b84153699', getOpsxArchiveCommandTemplate: '980109e5f8362610872c70fe0a0f1d48d3d2692275b2b17e2f4c91c3de89c2fd', getOpsxOnboardCommandTemplate: '4176f03b0be1a96668aa8c4234e8dcc1d61aba1226c1cbee9f4498a54cd0d546', getOpsxBulkArchiveCommandTemplate: '3db03eadb764abd74c8c180656c3f64a8b9a4971056c91624d38df3209d7b446', getOpsxVerifyCommandTemplate: '938f52f20fb9a3b811ea47314baac1034cd550e8ab363ae878ccba4b6329348f', - getOpsxProposeSkillTemplate: 'c4de09c1fd7e21a66a314fc5a8b3d35d7c4b6593ff3314495ecdc31fcb6e9c31', + getOpsxProposeSkillTemplate: '1aa2f2eb9c8cbc4dcab9d777bf8832b92ca04f9ef91d0494f1224a566aefdfe8', getOpsxProposeCommandTemplate: '3b7090ce5e79e879ab9b5bdaf4ff2b52e3c02211f71188838772d36ac337f96c', getFeedbackSkillTemplate: 'dabeb5e825b9349abc8156c3e7b8608f27987912a6d9bf47ef29addde6138133', - getUpdateChangeSkillTemplate: '92b05d159ce2ca5df670ec283e9ea824dc33ce9c6ee4583cae3b6be33ba4cb94', + getUpdateChangeSkillTemplate: 'f4c38adf3c82b3e0af7c460de97b72740d69a8966b5426b259f7c2cb6dc11d3d', getOpsxUpdateCommandTemplate: 'a3156c2c3b4a429fed56545f315f66a7cc25bc9f8822c5fe30a60ccd87159a0f', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': '1703134b9a33aabc3d9f45b5c47409c20fab8487c085d19a103a22a710cbbe63', - 'openspec-new-change': '81fe69229f937533174416b3d7d4198efbd38fd0f6f156e38802c13897dda18b', - 'openspec-continue-change': '7fe5bd31eeecc7903b151f6f2570dde5bb22fabf332fd0f07c2f9c18da683bcb', - 'openspec-apply-change': 'f2265b36445538c4adb9e5eae3710f6f639ba4825e9d638c34022c8ad8fb53a7', - 'openspec-ff-change': 'b64d7524aa6186d5cd901025bd249b69ba5a6bb6dfbd3f375743f1f826301654', - 'openspec-sync-specs': '52f12f5c14b38909b802018daf7c2e02c2a968dfb73235b1fcba93776e32318e', - 'openspec-archive-change': 'b5d2547ecdf2dd124beb19a325f90d098c224702910481a1837d043aa435a2be', - 'openspec-bulk-archive-change': '9615bf11e9e967217bb0a99733021ae595521af259b3e5109150c81eb19d89d0', - 'openspec-verify-change': 'c5f7b1f30c1fa5ef0297c9201ead621cedddbe6ee9389f72e2fa9ac750932f1d', - 'openspec-onboard': '07c5be759a5e0ada6f2593a7679ebc2535a237cca2b3134d0634054bafd2e92e', - 'openspec-propose': '54a623ded66f025dbd3a4a0c30ea3e832bf7c3dd1ef1f36e280f99a0fdea93df', - 'openspec-update-change': '82df7367098ec7d12de348d191dbfbe5bff1f98d9eb8a3beb9ae6ea321d15cfc', + 'openspec-explore': '8b02eb77ae87a4374a43ad33930e5ea19cbaf1c8a7624d5b613c03d7ee1c5f14', + 'openspec-new-change': '27e09d43785953827efc9a98bb9d6cf06db48fe6abe7e1c049409fe5b5061323', + 'openspec-continue-change': '182f015de6a1a114c79a6106c0565fd71f368d629641d0ad088de54bd871b52f', + 'openspec-apply-change': 'f3e92c229fab8d77df9f0a77dcb117cf46279b53a208d53aed89bfe0bab2ac09', + 'openspec-ff-change': '8ffad1b1a2deea5f097eb7294fb8b9474d5dfb1c31ee2fd3311d9a9d78259323', + 'openspec-sync-specs': 'f1d78a7f931330a766bafe5f619e587926fbd976474165eb6b8c0bc7e7e24dc1', + 'openspec-archive-change': '305a21a9c76a925055f3bdbaac504f208660ef6948d78f73928de166250609bf', + 'openspec-bulk-archive-change': '4bd638a50111d2ee3a667752a2355ed513f770695b137b93fc28848ca7bf60d2', + 'openspec-verify-change': 'ad8a3098bd27d852721687c47a12db7107ed8b8dfc7f071406bb19961652e7ee', + 'openspec-onboard': '41e50fb271850ff3de99c58cabdcd31338b86b592fcb1c97e1d4ed14f0fb850d', + 'openspec-propose': '66e3395adf9f2d93a09e8ef1d20e4efb010e5e8d4811f2d42a9316e4d1ca5a8b', + 'openspec-update-change': '19163b8c1b40ccdc0840019aa8005877a90a3a1cd9f7aadb87f76ccce1342f19', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates diff --git a/test/core/templates/workflow-verb-triggers.test.ts b/test/core/templates/workflow-verb-triggers.test.ts new file mode 100644 index 00000000..c4016e19 --- /dev/null +++ b/test/core/templates/workflow-verb-triggers.test.ts @@ -0,0 +1,247 @@ +import { Command, Help } from 'commander'; +import { describe, expect, it } from 'vitest'; +import { parse as parseYaml } from 'yaml'; + +import { program } from '../../../src/cli/index.js'; +import { ALL_WORKFLOWS } from '../../../src/core/profiles.js'; +import { + generateSkillContent, + getSkillTemplates, +} from '../../../src/core/shared/skill-generation.js'; + +/** + * The natural-language phrase a user or agent says for each workflow, as a + * suffix of `openspec `. Every workflow's skill description must name it so an + * agent that hears "do an openspec propose" matches the skill instead of + * hand-building the artifacts with the CLI (issue #1221). + * + * Keep in sync with getSkillTemplates(): the coverage test below fails when a + * workflow is added without a trigger phrase. + */ +const NATURAL_VERB_BY_WORKFLOW: Record = { + explore: 'explore', + new: 'new change', + continue: 'continue', + apply: 'apply', + update: 'update change', + ff: 'ff', + sync: 'sync', + archive: 'archive', + 'bulk-archive': 'bulk-archive', + verify: 'verify', + onboard: 'onboard', + propose: 'propose', +}; + +/** + * Phrases whose first word is a real CLI command and that a skill nonetheless + * claims on purpose, with the reason. Every other collision is a defect: a + * skill would send an LLM to re-do a deterministic command. + * + * `openspec update` is the case this rule exists for. It refreshes generated + * instruction files and has nothing to do with the update-change workflow, so + * that skill claims `openspec update change` and redirects to the CLI command + * in its description. + */ +const DELIBERATE_CLI_PHRASE_CLAIMS: Record = { + 'openspec archive': + 'both archive and merge delta specs, but the workflow confirms with the user and verifies the merge capability-by-capability before anything moves, where the bare command does it in one shot; an agent asked to archive should take the checked path', + 'openspec archive all': 'same, for several changes at once (bulk-archive)', + 'openspec archive these changes': 'same, for several changes at once (bulk-archive)', + 'openspec new change': + 'the new-change workflow runs this exact CLI command as its first step, then continues with the artifacts', + 'openspec update change': + 'distinct from `openspec update`, which the update-change description redirects to the CLI command', +}; + +/** + * Pairs where one skill's phrase is a prefix of another skill's, so an + * utterance matching the longer one also contains the shorter. Declared with + * the reason it is safe; anything undeclared is an accidental misroute. + */ +const DELIBERATE_PHRASE_SHADOWING: Record = { + 'openspec archive < openspec archive all': + 'plural requests are claimed explicitly by bulk-archive so they outweigh the bare literal', + 'openspec archive < openspec archive these changes': + 'plural requests are claimed explicitly by bulk-archive so they outweigh the bare literal', +}; + +/** + * Every command name the CLI registers, at any depth, including aliases. + * Walks the real commander tree rather than scanning a source file: seven + * command groups (spec, config, schema, store, doctor, context, workset) are + * registered from their own modules and a text scan of the entrypoint misses + * them. Importing `program` does not parse argv (see runCli). + */ +function collectCommandNames(command: Command, into = new Set()): Set { + const visible = new Set(new Help().visibleCommands(command)); + for (const sub of command.commands) { + // A hidden command named after a workflow is a verb hint (#1776): it only + // tells the user to run that workflow in their assistant, so a skill + // claiming the same phrase sends them to the same place. Visible commands, + // and hidden commands that are not workflow names, are still guarded. + if (!visible.has(sub) && (ALL_WORKFLOWS as readonly string[]).includes(sub.name())) continue; + into.add(sub.name()); + for (const alias of sub.aliases()) into.add(alias); + collectCommandNames(sub, into); + } + return into; +} + +/** + * Quoted `openspec …` / `opsx …` phrases a description claims. Only quoted + * text counts as a claim: descriptions also mention commands in prose (the + * update-change redirect names the openspec update CLI command unquoted), + * and prose is not a routing trigger. Matching is case-insensitive so a + * capitalized phrase cannot slip past the guards below. + */ +function claimedPhrases(description: string): string[] { + return [...description.matchAll(/(["`])((?:openspec|opsx) [^"`]+)\1/gi)].map(m => + m[2].toLowerCase() + ); +} + +function descriptionOf(workflowId: string): string { + const entry = getSkillTemplates().find(e => e.workflowId === workflowId); + if (!entry) throw new Error(`no skill template for workflow ${workflowId}`); + return entry.template.description; +} + +function allClaims(): { dirName: string; phrase: string }[] { + return getSkillTemplates().flatMap(entry => + claimedPhrases(entry.template.description).map(phrase => ({ dirName: entry.dirName, phrase })) + ); +} + +describe('workflow verb triggers', () => { + it('covers every workflow that ships a skill', () => { + const shipped = getSkillTemplates().map(e => e.workflowId).sort(); + expect(shipped).toEqual(Object.keys(NATURAL_VERB_BY_WORKFLOW).sort()); + }); + + it.each(Object.entries(NATURAL_VERB_BY_WORKFLOW))( + '%s names its natural "openspec" and "opsx" phrasings', + (workflowId, verb) => { + const description = descriptionOf(workflowId); + expect(description).toContain(`"openspec ${verb}"`); + expect(description).toContain(`"opsx ${workflowId}"`); + } + ); + + it.each(Object.keys(NATURAL_VERB_BY_WORKFLOW))( + '%s does not append the generic "doing the work by hand" clause', + workflowId => { + // Removed on purpose: it told the agent to follow the skill instead of + // doing the work, which contradicts explore being a stance rather than + // a workflow. The trigger sentence alone is the routing signal. + expect(descriptionOf(workflowId).toLowerCase()).not.toContain('doing the work by hand'); + } + ); + + it('sees the CLI commands registered outside the entrypoint', () => { + // Without this the collision test would pass vacuously, and it pins the + // blind spot that scanning src/cli/index.ts for `.command('…')` had: these + // seven groups are registered from their own modules. + const names = collectCommandNames(program); + for (const known of ['init', 'update', 'archive', 'new', 'validate', 'list']) { + expect(names, `CLI command "${known}" is missing`).toContain(known); + } + for (const delegated of ['spec', 'config', 'schema', 'store', 'doctor', 'context', 'workset']) { + expect(names, `delegated CLI command "${delegated}" is missing`).toContain(delegated); + } + }); + + it('ignores hidden workflow-verb hints but still guards every other command', () => { + const cli = new Command('openspec'); + cli.command('explore', { hidden: true }); + cli.command('propose'); + cli.command('legacy-thing', { hidden: true }); + const names = collectCommandNames(cli); + expect(names).not.toContain('explore'); + expect(names).toContain('propose'); + expect(names).toContain('legacy-thing'); + }); + + it('claims a real CLI command only on purpose', () => { + const cliCommands = collectCommandNames(program); + + for (const { dirName, phrase } of allClaims()) { + const [namespace, firstWord] = phrase.split(' '); + // `opsx` is not a binary, so those phrases collide with nothing. + if (namespace !== 'openspec' || !cliCommands.has(firstWord)) continue; + + expect( + DELIBERATE_CLI_PHRASE_CLAIMS[phrase], + `${dirName} claims "${phrase}", but "openspec ${firstWord}" is a real CLI command. ` + + `Either pick a phrase that does not shadow it, or add an entry to DELIBERATE_CLI_PHRASE_CLAIMS saying why this is right.` + ).toBeTruthy(); + } + }); + + it('never claims the bare `openspec update` CLI command', () => { + // A hard floor the allowlist cannot lift: refreshing generated files has + // nothing to do with revising a change. + for (const { dirName, phrase } of allClaims()) { + expect(phrase, `${dirName} would shadow the openspec update CLI command`).not.toBe( + 'openspec update' + ); + } + }); + + it('routes each phrase to exactly one skill', () => { + const seen = new Map(); + for (const { dirName, phrase } of allClaims()) { + const owner = seen.get(phrase); + expect(owner, `"${phrase}" is claimed by both ${owner} and ${dirName}`).toBeUndefined(); + seen.set(phrase, dirName); + } + }); + + it('shadows a shorter phrase only on purpose', () => { + const claims = allClaims(); + + for (const shorter of claims) { + for (const longer of claims) { + if (shorter.dirName === longer.dirName) continue; + if (!longer.phrase.startsWith(`${shorter.phrase} `)) continue; + + const key = `${shorter.phrase} < ${longer.phrase}`; + expect( + DELIBERATE_PHRASE_SHADOWING[key], + `${shorter.dirName} claims "${shorter.phrase}", which every utterance of ${longer.dirName}'s ` + + `"${longer.phrase}" also contains, so the shorter claim can win. Declare it in ` + + `DELIBERATE_PHRASE_SHADOWING as "${key}" with the reason it is safe.` + ).toBeTruthy(); + } + } + }); + + it('keeps no stale entries in either allowlist', () => { + const phrases = new Set(allClaims().map(c => c.phrase)); + for (const phrase of Object.keys(DELIBERATE_CLI_PHRASE_CLAIMS)) { + expect(phrases, `DELIBERATE_CLI_PHRASE_CLAIMS has "${phrase}", which no skill claims`).toContain( + phrase + ); + } + for (const key of Object.keys(DELIBERATE_PHRASE_SHADOWING)) { + const [shorter, longer] = key.split(' < '); + expect(phrases, `DELIBERATE_PHRASE_SHADOWING has "${key}", but "${shorter}" is unclaimed`).toContain(shorter); + expect(phrases, `DELIBERATE_PHRASE_SHADOWING has "${key}", but "${longer}" is unclaimed`).toContain(longer); + } + }); + + // The description is written into YAML frontmatter as an unquoted plain + // scalar (see generateSkillContent), so a trigger phrase must not introduce + // characters that change how the scalar parses. + it('keeps generated frontmatter parseable with the description intact', () => { + for (const entry of getSkillTemplates()) { + const content = generateSkillContent(entry.template, '1.0.0-test'); + const frontmatter = content.match(/^---\n([\s\S]*?)\n---\n/); + expect(frontmatter, `${entry.dirName} has no frontmatter`).not.toBeNull(); + + const parsed = parseYaml(frontmatter![1]) as Record; + expect(parsed.name).toBe(entry.template.name); + expect(parsed.description).toBe(entry.template.description); + } + }); +});