fix(skills): match natural "openspec <verb>" phrasing to its workflow (#1852)

* feat(skills): match natural "openspec <verb>" 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(skills): walk the real command tree instead of scanning the entrypoint

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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* docs(changeset): drop em dashes from the release note

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

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