diff --git a/.changeset/guard-uninitialized-projects.md b/.changeset/guard-uninitialized-projects.md new file mode 100644 index 0000000000..231794467c --- /dev/null +++ b/.changeset/guard-uninitialized-projects.md @@ -0,0 +1,7 @@ +--- +"@fission-ai/openspec": patch +--- + +### Bug Fixes + +- Generated skills and commands no longer adopt a project that never ran `openspec init`. Every workflow now checks `root` from `openspec list --json` before its first write, and `"root": null` means the project is not set up. What happens next depends on how the workflow was reached. A skill the agent picked on its own drops OpenSpec and answers the request normally, without asking about setup. A workflow the user asked for by name, or ran as a slash command, stops and asks whether to initialize the project, target a store, or handle the request without OpenSpec. A project whose `openspec/config.yaml` names a store this machine cannot resolve (not registered, or a malformed `store:` line) is not mistaken for an uninitialized one: the workflow stops and shows the store error. Neither path lets `openspec new change` create `openspec/` in the current directory as a side effect. Skill descriptions now name OpenSpec so hosts stop offering these workflows in unrelated repositories. `openspec new change` also says when it had to create the root itself, so a directory that was never set up no longer picks up an `openspec/` directory in silence (human output only; `--json` is unchanged). diff --git a/docs-lab/reference/cli.md b/docs-lab/reference/cli.md index 2f503e85af..b09393675e 100644 --- a/docs-lab/reference/cli.md +++ b/docs-lab/reference/cli.md @@ -1031,7 +1031,20 @@ Schema: spec-driven Next: openspec status --change add-caching ``` -With `--json`: +When no `openspec/` directory was found, `new change` creates one where you are and says so: + +``` +Created change 'add-caching' at openspec/changes/add-caching/ +Schema: spec-driven +Next: openspec status --change add-caching + +Note: no OpenSpec root was found here, so one was created at openspec/. +Run `openspec init` to finish setting this project up, or delete that directory if you meant a different project. +``` + +The notice goes to stdout with the rest of the human output, and never appears with `--json`. + +With `--json`, in a project that already has `openspec/`: ```json { @@ -1048,6 +1061,15 @@ With `--json`: } ``` +When no `openspec/` directory was found and `new change` created one, the JSON has the same shape. `root.path` is the directory you ran it from, and `root.source` reads `implicit`: + +```json +"root": { + "path": "/Users/you/projects/my-app", + "source": "implicit" +} +``` + **Exit codes** - `0`: change created. diff --git a/docs-lab/reference/skills.md b/docs-lab/reference/skills.md index 8482c16f60..cc833427d0 100644 --- a/docs-lab/reference/skills.md +++ b/docs-lab/reference/skills.md @@ -39,6 +39,13 @@ The skills come in two sets: - **Core**: installed by default, the main planning loop. - **Optional**: installed only when you add them, via [Profiles](../customize/profiles.md). +Every skill expects a project that already uses OpenSpec. Before its first step that writes anything, a skill checks for a resolved root. What happens when there is none depends on how the skill was reached: + +- **Auto-selected**: your agent picked the skill on its own, without you naming OpenSpec. It drops OpenSpec and answers your request normally, the way it would with OpenSpec not installed. +- **Explicit OpenSpec request**: you named OpenSpec, named the skill, or ran its command. It stops before writing and asks how to proceed: run `openspec init` here, target a store with `--store `, or continue without OpenSpec. It waits for your answer. + +Commands are always the second case. A project whose `openspec/config.yaml` names a store this machine cannot resolve (not registered, or a malformed `store:` line) is not treated as uninitialized: the skill stops and shows the store error with its fix. No skill creates an `openspec/` directory on its own in either case. The entries below describe what each skill does once a root is in place. + | Skill | Job | Type | |---|---|---| | [openspec-explore](#openspec-explore) | Think through an idea before it becomes a change proposal | Core | diff --git a/skills/openspec-apply-change/SKILL.md b/skills/openspec-apply-change/SKILL.md index 2e7eb9ed42..7869fac441 100644 --- a/skills/openspec-apply-change/SKILL.md +++ b/skills/openspec-apply-change/SKILL.md @@ -13,6 +13,17 @@ Implement tasks from an OpenSpec change. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: Optionally specify a change name (e.g., `/openspec-apply-change add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** diff --git a/skills/openspec-archive-change/SKILL.md b/skills/openspec-archive-change/SKILL.md index c3cbe6f706..d3a077d293 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 OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Archive a completed change in the experimental workflow. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + `` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. diff --git a/skills/openspec-bulk-archive-change/SKILL.md b/skills/openspec-bulk-archive-change/SKILL.md index ca51ec64c9..508740b1bd 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 OpenSpec changes at once. Use when archiving several parallel changes. allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -15,6 +15,17 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + `` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. **Input**: None required (prompts for selection) diff --git a/skills/openspec-continue-change/SKILL.md b/skills/openspec-continue-change/SKILL.md index 5991b06891..4a0d5e2f23 100644 --- a/skills/openspec-continue-change/SKILL.md +++ b/skills/openspec-continue-change/SKILL.md @@ -13,6 +13,17 @@ Continue working on a change by creating the next artifact. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** diff --git a/skills/openspec-explore/SKILL.md b/skills/openspec-explore/SKILL.md index 8883d31fbc..f7ae305612 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 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. allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -17,6 +17,17 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + --- ## The Stance diff --git a/skills/openspec-ff-change/SKILL.md b/skills/openspec-ff-change/SKILL.md index 72a95620bb..5607d7bc69 100644 --- a/skills/openspec-ff-change/SKILL.md +++ b/skills/openspec-ff-change/SKILL.md @@ -13,6 +13,17 @@ Fast-forward through artifact creation - generate everything needed to start imp **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** diff --git a/skills/openspec-new-change/SKILL.md b/skills/openspec-new-change/SKILL.md index 9aea11d391..ddb822386b 100644 --- a/skills/openspec-new-change/SKILL.md +++ b/skills/openspec-new-change/SKILL.md @@ -13,6 +13,17 @@ Start a new change using the experimental artifact-driven approach. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** diff --git a/skills/openspec-onboard/SKILL.md b/skills/openspec-onboard/SKILL.md index 06792a98d5..8e23c838f5 100644 --- a/skills/openspec-onboard/SKILL.md +++ b/skills/openspec-onboard/SKILL.md @@ -13,6 +13,17 @@ Guide the user through their first complete OpenSpec workflow cycle. This is a t **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + --- ## Preflight diff --git a/skills/openspec-propose/SKILL.md b/skills/openspec-propose/SKILL.md index 49454cd4a4..6332280526 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 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. allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -27,6 +27,17 @@ When the user is ready to implement, they must start the apply workflow explicit **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** @@ -44,7 +55,7 @@ When the user is ready to implement, they must start the apply workflow explicit 2. **Load project context** - Run `openspec context --json` from the current working directory (or `openspec context --json --store ""` when a registered store was explicitly selected). Use the returned `root.path` as the authoritative OpenSpec root. If context reports `no_openspec_root`, stop without creating or changing any files. Offer `openspec init` and wait for the user to request initialization. Do not initialize automatically or run `openspec new change`. After initialization, rerun this context check before continuing. For any other context failure, stop and report the error; do not fall back to the current directory or run later OpenSpec commands without the selected store. + Run `openspec context --json` from the current working directory (or `openspec context --json --store ""` when a registered store was explicitly selected). Use the returned `root.path` as the authoritative OpenSpec root. If context reports `no_openspec_root`, stop without creating or changing any files and follow the **Project check** above for how this workflow was reached. Offer `openspec init` only for an explicit OpenSpec request, and wait for the user to request initialization. Do not initialize automatically or run `openspec new change`. After initialization, rerun this context check before continuing. For any other context failure, stop and report the error; do not fall back to the current directory or run later OpenSpec commands without the selected store. Only when context returns a resolved `root.path`, read `/openspec/config.yaml`. Use `config.yml` only when `config.yaml` does not exist. If neither file exists, continue without project context. Do not fall back to `config.yml` if `config.yaml` is unreadable or invalid. diff --git a/skills/openspec-sync-specs/SKILL.md b/skills/openspec-sync-specs/SKILL.md index 1babf3b613..5480374ec2 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 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. allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -15,6 +15,17 @@ This is an **agent-driven** operation - you will read delta specs and directly e **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + `` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. diff --git a/skills/openspec-update-change/SKILL.md b/skills/openspec-update-change/SKILL.md index 909b327e64..f369cad3d0 100644 --- a/skills/openspec-update-change/SKILL.md +++ b/skills/openspec-update-change/SKILL.md @@ -13,6 +13,17 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. This workflow revises artifacts that already exist; `/openspec-continue-change` is what creates the ones that do not. diff --git a/skills/openspec-verify-change/SKILL.md b/skills/openspec-verify-change/SKILL.md index 0224b3841f..e906563614 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 OpenSpec change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving. allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Verify that an implementation matches the change artifacts (specs, tasks, design **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index 3e059242dc..9dc57fd69d 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -7,6 +7,7 @@ * this command. */ +import chalk from 'chalk'; import ora from 'ora'; import path from 'path'; import { createChange, validateChangeName } from '../../utils/change-utils.js'; @@ -85,6 +86,33 @@ function printCreatedChangeHuman( console.log(`Next: ${withStoreFlag(root, `openspec status --change ${payload.change.id}`)}`); } +/** + * An implicit root is the fallback taken when no `openspec/` directory was + * found: creating a change there materializes OpenSpec in whatever directory + * the caller happened to be in, which is how an agent ends up adopting a + * project that never ran `openspec init` (#1645). The creation itself stays + * zero-config; this only makes it visible. + */ +function printImplicitRootNotice(root: ResolvedOpenSpecRoot): void { + if (root.source !== 'implicit') { + return; + } + + const openspecDir = path.dirname(root.changesDir); + const relative = path.relative(process.cwd(), openspecDir); + const location = relative && !relative.startsWith('..') ? relative : openspecDir; + + console.log(); + console.log( + chalk.dim(`Note: no OpenSpec root was found here, so one was created at ${location}/.`) + ); + console.log( + chalk.dim( + 'Run `openspec init` to finish setting this project up, or delete that directory if you meant a different project.' + ) + ); +} + export async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise { const spinner = options.json ? undefined : ora(); @@ -153,6 +181,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha spinner?.stop(); printCreatedChangeHuman(payload, root); + printImplicitRootNotice(root); } catch (error) { spinner?.stop(); if (options.json) { diff --git a/src/core/templates/workflows/apply-change.ts b/src/core/templates/workflows/apply-change.ts index dcb6d606bb..de86e5450f 100644 --- a/src/core/templates/workflows/apply-change.ts +++ b/src/core/templates/workflows/apply-change.ts @@ -7,6 +7,7 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { optionalWorkflow } from '../optional-workflow.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +import { PROJECT_ROOT_GUARD } from './project-root.js'; /** * `/opsx:continue` is not in the `core` profile, so the blocked-state handoff @@ -40,6 +41,8 @@ export function getApplyInstructions(): string { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: Optionally specify a change name (e.g., \`/opsx:apply add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** diff --git a/src/core/templates/workflows/archive-change.ts b/src/core/templates/workflows/archive-change.ts index cd34023972..4ec03bab37 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -7,6 +7,7 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { optionalWorkflow } from '../optional-workflow.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +import { PROJECT_ROOT_GUARD } from './project-root.js'; /** * Archiving must merge delta specs into the main specs; the `sync` workflow is @@ -30,11 +31,13 @@ const SYNC_GUARDRAIL = optionalWorkflow( 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 OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.', instructions: `Archive a completed change in the experimental workflow. ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + \`\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec. **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. @@ -227,6 +230,8 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + \`\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec. **Input**: Optionally specify a change name after \`/opsx:archive\` (e.g., \`/opsx:archive add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. diff --git a/src/core/templates/workflows/bulk-archive-change.ts b/src/core/templates/workflows/bulk-archive-change.ts index 2fb62ecb0d..1efdbb5cce 100644 --- a/src/core/templates/workflows/bulk-archive-change.ts +++ b/src/core/templates/workflows/bulk-archive-change.ts @@ -7,6 +7,7 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { optionalWorkflow } from '../optional-workflow.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +import { PROJECT_ROOT_GUARD } from './project-root.js'; /** * Archiving must merge delta specs into the main specs; the `sync` workflow is @@ -30,13 +31,15 @@ const SYNC_GUARDRAIL = optionalWorkflow( 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 OpenSpec changes at once. Use when archiving several parallel 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. ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + \`\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec. **Input**: None required (prompts for selection) @@ -394,6 +397,8 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + \`\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec. **Input**: None required (prompts for selection) diff --git a/src/core/templates/workflows/continue-change.ts b/src/core/templates/workflows/continue-change.ts index 9b19b3ed32..a339db9a91 100644 --- a/src/core/templates/workflows/continue-change.ts +++ b/src/core/templates/workflows/continue-change.ts @@ -7,6 +7,7 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { optionalWorkflow } from '../optional-workflow.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +import { PROJECT_ROOT_GUARD } from './project-root.js'; /** * The planning-complete handoff. Neither `apply` nor `archive` is guaranteed @@ -31,6 +32,8 @@ export function getContinueChangeSkillTemplate(): SkillTemplate { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -149,6 +152,8 @@ export function getOpsxContinueCommandTemplate(): CommandTemplate { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: Optionally specify a change name after \`/opsx:continue\` (e.g., \`/opsx:continue add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** diff --git a/src/core/templates/workflows/explore.ts b/src/core/templates/workflows/explore.ts index b4daa9ea26..39bba7761c 100644 --- a/src/core/templates/workflows/explore.ts +++ b/src/core/templates/workflows/explore.ts @@ -7,6 +7,7 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { optionalWorkflow } from '../optional-workflow.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +import { PROJECT_ROOT_GUARD } from './project-root.js'; const PLANNING_GUIDANCE = `## Planning a Change @@ -75,7 +76,7 @@ const GUARDRAIL_HANDOFF = optionalWorkflow( 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 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.', 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. @@ -84,6 +85,8 @@ export function getExploreSkillTemplate(): SkillTemplate { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + --- ## The Stance @@ -407,6 +410,8 @@ export function getOpsxExploreCommandTemplate(): CommandTemplate { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: The argument after \`/opsx:explore\` is whatever the user wants to think about. Could be: - A vague idea: "real-time collaboration" - A specific problem: "the auth system is getting unwieldy" diff --git a/src/core/templates/workflows/ff-change.ts b/src/core/templates/workflows/ff-change.ts index c7c179be72..057218b47e 100644 --- a/src/core/templates/workflows/ff-change.ts +++ b/src/core/templates/workflows/ff-change.ts @@ -7,6 +7,7 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { optionalWorkflow } from '../optional-workflow.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +import { PROJECT_ROOT_GUARD } from './project-root.js'; /** * The implementation handoff, resolved at generation time so a profile @@ -36,6 +37,8 @@ export function getFfChangeSkillTemplate(): SkillTemplate { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** @@ -153,6 +156,8 @@ export function getOpsxFfCommandTemplate(): CommandTemplate { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: The argument after \`/opsx:ff\` is the change name (kebab-case), OR a description of what the user wants to build. **Steps** diff --git a/src/core/templates/workflows/new-change.ts b/src/core/templates/workflows/new-change.ts index 502b3a289a..97b5473868 100644 --- a/src/core/templates/workflows/new-change.ts +++ b/src/core/templates/workflows/new-change.ts @@ -7,6 +7,7 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { optionalWorkflow } from '../optional-workflow.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +import { PROJECT_ROOT_GUARD } from './project-root.js'; /** * Handoffs to `continue`, which is not guaranteed to be installed alongside @@ -32,6 +33,8 @@ export function getNewChangeSkillTemplate(): SkillTemplate { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** @@ -109,6 +112,8 @@ export function getOpsxNewCommandTemplate(): CommandTemplate { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: The argument after \`/opsx:new\` is the change name (kebab-case), OR a description of what the user wants to build. **Steps** diff --git a/src/core/templates/workflows/onboard.ts b/src/core/templates/workflows/onboard.ts index 1e74170d81..5db283f0a0 100644 --- a/src/core/templates/workflows/onboard.ts +++ b/src/core/templates/workflows/onboard.ts @@ -7,6 +7,7 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { onlyWithWorkflow, optionalWorkflow } from '../optional-workflow.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +import { PROJECT_ROOT_GUARD } from './project-root.js'; /** * The tutorial names other workflows throughout. Which of them exist depends @@ -85,6 +86,8 @@ function getOnboardInstructions(): string { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + --- ## Preflight diff --git a/src/core/templates/workflows/project-root.ts b/src/core/templates/workflows/project-root.ts new file mode 100644 index 0000000000..2815844210 --- /dev/null +++ b/src/core/templates/workflows/project-root.ts @@ -0,0 +1,37 @@ +/** + * Shared project-root guidance for skill template workflows. + * + * Generated skills and commands are installed once per machine, so they are + * offered in every repository the agent opens - including repositories that + * never ran `openspec init`. Nothing stops the workflow there: `openspec new + * change` falls back to an implicit root and creates `openspec/` in whatever + * directory the agent happens to be in. + * + * This guidance is interpolated into every workflow so the agent checks for a + * root before writing. `openspec list --json` is the check because it refuses + * to fabricate an implicit root: it reports `root: null` both when nothing is + * set up and when only stores are registered. + * + * What follows the check depends on how the workflow was reached, because the + * two cases want opposite things (#1645). A skill the model picked on its own + * in an unrelated repository must get out of the way: the user asked for help, + * not for OpenSpec, and answering with a setup menu is the reported bug. A + * user who named OpenSpec, named the skill, or ran its slash command is owed + * an answer about OpenSpec, so that case stops and asks. + * + * One text serves both surfaces. `apply-change` and `onboard` render a single + * body into the skill and the command alike, so a command-only variant would + * mean threading a surface flag through bodies that deliberately have none. + * The bullets scope themselves instead: a slash command is an explicit + * invocation, so its branch is the only one that can apply there. + */ +export const PROJECT_ROOT_GUARD = `**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (\`new change\`, \`archive\`, \`sync specs\`, or authoring an artifact file), confirm the project has a root: run \`openspec list --json\` (with \`--store \` when a store is selected, since the store is then the root) and read \`root\`. A root object means the project is set up. \`"root": null\` means it is not - there is no \`openspec/\` directory here, and a write such as \`openspec new change\` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One \`"root": null\` is not about setup: when a \`status\` error message starts with \`Declared in\` or \`Invalid store declaration in\` and names this project's \`openspec/config.yaml\` (or \`config.yml\`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the \`store:\` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's \`message\` and \`fix\`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (\`openspec init\`), target a store they already have (\`--store \`), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run \`openspec init\` until the user asks for it, do not hand-create \`openspec/\` files, and do not let a command create it.`; diff --git a/src/core/templates/workflows/propose.ts b/src/core/templates/workflows/propose.ts index 6c5886959e..c3754852d9 100644 --- a/src/core/templates/workflows/propose.ts +++ b/src/core/templates/workflows/propose.ts @@ -7,6 +7,7 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { optionalWorkflow } from '../optional-workflow.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +import { PROJECT_ROOT_GUARD } from './project-root.js'; /** * The implementation handoff. `apply` is not guaranteed to be installed, so @@ -32,7 +33,7 @@ const COMMAND_APPLY_HANDOFF = optionalWorkflow( 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 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.', 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. @@ -51,6 +52,8 @@ When the user is ready to implement, they must start the apply workflow explicit ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** @@ -68,7 +71,7 @@ ${STORE_SELECTION_GUIDANCE} 2. **Load project context** - Run \`openspec context --json\` from the current working directory (or \`openspec context --json --store ""\` when a registered store was explicitly selected). Use the returned \`root.path\` as the authoritative OpenSpec root. If context reports \`no_openspec_root\`, stop without creating or changing any files. Offer \`openspec init\` and wait for the user to request initialization. Do not initialize automatically or run \`openspec new change\`. After initialization, rerun this context check before continuing. For any other context failure, stop and report the error; do not fall back to the current directory or run later OpenSpec commands without the selected store. + Run \`openspec context --json\` from the current working directory (or \`openspec context --json --store ""\` when a registered store was explicitly selected). Use the returned \`root.path\` as the authoritative OpenSpec root. If context reports \`no_openspec_root\`, stop without creating or changing any files and follow the **Project check** above for how this workflow was reached. Offer \`openspec init\` only for an explicit OpenSpec request, and wait for the user to request initialization. Do not initialize automatically or run \`openspec new change\`. After initialization, rerun this context check before continuing. For any other context failure, stop and report the error; do not fall back to the current directory or run later OpenSpec commands without the selected store. Only when context returns a resolved \`root.path\`, read \`/openspec/config.yaml\`. Use \`config.yml\` only when \`config.yaml\` does not exist. If neither file exists, continue without project context. Do not fall back to \`config.yml\` if \`config.yaml\` is unreadable or invalid. @@ -214,6 +217,8 @@ When the user is ready to implement, they must start the apply workflow explicit ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: The argument after \`/opsx:propose\` is the change name (kebab-case), OR a description of what the user wants to build. **Steps** @@ -231,7 +236,7 @@ ${STORE_SELECTION_GUIDANCE} 2. **Load project context** - Run \`openspec context --json\` from the current working directory (or \`openspec context --json --store ""\` when a registered store was explicitly selected). Use the returned \`root.path\` as the authoritative OpenSpec root. If context reports \`no_openspec_root\`, stop without creating or changing any files. Offer \`openspec init\` and wait for the user to request initialization. Do not initialize automatically or run \`openspec new change\`. After initialization, rerun this context check before continuing. For any other context failure, stop and report the error; do not fall back to the current directory or run later OpenSpec commands without the selected store. + Run \`openspec context --json\` from the current working directory (or \`openspec context --json --store ""\` when a registered store was explicitly selected). Use the returned \`root.path\` as the authoritative OpenSpec root. If context reports \`no_openspec_root\`, stop without creating or changing any files and follow the **Project check** above for how this workflow was reached. Offer \`openspec init\` only for an explicit OpenSpec request, and wait for the user to request initialization. Do not initialize automatically or run \`openspec new change\`. After initialization, rerun this context check before continuing. For any other context failure, stop and report the error; do not fall back to the current directory or run later OpenSpec commands without the selected store. Only when context returns a resolved \`root.path\`, read \`/openspec/config.yaml\`. Use \`config.yml\` only when \`config.yaml\` does not exist. If neither file exists, continue without project context. Do not fall back to \`config.yml\` if \`config.yaml\` is unreadable or invalid. diff --git a/src/core/templates/workflows/sync-specs.ts b/src/core/templates/workflows/sync-specs.ts index 3f9e7aa362..d06ee6fe84 100644 --- a/src/core/templates/workflows/sync-specs.ts +++ b/src/core/templates/workflows/sync-specs.ts @@ -6,17 +6,20 @@ */ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +import { PROJECT_ROOT_GUARD } from './project-root.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 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.', 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). ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + \`\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec. **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. @@ -294,6 +297,8 @@ This is an **agent-driven** operation - you will read delta specs and directly e ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + \`\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec. **Input**: Optionally specify a change name after \`/opsx:sync\` (e.g., \`/opsx:sync add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. diff --git a/src/core/templates/workflows/update-change.ts b/src/core/templates/workflows/update-change.ts index 8c1bb57a8d..e5bdf9742d 100644 --- a/src/core/templates/workflows/update-change.ts +++ b/src/core/templates/workflows/update-change.ts @@ -7,6 +7,7 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { optionalWorkflow } from '../optional-workflow.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +import { PROJECT_ROOT_GUARD } from './project-root.js'; /** * Passages that hand off to `/opsx:continue` or `/opsx:new`. Neither workflow @@ -80,6 +81,8 @@ export function getUpdateChangeSkillTemplate(): SkillTemplate { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. ${CONTINUE_SCOPE_NOTE} @@ -172,6 +175,8 @@ export function getOpsxUpdateCommandTemplate(): CommandTemplate { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: Optionally specify a change name after \`/opsx:update\` (e.g., \`/opsx:update add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. ${CONTINUE_SCOPE_NOTE} diff --git a/src/core/templates/workflows/verify-change.ts b/src/core/templates/workflows/verify-change.ts index 4d513d81b6..dab988ba62 100644 --- a/src/core/templates/workflows/verify-change.ts +++ b/src/core/templates/workflows/verify-change.ts @@ -6,15 +6,18 @@ */ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; +import { PROJECT_ROOT_GUARD } from './project-root.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 OpenSpec change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.', instructions: `Verify that an implementation matches the change artifacts (specs, tasks, design). ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -192,6 +195,8 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate { ${STORE_SELECTION_GUIDANCE} +${PROJECT_ROOT_GUARD} + **Input**: Optionally specify a change name after \`/opsx:verify\` (e.g., \`/opsx:verify add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index b601cc7aa5..f4a818d85c 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -653,6 +653,79 @@ operations: expect(json.root.source).toBe('implicit'); }); + // Creating a change in a directory that was never set up silently + // materializes `openspec/` there (#1645). The creation stays zero-config, + // but a human who did not mean to adopt this directory has to be told. + it('says so when the first change creates the root in an unset-up directory', async () => { + const isolatedEnv = { + ...env, + XDG_DATA_HOME: path.join(tempDir, 'data-empty'), + }; + + const created = await runCLI(['new', 'change', 'adopt-me'], { + cwd: appRepo, + env: isolatedEnv, + }); + expect(created.exitCode).toBe(0); + const firstOutput = created.stdout + created.stderr; + expect(firstOutput).toContain('no OpenSpec root was found here'); + // Naming the directory it created is the point: the reader has to know + // what to delete if this was not the project they meant. The path is + // relative to where the command ran, so it reads the same on Windows. + expect(firstOutput).toContain('created at openspec/.'); + expect(firstOutput).toContain('openspec init'); + expect(fs.existsSync(path.join(appRepo, 'openspec', 'changes', 'adopt-me'))).toBe(true); + + // The root exists now, so the notice must not repeat on every change. + const second = await runCLI(['new', 'change', 'already-adopted'], { + cwd: appRepo, + env: isolatedEnv, + }); + expect(second.exitCode).toBe(0); + expect(second.stdout + second.stderr).not.toContain('no OpenSpec root was found here'); + }); + + // Run from a subdirectory and the subdirectory is what gets adopted - the + // note has to name that directory, not the repository above it. + it('names the directory it actually adopted when run from a subdirectory', async () => { + const isolatedEnv = { + ...env, + XDG_DATA_HOME: path.join(tempDir, 'data-empty'), + }; + const nested = path.join(appRepo, 'services', 'billing'); + fs.mkdirSync(nested, { recursive: true }); + + const created = await runCLI(['new', 'change', 'adopt-the-subdir'], { + cwd: nested, + env: isolatedEnv, + }); + expect(created.exitCode).toBe(0); + expect(created.stdout + created.stderr).toContain('created at openspec/.'); + + expect(fs.existsSync(path.join(nested, 'openspec', 'changes', 'adopt-the-subdir'))).toBe( + true + ); + expect(fs.existsSync(path.join(appRepo, 'openspec'))).toBe(false); + }); + + it('keeps the notice out of JSON output', async () => { + const isolatedEnv = { + ...env, + XDG_DATA_HOME: path.join(tempDir, 'data-empty'), + }; + + const result = await runCLI(['new', 'change', 'adopt-me-quietly', '--json'], { + cwd: appRepo, + env: isolatedEnv, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain('no OpenSpec root was found here'); + + // `root.source` is how a caller in JSON mode learns the same fact. + const json = parseJson(result); + expect(json.root.source).toBe('implicit'); + }); + it('keeps list working for a legacy project.md root when no stores are registered', async () => { const isolatedEnv = { ...env, @@ -732,6 +805,28 @@ operations: } }); + // The generated workflows read `root` from `openspec list --json` to decide + // whether a project is set up (#1645). That answer has to stay honest when + // stores are registered but this directory has no root of its own - + // an implicit root here would read as "set up" and the workflow would + // scaffold a change into an unrelated repository. + it('reports a missing root as JSON when only stores are registered', async () => { + const result = await runCLI(['list', '--json'], { cwd: appRepo, env }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(''); + + const json = parseJson(result); + expect(json.root).toBeNull(); + expect(json.changes).toEqual([]); + expect(json.status[0]).toEqual( + expect.objectContaining({ + severity: 'error', + code: 'no_root_with_registered_stores', + }) + ); + }); + it('still accepts an existing root with no items', async () => { const isolatedEnv = { ...env, diff --git a/test/core/root-selection.test.ts b/test/core/root-selection.test.ts index 3a09d2ee55..31bfbb3cc8 100644 --- a/test/core/root-selection.test.ts +++ b/test/core/root-selection.test.ts @@ -500,6 +500,46 @@ describe('resolveOpenSpecRoot', () => { ); expect(error.message).toContain(path.join(dir, 'openspec', 'config.yml')); }); + + // The generated workflows' project check (#1645) reads `root: null` as + // "never initialized" unless the status message starts with a prefix it + // names. Every failure of a project's own `store:` declaration must carry + // one of those prefixes, or the guard drops OpenSpec (or offers `openspec + // init`) in a project that already uses it. A stale global defaultStore in + // an unrelated repository must carry neither. + it('starts every declaration failure with a prefix the project check names', async () => { + const { PROJECT_ROOT_GUARD } = await import( + '../../src/core/templates/workflows/project-root.js' + ); + const prefixes = ['Declared in ', 'Invalid store declaration in ']; + for (const prefix of prefixes) { + expect(PROJECT_ROOT_GUARD).toContain(`\`${prefix.trim()}\``); + } + const namedByGuard = (message: string) => + prefixes.some((prefix) => message.startsWith(prefix)); + + const failures = [ + createPointerDir('guard-unregistered', 'store: ghost\n'), + createPointerDir('guard-bad-type', 'store: [a, b]\n'), + createPointerDir('guard-bad-yaml', 'store: [unclosed'), + createPointerDir('guard-bad-id', 'store: "BAD ID"\n'), + ]; + for (const dir of failures) { + const error = await resolveOpenSpecRoot({ startPath: dir, globalDataDir }).catch( + (caught: unknown) => caught + ); + expect(error, dir).toBeInstanceOf(RootSelectionError); + expect(namedByGuard((error as RootSelectionError).message), dir).toBe(true); + } + + setDefaultStore('ghost-plans'); + const stale = await resolveOpenSpecRoot({ + startPath: mkdir('guard-unrelated-repo'), + globalDataDir, + }).catch((caught: unknown) => caught); + expect(stale).toBeInstanceOf(RootSelectionError); + expect(namedByGuard((stale as RootSelectionError).message)).toBe(false); + }); }); it('skips openspec/ directories that are neither planning-shaped nor configured (the ~/openspec layout)', async () => { diff --git a/test/core/templates/project-root-guard.test.ts b/test/core/templates/project-root-guard.test.ts new file mode 100644 index 0000000000..05f417fd62 --- /dev/null +++ b/test/core/templates/project-root-guard.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; + +import { PROJECT_ROOT_GUARD } from '../../../src/core/templates/workflows/project-root.js'; +import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; +import { getFeedbackSkillTemplate } from '../../../src/core/templates/skill-templates.js'; +import { + generateSkillContent, + getCommandContents, + getSkillTemplates, +} from '../../../src/core/shared/skill-generation.js'; + +/** + * Regression coverage for #1645. + * + * Skills and commands are installed once per machine and offered in every + * repository, including ones that never ran `openspec init`. Nothing in the + * CLI stops the workflow there - `openspec new change` falls back to an + * implicit root and creates `openspec/` wherever the agent is standing - so + * the guard has to live in the instructions themselves, in every workflow. + */ +describe('project root guard', () => { + /** One bullet of the no-root branch table, from its anchor to the next. */ + function branch(anchor: string): string { + const start = PROJECT_ROOT_GUARD.indexOf(anchor); + expect(start, `${anchor} is missing`).toBeGreaterThanOrEqual(0); + const next = PROJECT_ROOT_GUARD.indexOf('\n- ', start); + return PROJECT_ROOT_GUARD.slice(start, next === -1 ? undefined : next); + } + + // Both surfaces, rendered exactly as they ship. + function renderedBodies(): Array<[string, string]> { + return [ + ...getSkillTemplates().map( + ({ template, dirName }): [string, string] => [ + `skill ${dirName}`, + generateSkillContent(template, 'PARITY-BASELINE'), + ] + ), + ...getCommandContents().map( + (entry): [string, string] => [`command ${entry.id}`, entry.body] + ), + ]; + } + + it('warns about an uninitialized project in every deployed skill', () => { + for (const { template, dirName } of getSkillTemplates()) { + const content = generateSkillContent(template, 'PARITY-BASELINE'); + expect(content, dirName).toContain(PROJECT_ROOT_GUARD); + } + }); + + it('warns about an uninitialized project in every deployed opsx command', () => { + for (const entry of getCommandContents()) { + expect(entry.body, entry.id).toContain(PROJECT_ROOT_GUARD); + } + }); + + // Feedback files a GitHub issue through `openspec feedback`; it never reads + // or writes a root, so it ships outside both registries and carries neither + // the store teaching nor this guard. + it('leaves the rootless feedback skill alone', () => { + expect(getFeedbackSkillTemplate().instructions).not.toContain('**Project check:**'); + }); + + // The CLI contract behind this check - `list` reporting `root: null` instead + // of fabricating an implicit root - is pinned in + // test/commands/store-root-selection.test.ts. + it('names the machine-readable signal rather than a guess', () => { + expect(PROJECT_ROOT_GUARD).toContain('openspec list --json'); + // A selected store is a root, so the check has to carry the flag or it + // answers a question about the wrong directory. + expect(PROJECT_ROOT_GUARD).toContain('with `--store ` when a store is selected'); + expect(PROJECT_ROOT_GUARD).toContain('`"root": null`'); + // An agent that reads the non-zero exit as a broken CLI is one step from + // hand-creating `openspec/` instead, which is the failure being guarded. + expect(PROJECT_ROOT_GUARD).toContain('also exits non-zero, which is that answer rather than a broken CLI'); + }); + + // A store-only project whose `store:` line names a store this machine has not + // registered (a teammate's fresh clone) also reports `root: null`, with + // `unknown_store` or `no_registered_stores`. A stale global `defaultStore` + // reports the same codes in unrelated repositories, so only the message + // prefix pinned in test/core/root-selection.test.ts tells them apart. Treating + // that project as uninitialized would silently drop OpenSpec, or offer + // `openspec init`, in a project that is already set up. + it('does not mistake an unregistered declared store for an uninitialized project', () => { + expect(PROJECT_ROOT_GUARD).toContain('starts with `Declared in`'); + expect(PROJECT_ROOT_GUARD).toContain('Do not treat it as uninitialized and skip the branches below'); + expect(PROJECT_ROOT_GUARD).toContain("show the user that error's `message` and `fix`"); + expect(PROJECT_ROOT_GUARD.indexOf('starts with `Declared in`')).toBeLessThan( + PROJECT_ROOT_GUARD.indexOf('**Auto-selected**') + ); + }); + + // #1645 asks for the workflow to get out of the way, not to interrogate the + // user: "if not exist it can go through the normal general propose not the + // openspec". So the two ways of arriving here get opposite answers, and both + // have to be pinned or the guard drifts back to one of them. + it('gets out of the way when it selected itself', () => { + const autoSelected = branch('**Auto-selected**'); + + expect(autoSelected).toContain('without the user naming OpenSpec'); + expect(autoSelected).toContain('answer the request normally'); + // The reported bug is being asked to choose a setup path for a project the + // user never said was an OpenSpec project. + expect(autoSelected).toContain('Do not ask them to set anything up'); + expect(autoSelected).not.toContain('openspec init'); + expect(autoSelected).not.toContain('--store '); + }); + + it('asks when the user named OpenSpec, this skill, or its command', () => { + const explicit = branch('**Explicit OpenSpec request**'); + + expect(explicit).toContain('named OpenSpec, named this skill, or ran its slash command'); + expect(explicit).toContain('Stop before writing and ask how to proceed'); + expect(explicit).toContain('`openspec init`'); + expect(explicit).toContain('`--store `'); + expect(explicit).toContain('continue without OpenSpec'); + expect(explicit).toContain('Wait for their answer'); + }); + + // A slash command is an explicit invocation, so the ask branch is the one + // that applies there. The guard ships whole into command files, which is what + // keeps that branch reachable from a command surface. + it('carries the explicit branch into every deployed opsx command', () => { + for (const [label, body] of renderedBodies()) { + if (!label.startsWith('command ')) continue; + expect(body, label).toContain('**Explicit OpenSpec request**'); + expect(body, label).toContain('Stop before writing and ask how to proceed'); + } + }); + + it('never lets any branch create the root as a side effect', () => { + expect(PROJECT_ROOT_GUARD).toContain('In both branches, never create the root as a side effect'); + expect(PROJECT_ROOT_GUARD).toContain('do not run `openspec init` until the user asks for it'); + expect(PROJECT_ROOT_GUARD).toContain('do not hand-create `openspec/` files'); + expect(PROJECT_ROOT_GUARD).toContain('do not let a command create it'); + }); + + // A guard printed after the workflow has already scaffolded a change is no + // guard at all, so nothing that runs a command or writes an artifact may + // appear before it. Asserting on the text *preceding* the guard catches a + // stray write wherever it sits - inside a fence or in bare prose - which + // looking only at the first fenced block would miss. + it('precedes every command block and write instruction it guards', () => { + const writeMarkers = [ + '```', // any command block, whatever the language tag + 'openspec new change', + 'openspec archive', + 'openspec sync', + 'openspec instructions', + 'openspec validate', + ]; + + for (const [label, body] of renderedBodies()) { + const guardStart = body.indexOf(PROJECT_ROOT_GUARD); + expect(guardStart, label).toBeGreaterThanOrEqual(0); + + // A skill's YAML frontmatter is metadata a host reads to pick the skill, + // not instructions the agent runs, so a description may quote a command + // name without running it. Only the body after the frontmatter is guarded. + const frontmatter = /^---\n[\s\S]*?\n---\n/.exec(body)?.[0] ?? ''; + const beforeGuard = body.slice(frontmatter.length, guardStart); + for (const marker of writeMarkers) { + expect(beforeGuard, `${label} runs "${marker}" before the project check`).not.toContain( + marker + ); + } + } + }); + + // The guard is worthless if it sits at the end of a long workflow, so pin + // where it lives: directly under the store-selection guidance, in the + // header every workflow reads before it starts. + it('sits directly under the store-selection guidance', () => { + for (const [label, body] of renderedBodies()) { + const storeStart = body.indexOf(STORE_SELECTION_GUIDANCE); + expect(storeStart, label).toBeGreaterThanOrEqual(0); + expect(body.indexOf(PROJECT_ROOT_GUARD), label).toBe( + storeStart + STORE_SELECTION_GUIDANCE.length + '\n\n'.length + ); + } + }); + + // The other half of #1645: a host picks skills by description, so a + // description that never says "OpenSpec" reads as a generic offer to + // explore or propose and wins in repositories that have no OpenSpec at all. + it('scopes every deployed skill description to OpenSpec', () => { + for (const { template, dirName } of getSkillTemplates()) { + expect(template.description, dirName).toContain('OpenSpec'); + } + }); +}); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 2427641447..29be2bd560 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -54,46 +54,46 @@ const asDeployed = (template: SkillTemplate): SkillTemplate => ({ }); const EXPECTED_FUNCTION_HASHES: Record = { - getExploreSkillTemplate: 'facc3c805a324c4ad7c484567372bbdc98a921bcfdd9f55cd50143979190cc9f', - getNewChangeSkillTemplate: 'eabd1e895c5881dcb17dcbaa3fb26098dd59e8eacb318e400820b4dc811ef781', - getContinueChangeSkillTemplate: '012136f6411a99c8fa228e2f9444cb64b0a89e0f56fdeac2fe03b2f5bee0c5d7', - getApplyChangeSkillTemplate: '796a56dfd16bd04df0c0adfe10c5378bb0c620307fb81eb28bf4f5378cd78aac', - getFfChangeSkillTemplate: 'dbf062f7018309bfd89993d215017964cc82b7eab413e8318051cd9421589da4', - getSyncSpecsSkillTemplate: '271825029d9fa83c19ceb07e6f1ca3c10a17cf0822899d7709180a7487b9ae29', - getOnboardSkillTemplate: '3549e6a34a59ff5a11cdabf0edfdac2e9171158dd78b83045505f5ec7c7b83bf', - getOpsxExploreCommandTemplate: 'f70b83a5d16d7ba73a13e8d7f17feecee526bc69f431c05c68c1a0bc654ff2e6', - getOpsxNewCommandTemplate: '00a1077bd71ad84ceaa149e35479b3041598fc4c0219b75b7bb74feac80624f4', - getOpsxContinueCommandTemplate: '1cfb9527dc2cdb267d56c1804a97346b0055861954fbe75a86949e962a5040a5', - getOpsxApplyCommandTemplate: '704da8e513f853ace5567997a29b1bcfd30fc6b4386687bd7d1352e06c0f0ffa', - getOpsxFfCommandTemplate: '52e3abb57200b026b53c3735037b7aa0d53532aa9d9c010a364fba8732b11401', - getArchiveChangeSkillTemplate: 'e6c1fd5b0bb80bac6c34960ec6a218a0382b4d86c73e651d761d06ee9691028a', - getBulkArchiveChangeSkillTemplate: '80c7c6c524b9e3f03c9af440dc6f29b174f44cea6101d05533ab19760801b93b', - getOpsxSyncCommandTemplate: 'b4b2bbcfa7e3709bc7cfdfefed7d4c5921878d03ba483e6c97bd33eb94cb583c', - getVerifyChangeSkillTemplate: '7c27431d32ece63f618d197a88375ffdd2f3979d5314ce3affacba923dc60c87', - getOpsxArchiveCommandTemplate: 'bdc1287f8ebe65bf29f5e3a5f06e945567d7c1f7ab8fd039352a72ddd1bda24a', - getOpsxOnboardCommandTemplate: '68c99792652c60a5e59381b47f9a7bc7f7836be0e7fe3276ac15a16a6e16a0c6', - getOpsxBulkArchiveCommandTemplate: '3fdbf66b8f4470ca5b3461fb86659ac67d9720c4ec44d7ea0a7501f8947ef264', - getOpsxVerifyCommandTemplate: 'b7c4e065817144c3f1bb61fb6598e4b4ec2da80c36f98b112f95a5f83290b126', - getOpsxProposeSkillTemplate: '9b0ab30d46af146499fbee556d00aa379138abad212e5e210be342cc78b202a4', - getOpsxProposeCommandTemplate: 'b0975734df8b4530cb6da5adaf85ad0d6760bda904989b60de26070d01d8569c', + getExploreSkillTemplate: '432ca3409df7deff108724116b715752f18352bfbeb0c13e8b67a9e8030aea4e', + getNewChangeSkillTemplate: 'c7baff552ed6adcde8603741311c01aa56e4d548ccc46e4e8139fc3bc751ec23', + getContinueChangeSkillTemplate: '281ffd3876e8d3e4c2f5a2bdd640116abf617b900b57d8144ada7975dd10bf49', + getApplyChangeSkillTemplate: '40f0af2661e8e2340bd1a16316e96d5ac6ddc4d1932d911671cc85232e5ede7d', + getFfChangeSkillTemplate: '35dfbabda840facba023c471377c39f17a8449d84eae44fd1f12a4bc9a0cf1fe', + getSyncSpecsSkillTemplate: '66f3540367fe96fb1598a985f29f494364265e72953a83b69a65556bcd694ba5', + getOnboardSkillTemplate: '9fb82967e8887a051eb9e44eb10cbd65676020b57179ae7e5f9a8a3159cc4646', + getOpsxExploreCommandTemplate: 'f6cf22825643281d653355745623a6c1a4566db46cc2f262d2282243c6d8169a', + getOpsxNewCommandTemplate: '6d504fef1e0d4ced7c423f4cc9d9d2cee11b1a6224edf685e06a3f0757e0ebff', + getOpsxContinueCommandTemplate: 'ace5c9cc239c12b57dc86fd9a1c02a6ca467cb8e1245127340c07ab1b9d37c11', + getOpsxApplyCommandTemplate: 'd70cecce3b7d1dd4dbd5fd1fc2bccb538f5e61f5b43d520e4beca896e3f9e6b3', + getOpsxFfCommandTemplate: '04cb49b0bf3ebe364b45268a283564ee4fd50b78b01ec1d3f975bcae68179d2d', + getArchiveChangeSkillTemplate: 'fad151a30728b9bbc67f2f9d9e72781b1923876d0e99c4820dfe204b2f5e4702', + getBulkArchiveChangeSkillTemplate: '7e494b5d96ddfc7d34721b27afe101b7529ad5a597ae9d3a1e5d92c451578b61', + getOpsxSyncCommandTemplate: '1664f4c4d8698ba058d03eaf977ff1de1718f6d86110e7987f681b6d2a6a9b1f', + getVerifyChangeSkillTemplate: '66a70271a3b3a07491784451a7237503807fceec2cb8ff6ec3d9dc35efff1da2', + getOpsxArchiveCommandTemplate: '980109e5f8362610872c70fe0a0f1d48d3d2692275b2b17e2f4c91c3de89c2fd', + getOpsxOnboardCommandTemplate: '4176f03b0be1a96668aa8c4234e8dcc1d61aba1226c1cbee9f4498a54cd0d546', + getOpsxBulkArchiveCommandTemplate: '3db03eadb764abd74c8c180656c3f64a8b9a4971056c91624d38df3209d7b446', + getOpsxVerifyCommandTemplate: '938f52f20fb9a3b811ea47314baac1034cd550e8ab363ae878ccba4b6329348f', + getOpsxProposeSkillTemplate: 'c4de09c1fd7e21a66a314fc5a8b3d35d7c4b6593ff3314495ecdc31fcb6e9c31', + getOpsxProposeCommandTemplate: '3b7090ce5e79e879ab9b5bdaf4ff2b52e3c02211f71188838772d36ac337f96c', getFeedbackSkillTemplate: 'dabeb5e825b9349abc8156c3e7b8608f27987912a6d9bf47ef29addde6138133', - getUpdateChangeSkillTemplate: 'd13e1424054795e8727aa56fd9c74c5bd90937e210351a7f44c2a9d19c44aafe', - getOpsxUpdateCommandTemplate: '54ac9a690c689bc50574309eaf8d460a5ce08810bb97f218eeffa071246d3519', + getUpdateChangeSkillTemplate: '92b05d159ce2ca5df670ec283e9ea824dc33ce9c6ee4583cae3b6be33ba4cb94', + getOpsxUpdateCommandTemplate: 'a3156c2c3b4a429fed56545f315f66a7cc25bc9f8822c5fe30a60ccd87159a0f', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': 'dce3fa2ee328cf66d3aacd1f90e62379073c3a0f4a58feecce9c90e0c159a24e', - 'openspec-new-change': 'ec4529beef978e34634a6f7286fab55d68fad8fb374dceb45691d52caab33fbb', - 'openspec-continue-change': 'bb6194a16c54891cdb253678e8f70ce53b2af86735243980f366ce551d37e42e', - 'openspec-apply-change': '149b1bcd19d867749f8a3e016bd452e8b5ef699f591dfb8793a9d527e98ddec5', - 'openspec-ff-change': '31355250514bce51b16ff37ee2b833bc9d475cd0dbd4b1f68fe2041694575623', - 'openspec-sync-specs': '0690c2290e74b3f7ce8f19d3204fb2d5630eb06290d1c6a79370026099758c98', - 'openspec-archive-change': 'd6e91de7430428956d33220a82abae14221eee56e10bd84938212935035d2959', - 'openspec-bulk-archive-change': '6b61b99bacd97a498a44652736ad5aa5c4cf053ab8d04da20b720d4d42a830cd', - 'openspec-verify-change': '847bc57688cc4987828f9535559d74f2d1ebe192c33f084a2defa9d60cb8bc67', - 'openspec-onboard': 'f1aa5f1601643cf8047ab0252813d469fcf313ca31d098e0410c89e1fe550fcf', - 'openspec-propose': '679d0f868bed23cfb34a8ecc6b4ba4ff7b88dd7dbaef91563423e98f194f988f', - 'openspec-update-change': '1e5f6c6baa1575bcfcdcff0dc1454cf6d31c874931ae660298384b3fdfec0411', + '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', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates diff --git a/test/core/templates/update-change.test.ts b/test/core/templates/update-change.test.ts index be952c667d..1801f46c0c 100644 --- a/test/core/templates/update-change.test.ts +++ b/test/core/templates/update-change.test.ts @@ -5,6 +5,7 @@ import { getOpsxUpdateCommandTemplate, } from '../../../src/core/templates/skill-templates.js'; import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; +import { PROJECT_ROOT_GUARD } from '../../../src/core/templates/workflows/project-root.js'; import { resolveOptionalWorkflows } from '../../../src/core/templates/optional-workflow.js'; import { ALL_WORKFLOWS, CORE_WORKFLOWS } from '../../../src/core/profiles.js'; @@ -104,7 +105,8 @@ function stepFive(body: string, label: string): string { return section(body, '5. **Confirm and apply', '6. **Point to the next step', `${label} step 5`); } -// Everything the agent reads except step 5 and the shared store preamble. +// Everything the agent reads except step 5 and the shared store and project-root +// preambles (the root guard says to stop before writing; it authorizes none). // #1836 lived in step 4, but a sentence in the intro, in step 3, in the // Guardrails or in the Output section would govern the agent just as well // while sitting outside any single-step slice. Returns the checks that tripped. @@ -113,6 +115,8 @@ function writeAuthorizationsOutsideStepFive(body: string, label: string): string .split(stepFive(body, label)) .join('\n') .split(STORE_SELECTION_GUIDANCE) + .join('') + .split(PROJECT_ROOT_GUARD) .join(''); for (const sanctioned of SANCTIONED_OUTSIDE_STEP_FIVE) { rest = rest.split(sanctioned).join('');