From 08234f7bb92e68c5ec77471bd9f019bdabf8f348 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 11 Sep 2026 11:56:22 -0500 Subject: [PATCH 1/8] feat(skills): match natural "openspec " phrasing to its workflow Users and agents say "openspec propose" / "openspec apply", but no workflow skill description contained that phrasing, so an agent hearing it had nothing to match and routinely hand-built the artifacts with the CLI instead of running the workflow. Each workflow skill's description now names the phrasings that should route to it. `openspec update` is deliberately left unclaimed: it is a real CLI command that refreshes generated files, unrelated to the update-change workflow, so that skill claims "openspec update change" instead. Descriptions are emitted as unquoted YAML plain scalars, so the new tests also pin that the generated frontmatter still parses and the description round-trips. Closes #1221 Co-Authored-By: Claude Opus 5 --- .changeset/workflow-verb-triggers.md | 5 + skills/openspec-apply-change/SKILL.md | 2 +- skills/openspec-archive-change/SKILL.md | 2 +- skills/openspec-bulk-archive-change/SKILL.md | 2 +- skills/openspec-continue-change/SKILL.md | 2 +- skills/openspec-explore/SKILL.md | 2 +- skills/openspec-ff-change/SKILL.md | 2 +- skills/openspec-new-change/SKILL.md | 2 +- skills/openspec-onboard/SKILL.md | 2 +- skills/openspec-propose/SKILL.md | 2 +- skills/openspec-sync-specs/SKILL.md | 2 +- skills/openspec-update-change/SKILL.md | 2 +- skills/openspec-verify-change/SKILL.md | 2 +- src/core/templates/workflows/apply-change.ts | 2 +- .../templates/workflows/archive-change.ts | 2 +- .../workflows/bulk-archive-change.ts | 2 +- .../templates/workflows/continue-change.ts | 2 +- src/core/templates/workflows/explore.ts | 2 +- src/core/templates/workflows/ff-change.ts | 2 +- src/core/templates/workflows/new-change.ts | 2 +- src/core/templates/workflows/onboard.ts | 2 +- src/core/templates/workflows/propose.ts | 2 +- src/core/templates/workflows/sync-specs.ts | 2 +- src/core/templates/workflows/update-change.ts | 2 +- src/core/templates/workflows/verify-change.ts | 2 +- .../templates/skill-templates-parity.test.ts | 48 ++++----- .../templates/workflow-verb-triggers.test.ts | 98 +++++++++++++++++++ 27 files changed, 151 insertions(+), 48 deletions(-) create mode 100644 .changeset/workflow-verb-triggers.md create mode 100644 test/core/templates/workflow-verb-triggers.test.ts diff --git a/.changeset/workflow-verb-triggers.md b/.changeset/workflow-verb-triggers.md new file mode 100644 index 0000000000..50b229f738 --- /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. Every workflow skill's description now lists the phrasings a user actually types ("openspec propose", "opsx apply", and so on), so an agent that hears "do an openspec propose" runs the workflow instead of hand-building the artifacts with the CLI. `openspec update` is deliberately left unclaimed: it is a real CLI command that refreshes generated files, unrelated to the update-change workflow. diff --git a/skills/openspec-apply-change/SKILL.md b/skills/openspec-apply-change/SKILL.md index 098f63fecb..0588f2ea99 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" or "opsx apply" - follow this skill rather than doing the work by hand. 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 5f34ed53a7..6972a6854a 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 change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. +description: Archive a completed 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" - follow this skill rather than doing the work by hand. 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 252e1dd155..737cfbf209 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 changes at once. Use when archiving several parallel changes. +description: Archive multiple completed changes at once. Use when archiving several parallel changes. Also use when the user says "openspec bulk-archive" or "opsx bulk-archive" - follow this skill rather than doing the work by hand. 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 5991b06891..d413e028bc 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" - follow this skill rather than doing the work by hand. 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 2ff15bfed1..63d3b5b917 100644 --- a/skills/openspec-explore/SKILL.md +++ b/skills/openspec-explore/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-explore -description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. +description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. Also use when the user says "openspec explore" or "opsx explore" - follow this skill rather than doing the work by hand. 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 72a95620bb..a284ddc25e 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" - follow this skill rather than doing the work by hand. 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 9aea11d391..7e18cb3d49 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" - follow this skill rather than doing the work by hand. 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 fb3f13bec7..8f80148656 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" - follow this skill rather than doing the work by hand. 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 49454cd4a4..5d99a7b2e1 100644 --- a/skills/openspec-propose/SKILL.md +++ b/skills/openspec-propose/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-propose -description: Propose a new 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 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" - follow this skill rather than doing the work by hand. 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 d12d56b857..81d8de47a2 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 a 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 a 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" - follow this skill rather than doing the work by hand. 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 24c9f88367..5194fb7d8e 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" - follow this skill rather than doing the work by hand. 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 2165a6a910..eba9dc6fd8 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 change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving. +description: Verify implementation matches 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" - follow this skill rather than doing the work by hand. 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 d7ebe2f4eb..dc1664e49d 100644 --- a/src/core/templates/workflows/apply-change.ts +++ b/src/core/templates/workflows/apply-change.ts @@ -198,7 +198,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" or "opsx apply" - follow this skill rather than doing the work by hand.', 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 2dae74d436..290953065e 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getArchiveChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-archive-change', - description: 'Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.', + description: 'Archive a completed 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" - follow this skill rather than doing the work by hand.', 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 cacede2543..9711101e7f 100644 --- a/src/core/templates/workflows/bulk-archive-change.ts +++ b/src/core/templates/workflows/bulk-archive-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getBulkArchiveChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-bulk-archive-change', - description: 'Archive multiple completed changes at once. Use when archiving several parallel changes.', + description: 'Archive multiple completed changes at once. Use when archiving several parallel changes. Also use when the user says "openspec bulk-archive" or "opsx bulk-archive" - follow this skill rather than doing the work by hand.', 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 14b3109e43..892d9272a9 100644 --- a/src/core/templates/workflows/continue-change.ts +++ b/src/core/templates/workflows/continue-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; 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" - follow this skill rather than doing the work by hand.', 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 20db318293..97cd14e9e8 100644 --- a/src/core/templates/workflows/explore.ts +++ b/src/core/templates/workflows/explore.ts @@ -32,7 +32,7 @@ adding a service to operate; shared state would need a separate sync design. export function getExploreSkillTemplate(): SkillTemplate { return { name: 'openspec-explore', - description: 'Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.', + description: 'Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. Also use when the user says "openspec explore" or "opsx explore" - follow this skill rather than doing the work by hand.', instructions: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. **IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. diff --git a/src/core/templates/workflows/ff-change.ts b/src/core/templates/workflows/ff-change.ts index 843fb79c87..2701f70d8c 100644 --- a/src/core/templates/workflows/ff-change.ts +++ b/src/core/templates/workflows/ff-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; 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" - follow this skill rather than doing the work by hand.', 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 e45858abbc..3a685de35a 100644 --- a/src/core/templates/workflows/new-change.ts +++ b/src/core/templates/workflows/new-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; 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" - follow this skill rather than doing the work by hand.', 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 414c6e18b5..cf15512660 100644 --- a/src/core/templates/workflows/onboard.ts +++ b/src/core/templates/workflows/onboard.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; 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" - follow this skill rather than doing the work by hand.', 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 09e80491cb..8091fe73b4 100644 --- a/src/core/templates/workflows/propose.ts +++ b/src/core/templates/workflows/propose.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getOpsxProposeSkillTemplate(): SkillTemplate { return { name: 'openspec-propose', - description: 'Propose a new 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 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" - follow this skill rather than doing the work by hand.', 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 bedbaa7164..08e2447b81 100644 --- a/src/core/templates/workflows/sync-specs.ts +++ b/src/core/templates/workflows/sync-specs.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getSyncSpecsSkillTemplate(): SkillTemplate { return { name: 'openspec-sync-specs', - description: 'Sync delta specs from a 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 a 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" - follow this skill rather than doing the work by hand.', 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 7700cd8d7e..502291c753 100644 --- a/src/core/templates/workflows/update-change.ts +++ b/src/core/templates/workflows/update-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; 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\" - follow this skill rather than doing the work by hand. 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 1aa540c76b..d0fc73c155 100644 --- a/src/core/templates/workflows/verify-change.ts +++ b/src/core/templates/workflows/verify-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getVerifyChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-verify-change', - description: 'Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.', + description: 'Verify implementation matches 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" - follow this skill rather than doing the work by hand.', 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 4d2e717cae..34e317487e 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -38,46 +38,46 @@ import { import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; const EXPECTED_FUNCTION_HASHES: Record = { - getExploreSkillTemplate: '06aba775c621e61f00995a9ebc3a02fe873ddcc9bf024e416c4adaf91ccce115', - getNewChangeSkillTemplate: 'eabd1e895c5881dcb17dcbaa3fb26098dd59e8eacb318e400820b4dc811ef781', - getContinueChangeSkillTemplate: '012136f6411a99c8fa228e2f9444cb64b0a89e0f56fdeac2fe03b2f5bee0c5d7', - getApplyChangeSkillTemplate: 'd1e7d5ceb85193c0964057dbb88e9651526754bd33f84020e2440ff0621d5dbb', - getFfChangeSkillTemplate: 'efa6a70c111b18b61a7720250b9622afa9a212fb64edf609cf80e2182a9bdf8c', - getSyncSpecsSkillTemplate: 'b099e2ff31859c9b10d928066e662524f9aad9ecf2be12fceacb732d718c4146', - getOnboardSkillTemplate: '3a836faae463d88c289a1c129cb7ee556a563b7e53e1a52a4711ff152a3b51f7', + getExploreSkillTemplate: 'dfdf9946a31df7ecf0556fb2cf9bdeba5cf2deb6c62a97e899a422fd658d1855', + getNewChangeSkillTemplate: 'fe80a2f2f66da1598e28319150e1c7d7fb8d624032716efe50341819962c6380', + getContinueChangeSkillTemplate: '6910e2b1e80a38c9385bce16cab9f7e181be0fe26416bd12829dfe68705e75c0', + getApplyChangeSkillTemplate: '3cf00dce53ec34b555682d579742c10ee6092d3c3e6337be2709e324411700d8', + getFfChangeSkillTemplate: 'b14d6dcd83563a77dbc1f70f25dd53895f081180ef05e6587443102f87dab3e1', + getSyncSpecsSkillTemplate: '221c6639f946b3593fe0c539039977103a2bd14f18a7f77354abbaa66fabf37e', + getOnboardSkillTemplate: 'afe92188946d48cd7552fa8338852b184aa106991a3c1821d038b5af88bac145', getOpsxExploreCommandTemplate: '8046003e97d885a86ed392d4fb522bb78544a02872b042e51347a5021cc10523', getOpsxNewCommandTemplate: 'f2d30e569798a4c92ba932859d6ba4e0ad10e18feccbade1cfee0957597b3463', getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0', getOpsxFfCommandTemplate: '21132fc9c6d3b3ab2d2295d6bbd72d1e0052eb35ea1be0258c8b1ab3e200c4db', - getArchiveChangeSkillTemplate: '56bfada1a5f35a127791b70de9d428a75b5aedd1584d6c9803a1ecb1fd1b4a23', - getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d', + getArchiveChangeSkillTemplate: '9e6e535264aa0c3800ef9fb117787c2d7b104c65d7ed98aa87df6837ab6a2cc1', + getBulkArchiveChangeSkillTemplate: 'e4806c1541fde683ed51bada953b0550f732d8994c219a94e4f9a6506d4b0bbf', getOpsxSyncCommandTemplate: '0d2427efb79986e8fff3f96bd075a739c80d45eb29159fae717e950030da8202', - getVerifyChangeSkillTemplate: '223b7ffd99299a7d430e13092b9a0a3421b39f0d3217232f46c39d79b5f619ff', + getVerifyChangeSkillTemplate: 'e84b9a3dbcb70e8a256e1549cd2d6d9dfbdd6508f8ccc3c562dab270f000e730', getOpsxArchiveCommandTemplate: '9f973c819b11620985b03322945f0e0a92a02a2ef455b94e74482f5e6292ac5d', getOpsxOnboardCommandTemplate: 'ee99aa99252c602720fbb8c63fb3ac438a5bd4e952fd961ddf1ae956cbfc2c8f', getOpsxBulkArchiveCommandTemplate: '9fa8cdebe2f5667ebfc37bdc023396762c59d5b038c771dac2d8fd2c19e2627b', getOpsxVerifyCommandTemplate: '1efcf7eff0671f48e9d9420f50865c563dd3079ee60f8c380bb7a90dd0102696', - getOpsxProposeSkillTemplate: 'b7215583fefddae0127076465de9b3de9c230f2f1ea9ae6e4fb2a46fe510e8d6', + getOpsxProposeSkillTemplate: '224b5e68307c5533308edd8a21ac6a5897ab0b427324f52dffc6b7e736aa0164', getOpsxProposeCommandTemplate: 'f016c66c2b6115b459751154c76a6270e444d6aee31973bb7cb8c0e6d505fb98', getFeedbackSkillTemplate: 'dabeb5e825b9349abc8156c3e7b8608f27987912a6d9bf47ef29addde6138133', - getUpdateChangeSkillTemplate: '7dc8abc6f64c58bf34d7581ed4ab095a3b7a53cb372349bee2d840db58622819', + getUpdateChangeSkillTemplate: 'bf0495bd05e75dc5196de3c8b6eae162b8d6b3c78eb07afc8a9dc6313c373e1b', getOpsxUpdateCommandTemplate: 'e2388521b22f92f74561df9a0c2f98e1fa4d265af93b5ba26f42fb47a6c5bfed', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': '32b20cfbcc7d51ff526bb19571ff3dc3d0c616a5911b8de74cf6d9b15650cf3e', - 'openspec-new-change': 'ec4529beef978e34634a6f7286fab55d68fad8fb374dceb45691d52caab33fbb', - 'openspec-continue-change': 'bb6194a16c54891cdb253678e8f70ce53b2af86735243980f366ce551d37e42e', - 'openspec-apply-change': '81ea96d9fa6ec8536cd23c1fe561ed28e1cc1cad0a8ceb700588e08974cc0e49', - 'openspec-ff-change': '31355250514bce51b16ff37ee2b833bc9d475cd0dbd4b1f68fe2041694575623', - 'openspec-sync-specs': 'd933d8856584d6c1253de91e652e7aee9e85c77ad4d3531f6476f79d84e6e5e8', - 'openspec-archive-change': '7c65053d674ba4e1e20e2bf73ba7e5a7f94baef2eaa9b33cee48d4cadea51b7a', - 'openspec-bulk-archive-change': '2039b9ecf6e64339dffe0e16272507a386d9fe326f419ff758315aa736fdd96c', - 'openspec-verify-change': 'af9be013dcbe8c6d8f6d9ab10c893fbd03f4c62933c384d82f63894dd0ceb84f', - 'openspec-onboard': 'f6f59476acaf5e4d65dbb180da4cef62432612f3cecf207d471a951295e2003a', - 'openspec-propose': '679d0f868bed23cfb34a8ecc6b4ba4ff7b88dd7dbaef91563423e98f194f988f', - 'openspec-update-change': '586547406aca94422dfeb3ffedce6c01049429b743f57ce829baa79ebc714d51', + 'openspec-explore': '16ce157678837a4ea252bacd7721d66dffa0d3e7acc9dae8c2de69372cb9d259', + 'openspec-new-change': '4fcb8fca6500ec8e76c5b238bfb4b78f5eb70d53bd438f3a714de5f4200d3bc9', + 'openspec-continue-change': '9611046fa51b68f01380e083ce7c8e611e7f965bb3a0f5f6a6f9ba4c8b8c7305', + 'openspec-apply-change': 'a2bd638b2cf7e266b91e70b169d6fdd34347e1f996cd851374f437667654b374', + 'openspec-ff-change': '63dd8155240c7ec9b46bc2fcdb0469440b7d594b89ec3afe7934548b02bad2cb', + 'openspec-sync-specs': '4f938600169e23eeae9a5a6730ffb9dbdb460f7d12aa117e49d5474fd36946f5', + 'openspec-archive-change': '531c09ac7197cb258543710f7a72e3a7c01423420b2c03cbe09392d136b79675', + 'openspec-bulk-archive-change': '589d69755c10b6ad3133a84d9b3351a82a17b81d1c952d3413708a4e21f6e53d', + 'openspec-verify-change': '8d4f3aaa6af1f69918427d21462399852d07c514b0c8532ec0c206fe3945db5a', + 'openspec-onboard': 'd1cf90943ed786d1e658cca285aef6404edd5fd5de625e36c2408bb6d17326b2', + 'openspec-propose': '8696dc53185042cae577c646505bfdb4795db11634e55cb0137152b7aae09dd3', + 'openspec-update-change': 'd4ae942aa6cd7164800c5ff636150db689aa110db9d08c57b8c6ade87c5b6bf7', }; // 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..557573f9a7 --- /dev/null +++ b/test/core/templates/workflow-verb-triggers.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { parse as parseYaml } from 'yaml'; + +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', +}; + +/** + * CLI commands that do something unrelated to the workflow of the same name. + * A skill description must never claim these bare phrasings, or the skill would + * hijack a real CLI invocation. `openspec update` refreshes generated + * instruction files; it has nothing to do with the update-change workflow. + */ +const RESERVED_CLI_PHRASES = ['"openspec update"']; + +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; +} + +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('never claims a bare CLI phrase that means something else', () => { + for (const entry of getSkillTemplates()) { + for (const reserved of RESERVED_CLI_PHRASES) { + expect( + entry.template.description, + `${entry.dirName} claims the reserved CLI phrase ${reserved}` + ).not.toContain(reserved); + } + } + }); + + it('routes each phrase to exactly one skill', () => { + const seen = new Map(); + for (const entry of getSkillTemplates()) { + for (const phrase of entry.template.description.match(/"(?:openspec|opsx) [a-z- ]+"/g) ?? []) { + const owner = seen.get(phrase); + expect(owner, `${phrase} is claimed by both ${owner} and ${entry.dirName}`).toBeUndefined(); + seen.set(phrase, entry.dirName); + } + } + }); + + // 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); + } + }); +}); From 68edf8ee6c23c07bdba3147e657df83ecb720585 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 11 Sep 2026 12:04:23 -0500 Subject: [PATCH 2/8] fix(skills): derive the CLI-collision guard instead of hardcoding it Review found the guard codified the one exception rather than the rule, so it could never catch the next collision. It now reads every command name the CLI registers and fails on any claimed phrase that shadows one, unless the phrase is listed in DELIBERATE_CLI_PHRASE_CLAIMS with a reason. Two routing fixes fall out of stating the rule: - bulk-archive also claims "openspec archive all", so an exact-phrase match on "openspec archive" no longer pulls a multi-change request to the single-change skill. - update-change now disclaims the openspec update CLI command in prose, not only by avoiding the string. Co-Authored-By: Claude Opus 5 --- skills/openspec-bulk-archive-change/SKILL.md | 2 +- skills/openspec-update-change/SKILL.md | 2 +- .../workflows/bulk-archive-change.ts | 2 +- src/core/templates/workflows/update-change.ts | 2 +- .../templates/skill-templates-parity.test.ts | 8 +- .../templates/workflow-verb-triggers.test.ts | 83 ++++++++++++++++--- 6 files changed, 79 insertions(+), 20 deletions(-) diff --git a/skills/openspec-bulk-archive-change/SKILL.md b/skills/openspec-bulk-archive-change/SKILL.md index 737cfbf209..29b69c9a4c 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 changes at once. Use when archiving several parallel changes. Also use when the user says "openspec bulk-archive" or "opsx bulk-archive" - follow this skill rather than doing the work by hand. +description: Archive multiple completed changes at once. Use when archiving several parallel changes. Also use when the user says "openspec bulk-archive", "opsx bulk-archive", or "openspec archive all" - follow this skill rather than doing the work by hand. 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 5194fb7d8e..3e59d188a9 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. Also use when the user says "openspec update change" or "opsx update" - follow this skill rather than doing the work by hand. 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" - follow this skill rather than doing the work by hand. Not for the openspec update CLI command, which refreshes generated files. Never edits code. allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/src/core/templates/workflows/bulk-archive-change.ts b/src/core/templates/workflows/bulk-archive-change.ts index 9711101e7f..a079598b83 100644 --- a/src/core/templates/workflows/bulk-archive-change.ts +++ b/src/core/templates/workflows/bulk-archive-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getBulkArchiveChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-bulk-archive-change', - description: 'Archive multiple completed changes at once. Use when archiving several parallel changes. Also use when the user says "openspec bulk-archive" or "opsx bulk-archive" - follow this skill rather than doing the work by hand.', + description: 'Archive multiple completed changes at once. Use when archiving several parallel changes. Also use when the user says "openspec bulk-archive", "opsx bulk-archive", or "openspec archive all" - follow this skill rather than doing the work by hand.', 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/update-change.ts b/src/core/templates/workflows/update-change.ts index 502291c753..c33853e373 100644 --- a/src/core/templates/workflows/update-change.ts +++ b/src/core/templates/workflows/update-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; 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. Also use when the user says \"openspec update change\" or \"opsx update\" - follow this skill rather than doing the work by hand. 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\" - follow this skill rather than doing the work by hand. Not for the openspec update CLI command, which refreshes generated files. Never edits code.", instructions: `Revise a change's existing planning artifacts and keep them coherent. Never edit code. ${STORE_SELECTION_GUIDANCE} diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 34e317487e..4d5b045003 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -51,7 +51,7 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0', getOpsxFfCommandTemplate: '21132fc9c6d3b3ab2d2295d6bbd72d1e0052eb35ea1be0258c8b1ab3e200c4db', getArchiveChangeSkillTemplate: '9e6e535264aa0c3800ef9fb117787c2d7b104c65d7ed98aa87df6837ab6a2cc1', - getBulkArchiveChangeSkillTemplate: 'e4806c1541fde683ed51bada953b0550f732d8994c219a94e4f9a6506d4b0bbf', + getBulkArchiveChangeSkillTemplate: '4b3317b9bf11b430caf49d34a88f5d4937cb6b11732eae1592e910f67b9787a5', getOpsxSyncCommandTemplate: '0d2427efb79986e8fff3f96bd075a739c80d45eb29159fae717e950030da8202', getVerifyChangeSkillTemplate: 'e84b9a3dbcb70e8a256e1549cd2d6d9dfbdd6508f8ccc3c562dab270f000e730', getOpsxArchiveCommandTemplate: '9f973c819b11620985b03322945f0e0a92a02a2ef455b94e74482f5e6292ac5d', @@ -61,7 +61,7 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxProposeSkillTemplate: '224b5e68307c5533308edd8a21ac6a5897ab0b427324f52dffc6b7e736aa0164', getOpsxProposeCommandTemplate: 'f016c66c2b6115b459751154c76a6270e444d6aee31973bb7cb8c0e6d505fb98', getFeedbackSkillTemplate: 'dabeb5e825b9349abc8156c3e7b8608f27987912a6d9bf47ef29addde6138133', - getUpdateChangeSkillTemplate: 'bf0495bd05e75dc5196de3c8b6eae162b8d6b3c78eb07afc8a9dc6313c373e1b', + getUpdateChangeSkillTemplate: 'f833215495833bba7aed364ba07e288652759488434f85991a1b57045018ab23', getOpsxUpdateCommandTemplate: 'e2388521b22f92f74561df9a0c2f98e1fa4d265af93b5ba26f42fb47a6c5bfed', }; @@ -73,11 +73,11 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-ff-change': '63dd8155240c7ec9b46bc2fcdb0469440b7d594b89ec3afe7934548b02bad2cb', 'openspec-sync-specs': '4f938600169e23eeae9a5a6730ffb9dbdb460f7d12aa117e49d5474fd36946f5', 'openspec-archive-change': '531c09ac7197cb258543710f7a72e3a7c01423420b2c03cbe09392d136b79675', - 'openspec-bulk-archive-change': '589d69755c10b6ad3133a84d9b3351a82a17b81d1c952d3413708a4e21f6e53d', + 'openspec-bulk-archive-change': 'f5c0f72c84d0c6d2c65f8210c2bf298b58b182c8e3227c7dfaf2e079912fb009', 'openspec-verify-change': '8d4f3aaa6af1f69918427d21462399852d07c514b0c8532ec0c206fe3945db5a', 'openspec-onboard': 'd1cf90943ed786d1e658cca285aef6404edd5fd5de625e36c2408bb6d17326b2', 'openspec-propose': '8696dc53185042cae577c646505bfdb4795db11634e55cb0137152b7aae09dd3', - 'openspec-update-change': 'd4ae942aa6cd7164800c5ff636150db689aa110db9d08c57b8c6ade87c5b6bf7', + 'openspec-update-change': 'ac7419680c42df97321f4d1a4705ee165ffaad4a248998775b0aeb85bc1338b9', }; // 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 index 557573f9a7..dde55658fa 100644 --- a/test/core/templates/workflow-verb-triggers.test.ts +++ b/test/core/templates/workflow-verb-triggers.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { parse as parseYaml } from 'yaml'; @@ -31,12 +33,44 @@ const NATURAL_VERB_BY_WORKFLOW: Record = { }; /** - * CLI commands that do something unrelated to the workflow of the same name. - * A skill description must never claim these bare phrasings, or the skill would - * hijack a real CLI invocation. `openspec update` refreshes generated - * instruction files; it has nothing to do with the update-change workflow. + * 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 says so in its description. */ -const RESERVED_CLI_PHRASES = ['"openspec update"']; +const DELIBERATE_CLI_PHRASE_CLAIMS: Record = { + 'openspec archive': + 'the archive workflow syncs specs before archiving; the bare CLI command does not, so an agent asked to archive should run the workflow', + 'openspec archive all': + '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 explicitly disclaims', +}; + +const CLI_ENTRYPOINT = fileURLToPath( + new URL('../../../src/cli/index.ts', import.meta.url) +); + +/** + * Every command name the CLI registers, at any nesting level. Deliberately + * over-broad: a superset only costs an entry in DELIBERATE_CLI_PHRASE_CLAIMS, + * while a subset would let a real collision through. + */ +function readCliCommandNames(): ReadonlySet { + const source = readFileSync(CLI_ENTRYPOINT, 'utf8'); + const names = [...source.matchAll(/\.command\(\s*'([a-z][a-z-]*)/g)].map(m => m[1]); + return new Set(names); +} + +/** Quoted `openspec …` / `opsx …` phrases a description claims. */ +function claimedPhrases(description: string): string[] { + return (description.match(/"(?:openspec|opsx) [^"]+"/g) ?? []).map(p => p.slice(1, -1)); +} function descriptionOf(workflowId: string): string { const entry = getSkillTemplates().find(e => e.workflowId === workflowId); @@ -59,23 +93,48 @@ describe('workflow verb triggers', () => { } ); - it('never claims a bare CLI phrase that means something else', () => { + it('reads the CLI command names it guards against', () => { + // Without this the collision test below would pass vacuously if the CLI + // entrypoint moved or changed how it registers commands. + const names = readCliCommandNames(); + for (const known of ['init', 'update', 'archive', 'new', 'validate', 'list']) { + expect(names, `CLI command "${known}" not found in ${CLI_ENTRYPOINT}`).toContain(known); + } + }); + + it('claims a real CLI command only on purpose', () => { + const cliCommands = readCliCommandNames(); + for (const entry of getSkillTemplates()) { - for (const reserved of RESERVED_CLI_PHRASES) { + for (const phrase of claimedPhrases(entry.template.description)) { + const [namespace, firstWord] = phrase.split(' '); + // `opsx` is not a binary, so those phrases collide with nothing. + if (namespace !== 'openspec' || !cliCommands.has(firstWord)) continue; + expect( - entry.template.description, - `${entry.dirName} claims the reserved CLI phrase ${reserved}` - ).not.toContain(reserved); + DELIBERATE_CLI_PHRASE_CLAIMS[phrase], + `${entry.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.` + ).toBeDefined(); } } }); + it('never claims the bare `openspec update` CLI command', () => { + for (const entry of getSkillTemplates()) { + expect( + claimedPhrases(entry.template.description), + `${entry.dirName} would shadow the openspec update CLI command` + ).not.toContain('openspec update'); + } + }); + it('routes each phrase to exactly one skill', () => { const seen = new Map(); for (const entry of getSkillTemplates()) { - for (const phrase of entry.template.description.match(/"(?:openspec|opsx) [a-z- ]+"/g) ?? []) { + for (const phrase of claimedPhrases(entry.template.description)) { const owner = seen.get(phrase); - expect(owner, `${phrase} is claimed by both ${owner} and ${entry.dirName}`).toBeUndefined(); + expect(owner, `"${phrase}" is claimed by both ${owner} and ${entry.dirName}`).toBeUndefined(); seen.set(phrase, entry.dirName); } } From 2eba06ad76f73cdbc08695c52d6b8975fe63e1c7 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 11 Sep 2026 12:24:45 -0500 Subject: [PATCH 3/8] fix(skills): drop the trailing clause and close the plural-archive hole Review found the "- follow this skill rather than doing the work by hand" trailer was decoration that contradicted two of the skills it was appended to: sync-specs opens "This is an agent-driven operation - you will read delta specs and directly edit main specs", and explore says "This is a stance, not a workflow. There are no fixed steps." A description is read at selection time, so the clause could not reach the hand-building it targeted anyway; the bodies already carry that guidance. Removing it from all 12 also drops ~800 chars of identical boilerplate that made update-change's CLI redirect read as filler. Routing fixes: - bulk-archive claims the plural phrasings that do not contain "all", so "openspec archive these three changes" no longer loses to the single-change skill on the bare literal. - update-change redirects to the CLI command positively instead of negating ("run that command instead"), which routers honor far better than "not for". - apply also claims "openspec implement", the natural English verb for it, which shadows no CLI command. Corrects the recorded reason for claiming "openspec archive": the CLI command does merge delta specs (docs/cli.md:631, src/core/archive.ts:1402). The real reason is that the workflow confirms and verifies the merge before anything moves, where the bare command does it in one shot. Co-Authored-By: Claude Opus 5 --- skills/openspec-apply-change/SKILL.md | 2 +- skills/openspec-archive-change/SKILL.md | 2 +- skills/openspec-bulk-archive-change/SKILL.md | 2 +- skills/openspec-continue-change/SKILL.md | 2 +- skills/openspec-ff-change/SKILL.md | 2 +- skills/openspec-new-change/SKILL.md | 2 +- skills/openspec-onboard/SKILL.md | 2 +- skills/openspec-propose/SKILL.md | 2 +- skills/openspec-sync-specs/SKILL.md | 2 +- skills/openspec-update-change/SKILL.md | 2 +- skills/openspec-verify-change/SKILL.md | 2 +- src/core/templates/workflows/apply-change.ts | 2 +- .../templates/workflows/archive-change.ts | 2 +- .../workflows/bulk-archive-change.ts | 2 +- .../templates/workflows/continue-change.ts | 2 +- src/core/templates/workflows/ff-change.ts | 2 +- src/core/templates/workflows/new-change.ts | 2 +- src/core/templates/workflows/onboard.ts | 2 +- src/core/templates/workflows/propose.ts | 2 +- src/core/templates/workflows/sync-specs.ts | 2 +- src/core/templates/workflows/update-change.ts | 2 +- src/core/templates/workflows/verify-change.ts | 2 +- .../templates/skill-templates-parity.test.ts | 44 +++++++++---------- .../templates/workflow-verb-triggers.test.ts | 6 ++- 24 files changed, 48 insertions(+), 46 deletions(-) diff --git a/skills/openspec-apply-change/SKILL.md b/skills/openspec-apply-change/SKILL.md index 0588f2ea99..1556e3058c 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. Also use when the user says "openspec apply" or "opsx apply" - follow this skill rather than doing the work by hand. +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 6972a6854a..71641705e4 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 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" - follow this skill rather than doing the work by hand. +description: Archive a completed 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 29b69c9a4c..4e3a4a7d7b 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 changes at once. Use when archiving several parallel changes. Also use when the user says "openspec bulk-archive", "opsx bulk-archive", or "openspec archive all" - follow this skill rather than doing the work by hand. +description: Archive multiple completed 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 d413e028bc..ee77849fc3 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. Also use when the user says "openspec continue" or "opsx continue" - follow this skill rather than doing the work by hand. +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-ff-change/SKILL.md b/skills/openspec-ff-change/SKILL.md index a284ddc25e..4c0871d9b7 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. Also use when the user says "openspec ff" or "opsx ff" - follow this skill rather than doing the work by hand. +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 7e18cb3d49..e5ecd4b459 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. Also use when the user says "openspec new change" or "opsx new" - follow this skill rather than doing the work by hand. +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 8f80148656..925f7e4a97 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. Also use when the user says "openspec onboard" or "opsx onboard" - follow this skill rather than doing the work by hand. +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 5d99a7b2e1..94d1172236 100644 --- a/skills/openspec-propose/SKILL.md +++ b/skills/openspec-propose/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-propose -description: Propose a new 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" - follow this skill rather than doing the work by hand. +description: Propose a new 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 81d8de47a2..16f9807e09 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 a 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" - follow this skill rather than doing the work by hand. +description: Sync delta specs from a 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 3e59d188a9..443a14e7f8 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. Also use when the user says "openspec update change" or "opsx update" - follow this skill rather than doing the work by hand. Not for the openspec update CLI command, which refreshes generated files. 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 eba9dc6fd8..9ead2e68c2 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 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" - follow this skill rather than doing the work by hand. +description: Verify implementation matches 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 dc1664e49d..b45afedada 100644 --- a/src/core/templates/workflows/apply-change.ts +++ b/src/core/templates/workflows/apply-change.ts @@ -198,7 +198,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. Also use when the user says "openspec apply" or "opsx apply" - follow this skill rather than doing the work by hand.', + 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 290953065e..a2819196de 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getArchiveChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-archive-change', - description: 'Archive a completed 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" - follow this skill rather than doing the work by hand.', + description: 'Archive a completed 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 a079598b83..7d91a13b5e 100644 --- a/src/core/templates/workflows/bulk-archive-change.ts +++ b/src/core/templates/workflows/bulk-archive-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getBulkArchiveChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-bulk-archive-change', - description: 'Archive multiple completed changes at once. Use when archiving several parallel changes. Also use when the user says "openspec bulk-archive", "opsx bulk-archive", or "openspec archive all" - follow this skill rather than doing the work by hand.', + description: 'Archive multiple completed 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 892d9272a9..cb2aef8d56 100644 --- a/src/core/templates/workflows/continue-change.ts +++ b/src/core/templates/workflows/continue-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; 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. Also use when the user says "openspec continue" or "opsx continue" - follow this skill rather than doing the work by hand.', + 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/ff-change.ts b/src/core/templates/workflows/ff-change.ts index 2701f70d8c..96b07c58dd 100644 --- a/src/core/templates/workflows/ff-change.ts +++ b/src/core/templates/workflows/ff-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; 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. Also use when the user says "openspec ff" or "opsx ff" - follow this skill rather than doing the work by hand.', + 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 3a685de35a..ca6528e384 100644 --- a/src/core/templates/workflows/new-change.ts +++ b/src/core/templates/workflows/new-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; 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. Also use when the user says "openspec new change" or "opsx new" - follow this skill rather than doing the work by hand.', + 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 cf15512660..be37f7cfb3 100644 --- a/src/core/templates/workflows/onboard.ts +++ b/src/core/templates/workflows/onboard.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; 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. Also use when the user says "openspec onboard" or "opsx onboard" - follow this skill rather than doing the work by hand.', + 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 8091fe73b4..043bef506b 100644 --- a/src/core/templates/workflows/propose.ts +++ b/src/core/templates/workflows/propose.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getOpsxProposeSkillTemplate(): SkillTemplate { return { name: 'openspec-propose', - description: 'Propose a new 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" - follow this skill rather than doing the work by hand.', + description: 'Propose a new 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 08e2447b81..4c29047e7f 100644 --- a/src/core/templates/workflows/sync-specs.ts +++ b/src/core/templates/workflows/sync-specs.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getSyncSpecsSkillTemplate(): SkillTemplate { return { name: 'openspec-sync-specs', - description: 'Sync delta specs from a 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" - follow this skill rather than doing the work by hand.', + description: 'Sync delta specs from a 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 c33853e373..483a545f31 100644 --- a/src/core/templates/workflows/update-change.ts +++ b/src/core/templates/workflows/update-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; 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. Also use when the user says \"openspec update change\" or \"opsx update\" - follow this skill rather than doing the work by hand. Not for the openspec update CLI command, which refreshes generated files. 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 d0fc73c155..fcf06f418f 100644 --- a/src/core/templates/workflows/verify-change.ts +++ b/src/core/templates/workflows/verify-change.ts @@ -10,7 +10,7 @@ import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getVerifyChangeSkillTemplate(): SkillTemplate { return { name: 'openspec-verify-change', - description: 'Verify implementation matches 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" - follow this skill rather than doing the work by hand.', + description: 'Verify implementation matches 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 4d5b045003..8a4f9de991 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -39,45 +39,45 @@ import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/ const EXPECTED_FUNCTION_HASHES: Record = { getExploreSkillTemplate: 'dfdf9946a31df7ecf0556fb2cf9bdeba5cf2deb6c62a97e899a422fd658d1855', - getNewChangeSkillTemplate: 'fe80a2f2f66da1598e28319150e1c7d7fb8d624032716efe50341819962c6380', - getContinueChangeSkillTemplate: '6910e2b1e80a38c9385bce16cab9f7e181be0fe26416bd12829dfe68705e75c0', - getApplyChangeSkillTemplate: '3cf00dce53ec34b555682d579742c10ee6092d3c3e6337be2709e324411700d8', - getFfChangeSkillTemplate: 'b14d6dcd83563a77dbc1f70f25dd53895f081180ef05e6587443102f87dab3e1', - getSyncSpecsSkillTemplate: '221c6639f946b3593fe0c539039977103a2bd14f18a7f77354abbaa66fabf37e', - getOnboardSkillTemplate: 'afe92188946d48cd7552fa8338852b184aa106991a3c1821d038b5af88bac145', + getNewChangeSkillTemplate: '6d65453e7567c3277dd1eb69bd5f77c2d19c485cbdbd26d6723f4d8779cc21a7', + getContinueChangeSkillTemplate: 'c36513c1d2e96073aa1469746faee81ffd70706cd10f8b3db1ee9058c9f7c911', + getApplyChangeSkillTemplate: 'dc1d041dd7460553395c8c38f5801a150b00b3a986fbb666be4d10802465c2d2', + getFfChangeSkillTemplate: 'c82d0d80a476daabf7a0a7775f4095d04ed348fc988dd151918dedf5efd22bda', + getSyncSpecsSkillTemplate: '7262a9cd5c24cf42087b53935a375ce441071e9f23e380de0e01779fc6bc2dea', + getOnboardSkillTemplate: '6cc0c65ddbf8e90183748201bf3a6ef8c27e70cafef356c1c7f53ccdc7dbaca9', getOpsxExploreCommandTemplate: '8046003e97d885a86ed392d4fb522bb78544a02872b042e51347a5021cc10523', getOpsxNewCommandTemplate: 'f2d30e569798a4c92ba932859d6ba4e0ad10e18feccbade1cfee0957597b3463', getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0', getOpsxFfCommandTemplate: '21132fc9c6d3b3ab2d2295d6bbd72d1e0052eb35ea1be0258c8b1ab3e200c4db', - getArchiveChangeSkillTemplate: '9e6e535264aa0c3800ef9fb117787c2d7b104c65d7ed98aa87df6837ab6a2cc1', - getBulkArchiveChangeSkillTemplate: '4b3317b9bf11b430caf49d34a88f5d4937cb6b11732eae1592e910f67b9787a5', + getArchiveChangeSkillTemplate: '52106c66bebbb64fb11446b6e04b791df5b227d1bd6e044adc47ed9c7c5741a7', + getBulkArchiveChangeSkillTemplate: '7ad707ad23153f50636fadcb159f4240c78de7e2674d8457e80d6aa6066dfbc4', getOpsxSyncCommandTemplate: '0d2427efb79986e8fff3f96bd075a739c80d45eb29159fae717e950030da8202', - getVerifyChangeSkillTemplate: 'e84b9a3dbcb70e8a256e1549cd2d6d9dfbdd6508f8ccc3c562dab270f000e730', + getVerifyChangeSkillTemplate: 'ad2eb07ecfd35e59669f26f1a862320e5b96916c4e8949b9ae5f2171aa7d1d92', getOpsxArchiveCommandTemplate: '9f973c819b11620985b03322945f0e0a92a02a2ef455b94e74482f5e6292ac5d', getOpsxOnboardCommandTemplate: 'ee99aa99252c602720fbb8c63fb3ac438a5bd4e952fd961ddf1ae956cbfc2c8f', getOpsxBulkArchiveCommandTemplate: '9fa8cdebe2f5667ebfc37bdc023396762c59d5b038c771dac2d8fd2c19e2627b', getOpsxVerifyCommandTemplate: '1efcf7eff0671f48e9d9420f50865c563dd3079ee60f8c380bb7a90dd0102696', - getOpsxProposeSkillTemplate: '224b5e68307c5533308edd8a21ac6a5897ab0b427324f52dffc6b7e736aa0164', + getOpsxProposeSkillTemplate: '9dceb9049424cffeea43871a1e3cdd4d5b3737ab125e76629a0e2febe1ac184a', getOpsxProposeCommandTemplate: 'f016c66c2b6115b459751154c76a6270e444d6aee31973bb7cb8c0e6d505fb98', getFeedbackSkillTemplate: 'dabeb5e825b9349abc8156c3e7b8608f27987912a6d9bf47ef29addde6138133', - getUpdateChangeSkillTemplate: 'f833215495833bba7aed364ba07e288652759488434f85991a1b57045018ab23', + getUpdateChangeSkillTemplate: 'ebc4799a3537b8fdf3c0d252614985276c469c61862c489f935a2b497e3c8987', getOpsxUpdateCommandTemplate: 'e2388521b22f92f74561df9a0c2f98e1fa4d265af93b5ba26f42fb47a6c5bfed', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-explore': '16ce157678837a4ea252bacd7721d66dffa0d3e7acc9dae8c2de69372cb9d259', - 'openspec-new-change': '4fcb8fca6500ec8e76c5b238bfb4b78f5eb70d53bd438f3a714de5f4200d3bc9', - 'openspec-continue-change': '9611046fa51b68f01380e083ce7c8e611e7f965bb3a0f5f6a6f9ba4c8b8c7305', - 'openspec-apply-change': 'a2bd638b2cf7e266b91e70b169d6fdd34347e1f996cd851374f437667654b374', - 'openspec-ff-change': '63dd8155240c7ec9b46bc2fcdb0469440b7d594b89ec3afe7934548b02bad2cb', - 'openspec-sync-specs': '4f938600169e23eeae9a5a6730ffb9dbdb460f7d12aa117e49d5474fd36946f5', - 'openspec-archive-change': '531c09ac7197cb258543710f7a72e3a7c01423420b2c03cbe09392d136b79675', - 'openspec-bulk-archive-change': 'f5c0f72c84d0c6d2c65f8210c2bf298b58b182c8e3227c7dfaf2e079912fb009', - 'openspec-verify-change': '8d4f3aaa6af1f69918427d21462399852d07c514b0c8532ec0c206fe3945db5a', - 'openspec-onboard': 'd1cf90943ed786d1e658cca285aef6404edd5fd5de625e36c2408bb6d17326b2', - 'openspec-propose': '8696dc53185042cae577c646505bfdb4795db11634e55cb0137152b7aae09dd3', - 'openspec-update-change': 'ac7419680c42df97321f4d1a4705ee165ffaad4a248998775b0aeb85bc1338b9', + 'openspec-new-change': 'a3834973960ce51cfe916fb8823dd2fd152418aea7fc350598db6dc6952c3a72', + 'openspec-continue-change': '034cf94773f0030a5c22a098f9221ef19c8d17249e9ae45f456dabf9e2d0a005', + 'openspec-apply-change': 'ac678931f705085fbf42bfc61f0ee3d47cf84408bc527f9f210b1ba2e69571f0', + 'openspec-ff-change': '94b20a38eddfe9cdee54cc7d4e4748c1b124015c384d8eb59e163034a891fb82', + 'openspec-sync-specs': 'faf8898db8b9f9d8f8234f76fc7421a82745b18595eb25b00bdd54922b7bef0c', + 'openspec-archive-change': '1c3527ad901a55276479cd3bbd06535656bd25da473f19db451b9e6936e18d6e', + 'openspec-bulk-archive-change': '782aa1faf4ee6176a14389defe2ef9c6b097d0dc1c3578b752c6bf0976996ad6', + 'openspec-verify-change': '0d570436b7f8d76f2a9b7420f4f40eb304a6cb3d23a398227bea0d85516f1d7c', + 'openspec-onboard': 'ea4f8adf25fd19a04a35466a38c7f79499fac21618c2ce6595f8f33c7d42c006', + 'openspec-propose': '920893c5a0e7e146d711163b4ab4c58e5b5ed1e496126579d6a3503a13d89d83', + 'openspec-update-change': 'cfc1121d2e3cddf066e5e9a2aa8a379dfcbb2fe518ff10c3bf4c0f70079f299c', }; // 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 index dde55658fa..1ab9ac8fdb 100644 --- a/test/core/templates/workflow-verb-triggers.test.ts +++ b/test/core/templates/workflow-verb-triggers.test.ts @@ -43,13 +43,15 @@ const NATURAL_VERB_BY_WORKFLOW: Record = { */ const DELIBERATE_CLI_PHRASE_CLAIMS: Record = { 'openspec archive': - 'the archive workflow syncs specs before archiving; the bare CLI command does not, so an agent asked to archive should run the workflow', + '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 explicitly disclaims', + 'distinct from `openspec update`, which the update-change description redirects to the CLI command', }; const CLI_ENTRYPOINT = fileURLToPath( From b6678a9004899539dff4fda716834a602afe57eb Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 11 Sep 2026 12:25:21 -0500 Subject: [PATCH 4/8] docs(quickstart): name the verb phrasing that now routes to a workflow Also rewrites the changeset to house style: links the issue, names the commands-only scope limit, and tells a reader they need `openspec update` to pick it up. Co-Authored-By: Claude Opus 5 --- .changeset/workflow-verb-triggers.md | 2 +- docs-lab/start/quickstart.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/workflow-verb-triggers.md b/.changeset/workflow-verb-triggers.md index 50b229f738..c0832b64ef 100644 --- a/.changeset/workflow-verb-triggers.md +++ b/.changeset/workflow-verb-triggers.md @@ -2,4 +2,4 @@ "@fission-ai/openspec": patch --- -Match the natural "openspec " phrasing to the workflow it names. Every workflow skill's description now lists the phrasings a user actually types ("openspec propose", "opsx apply", and so on), so an agent that hears "do an openspec propose" runs the workflow instead of hand-building the artifacts with the CLI. `openspec update` is deliberately left unclaimed: it is a real CLI command that refreshes generated files, unrelated to the update-change workflow. +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 a822f3d12a..faab4a2a28 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 From 429ec617b084c4048e784dd3a1aa41ac5702b0cb Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 11 Sep 2026 12:29:44 -0500 Subject: [PATCH 5/8] fix(skills): walk the real command tree instead of scanning the entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutation testing found the collision guard was a strict subset of reality, not the superset its comment claimed. It scanned src/cli/index.ts for `.command('…')`, but seven groups — spec, config, schema, store, doctor, context, workset — are registered from their own modules, so 23 real command names were invisible. A description claiming "openspec doctor" or "openspec spec" passed 18/18 green. It now walks the commander tree from the exported `program` (importing it does not parse argv; runCli does that), and a sanity test pins the seven delegated groups so the blind spot cannot come back. Three more holes the same pass found, all confirmed by re-running the mutations that previously slipped through: - phrase extraction was case-sensitive and double-quote-only, so "Openspec update" and `openspec update` in backticks both evaded every guard. Matching is now case-insensitive and accepts either delimiter. Unquoted prose stays excluded on purpose: the update-change redirect names the CLI command in prose, and prose is not a routing trigger. - prefix shadowing was unguarded, which is the exact shape of the archive/bulk-archive tension. A shorter phrase contained in another skill's longer phrase must now be declared in DELIBERATE_PHRASE_SHADOWING. - both allowlists accepted an empty reason and never flagged stale entries. Co-Authored-By: Claude Opus 5 --- .../templates/workflow-verb-triggers.test.ts | 152 ++++++++++++------ 1 file changed, 106 insertions(+), 46 deletions(-) diff --git a/test/core/templates/workflow-verb-triggers.test.ts b/test/core/templates/workflow-verb-triggers.test.ts index 1ab9ac8fdb..5ca537ac78 100644 --- a/test/core/templates/workflow-verb-triggers.test.ts +++ b/test/core/templates/workflow-verb-triggers.test.ts @@ -1,8 +1,8 @@ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import type { Command } from 'commander'; import { describe, expect, it } from 'vitest'; import { parse as parseYaml } from 'yaml'; +import { program } from '../../../src/cli/index.js'; import { generateSkillContent, getSkillTemplates, @@ -39,39 +39,59 @@ const NATURAL_VERB_BY_WORKFLOW: Record = { * * `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 says so in its description. + * 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 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', }; -const CLI_ENTRYPOINT = fileURLToPath( - new URL('../../../src/cli/index.ts', import.meta.url) -); +/** + * 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 nesting level. Deliberately - * over-broad: a superset only costs an entry in DELIBERATE_CLI_PHRASE_CLAIMS, - * while a subset would let a real collision through. + * 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 readCliCommandNames(): ReadonlySet { - const source = readFileSync(CLI_ENTRYPOINT, 'utf8'); - const names = [...source.matchAll(/\.command\(\s*'([a-z][a-z-]*)/g)].map(m => m[1]); - return new Set(names); +function collectCommandNames(command: Command, into = new Set()): Set { + for (const sub of command.commands) { + 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. */ +/** + * 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.match(/"(?:openspec|opsx) [^"]+"/g) ?? []).map(p => p.slice(1, -1)); + return [...description.matchAll(/(["`])((?:openspec|opsx) [^"`]+)\1/gi)].map(m => + m[2].toLowerCase() + ); } function descriptionOf(workflowId: string): string { @@ -80,6 +100,12 @@ function descriptionOf(workflowId: string): string { 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(); @@ -95,53 +121,87 @@ describe('workflow verb triggers', () => { } ); - it('reads the CLI command names it guards against', () => { - // Without this the collision test below would pass vacuously if the CLI - // entrypoint moved or changed how it registers commands. - const names = readCliCommandNames(); + 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}" not found in ${CLI_ENTRYPOINT}`).toContain(known); + 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('claims a real CLI command only on purpose', () => { - const cliCommands = readCliCommandNames(); + const cliCommands = collectCommandNames(program); - for (const entry of getSkillTemplates()) { - for (const phrase of claimedPhrases(entry.template.description)) { - const [namespace, firstWord] = phrase.split(' '); - // `opsx` is not a binary, so those phrases collide with nothing. - if (namespace !== 'openspec' || !cliCommands.has(firstWord)) continue; + 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], - `${entry.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.` - ).toBeDefined(); - } + 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', () => { - for (const entry of getSkillTemplates()) { - expect( - claimedPhrases(entry.template.description), - `${entry.dirName} would shadow the openspec update CLI command` - ).not.toContain('openspec update'); + // 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 entry of getSkillTemplates()) { - for (const phrase of claimedPhrases(entry.template.description)) { - const owner = seen.get(phrase); - expect(owner, `"${phrase}" is claimed by both ${owner} and ${entry.dirName}`).toBeUndefined(); - seen.set(phrase, entry.dirName); + 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. From f2e98c5fa1027f03777e511dac7164cf2071c61e Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 15 Sep 2026 07:55:54 -0500 Subject: [PATCH 6/8] test(skills): let the CLI-collision guard ignore hidden workflow-verb hints PR #1776 registers the workflow verbs (explore, propose, apply, ...) as hidden CLI commands that only point the user at the workflow. Walking the commander tree then saw "openspec explore" as a real command and failed the collision guard for every skill trigger. Skip a subcommand only when it is hidden AND named after a workflow. Visible commands and hidden non-workflow commands are still guarded, pinned by a synthetic commander tree. Co-Authored-By: Claude Opus 5 --- .../templates/workflow-verb-triggers.test.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/core/templates/workflow-verb-triggers.test.ts b/test/core/templates/workflow-verb-triggers.test.ts index 5ca537ac78..ae604b5601 100644 --- a/test/core/templates/workflow-verb-triggers.test.ts +++ b/test/core/templates/workflow-verb-triggers.test.ts @@ -1,8 +1,9 @@ -import type { Command } from 'commander'; +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, @@ -73,7 +74,13 @@ const DELIBERATE_PHRASE_SHADOWING: Record = { * 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); @@ -134,6 +141,17 @@ describe('workflow verb triggers', () => { } }); + 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); From eae708ff75043b80d2c8c0cf04655a85da6a24a2 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 15 Sep 2026 08:22:22 -0500 Subject: [PATCH 7/8] docs(changeset): drop em dashes from the release note Co-Authored-By: Claude Opus 5 --- .changeset/workflow-verb-triggers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/workflow-verb-triggers.md b/.changeset/workflow-verb-triggers.md index c0832b64ef..229d9daa62 100644 --- a/.changeset/workflow-verb-triggers.md +++ b/.changeset/workflow-verb-triggers.md @@ -2,4 +2,4 @@ "@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. +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. From 350da5d489082d4c81b9e5825ef500d0aca4300a Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 15 Sep 2026 11:21:57 -0500 Subject: [PATCH 8/8] fix(skills): drop the generic by-hand clause from the explore description The other eleven descriptions dropped it; explore is a stance, not a workflow, so telling the agent to follow it instead of doing the work contradicts it. Adds a regression over every workflow description. Co-Authored-By: Claude Opus 5 --- skills/openspec-explore/SKILL.md | 2 +- src/core/templates/workflows/explore.ts | 2 +- test/core/templates/skill-templates-parity.test.ts | 4 ++-- test/core/templates/workflow-verb-triggers.test.ts | 10 ++++++++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/skills/openspec-explore/SKILL.md b/skills/openspec-explore/SKILL.md index 63d3b5b917..372defa6ad 100644 --- a/skills/openspec-explore/SKILL.md +++ b/skills/openspec-explore/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-explore -description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. Also use when the user says "openspec explore" or "opsx explore" - follow this skill rather than doing the work by hand. +description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. Also use when the user says "openspec explore" or "opsx explore". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. diff --git a/src/core/templates/workflows/explore.ts b/src/core/templates/workflows/explore.ts index 97cd14e9e8..dae5b02d34 100644 --- a/src/core/templates/workflows/explore.ts +++ b/src/core/templates/workflows/explore.ts @@ -32,7 +32,7 @@ adding a service to operate; shared state would need a separate sync design. export function getExploreSkillTemplate(): SkillTemplate { return { name: 'openspec-explore', - description: 'Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. Also use when the user says "openspec explore" or "opsx explore" - follow this skill rather than doing the work by hand.', + description: 'Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. 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, remind them to exit explore mode first and create a change proposal. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 8a4f9de991..c167e28e8e 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -38,7 +38,7 @@ import { import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; const EXPECTED_FUNCTION_HASHES: Record = { - getExploreSkillTemplate: 'dfdf9946a31df7ecf0556fb2cf9bdeba5cf2deb6c62a97e899a422fd658d1855', + getExploreSkillTemplate: 'd82302d1920ad3a83b19ffd46bf0d4d6c6de203f9641af7955591d8f1e7921c4', getNewChangeSkillTemplate: '6d65453e7567c3277dd1eb69bd5f77c2d19c485cbdbd26d6723f4d8779cc21a7', getContinueChangeSkillTemplate: 'c36513c1d2e96073aa1469746faee81ffd70706cd10f8b3db1ee9058c9f7c911', getApplyChangeSkillTemplate: 'dc1d041dd7460553395c8c38f5801a150b00b3a986fbb666be4d10802465c2d2', @@ -66,7 +66,7 @@ const EXPECTED_FUNCTION_HASHES: Record = { }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': '16ce157678837a4ea252bacd7721d66dffa0d3e7acc9dae8c2de69372cb9d259', + 'openspec-explore': '7ddc475b6e9787df3506d514e8f1c0e8cc572fa1e6ca923e517a98f3489f08ba', 'openspec-new-change': 'a3834973960ce51cfe916fb8823dd2fd152418aea7fc350598db6dc6952c3a72', 'openspec-continue-change': '034cf94773f0030a5c22a098f9221ef19c8d17249e9ae45f456dabf9e2d0a005', 'openspec-apply-change': 'ac678931f705085fbf42bfc61f0ee3d47cf84408bc527f9f210b1ba2e69571f0', diff --git a/test/core/templates/workflow-verb-triggers.test.ts b/test/core/templates/workflow-verb-triggers.test.ts index ae604b5601..c4016e19a0 100644 --- a/test/core/templates/workflow-verb-triggers.test.ts +++ b/test/core/templates/workflow-verb-triggers.test.ts @@ -128,6 +128,16 @@ describe('workflow verb triggers', () => { } ); + 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