diff --git a/.changeset/workflow-verb-triggers.md b/.changeset/workflow-verb-triggers.md new file mode 100644 index 0000000000..229d9daa62 --- /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 fa7deae19b..09d6a4c295 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 7869fac441..ed033ba934 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 d3a077d293..dd98c4499f 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 508740b1bd..b388f0b802 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 4a0d5e2f23..1693da7fa3 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 f7ae305612..5b4534e6b9 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 5607d7bc69..f7b4b69611 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 ddb822386b..432cc45d97 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 8e23c838f5..7beeac03c7 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 6332280526..7f41c4b27a 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 5480374ec2..41654b814f 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 f369cad3d0..9aae524ba0 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 e906563614..355febc9b2 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 de86e5450f..6c03cba10d 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 4ec03bab37..3c1c18c5e7 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 1efdbb5cce..1235bba8e1 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 a339db9a91..56d5b34a63 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 39bba7761c..96fe854bd7 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 057218b47e..42edd7f960 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 97b5473868..fee477ea8a 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 5db283f0a0..8b4f3b5f6a 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 c3754852d9..625bbeee2b 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 d06ee6fe84..45280dff4f 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 e5bdf9742d..6735d1dbe8 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 dab988ba62..8bc85edfb6 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 29be2bd560..18e7933b0b 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 0000000000..c4016e19a0 --- /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); + } + }); +});