mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db23097835 | ||
|
|
7ac58dc790 | ||
|
|
336414665f | ||
|
|
072de6bc39 | ||
|
|
d6bdef6577 | ||
|
|
fb1b87613b | ||
|
|
f2812f6d18 | ||
|
|
72fbe4c904 | ||
|
|
ed5d386a55 | ||
|
|
1d35e90880 | ||
|
|
8826c0c4a1 | ||
|
|
f179ed4e40 | ||
|
|
1515edbbbd | ||
|
|
02d8c243c4 | ||
|
|
fd56e12c9e | ||
|
|
0b5ce44b55 | ||
|
|
fe81461d51 | ||
|
|
2a8500a849 | ||
|
|
1d2f8f2b75 | ||
|
|
fe429a13dc | ||
|
|
5b55263775 | ||
|
|
a5ceea32cf | ||
|
|
d3d770736f | ||
|
|
e09916e232 | ||
|
|
c681df7058 | ||
|
|
91f2925c63 | ||
|
|
416599a85e | ||
|
|
0dde57b401 | ||
|
|
a64303fe1e | ||
|
|
02fade2077 | ||
|
|
518e1a0124 | ||
|
|
bae58cf614 |
@@ -81,7 +81,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -180,10 +180,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
|
||||
uses: DeterminateSystems/nix-installer-action@3138316df39ed29be04236d7ffc686fa525866aa # v23
|
||||
|
||||
- name: Setup Nix cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # v14
|
||||
uses: DeterminateSystems/magic-nix-cache-action@84c0677f58dcedf3b91f8223ce36a9ea5b3c84b7 # v15
|
||||
|
||||
# Run the update script before `nix build`, not after. The script recomputes
|
||||
# the pnpmDeps hash from pnpm-lock.yaml and rewrites flake.nix in place, so a
|
||||
@@ -281,7 +281,7 @@ jobs:
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.changed-changesets.outputs.has_changesets == 'true'
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.changed-changesets.outputs.has_changesets == 'true'
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
# Opens/updates the Version Packages PR; publishes when the Version PR merges
|
||||
- name: Create/Update Version PR
|
||||
id: changesets
|
||||
uses: changesets/action@8488615a623b1b9c987934bb89eae8af6a946ac1 # v2.1.1
|
||||
uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
pr-title: 'chore(release): version packages'
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
# No dependency cache: `pnpm audit` reads the lockfile, nothing is installed,
|
||||
# so a cache-save step would fail on the missing store path.
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 1.13.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1940](https://github.com/Fission-AI/OpenSpec/pull/1940) [`0b5ce44`](https://github.com/Fission-AI/OpenSpec/commit/0b5ce44b55e0d793a312290ba5a41170a78e47c6) Thanks [@clay-good](https://github.com/clay-good)! - Keep fast-forward clarification guidance and onboarding task approval consistent across generated skills and commands. Fast-forward now asks only when context is critically unclear, while onboarding asks users to approve the task breakdown before saving it and separately asks whether to begin implementation.
|
||||
|
||||
- [#1926](https://github.com/Fission-AI/OpenSpec/pull/1926) [`f2812f6`](https://github.com/Fission-AI/OpenSpec/commit/f2812f6d185f47cb577055f2fe243f12000d6cd2) Thanks [@kevin9327](https://github.com/kevin9327)! - ### Bug Fixes
|
||||
|
||||
- **Archive** — When Windows `EPERM` blocks renaming a change directory that still has children, copy from the original source instead of requiring a staging rename that fails the same way. That lets archive finish instead of rolling back the spec write and leaving an empty capability directory git cannot see. A staging failure that is not `EPERM`/`EXDEV` still leaves the source untouched.
|
||||
|
||||
The source of that unstaged copy is still the live change directory, which the archive claim does not cover, so cleanup removes only the entries it copied and verified rather than whatever is present when it runs. A file written in that window is left alone and the complete destination is retained for recovery, instead of being deleted without ever reaching the archive.
|
||||
|
||||
An edit to a file that was already verified is covered too. Cleanup claims each entry with an atomic rename before reading it, then compares what it claimed against the copy. A rewrite that lands first is caught by that comparison and the file is put back; one that lands after creates a new file at the original path, which is never deleted. Either way the newer bytes stay on disk and archive reports the move as incomplete rather than succeeding with the older copy.
|
||||
|
||||
Rollback of a newly created spec now also prunes the capability directory it created — and only that one. An empty capability directory that was already there is left in place with its own permissions.
|
||||
|
||||
- [#1795](https://github.com/Fission-AI/OpenSpec/pull/1795) [`fb1b876`](https://github.com/Fission-AI/OpenSpec/commit/fb1b87613b7cdbe8d74e8147833904f46f0468c6) Thanks [@runsonmypc](https://github.com/runsonmypc)! - Archive workflows now use schema-aware task progress from `openspec list --json`, so custom task files and globs still trigger incomplete-task warnings.
|
||||
|
||||
- [#1885](https://github.com/Fission-AI/OpenSpec/pull/1885) [`fd56e12`](https://github.com/Fission-AI/OpenSpec/commit/fd56e12c9e7fdbbfdc2dcd0a5ef3fab04840909d) Thanks [@philo-x](https://github.com/philo-x)! - Fix artifact output resolution to recognize brace expansion and extglob patterns while preserving literal output filenames and confining brace-expanded paths to the change directory.
|
||||
|
||||
- [#1964](https://github.com/Fission-AI/OpenSpec/pull/1964) [`7ac58dc`](https://github.com/Fission-AI/OpenSpec/commit/7ac58dc7905a4eeaaad7eff2b2b64cf971fd6ec7) Thanks [@clay-good](https://github.com/clay-good)! - Continue commands now open with an instruction to follow the active OpenSpec workflow directly, so local models no longer try to call a tool named after it ([#1944](https://github.com/Fission-AI/OpenSpec/issues/1944)).
|
||||
|
||||
- [#1964](https://github.com/Fission-AI/OpenSpec/pull/1964) [`7ac58dc`](https://github.com/Fission-AI/OpenSpec/commit/7ac58dc7905a4eeaaad7eff2b2b64cf971fd6ec7) Thanks [@clay-good](https://github.com/clay-good)! - Generate Kilo Code commands in `.kilo/command/`, the directory Kilo Code reads, instead of `.kilocode/workflows/` ([#1938](https://github.com/Fission-AI/OpenSpec/issues/1938)). `openspec init` and legacy cleanup remove the workflow files OpenSpec generated there, matched by their known file names (including copies you edited), and leave files with other names in place.
|
||||
|
||||
- [#1958](https://github.com/Fission-AI/OpenSpec/pull/1958) [`1d35e90`](https://github.com/Fission-AI/OpenSpec/commit/1d35e908804dbb3c4a1851516759c5de190aa4d5) Thanks [@clay-good](https://github.com/clay-good)! - Preserve a file's existing line endings when rewriting it, so Windows users no longer get whole-file diffs. Applying a delta to a CRLF spec (the default on a Windows checkout with `core.autocrlf=true`) rewrote the file to LF, turning a one-requirement change into a diff that touched every line. `openspec archive` now writes the spec back with the convention it already used; a spec that does not exist yet is still written with LF.
|
||||
|
||||
The same fix covers marker-managed files: installing or updating shell completions in a CRLF `.bashrc` or `.zshrc` no longer leaves the file with mixed endings, which `bash` reports as `$'\r': command not found`.
|
||||
|
||||
Removing a managed block is fixed the same way: the blank-line collapse in `removeMarkerBlock` rebuilt its separator as a bare LF, so cleaning up legacy artifacts left a lone LF inside an otherwise-CRLF `CLAUDE.md` or rc file. Both write paths now read the file the same way, by dominant ending, so one stray CRLF in an otherwise-LF file no longer pulls the whole rewrite to CRLF.
|
||||
|
||||
`scripts/pack-version-check.mjs` now spawns `npm` through `cross-spawn`, so the release guard can run on Windows, where `npm` is `npm.cmd` and cannot be resolved by `execFile`.
|
||||
|
||||
- [#1912](https://github.com/Fission-AI/OpenSpec/pull/1912) [`8826c0c`](https://github.com/Fission-AI/OpenSpec/commit/8826c0c4a17d3511947b7c5e0934257f153f0ed2) Thanks [@Tyagiquamar](https://github.com/Tyagiquamar)! - Fix `validate --strict` reporting `PURPOSE_IS_PLACEHOLDER` for a Purpose that opens with the ordinary word "Todo" followed by prose, as in Spanish ("Todo el…") and Portuguese ("Todo o…") specs ([#1897](https://github.com/Fission-AI/OpenSpec/issues/1897)).
|
||||
|
||||
- Case now separates the marker from the word. `TBD`/`TODO` in capitals is still a placeholder marker whatever follows it, so `TODO write this later` is still reported.
|
||||
- In any other case it counts as a marker only when followed by the end of the Purpose, a line break, or marker punctuation (`todo -`, `tbd.`), so an authored Spanish or Portuguese sentence is not reported.
|
||||
|
||||
- [#1744](https://github.com/Fission-AI/OpenSpec/pull/1744) [`5b55263`](https://github.com/Fission-AI/OpenSpec/commit/5b5526377506c2f0179674a869c1ac64ca9ab72d) Thanks [@javigomez](https://github.com/javigomez)! - Clarify the Codex setup hint for CLI, IDE, and desktop app users.
|
||||
|
||||
- [#1809](https://github.com/Fission-AI/OpenSpec/pull/1809) [`a5ceea3`](https://github.com/Fission-AI/OpenSpec/commit/a5ceea32cf110b6d8bbfea0bf1c65fe55abb133b) Thanks [@ryandemelo](https://github.com/ryandemelo)! - Say what a `MODIFIED` block adds when the scenario-loss guard fires ([#1809](https://github.com/Fission-AI/OpenSpec/pull/1809)). `openspec validate` and `openspec archive` already named the scenarios a block omits. They now also print how many scenarios each side has and which ones the block introduces, capped at three names, so a rename and a truncation read differently without opening either file. The guard catches exactly what it did before, and no exit code changes.
|
||||
|
||||
- [#1731](https://github.com/Fission-AI/OpenSpec/pull/1731) [`d6bdef6`](https://github.com/Fission-AI/OpenSpec/commit/d6bdef6577a077614382ef47b64100852182d6a6) Thanks [@runsonmypc](https://github.com/runsonmypc)! - Stop workflows from displaying schema names that `openspec list --json` does not return. Update and continue no longer fabricate a `spec-driven` picker label, while bulk archive and explore describe only the change fields the list command actually provides.
|
||||
|
||||
- [#1955](https://github.com/Fission-AI/OpenSpec/pull/1955) [`ed5d386`](https://github.com/Fission-AI/OpenSpec/commit/ed5d386a559c0215af1182d479d7f99b309fdcd2) Thanks [@clay-good](https://github.com/clay-good)! - Task guidance now requires each task group to land its own tests and documentation updates instead of deferring them to a trailing group. The onboarding walkthrough teaches the same rule, and the published schema reference no longer quotes stale instruction text.
|
||||
|
||||
- [#1939](https://github.com/Fission-AI/OpenSpec/pull/1939) [`a64303f`](https://github.com/Fission-AI/OpenSpec/commit/a64303fe1e24f08dbf44f78032fadbeac3a6f7fa) Thanks [@clay-good](https://github.com/clay-good)! - Return a nonzero exit status when `openspec update --force` cannot replace a legacy-only Codex installation.
|
||||
|
||||
- [#1733](https://github.com/Fission-AI/OpenSpec/pull/1733) [`72fbe4c`](https://github.com/Fission-AI/OpenSpec/commit/72fbe4c904707396151921a20b506d081a9dc024) Thanks [@runsonmypc](https://github.com/runsonmypc)! - Let `/opsx:update` fill a missing file under an already-satisfied glob artifact. A glob artifact is complete once one file matches it, and `/opsx:continue` only picks up `ready` artifacts, so the previous "point the user to `/opsx:continue`" handoff was unreachable and the missing file could never be created through the documented flow.
|
||||
|
||||
- [#1962](https://github.com/Fission-AI/OpenSpec/pull/1962) [`3364146`](https://github.com/Fission-AI/OpenSpec/commit/336414665f3f987ae424177ab1b6891a4304baeb) Thanks [@ryandemelo](https://github.com/ryandemelo)! - Stop `/opsx:verify` from reporting a correctly removed requirement as missing. Verify now reads which delta section each requirement sits under: ADDED and MODIFIED requirements are checked for an implementation as before, a REMOVED requirement passes once its behavior is gone and is flagged only while it is still present, and the old name of a RENAMED requirement is no longer reported as missing.
|
||||
|
||||
- [#1732](https://github.com/Fission-AI/OpenSpec/pull/1732) [`072de6b`](https://github.com/Fission-AI/OpenSpec/commit/072de6bc39b1c47b9aacf4d484be16345ca4f38e) Thanks [@runsonmypc](https://github.com/runsonmypc)! - Stop `/opsx:verify` from reporting skipped checks as passing. Task completion now uses the schema-aware `tasks` and `progress` fields returned by apply instructions, while absent spec or design inputs are mapped to every check they prevent. Apply instructions aggregate every file matched by the configured task path or glob, regardless of the tracked artifact ID. Verification stays advisory and does not require optional or intentionally omitted artifacts. The scorecard identifies each skipped check, and the final assessment does not claim archive readiness when any check did not run.
|
||||
|
||||
- [#1769](https://github.com/Fission-AI/OpenSpec/pull/1769) [`d3d7707`](https://github.com/Fission-AI/OpenSpec/commit/d3d770736fc01bb246b4f12a7cef7e3572ec1fb6) Thanks [@kikeprzn](https://github.com/kikeprzn)! - Fix `openspec archive` leaving `.openspec-archive.lock` behind on Windows. Node can report `dev: 0n` from a path stat while the open file handle reports the real volume id, so the claim-ownership check never matched and the stale lock blocked every later archive. The check now treats an absent device id as unavailable while still requiring the inode and the claim's contents to match before unlinking.
|
||||
|
||||
## 1.13.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -122,7 +122,7 @@ Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the har
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Requires Node.js 20.19.0 or higher.**
|
||||
**Requires Node.js 20.19.0 or higher.** Homebrew installs it as a dependency.
|
||||
|
||||
Install OpenSpec globally:
|
||||
|
||||
@@ -130,6 +130,12 @@ Install OpenSpec globally:
|
||||
npm install -g @fission-ai/openspec@latest
|
||||
```
|
||||
|
||||
Or install the official [Homebrew formula](https://formulae.brew.sh/formula/openspec) on macOS or Linux:
|
||||
|
||||
```bash
|
||||
brew install openspec
|
||||
```
|
||||
|
||||
Then navigate to your project directory and initialize:
|
||||
|
||||
```bash
|
||||
@@ -137,7 +143,7 @@ cd your-project
|
||||
openspec init
|
||||
```
|
||||
|
||||
> **Want your AI to do it?** Paste the [setup prompt](docs/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
|
||||
> **Want your AI to do it?** Paste the [setup prompt](docs-lab/start/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
|
||||
|
||||
Now talk to your AI:
|
||||
|
||||
@@ -151,7 +157,7 @@ Both are in the default profile. If you want the expanded workflow (`/opsx:new`,
|
||||
> [!NOTE]
|
||||
> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 30+ tools and growing.
|
||||
>
|
||||
> Also works with pnpm, yarn, bun, and nix. [See installation options](docs/installation.md).
|
||||
> Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](docs-lab/start/installation.md).
|
||||
|
||||
## Docs
|
||||
|
||||
@@ -208,6 +214,12 @@ AI coding assistants are powerful but unpredictable when requirements live only
|
||||
npm install -g @fission-ai/openspec@latest
|
||||
```
|
||||
|
||||
If you installed OpenSpec with Homebrew:
|
||||
|
||||
```bash
|
||||
brew upgrade openspec
|
||||
```
|
||||
|
||||
**Refresh agent instructions**
|
||||
|
||||
Run this inside each project to regenerate AI guidance and ensure the latest slash commands are active:
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ the page or rewriting the goal in both places, never letting them drift.
|
||||
| [Overview](start/overview.md) | _TODO: emptied 2026-08-21 for a from-scratch rewrite and pulled from the site (`/docs` redirects to Installation meanwhile); the old goal line was dropped as too weak a pitch. Brief in Notes.md._ |
|
||||
| [Installation](start/installation.md) | Install the `openspec` CLI on your machine, update it, and uninstall it. |
|
||||
| [Set up your project](start/setup.md) | Add OpenSpec to a project: run init, see what it wrote, and adjust it. |
|
||||
| [Quickstart](start/quickstart.md) | Your first change on your existing repo, from idea to archived. |
|
||||
| [Quickstart](start/quickstart.md) | Your first change in a new or existing project, from idea to archived. |
|
||||
|
||||
### Guides: understand the system, use it well, bring it to your codebase and team
|
||||
|
||||
|
||||
@@ -58,8 +58,6 @@ For example, with a `context` field and the rule from the top of this page, here
|
||||
|
||||
Your config arrives first, then OpenSpec's built-in instruction and template. Rules add to the built-ins and never replace them. Edits to config.yaml reach the agent on the next run.
|
||||
|
||||
[Workflow runs](../reference/architecture/workflow-runs.md) covers the full run, from invocation to written artifacts.
|
||||
|
||||
## The fields
|
||||
|
||||
Three fields shape what the agent receives. Each field's exact contract (types, limits, validation) is in [Project configuration (config.yaml)](../reference/configuration/config-yaml.md).
|
||||
|
||||
@@ -1288,7 +1288,9 @@ With `--json`, each form returns one object. The artifact form starts:
|
||||
...
|
||||
```
|
||||
|
||||
and continues with `outputPath`, `existingOutputPaths`, the full `instruction` and `template` strings, `dependencies`, `unlocks`, and `root`. The `apply` form carries `contextFiles`, `progress`, `tasks`, `state` (`blocked`, `ready`, `all_done`), and `instruction`.
|
||||
and continues with `outputPath`, `existingOutputPaths`, the full `instruction` and `template` strings, `dependencies`, `unlocks`, and `root`. The `apply` form carries `contextFiles`, `progress`, `tasks`, `taskTrackingConfigured`, `state` (`blocked`, `ready`, `all_done`), and `instruction`.
|
||||
|
||||
`taskTrackingConfigured` is always a boolean: `true` when the schema sets a non-null [`apply.tracks`](schemas/schema-yaml.md#tracks), even if no file matches, and `false` otherwise. If a matched tracking file cannot be read, `unavailableTrackingFiles` contains its absolute `path` and error `reason`. This field is omitted when every matched file is readable. Readable files still contribute to `tasks` and `progress`, but `state` cannot be `all_done` until every matched file is read.
|
||||
|
||||
**Exit codes**
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Each change keeps its metadata at `openspec/changes/<change-name>/.openspec.yaml
|
||||
|
||||
### schema
|
||||
|
||||
The workflow schema this change follows. It is set when the change is created and wins over the project config, so a change keeps its schema even if `openspec/config.yaml` changes afterwards. Valid names are listed in [Schemas](../schemas/index.md).
|
||||
The workflow schema this change follows. It is set when the change is created and wins over the project config, so a change keeps its schema even if `openspec/config.yaml` changes afterwards. Run [`openspec schemas`](../cli.md#openspec-schemas) to list the available schema names.
|
||||
|
||||
### initiative
|
||||
|
||||
@@ -36,11 +36,11 @@ Keys other than `store` and `id` are rejected. No command reads the link today.
|
||||
|
||||
### skip_specs
|
||||
|
||||
Declares the change intentionally makes no spec deltas: a pure refactor, tooling, or docs change. With it set, validation accepts zero deltas, and artifacts that would generate spec files count as complete. Setting it while spec files exist under specs/ is a validation error. Its effect on deltas and archive is on [spec-driven](../schemas/spec-driven/index.md).
|
||||
Declares the change intentionally makes no spec deltas: a pure refactor, tooling, or docs change. With it set, validation accepts zero deltas, and artifacts that would generate spec files count as complete. Setting it while spec files exist under specs/ is a validation error.
|
||||
|
||||
### retire_capabilities
|
||||
|
||||
Authorizes archive to retire a capability. When this change's REMOVED deltas take away the last requirement a capability has, archive deletes that capability's main spec instead of stopping. The flag exists because the deletion is only recoverable from git, so it stays the author's call. The archive behavior is on [spec-driven](../schemas/spec-driven/index.md).
|
||||
Authorizes archive to retire a capability. When this change's REMOVED deltas take away the last requirement a capability has, archive deletes that capability's main spec instead of stopping. The flag exists because the deletion is only recoverable from git, so it stays the author's call.
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ The CLI keeps its machine-level settings at `~/.config/openspec/config.json` on
|
||||
| `workflows` | list of strings | No | The workflow list a `custom` profile installs |
|
||||
| `featureFlags` | map: flag → boolean | No | Boolean feature toggles |
|
||||
| `defaultStore` | string | No | Machine-level fallback store for root resolution |
|
||||
| `openers` | list | No | The tools worksets open in, and how each is launched |
|
||||
| `telemetry` | map | No | State the CLI keeps: anonymous id and notice-seen |
|
||||
| `openers` | map: tool id → settings | No | The tools worksets open in, and how each is launched |
|
||||
| `telemetry` | map | No | Telemetry opt-out, anonymous id, and notice-seen state |
|
||||
|
||||
### profile
|
||||
|
||||
@@ -40,11 +40,45 @@ The machine-level fallback store id for root resolution, consulted only when no
|
||||
|
||||
### openers
|
||||
|
||||
The tools a workset can open in, and how each is launched. Entries are hand-edited and validated on use. Each may set `style` (`workspace-file` or `attach-dirs`), `label`, `command`, `args`, and `attach_flag`, and is merged over the built-in defaults.
|
||||
The tools a workset can open in, keyed by tool id. Edit `openers` in the global `config.json` with `openspec config edit` in your terminal.
|
||||
|
||||
| Field | Contract |
|
||||
| --- | --- |
|
||||
| `style` | `workspace-file` or `attach-dirs`. Required for a new tool; optional for a built-in. |
|
||||
| `label` | Non-empty string shown in the tool picker. Defaults to the id for a new tool. |
|
||||
| `command` | Non-empty executable name or path. Defaults to the id for a new tool. Put arguments in `args`, not in this string. |
|
||||
| `args` | Array of strings passed before the workspace file or attach flags. Defaults to `[]` for a new tool. |
|
||||
| `attach_flag` | Non-empty string paired with each member path for `attach-dirs`. Defaults to `--add-dir` for a new tool. Ignored for `workspace-file`. |
|
||||
|
||||
**Built-in overrides:** `code`, `cursor`, `claude`, and `codex` retain any fields you omit. Setting `args` replaces the entire argument list; `[]` clears it.
|
||||
|
||||
**Launch styles:** `workspace-file` passes the generated `.code-workspace` path to the executable. `attach-dirs` passes one flag/path pair per member, including the primary member.
|
||||
|
||||
**Availability:** `attach-dirs` openers, including Claude Code and Codex, are disabled by default. You cannot select or save them with `--tool`, and OpenSpec refuses to open a workset that already names one. Configuration overrides do not enable the `attach-dirs` launch style.
|
||||
|
||||
**Validation:** unknown fields, invalid types, and a new tool without `style` fail when a workset command reads the opener table.
|
||||
|
||||
This example adds VS Code Insiders and passes `--new-window` whenever the built-in VS Code opener launches:
|
||||
|
||||
```json
|
||||
{
|
||||
"openers": {
|
||||
"code-insiders": {
|
||||
"style": "workspace-file",
|
||||
"label": "VS Code Insiders"
|
||||
},
|
||||
"code": {
|
||||
"args": ["--new-window"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The corresponding `code-insiders` or `code` executable must be installed and available on `PATH`.
|
||||
|
||||
### telemetry
|
||||
|
||||
State the CLI writes for telemetry: your anonymous id and whether the first-run notice was shown. It is not the opt-out. Disabling telemetry is an environment variable, on [Environment variables](environment-variables.md).
|
||||
The CLI stores your anonymous id and whether the first-run notice was shown. Set `telemetry.enabled` to `false` to disable telemetry. You can also opt out with `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` in your environment.
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ What to write in these fields is covered in [Project configuration](../../custom
|
||||
|
||||
### schema
|
||||
|
||||
The workflow schema every change in this project follows. Valid values are `spec-driven` or a schema name the project defines. The names are listed in [Schemas](../schemas/index.md).
|
||||
The workflow schema every change in this project follows. Valid values are `spec-driven` or a schema name the project defines. Run [`openspec schemas`](../cli.md#openspec-schemas) to list the available schema names.
|
||||
|
||||
### context
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
| [Project configuration (config.yaml)](config-yaml.md) | `openspec/config.yaml` | The schema, context, and rules this project plans with |
|
||||
| [Change metadata (.openspec.yaml)](change-metadata.md) | `openspec/changes/<name>/.openspec.yaml` | The workflow schema, goal, scope, and spec exceptions for one change |
|
||||
| [CLI settings (config.json)](config-json.md) | `~/.config/openspec/config.json` (Windows varies) | How the openspec CLI behaves on your machine |
|
||||
| [Environment variables](environment-variables.md) | Your shell or CI environment | Telemetry opt-out, and where the config and data directories live |
|
||||
| Environment variables | Your shell or CI environment | Telemetry opt-out, and where the config and data directories live |
|
||||
| Stores | `~/.local/share/openspec/stores/` (Windows varies) | The registry and metadata behind multi-repo stores |
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
|
||||
> Every OpenSpec term, one line each.
|
||||
|
||||
OpenSpec reuses words that mean something else in git, CI, and agent tooling. Each row gives the OpenSpec meaning, and the last column links to the page that teaches the term.
|
||||
OpenSpec reuses words that mean something else in git, CI, and agent tooling. Each row gives the OpenSpec meaning. The last column links to more detail where available.
|
||||
|
||||
| Term | Definition | More |
|
||||
|---|---|---|
|
||||
| **Apply** | Implement the tasks in a change proposal. Skill: `openspec-apply-change`. | [Apply a change](../guides/apply.md) |
|
||||
| **Apply** | Implement the tasks in a change proposal. Skill: `openspec-apply-change`. | [Apply a change](skills.md#openspec-apply-change) |
|
||||
| **Archive** | Complete a change proposal: merge its deltas into the main specs and move its folder to `openspec/changes/archive/`. | [Quickstart](../start/quickstart.md) |
|
||||
| **Artifact** | A planning document inside a change proposal: `proposal.md`, delta specs, `design.md`, `tasks.md`. Not a build output. | [Concepts](../guides/concepts.md) |
|
||||
| **Capability** | One behavior area of your system. Each has one spec at `openspec/specs/<capability>/spec.md`. | [Concepts](../guides/concepts.md) |
|
||||
| **Change proposal** | One unit of work: a folder under `openspec/changes/<name>/` holding its planning artifacts. Often shortened to "change". Not a git commit. | [Concepts](../guides/concepts.md) |
|
||||
| **Artifact** | A planning document inside a change proposal: `proposal.md`, delta specs, `design.md`, `tasks.md`. Not a build output. | [Artifacts](schemas/spec-driven/index.md#artifacts) |
|
||||
| **Capability** | One behavior area of your system. Each has one spec at `openspec/specs/<capability>/spec.md`. | [Capabilities](schemas/spec-driven/index.md#proposalmd) |
|
||||
| **Change proposal** | One unit of work: a folder under `openspec/changes/<name>/` holding its planning artifacts. Often shortened to "change". Not a git commit. | [Propose](../start/quickstart.md#step-2-propose) |
|
||||
| **Command** | A typed entry point for a workflow. Spelling varies per tool (`/opsx:propose`, `/opsx-propose`). The docs name workflows by skill instead. | [Supported tools](supported-tools.md) |
|
||||
| **Continue** | Create the next planning artifact for an existing change proposal. Skill: `openspec-continue-change`. | [Skills](skills.md) |
|
||||
| **Delivery** | How workflows are installed: as skills, commands, or both. | [Set up your project](../start/setup.md) |
|
||||
| **Delta spec** | A spec inside a change proposal listing only what changes, under `ADDED`, `MODIFIED`, `REMOVED`, and `RENAMED` headers. | [Delta specs](schemas/spec-driven/index.md#delta-specs-specmd) |
|
||||
| **Explore** | Think an idea through with the agent before proposing. Writes no code. Skill: `openspec-explore`. | [Explore an idea](../guides/explore.md) |
|
||||
| **Explore** | Think an idea through with the agent before proposing. Writes no code. Skill: `openspec-explore`. | [Explore an idea](skills.md#openspec-explore) |
|
||||
| **Fast-forward** | Create a change proposal with every planning artifact in one pass, ready to implement. Skill: `openspec-ff-change`. Not a git fast-forward. | [Skills](skills.md) |
|
||||
| **Legacy workflow** | The pre-OPSX `/openspec:*` commands. | [Migration](../help/legacy/migration.md) |
|
||||
| **Legacy workflow** | The pre-OPSX `/openspec:*` commands. | |
|
||||
| **Loop** | The cycle a change proposal moves through: explore, propose, review, apply, archive. | [Quickstart](../start/quickstart.md) |
|
||||
| **Main specs** | The `openspec/specs/` tree: the current, agreed behavior of your system. Archiving merges deltas into it. A capability with no spec yet gets one from its `ADDED` requirements. | [Concepts](../guides/concepts.md) |
|
||||
| **Main specs** | The `openspec/specs/` tree: the current, agreed behavior of your system. Archiving merges deltas into it. A capability with no spec yet gets one from its `ADDED` requirements. | [Archive](../start/quickstart.md#step-5-archive) |
|
||||
| **OpenSpec root** | The `openspec/` tree a command resolves to and operates on: your repo's, or a store's. | [Stores](../multi-repo/stores.md#where-artifacts-get-created-when-using-stores) |
|
||||
| **OPSX** | The current OpenSpec workflow system, and the command prefix it installs (`/opsx:`). | [Architecture](architecture/index.md) |
|
||||
| **OPSX** | The current OpenSpec workflow system, and the command prefix it installs (`/opsx:`). | |
|
||||
| **Profile** | Which workflows init installs: `core` or `custom`. | [Profiles](../customize/profiles.md) |
|
||||
| **Propose** | Create a change proposal and generate all its planning artifacts in one step. Skill: `openspec-propose`. | [Quickstart](../start/quickstart.md) |
|
||||
| **Registry** | The machine-level list of registered stores, in `registry.yaml`. Not a package registry. | [CLI](cli.md#openspec-store) |
|
||||
@@ -29,12 +29,12 @@ OpenSpec reuses words that mean something else in git, CI, and agent tooling. Ea
|
||||
| **Scenario** | A testable example under a requirement, in WHEN/THEN form. | [Delta specs](schemas/spec-driven/index.md#delta-specs-specmd) |
|
||||
| **Schema** | The definition of which artifacts a change proposal produces, and in what order. Not JSON Schema. | [Schemas](schemas/index.md) |
|
||||
| **Skill** | A workflow's instructions, installed where your AI tool reads them (`.agents/skills/`, ...). | [Skills](skills.md) |
|
||||
| **Spec** | A file describing how one capability behaves today, at `openspec/specs/<capability>/spec.md`. | [Concepts](../guides/concepts.md) |
|
||||
| **Spec** | A file describing how one capability behaves today, at `openspec/specs/<capability>/spec.md`. | [Archive](../start/quickstart.md#step-5-archive) |
|
||||
| **spec-driven** | The default schema: proposal, then delta specs, then design, then tasks. | [spec-driven](schemas/spec-driven/index.md) |
|
||||
| **Store** | A standalone OpenSpec repo registered on your machine, for planning that spans repositories. Not a data store. | [Stores (beta)](../multi-repo/stores.md) |
|
||||
| **Sync** | Merge implemented deltas into the main specs without archiving. Skill: `openspec-sync-specs`. | [Skills](skills.md) |
|
||||
| **Template** | The starting content a schema gives each artifact. | [Schemas](../customize/schemas.md) |
|
||||
| **Update** | As a skill (`openspec-update-change`): revise a change proposal's planning artifacts. As a CLI command (`openspec update`): refresh OpenSpec's installed files. | [Change course](../guides/change-course.md), [CLI](cli.md) |
|
||||
| **Update** | As a skill (`openspec-update-change`): revise a change proposal's planning artifacts. As a CLI command (`openspec update`): refresh OpenSpec's installed files. | [Update a change](skills.md#openspec-update-change), [CLI](cli.md) |
|
||||
| **Verify** | Check the implementation matches a change proposal's artifacts before archiving. Skill: `openspec-verify-change`. | [Skills](skills.md) |
|
||||
| **Workflow** | A named OpenSpec action (propose, apply, archive, ...), installed into your AI tool as a skill or command. | [Set up your project](../start/setup.md) |
|
||||
| **Workset** | A personal, local group of folders opened together in one tool. Not a store, and nothing is shared. | [Worksets (beta)](../multi-repo/worksets.md) |
|
||||
|
||||
@@ -74,7 +74,15 @@ A glob can match several files:
|
||||
generates: specs/**/*.md
|
||||
```
|
||||
|
||||
This matches Markdown files below `openspec/changes/add-auth/specs/`. OpenSpec treats a value containing `*`, `?`, or `[` as a glob.
|
||||
This matches Markdown files below `openspec/changes/add-auth/specs/`.
|
||||
|
||||
OpenSpec recognizes these glob forms in `generates`:
|
||||
|
||||
- **Wildcards and character classes**: values containing `*`, `?`, or `[`, such as `specs/**/*.md` and `review-[ab].md`.
|
||||
- **Brace expansions**: alternatives such as `review-{api,ui}.md` and ranges such as `file-{1..3}.md`.
|
||||
- **Extglobs**: patterns such as `@(proposal|design).md`, `+(proposal|design).md`, and `!(proposal|design).md`.
|
||||
|
||||
**Literal filenames**: a leading `!` alone does not make a glob. Use `generates: '!review.md'` to name that file. Plain parentheses such as `(proposal|design).md` and single-element braces such as `review-{api}.md` also remain literal.
|
||||
|
||||
OpenSpec rejects absolute paths and paths containing a `..` segment.
|
||||
|
||||
@@ -119,7 +127,7 @@ OpenSpec rejects absolute paths and paths containing a `..` segment.
|
||||
| Field | Contract |
|
||||
|---|---|
|
||||
| `requires` | **Required.** A non-empty list of artifacts that must exist before apply instructions become ready. |
|
||||
| `tracks` | An optional relative path to a Markdown task file in the change folder. Default: `null`. |
|
||||
| `tracks` | An optional relative path or glob for Markdown task files in the change folder. Default: `null`. |
|
||||
| `instruction` | Optional guidance sent to the agent when apply is ready. OpenSpec uses built-in guidance by default. |
|
||||
|
||||
Artifact `requires` controls planning order. `apply.requires` controls when apply instructions become ready.
|
||||
@@ -132,7 +140,9 @@ The path starts from the change folder. For a change named `add-auth`, `tracks:
|
||||
openspec/changes/add-auth/tasks.md
|
||||
```
|
||||
|
||||
Apply stays blocked if that file is missing or contains no checkbox with task text. OpenSpec counts these checkbox forms:
|
||||
A glob such as `tracks: "**/tasks.md"` reads every matching file, such as `backend/tasks.md` and `frontend/tasks.md`. OpenSpec combines their tasks and progress. Use the same value for an artifact's `generates` field so status and list track the same files.
|
||||
|
||||
Apply stays blocked if no file matches or the matched files contain no checkbox with task text. OpenSpec counts these checkbox forms:
|
||||
|
||||
```markdown
|
||||
- [ ] Pending task
|
||||
@@ -145,11 +155,13 @@ Apply stays blocked if that file is missing or contains no checkbox with task te
|
||||
|
||||
Any Markdown list marker works: `-`, `*`, `+`, or a number of up to nine digits followed by `.` or `)`. Leading spaces are allowed. The [tasks.md section of the spec-driven page](spec-driven/index.md#tasksmd) defines the stricter format produced by the default schema.
|
||||
|
||||
The tracked file drives the apply state:
|
||||
The tracked files drive the apply state:
|
||||
|
||||
- **`blocked`**: the file is missing, or no checkbox has task text.
|
||||
- **`ready`**: at least one tracked task is pending.
|
||||
- **`all_done`**: every tracked task is checked.
|
||||
- **`blocked`**: no file matches, or no readable file has a checkbox with task text.
|
||||
- **`ready`**: at least one task is pending, or a matched file could not be read while another provides tasks.
|
||||
- **`all_done`**: every tracked task is checked and every matched file was read.
|
||||
|
||||
If a matched file cannot be read, apply keeps the tasks and progress from readable files but does not mark the change `all_done`. [Apply JSON output](../cli.md#openspec-instructions) identifies each unavailable file and the reason.
|
||||
|
||||
OpenSpec rejects absolute paths and paths containing a `..` segment.
|
||||
|
||||
|
||||
@@ -214,10 +214,16 @@ left with a `TBD ... Update Purpose after archive` placeholder to fill in
|
||||
by hand. Do NOT add `## Purpose` to a delta for an existing capability -
|
||||
that spec already has one and the delta's is ignored. To change an
|
||||
existing capability's Purpose - including a leftover `TBD` placeholder -
|
||||
edit `openspec/specs/<capability-path>/spec.md` directly.
|
||||
edit `<planningHome.root>/openspec/specs/<capability-path>/spec.md`
|
||||
directly. `planningHome.root` comes from the `openspec instructions ...
|
||||
--json` response. Always use it rather than a repo-relative path: it
|
||||
resolves to the store whenever the change lives in one - whether that
|
||||
came from `--store`, a project `store:` pointer, or a global default
|
||||
store - and to the current repository otherwise. Do not try to work out
|
||||
which case applies; the field already has.
|
||||
|
||||
MODIFIED requirements workflow:
|
||||
1. Locate the existing requirement in openspec/specs/<capability-path>/spec.md
|
||||
1. Locate the existing requirement in `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (the same store-aware root as above)
|
||||
2. Copy the ENTIRE requirement block (from `### Requirement:` through all scenarios)
|
||||
3. Paste under `## MODIFIED Requirements` and edit to reflect new behavior
|
||||
4. Ensure header text matches exactly (whitespace-insensitive)
|
||||
@@ -362,6 +368,18 @@ Guidelines:
|
||||
- Each task MUST be a checkbox: `- [ ] X.Y Task description`
|
||||
- Tasks should be small enough to complete in one session
|
||||
- Order tasks by dependency (what must be done first?)
|
||||
- Each task MUST state how to verify completion (a test, command,
|
||||
observable behavior, or delivered artifact). Put the verification in
|
||||
that task's checkbox description. Use a separate verification task only
|
||||
when it checks broader integration or system behavior that spans
|
||||
multiple implementation tasks.
|
||||
- Each task group MUST land the tests and documentation its own work
|
||||
calls for. Do NOT collect testing or documentation into a final group -
|
||||
when a late group first exercises work from an early one, the failures
|
||||
cascade back through every group in between and force rework. A group
|
||||
whose work calls for neither, such as scaffolding or dependency setup,
|
||||
carries neither. A final group is for integration checks only, not for
|
||||
the tests and docs an earlier group owed.
|
||||
|
||||
Example:
|
||||
```
|
||||
@@ -369,17 +387,17 @@ Example:
|
||||
|
||||
## 1. Setup
|
||||
|
||||
- [ ] 1.1 Create new module structure
|
||||
- [ ] 1.2 Add dependencies to package.json
|
||||
- [ ] 1.1 Create new module structure and verify expected files are present
|
||||
- [ ] 1.2 Add dependencies to package.json and verify package installation succeeds
|
||||
|
||||
## 2. Core Implementation
|
||||
|
||||
- [ ] 2.1 Implement data export function
|
||||
- [ ] 2.2 Add CSV formatting utilities
|
||||
- [ ] 2.1 Implement data export function and verify the export test passes
|
||||
- [ ] 2.2 Add CSV formatting utilities and verify unit tests cover quoting and delimiters
|
||||
- [ ] 2.3 Document the export API in docs/export.md and verify the documented command runs as written
|
||||
```
|
||||
|
||||
Reference specs for what needs to be built, design for how to build it.
|
||||
Each task should be verifiable - you know when it's done.
|
||||
````
|
||||
|
||||
## Apply
|
||||
|
||||
@@ -101,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. Without that skill (the core profile leaves it out), it points to `openspec status` and `openspec instructions` instead. Never code. |
|
||||
| **Creates** | Edits artifact files that already exist. One exception: for an artifact written as a glob, such as `specs/**/*.md`, that already has at least one file, it can add a missing companion file once you confirm the path. An artifact with no files yet is `openspec-continue-change`'s job. Without that skill (the core profile leaves it out), it points to `openspec status` and `openspec instructions` instead. Never code. |
|
||||
| **Response** | Shows each proposed revision and writes it only after you confirm, one artifact at a time. Ends with what was revised and the next step; implementation waits for `openspec-apply-change`. |
|
||||
|
||||
## openspec-sync-specs
|
||||
|
||||
@@ -35,7 +35,7 @@ The id goes to `openspec init --tools <id>` to skip the picker ([CLI](cli.md)).
|
||||
| Hermes Agent | `hermes` | `.hermes/skills/` | `/openspec-apply-change` | none | none |
|
||||
| iFlow | `iflow` | `.iflow/skills/` | `/openspec-apply-change` | `.iflow/commands/` | `/opsx-apply` |
|
||||
| Junie | `junie` | `.junie/skills/` | `/openspec-apply-change` | `.junie/commands/` | `/opsx-apply` |
|
||||
| Kilo Code | `kilocode` | `.kilocode/skills/` | `/openspec-apply-change` | `.kilocode/workflows/` | `/opsx-apply` |
|
||||
| Kilo Code | `kilocode` | `.kilocode/skills/` | `/openspec-apply-change` | `.kilo/command/` | `/opsx-apply` |
|
||||
| Kimi Code | `kimi` | `.kimi-code/skills/` | `/skill:openspec-apply-change` | none | none |
|
||||
| Kiro | `kiro` | `.kiro/skills/` | `/openspec-apply-change` | `.kiro/prompts/` | `/opsx-apply` |
|
||||
| Lingma | `lingma` | `.lingma/skills/` | `/openspec-apply-change` | `.lingma/commands/opsx/` | `/opsx:apply` |
|
||||
@@ -80,8 +80,12 @@ Skills stay in `.cline/skills/`.
|
||||
|
||||
### Codex
|
||||
|
||||
- **Invocation**: type `$openspec-<skill>`. Codex does not recognize the
|
||||
`/openspec-<skill>` form ([upstream issue](https://github.com/openai/codex/issues/11817)).
|
||||
- **CLI and IDE extension**: mention `$openspec-propose` with your idea, or run
|
||||
`/skills` to select the skill. Codex does not recognize `/openspec-propose`
|
||||
([upstream issue](https://github.com/openai/codex/issues/11817)).
|
||||
- **Desktop app**: open Skills in the sidebar and select `openspec-propose`.
|
||||
[OpenAI's skills documentation](https://learn.chatgpt.com/docs/build-skills)
|
||||
describes both interfaces.
|
||||
- **No command files**: Codex runs skills directly, so init skips commands even when
|
||||
delivery includes them and prints `Commands skipped for: codex (uses skills)`.
|
||||
- **Shared folder**: Codex skills land in `.agents/skills/`, the same tree Antigravity,
|
||||
@@ -102,8 +106,13 @@ Skills stay in `.cline/skills/`.
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
Prompt files register as slash commands in the Copilot IDE extensions (VS Code,
|
||||
JetBrains, Visual Studio). Copilot CLI does not read `.github/prompts/`.
|
||||
- **IDE extensions (command delivery)**: VS Code, JetBrains, and Visual Studio load
|
||||
`.github/prompts/opsx-<id>.prompt.md` as `/opsx-<id>`. If a command disappears
|
||||
while its file still exists, restart the IDE.
|
||||
- **Copilot CLI (skill delivery)**: the CLI ignores `.github/prompts/` and loads
|
||||
`.github/skills/openspec-*/SKILL.md` instead. Invoke a skill as
|
||||
`/openspec-<skill>`. If a skill disappears while its file still exists, run
|
||||
`/skills reload`, then `/skills info openspec-propose` to confirm discovery.
|
||||
|
||||
### Hermes Agent
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
## Prerequisites
|
||||
|
||||
OpenSpec is a Node.js CLI. You need version 20.19.0 or newer.
|
||||
OpenSpec runs on Node.js 20.19.0 or newer. Homebrew installs Node.js as a
|
||||
dependency, and the Nix package includes the runtime. Check your installed version
|
||||
before using another install method.
|
||||
|
||||
In your terminal:
|
||||
|
||||
@@ -13,7 +15,9 @@ In your terminal:
|
||||
node --version
|
||||
```
|
||||
|
||||
If that prints `v20.19.0` or higher, you're set. If not, install a newer Node from [nodejs.org](https://nodejs.org) or through your version manager (nvm, fnm, asdf, volta).
|
||||
If that prints `v20.19.0` or higher, you're set. If not, install a newer Node from
|
||||
[nodejs.org](https://nodejs.org) or through your version manager (nvm, fnm, asdf,
|
||||
volta). You can skip this check when you install with Homebrew or Nix.
|
||||
|
||||
The workflow itself runs inside an AI coding tool: Claude Code, Cursor, or any other tool on the [supported list](../reference/supported-tools.md).
|
||||
|
||||
@@ -53,6 +57,16 @@ In your terminal:
|
||||
npm install -g @fission-ai/openspec@latest
|
||||
```
|
||||
|
||||
### Homebrew
|
||||
|
||||
Homebrew installs OpenSpec and its Node.js dependency on macOS or Linux. In your terminal:
|
||||
|
||||
```bash
|
||||
brew install openspec
|
||||
```
|
||||
|
||||
The formula is published in [homebrew-core](https://formulae.brew.sh/formula/openspec), so you don't need to add a tap.
|
||||
|
||||
### Yarn
|
||||
|
||||
`yarn global add` is Yarn Classic (1.x) only. Modern Yarn removed global installs, so use npm, pnpm, or bun instead. A global CLI doesn't have to share your project's package manager.
|
||||
@@ -123,7 +137,7 @@ When a newer CLI is out, [`openspec update`](../reference/cli.md#openspec-update
|
||||
|
||||
|
||||
> [!WARNING]
|
||||
> On Deno, re-run the [Deno install](#deno) with `-f`; it won't overwrite the installed command without it. On Nix, use `nix profile upgrade openspec`.
|
||||
> On Homebrew, run `brew upgrade openspec`. On Deno, re-run the [Deno install](#deno) with `-f`; it won't overwrite the installed command without it. On Nix, use `nix profile upgrade openspec`.
|
||||
|
||||
> [!NOTE]
|
||||
> A global npm install belongs to one Node installation. Switch Node versions with nvm and the `openspec` command doesn't come along, so install it again under the new version.
|
||||
@@ -144,7 +158,7 @@ openspec completion uninstall
|
||||
npm uninstall -g @fission-ai/openspec
|
||||
```
|
||||
|
||||
On Deno: `deno uninstall --global openspec`. On Nix: `nix profile remove openspec`. Your shell should no longer find `openspec`.
|
||||
On Homebrew: `brew uninstall openspec`. On Deno: `deno uninstall --global openspec`. On Nix: `nix profile remove openspec`. Your shell should no longer find `openspec`.
|
||||
|
||||
**3. Delete what's left, or keep it.**
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
# Quickstart
|
||||
|
||||
> Your first change on your existing repo, from idea to archived.
|
||||
> Your first change, from idea to archived, in a new or existing project.
|
||||
|
||||
Before you start, you need the CLI on your machine ([Installation](installation.md)) and OpenSpec initialized in your project ([Set up your project](setup.md)).
|
||||
|
||||
## Start from an empty project
|
||||
|
||||
You can start without a chosen stack or a complete architecture. Initialize OpenSpec in your project folder, then ask your agent to explore the options with you. In your AI chat:
|
||||
|
||||
```text
|
||||
Help me explore a task tracker from scratch. I have not picked a stack. Compare the options and help me choose the first behavior to build.
|
||||
```
|
||||
|
||||
Decide what the first change needs and leave later architecture choices open. Ask your agent to propose that one change, then follow the steps below. You can revisit the architecture as the project grows.
|
||||
|
||||
## The loop at a glance
|
||||
|
||||
Every change moves through the same five steps: you think the idea through with your agent, it drafts a plan, you correct the plan before any code exists, the agent builds from it, and archiving updates your specs with what shipped.
|
||||
@@ -17,14 +27,14 @@ 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"), 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)).
|
||||
Every prompt below goes in your AI chat, the same place you ask for code. The examples use plain language so they work across tools. You can also invoke a skill directly; the syntax varies by tool ([supported tools](../reference/supported-tools.md)).
|
||||
|
||||
## Step 1: Explore
|
||||
|
||||
Think the idea through with your agent before you ask for a plan. In your AI chat:
|
||||
|
||||
```text
|
||||
/openspec-explore how rate limiting should work in this app
|
||||
Help me explore how rate limiting should work in this app.
|
||||
```
|
||||
|
||||
Explore is a thinking mode. The agent investigates your codebase, asks the questions that matter, sketches options, and challenges assumptions. It 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.
|
||||
@@ -32,7 +42,7 @@ Explore is a thinking mode. The agent investigates your codebase, asks the quest
|
||||
Stay here as long as the problem needs. When the shape feels right, hand it off:
|
||||
|
||||
```text
|
||||
/openspec-propose
|
||||
Propose the change we just discussed.
|
||||
```
|
||||
|
||||
That line starts propose for you, carrying everything you settled. Skip the first prompt in step 2.
|
||||
@@ -42,7 +52,7 @@ That line starts propose for you, carrying everything you settled. Skip the firs
|
||||
Propose turns the idea into a reviewable plan. Coming from explore, it's already running. Starting cold, when the change is clear in your head, ask directly. In your AI chat:
|
||||
|
||||
```text
|
||||
/openspec-propose add rate limiting
|
||||
Propose a change to add rate limiting.
|
||||
```
|
||||
|
||||
The agent asks what it needs to, then writes a change folder:
|
||||
@@ -75,7 +85,7 @@ To fix something, either works:
|
||||
Apply turns the plan into code. Start a fresh chat session, since implementation goes better on a clean context window. In your AI chat:
|
||||
|
||||
```text
|
||||
/openspec-apply-change add-rate-limiting
|
||||
Apply the add-rate-limiting change.
|
||||
```
|
||||
|
||||
The agent reads the change folder, then works through `tasks.md`, checking off each task as it lands.
|
||||
@@ -91,7 +101,7 @@ Archiving does two things: it updates your main specs with the change's requirem
|
||||
When every box in `tasks.md` is checked, in your AI chat:
|
||||
|
||||
```text
|
||||
/openspec-archive-change add-rate-limiting
|
||||
Archive the add-rate-limiting change.
|
||||
```
|
||||
|
||||
Step through what archiving does:
|
||||
@@ -146,14 +156,11 @@ Step through what archiving does:
|
||||
└── 2026-08-08-add-rate-limiting/
|
||||
```
|
||||
|
||||
Git is a separate concern. Commit the change folder with the code, and nothing else about your workflow changes. When to archive relative to a PR is a team convention; the [Teams](../guides/teams.md) guide has the tradeoff.
|
||||
Git is a separate concern. Commit the change folder with the code, and nothing else about your workflow changes.
|
||||
|
||||
## Going further
|
||||
|
||||
- [Concepts](../guides/concepts.md): what the two artifacts are, and how a delta describes a change.
|
||||
- [Explore](../guides/explore.md): getting more out of explore mode.
|
||||
- [Apply](../guides/apply.md): pacing, context windows, resuming long changes.
|
||||
- [Review the plan](../guides/review-the-plan.md): what to look for in specs before you build.
|
||||
- [Delta specs](../reference/schemas/spec-driven/index.md#delta-specs-specmd): how to write the behavior changes in a delta spec.
|
||||
- [Profiles](../customize/profiles.md): optional workflows beyond the core set (verify before archive, incremental planning).
|
||||
|
||||
## Advanced guides
|
||||
|
||||
+24
-2
@@ -41,7 +41,7 @@ Running init creates two things in your project:
|
||||
- An `openspec/` folder at the repo root
|
||||
- Workflow files (skills and commands) added to your AI tool's folder (`.agents/`, `.claude/`, etc.)
|
||||
|
||||
Commit all of it like the rest of your source ([FAQ](../help/faq.md) covers why). Init changes nothing else in your repo (if it finds leftovers from an older OpenSpec version, it asks before cleaning them up).
|
||||
Commit all of it like the rest of your source. Init changes nothing else in your repo (if it finds leftovers from an older OpenSpec version, it asks before cleaning them up).
|
||||
|
||||
### The `openspec/` folder
|
||||
|
||||
@@ -55,7 +55,7 @@ openspec/
|
||||
└── archive/ completed changes move here
|
||||
```
|
||||
|
||||
[Concepts](../guides/concepts.md) explains both artifacts; [Project config](../customize/project-config.md) covers `config.yaml`.
|
||||
[Project config](../customize/project-config.md) covers `config.yaml`.
|
||||
|
||||
### The workflow files (skills and commands)
|
||||
|
||||
@@ -112,4 +112,26 @@ Config changes:
|
||||
|
||||
Answering yes applies it to the current project on the spot. Other projects pick it up on their next `openspec update`. The setting is global, per machine.
|
||||
|
||||
#### Claude Code doesn't show the workflows
|
||||
|
||||
Claude Code loads OpenSpec workflows from one or both of these project paths, based on your delivery setting:
|
||||
|
||||
- **Skills**: `.claude/skills/openspec-*/SKILL.md`
|
||||
- **Commands**: `.claude/commands/opsx/<id>.md`
|
||||
|
||||
If the files are missing, refresh the project. In your terminal:
|
||||
|
||||
```bash
|
||||
openspec update
|
||||
```
|
||||
|
||||
If the command files exist but `/opsx:` shows no OpenSpec commands, update Claude Code and restart it. If commands still don't load, enable skills too. In your terminal:
|
||||
|
||||
```bash
|
||||
openspec config set delivery both
|
||||
openspec update
|
||||
```
|
||||
|
||||
Restart Claude Code, then run `/openspec-propose` in its chat. If only some workflows are missing, [change your profile](../customize/profiles.md#expanding-the-set-optional-workflows).
|
||||
|
||||
Setup is done. The [Quickstart](quickstart.md) takes your first change from here.
|
||||
|
||||
+8
-2
@@ -352,7 +352,13 @@ Revise a change's existing planning artifacts and keep them coherent with one an
|
||||
- Applies your requested revision, or reviews the artifacts for contradictions if you didn't name one
|
||||
- Reconciles the other existing artifacts in any direction (a design edit may ripple back to the proposal)
|
||||
- Confirms every edit with you before writing, one artifact at a time
|
||||
- Ends by recommending the next step: `/opsx:continue` (artifacts missing), `/opsx:apply` (carry a revised plan into code), or `/opsx:archive` (all done)
|
||||
- Ends by recommending the next step: `/opsx:continue` (unstarted artifacts), `/opsx:apply` (carry a revised plan into code), or `/opsx:archive` (all done)
|
||||
|
||||
**Missing files:**
|
||||
|
||||
- For a glob artifact such as `specs/**/*.md` with at least one existing file, update can propose a missing companion file. It uses the schema's instructions and asks you to confirm the concrete path before creating it.
|
||||
- Artifacts with no files yet remain with `/opsx:continue`. Intentionally skipped artifacts stay untouched.
|
||||
- New files must stay inside the change directory. If a file appears at the confirmed path before creation, update stops instead of overwriting it.
|
||||
|
||||
**Example:**
|
||||
|
||||
@@ -373,7 +379,7 @@ AI: Reading add-dark-mode artifacts...
|
||||
|
||||
**Tips:**
|
||||
|
||||
- It won't create missing artifacts - that's `/opsx:continue`
|
||||
- It won't start an artifact with no existing files. Enable `/opsx:continue` for that, or use `openspec status` and `openspec instructions` if that optional workflow isn't installed.
|
||||
- If the change was already implemented, follow up with `/opsx:apply` so the code matches the revised plan
|
||||
- If your revision changes the *intent* of the change, start fresh with a new change instead (see [When to Update vs. Start Fresh](opsx.md#when-to-update-vs-start-fresh))
|
||||
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ Listed projects are maintained independently. Inclusion does not imply official
|
||||
|
||||
## Projects and resources
|
||||
|
||||
- **[OpenSpec UI](https://github.com/VeryComplexAndLongName/OpenSpec-UI)**: A standalone web dashboard and VS Code extension for browsing OpenSpec changes, archives, specs, and tasks.
|
||||
- **[OpenSpec Workbench](https://github.com/VeryComplexAndLongName/OpenSpec-UI)**: Running and supervising agents on OpenSpec changes.
|
||||
- **[openspec-guard](https://github.com/guillaume-flambard/spec-guard)**: CLI and GitHub Action that reports which OpenSpec scenarios are covered by a Vitest or Jest test, without running the tests.
|
||||
|
||||
## Add your project
|
||||
|
||||
|
||||
@@ -341,6 +341,8 @@ Tasks are the **implementation checklist** — concrete steps with checkboxes.
|
||||
- Group related tasks under headings
|
||||
- Use hierarchical numbering (1.1, 1.2, etc.)
|
||||
- Keep tasks small enough to complete in one session
|
||||
- State how each task is verified (a test, command, or observable result)
|
||||
- Land the tests and documentation each group's work calls for inside that group, not in a final catch-up group
|
||||
- Check tasks off as you complete them
|
||||
|
||||
## Delta Specs
|
||||
|
||||
+3
-1
@@ -213,7 +213,9 @@ Works through tasks, checking them off as you go. If you're juggling multiple ch
|
||||
```
|
||||
/opsx:update add-dark-mode - we're storing the theme in a cookie now
|
||||
```
|
||||
Revises the change's existing planning artifacts and keeps them coherent - in any direction (a design edit may ripple back to the proposal). Planning artifacts only: it never edits code, and it never creates missing artifacts (that's `/opsx:continue`). Every edit is confirmed with you first. If the change was already implemented, it recommends `/opsx:apply` so the code catches up with the revised plan. If your revision changes the change's *intent*, start fresh instead - see [When to Update vs. Start Fresh](#when-to-update-vs-start-fresh).
|
||||
Revises the change's existing planning artifacts and keeps them coherent in any direction (a design edit may ripple back to the proposal). It never edits code. Every edit is confirmed with you first. See [the update reference](commands.md#opsxupdate) for how it handles missing files without starting a new artifact.
|
||||
|
||||
If the change was already implemented, it recommends `/opsx:apply` so the code catches up with the revised plan. If your revision changes the change's *intent*, start fresh instead. See [When to Update vs. Start Fresh](#when-to-update-vs-start-fresh).
|
||||
|
||||
### Sync delta specs
|
||||
```text
|
||||
|
||||
@@ -86,7 +86,7 @@ to read the hint.
|
||||
| Hermes Agent (`hermes`) | `.hermes/skills/openspec-*/SKILL.md`\*\*\* | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
|
||||
| iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-<id>.md` |
|
||||
| Junie (`junie`) | `.junie/skills/openspec-*/SKILL.md` | `.junie/commands/opsx-<id>.md` |
|
||||
| Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-<id>.md` |
|
||||
| Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilo/command/opsx-<id>.md` |
|
||||
| Kimi Code (`kimi`) | `.kimi-code/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/skill:openspec-*` invocations) |
|
||||
| Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-<id>.prompt.md` |
|
||||
| Lingma (`lingma`) | `.lingma/skills/openspec-*/SKILL.md` | `.lingma/commands/opsx/<id>.md` |
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
inherit (finalAttrs) pname version src;
|
||||
pnpm = pkgs.pnpm_10;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-oz4tsfu05IPDMaBBp5jLbfsxvTmw1oVtNFtpvudCOPE=";
|
||||
hash = "sha256-ifgjl6/g7wpvcF4Ly/p+rxUbNEKiXF2u69CmpUM0olg=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
|
||||
@@ -105,6 +105,12 @@ Review feedback flagged that "update" alone is generic — could it apply to any
|
||||
### 6. Next-step guidance, especially for already-implemented changes
|
||||
A change can be revised after it was built — tasks checked off, `/opsx:apply` already run. The update itself behaves identically (planning artifacts only), but stopping silently would strand the user: the code and the revised plan now disagree. So the skill ends by reporting where the change stands (from the status JSON and the tasks checklist) and recommending the next command — `/opsx:continue` if artifacts are missing, `/opsx:apply` to carry a revised plan into code, `/opsx:archive` when everything is done. Guidance only: the skill never implements, mirroring the "All artifacts created! You can now implement this change with `/opsx:apply`" hand-off that `continue-change.ts` already uses.
|
||||
|
||||
### 7. Companion-file correction (#1733)
|
||||
|
||||
The original glob-file deferral was unreachable: one matching file marks an artifact `done`, while continue selects only `ready` artifacts. Update can therefore propose a missing companion file within an already populated glob. This corrects the unarchived spec's former blanket deferral without changing the graph's completion rule or starting another artifact.
|
||||
|
||||
The exception uses existing status and instructions output, requires current dependency context and user confirmation, and preserves the change-only planning scope. Immediately before creation, it rechecks scope and the concrete path and uses an operation that refuses an existing target. Delegated creators must obey the same limits. No new CLI command, metadata, graph state, or automatic artifact writer is introduced.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **No deterministic staleness signal.** With no digest/ledger, the skill relies on the agent reading the artifacts to spot incoherence. Trade-off accepted: an agent that rewrites prose must read it anyway, and a content-blind signal earns its cost only for use cases this change excludes (Decision 3).
|
||||
|
||||
@@ -19,7 +19,7 @@ The system SHALL provide a `/opsx:update` workflow skill that revises a change's
|
||||
|
||||
#### Scenario: Missing artifacts are deferred to continue
|
||||
|
||||
- **WHEN** keeping the change coherent would require an artifact that has not been created yet
|
||||
- **WHEN** keeping the change coherent would require an artifact with no existing output files and status `ready` or `blocked`
|
||||
- **THEN** the skill revises only the artifacts that currently exist
|
||||
- **AND** it notes the not-yet-created artifacts and points the user to `/opsx:continue` to create them
|
||||
|
||||
@@ -53,7 +53,7 @@ The `/opsx:update` skill SHALL learn which artifacts exist and where they live b
|
||||
|
||||
#### Scenario: Resolve artifact paths cross-platform
|
||||
|
||||
- **WHEN** the skill reads or writes an artifact on macOS, Linux, or Windows
|
||||
- **WHEN** the skill reads or revises an existing artifact file on macOS, Linux, or Windows
|
||||
- **THEN** it uses the `existingOutputPaths` provided by the CLI status output
|
||||
- **AND** it does not assume forward-slash separators
|
||||
|
||||
@@ -63,11 +63,30 @@ The `/opsx:update` skill SHALL learn which artifacts exist and where they live b
|
||||
- **THEN** the skill edits the concrete files reported in that artifact's `existingOutputPaths`
|
||||
- **AND** it does not write to `resolvedOutputPath`, which for a glob artifact remains the glob pattern rather than a real file
|
||||
|
||||
#### Scenario: A new file under a glob artifact is deferred to continue
|
||||
#### Scenario: A missing companion file under a populated glob artifact
|
||||
|
||||
- **WHEN** keeping the change coherent would require a new file under a glob artifact that does not exist yet (for example a spec for a not-yet-captured capability)
|
||||
- **THEN** the skill revises only the files already present in `existingOutputPaths`
|
||||
- **AND** it points the user to `/opsx:continue`/`/opsx:propose` to create the new file rather than inventing a path from the glob
|
||||
- **WHEN** reconciliation identifies a missing companion file for a glob artifact with non-empty `existingOutputPaths`
|
||||
- **THEN** the skill MAY propose creating that file using the artifact's instructions, template, project context, rules, and current dependency files
|
||||
- **AND** it selects an unused concrete path matching the artifact's `outputPath` inside `changeRoot`, including after resolving linked parent directories
|
||||
- **AND** it creates the file only after user confirmation, refreshing status, instructions, and path checks immediately before creation
|
||||
- **AND** creation SHALL fail rather than overwrite a file that appeared in the meantime
|
||||
- **AND** it SHALL NOT start another artifact, write main specs, or edit implementation code
|
||||
|
||||
#### Scenario: Required inputs are no longer available
|
||||
|
||||
- **WHEN** a populated glob artifact remains `done` but a required non-skipped dependency is missing
|
||||
- **THEN** the skill SHALL stop new companion creation and ask the user to restore the dependency first
|
||||
|
||||
#### Scenario: Schema delegates companion creation
|
||||
|
||||
- **WHEN** the artifact instruction delegates creation to another skill or command
|
||||
- **THEN** the skill SHALL invoke it only if it can honor the confirmed concrete path and the update guardrails
|
||||
- **AND** otherwise it SHALL stop rather than invoke broader generation
|
||||
|
||||
#### Scenario: Intentionally skipped artifact
|
||||
|
||||
- **WHEN** status or instructions mark an artifact as skipped
|
||||
- **THEN** the skill SHALL leave it untouched and SHALL NOT treat its empty outputs as missing or send it to continue
|
||||
|
||||
### Requirement: Bidirectional Coherence Review
|
||||
|
||||
@@ -109,7 +128,7 @@ After applying confirmed revisions (or finding none needed), the `/opsx:update`
|
||||
|
||||
#### Scenario: Next step when artifacts are incomplete
|
||||
|
||||
- **WHEN** the update finishes and the change still has not-yet-created artifacts
|
||||
- **WHEN** the update finishes and the change still has artifacts with no outputs and status `ready` or `blocked`
|
||||
- **THEN** the skill recommends `/opsx:continue` to create them
|
||||
|
||||
#### Scenario: Next step when the change is fully done
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-02
|
||||
@@ -0,0 +1,28 @@
|
||||
# Release archive claims on Windows
|
||||
|
||||
## Why
|
||||
|
||||
`openspec archive` creates `.openspec-archive.lock` before moving a change into
|
||||
the archive. On Windows, a successful archive can leave that lock behind because
|
||||
the cleanup check compares the device id returned by the open file handle with
|
||||
the one returned by `fs.lstat()`. Node reports a real device id from the handle
|
||||
and `0n` from the path stat on the affected Windows/NTFS setup, so the ownership
|
||||
check never passes.
|
||||
|
||||
The archive itself succeeds, but the next archive is blocked by the stale claim
|
||||
and the user has to delete `.openspec-archive.lock` by hand.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Treat an inode match plus matching claim contents as sufficient when either
|
||||
side reports `dev: 0n`, while still requiring the two path stats around the
|
||||
read to match.
|
||||
- Keep the existing protection against deleting a claim that was replaced by
|
||||
another process.
|
||||
- Add a regression test that simulates the Windows path-stat device id behavior.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected spec: `cli-archive`
|
||||
- Affected code: `src/core/archive.ts`
|
||||
- Affected tests: `test/core/archive.test.ts`
|
||||
@@ -0,0 +1,36 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Archive Process
|
||||
|
||||
The archive operation SHALL follow a structured process to safely move changes to the archive.
|
||||
|
||||
#### Scenario: Performing archive
|
||||
|
||||
- **WHEN** archiving a change
|
||||
- **THEN** execute these steps:
|
||||
1. Create archive/ directory if it doesn't exist
|
||||
2. Generate target name as `YYYY-MM-DD-[change-name]` using current date, keeping the name as-is when it already starts with a `YYYY-MM-DD-` prefix
|
||||
3. Claim the target and verify that it does not already exist
|
||||
4. Prepare and validate spec updates from the active change's delta specs
|
||||
5. Apply the spec updates as a rollback-capable transaction
|
||||
6. Move the entire change directory to the archive location
|
||||
7. If a spec mutation or final move fails before a complete archive is secured, restore the spec transaction and leave or return the change at its active path
|
||||
8. If a verified fallback copy completes but staged-source cleanup fails, retain the complete archive and committed spec state for recovery instead of risking the only complete copy
|
||||
|
||||
#### Scenario: Archive already exists
|
||||
|
||||
- **WHEN** target archive already exists
|
||||
- **THEN** fail with error message
|
||||
- **AND** do not overwrite existing archive
|
||||
|
||||
#### Scenario: Successful archive
|
||||
|
||||
- **WHEN** move succeeds
|
||||
- **THEN** display success message with archived name and list of updated specs
|
||||
|
||||
#### Scenario: Successful archive releases its own claim
|
||||
|
||||
- **WHEN** an archive run successfully moves a change to its archive destination
|
||||
- **THEN** remove the temporary archive claim it created
|
||||
- **AND** do so on supported platforms even when a path stat does not report a device id
|
||||
- **AND** never remove a claim whose path identity or contents changed before cleanup
|
||||
@@ -0,0 +1,12 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Release owned claims cross-platform
|
||||
- [x] 1.1 Compare archive claim files by inode and tolerate a missing device id from either stat result
|
||||
- [x] 1.2 Preserve the content and repeated-path-stat checks before unlinking
|
||||
|
||||
## 2. Verify behavior
|
||||
- [x] 2.1 Add regression coverage for the Windows `dev: 0n` path-stat case
|
||||
- [x] 2.2 Run the focused archive regression test
|
||||
|
||||
## 3. Record behavior
|
||||
- [x] 3.1 Add a `cli-archive` spec delta for successful claim cleanup
|
||||
@@ -53,6 +53,10 @@ The system SHALL compute a valid topological build order for artifacts.
|
||||
### Requirement: State Detection
|
||||
The system SHALL detect artifact completion state by scanning the filesystem.
|
||||
|
||||
The system SHALL recognize `generates` values containing `*`, `?`, or `[` as glob patterns. It SHALL also support brace alternatives, brace ranges, and the `@()`, `+()`, `!()`, `*()`, and `?()` extglob forms. An artifact with a glob output SHALL be completed when at least one matching file exists.
|
||||
|
||||
The system SHALL preserve literal filenames with a bare leading `!`, plain parentheses, or single-element braces when no supported glob syntax is present. Brace expansion SHALL preserve literal brace groups and recognize later and nested expansion groups. Expanded output paths and traversed symbolic links SHALL remain within the change directory.
|
||||
|
||||
#### Scenario: Simple file exists
|
||||
- **WHEN** an artifact generates "proposal.md" and the file exists
|
||||
- **THEN** the artifact is marked as completed
|
||||
@@ -73,6 +77,48 @@ The system SHALL detect artifact completion state by scanning the filesystem.
|
||||
- **WHEN** the change directory does not exist
|
||||
- **THEN** all artifacts are marked as not completed (empty state)
|
||||
|
||||
#### Scenario: Brace alternatives with matching files
|
||||
- **WHEN** an artifact generates "review-{api,ui}.md" and "review-api.md" exists
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Brace range after a literal brace group
|
||||
- **WHEN** an artifact generates "report-{draft}-{1..3}.md"
|
||||
- **AND** "report-{draft}-1.md", "report-{draft}-2.md", "report-{draft}-3.md", and "report-{draft}-4.md" exist
|
||||
- **THEN** its resolved outputs contain exactly the first three files
|
||||
- **AND** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Later and nested brace alternatives
|
||||
- **WHEN** an artifact generates "report-{draft}-{{api},ui}.md" and "report-{draft}-{api}.md" exists
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Extglob alternatives with matching files
|
||||
- **WHEN** an artifact generates "@(proposal|design).md" or "+(proposal|design).md" and "proposal.md" exists
|
||||
- **THEN** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Negative extglob excludes its alternatives
|
||||
- **WHEN** an artifact generates "!(proposal|design).md"
|
||||
- **AND** "proposal.md", "design.md", and "notes.md" exist
|
||||
- **THEN** its resolved outputs contain only "notes.md"
|
||||
- **AND** the artifact is marked as completed
|
||||
|
||||
#### Scenario: Brace or extglob pattern without matching files
|
||||
- **WHEN** an artifact generates "review-{api,ui}.md" or "@(proposal|design).md" and no matching files exist
|
||||
- **THEN** the artifact is not marked as completed
|
||||
|
||||
#### Scenario: Literal output names remain literal
|
||||
- **WHEN** an artifact generates "!review.md", "(proposal|design).md", or "review-{api}.md"
|
||||
- **THEN** completion depends on the existence of a file with that exact name
|
||||
|
||||
#### Scenario: Brace expansion escapes the change directory
|
||||
- **WHEN** an artifact generates "{safe,../outside}/review.md"
|
||||
- **THEN** output resolution rejects the expanded path outside the change directory before matching files
|
||||
- **AND** rejection does not depend on whether the outside file exists
|
||||
|
||||
#### Scenario: Expanded directory pattern reaches an outbound symbolic link
|
||||
- **WHEN** an artifact generates "content/{safe,linked}/review.md" or "content/@(safe|linked)/review.md"
|
||||
- **AND** "content/linked" is a symbolic link to a directory outside the change directory
|
||||
- **THEN** output resolution rejects traversal through that link even when no matching files exist
|
||||
|
||||
### Requirement: Ready Artifact Query
|
||||
The system SHALL identify which artifacts are ready to be created based on dependency completion.
|
||||
|
||||
@@ -137,4 +183,3 @@ The system SHALL support self-contained schema directories with co-located templ
|
||||
#### Scenario: List available schemas
|
||||
- **WHEN** listing schemas
|
||||
- **THEN** the system returns schema names from both user and package directories
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ The update command SHALL refresh existing slash command files for configured too
|
||||
- **AND** skip creating missing files (the update command only refreshes what already exists)
|
||||
|
||||
#### Scenario: Updating slash commands for Kilo Code
|
||||
- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **WHEN** `.kilo/command/` contains OpenSpec-managed `opsx-*.md` command files for the configured profile
|
||||
- **THEN** refresh each file using shared templates wrapped in OpenSpec markers
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
- **AND** skip creating missing files (the update command only refreshes what already exists)
|
||||
|
||||
@@ -32,6 +32,13 @@ The system SHALL detect legacy OpenSpec artifacts from previous init versions.
|
||||
- `.windsurf/workflows/openspec-*.md`
|
||||
- And equivalent directories for all tools in the legacy SlashCommandRegistry
|
||||
|
||||
#### Scenario: Detecting legacy Kilo Code workflows
|
||||
|
||||
- **WHEN** `.kilocode/workflows/` contains OpenSpec-managed `opsx-*.md` or `openspec-*.md` workflow files
|
||||
- **THEN** `openspec init` or legacy cleanup SHALL remove those files
|
||||
- **AND** Kilo Code commands SHALL be generated under `.kilo/command/`
|
||||
- **AND** `openspec update` SHALL NOT refresh files that remain only under `.kilocode/workflows/`
|
||||
|
||||
#### Scenario: Detecting legacy OpenSpec structure files
|
||||
|
||||
- **WHEN** running `openspec init` on an existing project
|
||||
@@ -160,4 +167,3 @@ The system SHALL report what was cleaned up.
|
||||
- **WHEN** no legacy artifacts are found
|
||||
- **THEN** the system SHALL NOT display the cleanup section
|
||||
- **AND** proceed directly with skill setup
|
||||
|
||||
|
||||
@@ -45,27 +45,37 @@ The skill SHALL check artifact completion status using the artifact graph before
|
||||
|
||||
### Requirement: Task Completion Check
|
||||
|
||||
The skill SHALL check task completion status from tasks.md before archiving.
|
||||
The skill SHALL check the selected change's task completion using `totalTasks` and `completedTasks` from `openspec list --json`, with the same selected-root flags used for the rest of the workflow. It SHALL match the change by name and use the CLI's schema-aware task resolution in both single and bulk archive workflows.
|
||||
|
||||
#### Scenario: Incomplete tasks found
|
||||
|
||||
- **WHEN** agent reads tasks.md
|
||||
- **AND** incomplete tasks are found (marked with `- [ ]`)
|
||||
- **WHEN** the selected change has `totalTasks` greater than `completedTasks`
|
||||
- **THEN** display warning showing count of incomplete tasks
|
||||
- **AND** prompt user for confirmation to continue
|
||||
- **AND** proceed if user confirms
|
||||
|
||||
#### Scenario: All tasks complete
|
||||
|
||||
- **WHEN** agent reads tasks.md
|
||||
- **AND** all tasks are complete (marked with `- [x]`)
|
||||
- **WHEN** the selected change has equal `totalTasks` and `completedTasks`
|
||||
- **THEN** proceed without task-related warning
|
||||
|
||||
#### Scenario: No tasks file
|
||||
#### Scenario: No tracked tasks
|
||||
|
||||
- **WHEN** tasks.md does not exist
|
||||
- **WHEN** the CLI reports `totalTasks` as zero for the selected change
|
||||
- **THEN** proceed without task-related warning
|
||||
|
||||
#### Scenario: Custom task artifact or output path
|
||||
|
||||
- **WHEN** the schema tracks tasks under a custom artifact name, output path, or glob
|
||||
- **THEN** use the CLI totals across the schema-resolved files
|
||||
- **AND** do not infer completion from artifact existence, an artifact id of `tasks`, or the absence of a top-level `tasks.md`
|
||||
|
||||
#### Scenario: Task progress lookup unavailable
|
||||
|
||||
- **WHEN** the list command fails, returns invalid JSON, omits or duplicates a selected change, or reports invalid task counts
|
||||
- **THEN** report the lookup problem and stop before syncing or archiving
|
||||
- **AND** do not treat the missing progress as zero tasks
|
||||
|
||||
### Requirement: Spec Sync Prompt
|
||||
|
||||
The skill SHALL prompt to sync delta specs before archiving if specs exist.
|
||||
|
||||
@@ -15,34 +15,51 @@ The system SHALL provide an `/opsx:verify` skill that validates implementation a
|
||||
#### Scenario: Verify without change name
|
||||
- **WHEN** agent executes `/opsx:verify` without a change name
|
||||
- **THEN** the agent infers the change from conversation context, or auto-selects it when only one active change exists
|
||||
- **AND** when ambiguous, prompts user to select from available changes, showing only changes that have implementation tasks
|
||||
- **AND** when ambiguous, prompts user to select from all active changes, including changes with no tracked tasks
|
||||
- **AND** announces which change was selected and how to override
|
||||
|
||||
#### Scenario: Change has no tasks
|
||||
- **WHEN** selected change has no tasks.md or tasks are empty
|
||||
- **THEN** the agent reports "No tasks to verify"
|
||||
- **AND** suggests running `/opsx:continue` to create tasks
|
||||
#### Scenario: Change has no task descriptions
|
||||
- **WHEN** the schema configures task tracking but the structured task list provides no usable task descriptions, even if task progress reports nonzero totals
|
||||
- **THEN** the agent reports Task Completion as not verified with the reason
|
||||
- **AND** continues checks supported by the remaining artifacts
|
||||
|
||||
#### Scenario: Schema has no task tracking
|
||||
- **WHEN** the schema does not configure `apply.tracks`
|
||||
- **THEN** apply instructions report `taskTrackingConfigured: false`
|
||||
- **AND** the agent reports Task Completion as not applicable, not as skipped or failed
|
||||
- **AND** continues the checks that apply to the schema
|
||||
|
||||
### Requirement: Completeness Verification
|
||||
The agent SHALL verify that all required work has been completed.
|
||||
|
||||
#### Scenario: Task completion check
|
||||
- **WHEN** verifying completeness
|
||||
- **THEN** the agent reads tasks.md
|
||||
- **AND** counts tasks marked `- [x]` (complete) vs `- [ ]` (incomplete)
|
||||
- **THEN** the agent uses the top-level `tasks` and `progress` from apply instructions
|
||||
- **AND** apply instructions aggregate every concrete file matched by the active schema's `apply.tracks`, regardless of the tracked artifact's ID
|
||||
- **AND** reports complete and total task counts from `progress`
|
||||
- **AND** reports completion status with specific incomplete tasks listed
|
||||
- **AND** reports remaining checkboxes without descriptions when `progress.remaining` exceeds the listed incomplete tasks
|
||||
|
||||
#### Scenario: Tracking evidence becomes unavailable
|
||||
- **WHEN** one or more files matched by `apply.tracks` cannot be read after resolution
|
||||
- **THEN** apply instructions include every unavailable path and reason
|
||||
- **AND** preserve tasks and progress from readable tracking files
|
||||
- **AND** do not report `all_done`
|
||||
- **AND** the agent marks Task Completion as not verified from partial evidence
|
||||
|
||||
#### Scenario: Spec coverage check
|
||||
- **WHEN** verifying completeness
|
||||
- **AND** delta specs exist in `openspec/changes/<name>/specs/`
|
||||
- **THEN** the agent extracts all requirements from delta specs
|
||||
- **AND** searches codebase for implementation of each requirement
|
||||
- **AND** reports which requirements appear to have implementation vs which are missing
|
||||
- **THEN** the agent extracts all requirements from delta specs, noting the delta section each one sits under
|
||||
- **AND** searches codebase for implementation of each ADDED or MODIFIED requirement
|
||||
- **AND** reports which ADDED or MODIFIED requirements appear to have implementation vs which are missing
|
||||
- **AND** checks REMOVED and RENAMED requirements as described in the Removed requirement and Renamed requirement scenarios
|
||||
|
||||
#### Scenario: All tasks complete
|
||||
- **WHEN** all tasks are marked complete
|
||||
- **THEN** report "Tasks: N/N complete"
|
||||
- **AND** mark completeness dimension as passed
|
||||
- **AND** mark Task Completion as passed only when task descriptions are available
|
||||
- **AND** mark the completeness dimension as passed only when all applicable checks ran and passed
|
||||
|
||||
#### Scenario: Incomplete tasks found
|
||||
- **WHEN** some tasks are incomplete
|
||||
@@ -56,14 +73,14 @@ The agent SHALL verify that implementation matches the specifications.
|
||||
|
||||
#### Scenario: Requirement implementation mapping
|
||||
- **WHEN** verifying correctness
|
||||
- **THEN** for each requirement in delta specs:
|
||||
- **THEN** for each ADDED or MODIFIED requirement in delta specs:
|
||||
- Search codebase for implementation
|
||||
- Identify relevant files and line numbers
|
||||
- Assess whether implementation satisfies the requirement
|
||||
|
||||
#### Scenario: Scenario coverage check
|
||||
- **WHEN** verifying correctness
|
||||
- **THEN** for each scenario in delta specs:
|
||||
- **THEN** for each scenario under an ADDED or MODIFIED requirement in delta specs:
|
||||
- Check if the scenario's conditions are handled in code
|
||||
- Check if tests exist that cover the scenario
|
||||
- Report coverage status
|
||||
@@ -80,10 +97,33 @@ The agent SHALL verify that implementation matches the specifications.
|
||||
- **AND** suggest: either update implementation or update spec to match reality
|
||||
|
||||
#### Scenario: Missing implementation
|
||||
- **WHEN** no implementation found for a requirement
|
||||
- **WHEN** no implementation found for an ADDED or MODIFIED requirement
|
||||
- **THEN** report as CRITICAL issue
|
||||
- **AND** suggest: "Implement requirement X" with guidance on what's needed
|
||||
|
||||
#### Scenario: Removed requirement
|
||||
- **WHEN** a requirement sits under `## REMOVED Requirements` in a delta spec
|
||||
- **THEN** the agent treats the absence of its implementation as the expected result
|
||||
- **AND** does not report it as missing or suggest implementing it
|
||||
- **AND** reports it as CRITICAL only if the removed behavior is still present in the codebase
|
||||
- **AND** skips scenario coverage for it
|
||||
- **AND** does not treat matches in OpenSpec artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, as evidence by themselves
|
||||
- **AND** still reports a code path that delivers the removed behavior, even when it is shared with an ADDED requirement
|
||||
|
||||
#### Scenario: Renamed requirement
|
||||
- **WHEN** a requirement is listed under `## RENAMED Requirements` in a delta spec
|
||||
- **THEN** the agent does not report its FROM name as missing
|
||||
- **AND** does not require code symbols or file names to be renamed
|
||||
- **AND** unless the TO name also appears under MODIFIED, verifies that the behavior of the baseline requirement (its body and scenarios in the main spec, under the FROM name, or under the TO name only when the main spec is already synced) is still implemented
|
||||
- **AND** reports CRITICAL "Renamed requirement not found" when that behavior is missing
|
||||
- **AND** marks spec coverage as not verified for the entry when the baseline requirement cannot be found or read
|
||||
|
||||
#### Scenario: Change that only removes or renames requirements
|
||||
- **WHEN** the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements
|
||||
- **THEN** the agent reports requirement implementation mapping and scenario coverage as not applicable
|
||||
- **AND** does not mark them as not verified or withhold readiness because of them
|
||||
- **AND** a delta spec with no parseable requirements still marks them as not verified
|
||||
|
||||
### Requirement: Coherence Verification
|
||||
The agent SHALL verify that implementation is sensible and follows design decisions.
|
||||
|
||||
@@ -98,7 +138,7 @@ The agent SHALL verify that implementation is sensible and follows design decisi
|
||||
- **WHEN** verifying coherence
|
||||
- **AND** no design.md exists
|
||||
- **THEN** skip design adherence check
|
||||
- **AND** note "No design.md to verify against"
|
||||
- **AND** report "Design Adherence: Not verified (No design.md to verify against)"
|
||||
|
||||
#### Scenario: Design decision followed
|
||||
- **WHEN** implementation follows a design decision
|
||||
@@ -113,8 +153,10 @@ The agent SHALL verify that implementation is sensible and follows design decisi
|
||||
|
||||
#### Scenario: Code pattern consistency
|
||||
- **WHEN** verifying coherence
|
||||
- **AND** available artifacts support identifying implementation changes beyond a tasks-only check
|
||||
- **THEN** check if new code follows existing project patterns
|
||||
- **AND** flag any significant deviations as suggestions
|
||||
- **AND** report Code Pattern Consistency as not verified if implementation changes cannot be identified
|
||||
|
||||
### Requirement: Verification Report Format
|
||||
The agent SHALL produce a structured, prioritized report.
|
||||
@@ -132,6 +174,8 @@ The agent SHALL produce a structured, prioritized report.
|
||||
| Correctness | X/Y |
|
||||
| Coherence | Followed |
|
||||
```
|
||||
- **AND** report `Not verified (<reason>)` for every skipped or partially verified check in its dimension's status
|
||||
- **AND** never count a skipped check as passing
|
||||
|
||||
#### Scenario: Issue prioritization
|
||||
- **WHEN** issues are found
|
||||
@@ -147,7 +191,7 @@ The agent SHALL produce a structured, prioritized report.
|
||||
- **AND** avoid vague suggestions like "consider reviewing"
|
||||
|
||||
#### Scenario: All checks pass
|
||||
- **WHEN** no issues found across all dimensions
|
||||
- **WHEN** every applicable check ran and no issues were found across all dimensions
|
||||
- **THEN** display:
|
||||
```text
|
||||
All checks passed. Ready for archive.
|
||||
@@ -160,15 +204,31 @@ The agent SHALL produce a structured, prioritized report.
|
||||
X critical issue(s) found. Fix before archiving.
|
||||
```
|
||||
- **AND** do NOT suggest running archive
|
||||
- **AND** name every skipped check and its reason, if any
|
||||
|
||||
#### Scenario: Only warnings/suggestions
|
||||
- **WHEN** no CRITICAL issues but warnings exist
|
||||
#### Scenario: Only warnings
|
||||
- **WHEN** every applicable check ran and no CRITICAL issues but warnings exist
|
||||
- **THEN** display:
|
||||
```text
|
||||
No critical issues. Y warning(s) to consider.
|
||||
Ready for archive (with noted improvements).
|
||||
```
|
||||
|
||||
#### Scenario: Only suggestions
|
||||
- **WHEN** every applicable check ran and only suggestions exist
|
||||
- **THEN** report "No critical issues or warnings. Z suggestion(s) to consider. Ready for archive (with noted improvements)."
|
||||
|
||||
#### Scenario: Checks skipped
|
||||
- **WHEN** any check was skipped or partially verified and no CRITICAL issues exist
|
||||
- **THEN** report "No critical issues found in the checks that ran"
|
||||
- **AND** name every unverified check and its reason
|
||||
- **AND** include the warning count when nonzero
|
||||
- **AND** do not claim archive readiness
|
||||
|
||||
#### Scenario: Suggestions in final assessment
|
||||
- **WHEN** suggestions exist
|
||||
- **THEN** include their count in the final assessment, including assessments with critical issues or skipped checks
|
||||
|
||||
### Requirement: Flexible Artifact Handling
|
||||
The agent SHALL gracefully handle changes with varying artifact completeness.
|
||||
|
||||
@@ -188,3 +248,16 @@ The agent SHALL gracefully handle changes with varying artifact completeness.
|
||||
- **WHEN** change has proposal, design, specs, and tasks
|
||||
- **THEN** perform all verification checks
|
||||
- **AND** cross-reference artifacts for consistency
|
||||
|
||||
#### Scenario: Unusable or partial artifact evidence
|
||||
- **WHEN** an artifact cannot be read or lacks usable requirements, scenarios, or design decisions
|
||||
- **THEN** mark each affected check as not verified with its reason
|
||||
- **AND** continue checks supported by the remaining evidence without treating partial coverage as a fully verified check
|
||||
|
||||
#### Scenario: Intentional artifact omissions
|
||||
- **WHEN** a check has no supporting artifacts because the schema omits task tracking or optional artifacts, or the change declares `skip_specs: true`
|
||||
- **THEN** report the corresponding checks as not applicable and explain why
|
||||
- **AND** exclude not-applicable checks from skipped-check counts and readiness assessment
|
||||
- **AND** do not require or create optional or intentionally skipped artifacts to obtain a passing report
|
||||
- **AND** treat verification as advisory: not verified describes missing evidence for an applicable check, not a new archive gate
|
||||
- **AND** leave archive checks and user-confirmation behavior unchanged
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "1.13.1",
|
||||
"version": "1.13.2",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
@@ -71,7 +71,7 @@
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/core": "^12.0.0",
|
||||
"@inquirer/prompts": "^8.5.2",
|
||||
"chalk": "^5.6.2",
|
||||
"commander": "^14.0.0",
|
||||
|
||||
Generated
+188
-142
@@ -17,8 +17,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@inquirer/core':
|
||||
specifier: ^11.2.1
|
||||
version: 11.2.1(@types/node@20.19.43)
|
||||
specifier: ^12.0.0
|
||||
version: 12.0.3(@types/node@20.19.43)
|
||||
'@inquirer/prompts':
|
||||
specifier: ^8.5.2
|
||||
version: 8.5.2(@types/node@20.19.43)
|
||||
@@ -42,17 +42,17 @@ importers:
|
||||
version: 9.4.1
|
||||
yaml:
|
||||
specifier: ^2.8.3
|
||||
version: 2.9.0
|
||||
version: 2.9.1
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.5.4
|
||||
version: 4.6.5
|
||||
devDependencies:
|
||||
'@changesets/changelog-github':
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.1
|
||||
'@changesets/cli':
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.2
|
||||
version: 3.0.3
|
||||
'@types/node':
|
||||
specifier: ^20.19.43
|
||||
version: 20.19.43
|
||||
@@ -73,7 +73,7 @@ importers:
|
||||
version: 8.70.0(eslint@10.10.0)(typescript@6.0.3)
|
||||
vitest:
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(@types/node@20.19.43)(@vitest/ui@4.1.11)(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.0))
|
||||
version: 4.1.11(@types/node@20.19.43)(@vitest/ui@4.1.11)(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.1))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -83,8 +83,8 @@ packages:
|
||||
'@cacheable/utils@2.5.0':
|
||||
resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==}
|
||||
|
||||
'@changesets/apply-release-plan@8.1.0':
|
||||
resolution: {integrity: sha512-M93HOGyX3ssg6He3b5NotaHtQVu6uajHS6UIQ28xKt2RzskCy73ayX4I914qBKjTJmrE4YFlELQjfcqxbmGLJw==}
|
||||
'@changesets/apply-release-plan@8.1.1':
|
||||
resolution: {integrity: sha512-nk2Hbq3M9NeHMdaIRBDvawKrQJzXB9sGyBUE5yWnIKtvdcNCPj75zBekJYr0gTT2ecJH3eFeTrc8eisPImcv0Q==}
|
||||
engines: {node: ^22.11 || ^24 || >=26}
|
||||
|
||||
'@changesets/assemble-release-plan@7.0.0':
|
||||
@@ -99,13 +99,13 @@ packages:
|
||||
resolution: {integrity: sha512-QUcrV7878yHlWPXXPA6gqSxNKwtVHCd1K22L7ZAoJw2yGy8K4QvjwOStD75+1Q9KD9ZSUgGS94HuYdd7HOVtpA==}
|
||||
engines: {node: ^22.11 || ^24 || >=26}
|
||||
|
||||
'@changesets/cli@3.0.2':
|
||||
resolution: {integrity: sha512-t/omGJj/I+Jv0kmJAkj5cstYEdUQiJnpip2F+2m3F4lQ3kAZ3o8Exep4/Fm1qq4rvw6ovlhJvZNrKdwLJ4lkuQ==}
|
||||
'@changesets/cli@3.0.3':
|
||||
resolution: {integrity: sha512-L1i67bMfkK6iC/kDejuNO4pFgTRcWIObsD6Qof2woP1I4M8tj17DGlcpRaTzTv1DYSP5EMwWdEtR8X0Pj5Jg3Q==}
|
||||
engines: {node: ^22.11 || ^24 || >=26, npm: '>=10.9.0', pnpm: '>=10.0.0', yarn: '>=4.5.2'}
|
||||
hasBin: true
|
||||
|
||||
'@changesets/config@4.0.0':
|
||||
resolution: {integrity: sha512-mw95/YrkOuhZZxfnVAA4bSXOFUi+KlhzOBTM8C4x777NhUU6HWIl9Z+K+nME+E4PVsv5NQVQwTfiHihAS1A/ow==}
|
||||
'@changesets/config@4.0.1':
|
||||
resolution: {integrity: sha512-PxpxSQLQSLDjceAgRq+4FWlU+HflDIw03mmPVipxoQx9xrXFUjmqTy3SE+n/2t+DqPdpPiLWVr5NRiLZvjyLwg==}
|
||||
engines: {node: ^22.11 || ^24 || >=26}
|
||||
|
||||
'@changesets/errors@1.0.0':
|
||||
@@ -370,6 +370,10 @@ packages:
|
||||
resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
|
||||
'@inquirer/ansi@2.0.8':
|
||||
resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
|
||||
'@inquirer/checkbox@5.2.1':
|
||||
resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
@@ -397,6 +401,15 @@ packages:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/core@12.0.3':
|
||||
resolution: {integrity: sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/editor@5.2.2':
|
||||
resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
@@ -428,6 +441,10 @@ packages:
|
||||
resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
|
||||
'@inquirer/figures@2.0.9':
|
||||
resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
|
||||
'@inquirer/input@5.1.2':
|
||||
resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
@@ -500,6 +517,15 @@ packages:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/type@4.1.1':
|
||||
resolution: {integrity: sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0':
|
||||
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
|
||||
|
||||
@@ -550,141 +576,141 @@ packages:
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.63.1':
|
||||
resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==}
|
||||
'@rollup/rollup-android-arm-eabi@4.63.4':
|
||||
resolution: {integrity: sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.63.1':
|
||||
resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==}
|
||||
'@rollup/rollup-android-arm64@4.63.4':
|
||||
resolution: {integrity: sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.63.1':
|
||||
resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==}
|
||||
'@rollup/rollup-darwin-arm64@4.63.4':
|
||||
resolution: {integrity: sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.63.1':
|
||||
resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==}
|
||||
'@rollup/rollup-darwin-x64@4.63.4':
|
||||
resolution: {integrity: sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.63.1':
|
||||
resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==}
|
||||
'@rollup/rollup-freebsd-arm64@4.63.4':
|
||||
resolution: {integrity: sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.63.1':
|
||||
resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==}
|
||||
'@rollup/rollup-freebsd-x64@4.63.4':
|
||||
resolution: {integrity: sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.63.1':
|
||||
resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==}
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.63.4':
|
||||
resolution: {integrity: sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.63.1':
|
||||
resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==}
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.63.4':
|
||||
resolution: {integrity: sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.63.1':
|
||||
resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==}
|
||||
'@rollup/rollup-linux-arm64-gnu@4.63.4':
|
||||
resolution: {integrity: sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.63.1':
|
||||
resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==}
|
||||
'@rollup/rollup-linux-arm64-musl@4.63.4':
|
||||
resolution: {integrity: sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.63.1':
|
||||
resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==}
|
||||
'@rollup/rollup-linux-loong64-gnu@4.63.4':
|
||||
resolution: {integrity: sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.63.1':
|
||||
resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==}
|
||||
'@rollup/rollup-linux-loong64-musl@4.63.4':
|
||||
resolution: {integrity: sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.63.1':
|
||||
resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==}
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.63.4':
|
||||
resolution: {integrity: sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.63.1':
|
||||
resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==}
|
||||
'@rollup/rollup-linux-ppc64-musl@4.63.4':
|
||||
resolution: {integrity: sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.63.1':
|
||||
resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==}
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.63.4':
|
||||
resolution: {integrity: sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.63.1':
|
||||
resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==}
|
||||
'@rollup/rollup-linux-riscv64-musl@4.63.4':
|
||||
resolution: {integrity: sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.63.1':
|
||||
resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==}
|
||||
'@rollup/rollup-linux-s390x-gnu@4.63.4':
|
||||
resolution: {integrity: sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.63.1':
|
||||
resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==}
|
||||
'@rollup/rollup-linux-x64-gnu@4.63.4':
|
||||
resolution: {integrity: sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.63.1':
|
||||
resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==}
|
||||
'@rollup/rollup-linux-x64-musl@4.63.4':
|
||||
resolution: {integrity: sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.63.1':
|
||||
resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==}
|
||||
'@rollup/rollup-openbsd-x64@4.63.4':
|
||||
resolution: {integrity: sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.63.1':
|
||||
resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==}
|
||||
'@rollup/rollup-openharmony-arm64@4.63.4':
|
||||
resolution: {integrity: sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.63.1':
|
||||
resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==}
|
||||
'@rollup/rollup-win32-arm64-msvc@4.63.4':
|
||||
resolution: {integrity: sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.63.1':
|
||||
resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==}
|
||||
'@rollup/rollup-win32-ia32-msvc@4.63.4':
|
||||
resolution: {integrity: sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.63.1':
|
||||
resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==}
|
||||
'@rollup/rollup-win32-x64-gnu@4.63.4':
|
||||
resolution: {integrity: sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.63.1':
|
||||
resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==}
|
||||
'@rollup/rollup-win32-x64-msvc@4.63.4':
|
||||
resolution: {integrity: sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -1139,8 +1165,8 @@ packages:
|
||||
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
nanoid@3.3.18:
|
||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||
nanoid@3.3.19:
|
||||
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
@@ -1227,8 +1253,8 @@ packages:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rollup@4.63.1:
|
||||
resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==}
|
||||
rollup@4.63.4:
|
||||
resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1445,8 +1471,8 @@ packages:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
yaml@2.9.0:
|
||||
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||
yaml@2.9.1:
|
||||
resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1458,8 +1484,8 @@ packages:
|
||||
resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
zod@4.5.4:
|
||||
resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==}
|
||||
zod@4.6.5:
|
||||
resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
|
||||
|
||||
snapshots:
|
||||
|
||||
@@ -1475,9 +1501,9 @@ snapshots:
|
||||
hashery: 1.5.1
|
||||
keyv: 5.6.0
|
||||
|
||||
'@changesets/apply-release-plan@8.1.0':
|
||||
'@changesets/apply-release-plan@8.1.1':
|
||||
dependencies:
|
||||
'@changesets/config': 4.0.0
|
||||
'@changesets/config': 4.0.1
|
||||
'@changesets/format': 0.1.2
|
||||
'@changesets/git': 4.0.1
|
||||
'@changesets/should-skip-package': 1.0.0
|
||||
@@ -1503,12 +1529,12 @@ snapshots:
|
||||
'@changesets/get-github-info': 1.0.1
|
||||
'@changesets/types': 7.0.0
|
||||
|
||||
'@changesets/cli@3.0.2':
|
||||
'@changesets/cli@3.0.3':
|
||||
dependencies:
|
||||
'@changesets/apply-release-plan': 8.1.0
|
||||
'@changesets/apply-release-plan': 8.1.1
|
||||
'@changesets/assemble-release-plan': 7.0.0
|
||||
'@changesets/changelog-git': 1.0.0
|
||||
'@changesets/config': 4.0.0
|
||||
'@changesets/config': 4.0.1
|
||||
'@changesets/errors': 1.0.0
|
||||
'@changesets/get-dependents-graph': 3.0.0
|
||||
'@changesets/git': 4.0.1
|
||||
@@ -1527,7 +1553,7 @@ snapshots:
|
||||
semver: 7.8.5
|
||||
tinyexec: 1.3.1
|
||||
|
||||
'@changesets/config@4.0.0':
|
||||
'@changesets/config@4.0.1':
|
||||
dependencies:
|
||||
'@changesets/get-dependents-graph': 3.0.0
|
||||
'@changesets/should-skip-package': 1.0.0
|
||||
@@ -1562,7 +1588,7 @@ snapshots:
|
||||
'@changesets/parse@1.0.0':
|
||||
dependencies:
|
||||
'@changesets/types': 7.0.0
|
||||
yaml: 2.9.0
|
||||
yaml: 2.9.1
|
||||
|
||||
'@changesets/pre@3.0.0':
|
||||
dependencies:
|
||||
@@ -1726,6 +1752,8 @@ snapshots:
|
||||
|
||||
'@inquirer/ansi@2.0.7': {}
|
||||
|
||||
'@inquirer/ansi@2.0.8': {}
|
||||
|
||||
'@inquirer/checkbox@5.2.1(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
@@ -1754,6 +1782,18 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/core@12.0.3(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.8
|
||||
'@inquirer/figures': 2.0.9
|
||||
'@inquirer/type': 4.1.1(@types/node@20.19.43)
|
||||
cli-width: 4.1.0
|
||||
fast-wrap-ansi: 0.2.2
|
||||
mute-stream: 3.0.0
|
||||
signal-exit: 4.1.0
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/editor@5.2.2(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||
@@ -1778,6 +1818,8 @@ snapshots:
|
||||
|
||||
'@inquirer/figures@2.0.7': {}
|
||||
|
||||
'@inquirer/figures@2.0.9': {}
|
||||
|
||||
'@inquirer/input@5.1.2(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||
@@ -1843,6 +1885,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/type@4.1.1(@types/node@20.19.43)':
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0': {}
|
||||
|
||||
'@keyv/bigmap@1.3.1(keyv@5.6.0)':
|
||||
@@ -1866,7 +1912,7 @@ snapshots:
|
||||
dependencies:
|
||||
jju: 1.4.0
|
||||
tinyglobby: 0.2.17
|
||||
yaml: 2.9.0
|
||||
yaml: 2.9.1
|
||||
|
||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||
optional: true
|
||||
@@ -1887,79 +1933,79 @@ snapshots:
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.63.1':
|
||||
'@rollup/rollup-android-arm-eabi@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.63.1':
|
||||
'@rollup/rollup-android-arm64@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.63.1':
|
||||
'@rollup/rollup-darwin-arm64@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.63.1':
|
||||
'@rollup/rollup-darwin-x64@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.63.1':
|
||||
'@rollup/rollup-freebsd-arm64@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.63.1':
|
||||
'@rollup/rollup-freebsd-x64@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.63.1':
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.63.1':
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.63.1':
|
||||
'@rollup/rollup-linux-arm64-gnu@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.63.1':
|
||||
'@rollup/rollup-linux-arm64-musl@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.63.1':
|
||||
'@rollup/rollup-linux-loong64-gnu@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.63.1':
|
||||
'@rollup/rollup-linux-loong64-musl@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.63.1':
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.63.1':
|
||||
'@rollup/rollup-linux-ppc64-musl@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.63.1':
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.63.1':
|
||||
'@rollup/rollup-linux-riscv64-musl@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.63.1':
|
||||
'@rollup/rollup-linux-s390x-gnu@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.63.1':
|
||||
'@rollup/rollup-linux-x64-gnu@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.63.1':
|
||||
'@rollup/rollup-linux-x64-musl@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.63.1':
|
||||
'@rollup/rollup-openbsd-x64@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.63.1':
|
||||
'@rollup/rollup-openharmony-arm64@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.63.1':
|
||||
'@rollup/rollup-win32-arm64-msvc@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.63.1':
|
||||
'@rollup/rollup-win32-ia32-msvc@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.63.1':
|
||||
'@rollup/rollup-win32-x64-gnu@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.63.1':
|
||||
'@rollup/rollup-win32-x64-msvc@4.63.4':
|
||||
optional: true
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
@@ -2080,13 +2126,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@vitest/mocker@4.1.11(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.0))':
|
||||
'@vitest/mocker@4.1.11(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.11
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.6(@types/node@20.19.43)(yaml@2.9.0)
|
||||
vite: 7.3.6(@types/node@20.19.43)(yaml@2.9.1)
|
||||
|
||||
'@vitest/pretty-format@4.1.11':
|
||||
dependencies:
|
||||
@@ -2115,7 +2161,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.1
|
||||
vitest: 4.1.11(@types/node@20.19.43)(@vitest/ui@4.1.11)(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.0))
|
||||
vitest: 4.1.11(@types/node@20.19.43)(@vitest/ui@4.1.11)(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.1))
|
||||
|
||||
'@vitest/utils@4.1.11':
|
||||
dependencies:
|
||||
@@ -2457,7 +2503,7 @@ snapshots:
|
||||
|
||||
mute-stream@3.0.0: {}
|
||||
|
||||
nanoid@3.3.18: {}
|
||||
nanoid@3.3.19: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
@@ -2513,7 +2559,7 @@ snapshots:
|
||||
|
||||
postcss@8.5.28:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
nanoid: 3.3.19
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
@@ -2534,36 +2580,36 @@ snapshots:
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rollup@4.63.1:
|
||||
rollup@4.63.4:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
optionalDependencies:
|
||||
'@napi-rs/lzma-linux-x64-gnu': 1.5.1
|
||||
'@rollup/rollup-android-arm-eabi': 4.63.1
|
||||
'@rollup/rollup-android-arm64': 4.63.1
|
||||
'@rollup/rollup-darwin-arm64': 4.63.1
|
||||
'@rollup/rollup-darwin-x64': 4.63.1
|
||||
'@rollup/rollup-freebsd-arm64': 4.63.1
|
||||
'@rollup/rollup-freebsd-x64': 4.63.1
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.63.1
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.63.1
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.63.1
|
||||
'@rollup/rollup-linux-arm64-musl': 4.63.1
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.63.1
|
||||
'@rollup/rollup-linux-loong64-musl': 4.63.1
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.63.1
|
||||
'@rollup/rollup-linux-ppc64-musl': 4.63.1
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.63.1
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.63.1
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.63.1
|
||||
'@rollup/rollup-linux-x64-gnu': 4.63.1
|
||||
'@rollup/rollup-linux-x64-musl': 4.63.1
|
||||
'@rollup/rollup-openbsd-x64': 4.63.1
|
||||
'@rollup/rollup-openharmony-arm64': 4.63.1
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.63.1
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.63.1
|
||||
'@rollup/rollup-win32-x64-gnu': 4.63.1
|
||||
'@rollup/rollup-win32-x64-msvc': 4.63.1
|
||||
'@rollup/rollup-android-arm-eabi': 4.63.4
|
||||
'@rollup/rollup-android-arm64': 4.63.4
|
||||
'@rollup/rollup-darwin-arm64': 4.63.4
|
||||
'@rollup/rollup-darwin-x64': 4.63.4
|
||||
'@rollup/rollup-freebsd-arm64': 4.63.4
|
||||
'@rollup/rollup-freebsd-x64': 4.63.4
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.63.4
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.63.4
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.63.4
|
||||
'@rollup/rollup-linux-arm64-musl': 4.63.4
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.63.4
|
||||
'@rollup/rollup-linux-loong64-musl': 4.63.4
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.63.4
|
||||
'@rollup/rollup-linux-ppc64-musl': 4.63.4
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.63.4
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.63.4
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.63.4
|
||||
'@rollup/rollup-linux-x64-gnu': 4.63.4
|
||||
'@rollup/rollup-linux-x64-musl': 4.63.4
|
||||
'@rollup/rollup-openbsd-x64': 4.63.4
|
||||
'@rollup/rollup-openharmony-arm64': 4.63.4
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.63.4
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.63.4
|
||||
'@rollup/rollup-win32-x64-gnu': 4.63.4
|
||||
'@rollup/rollup-win32-x64-msvc': 4.63.4
|
||||
fsevents: 2.3.3
|
||||
|
||||
run-parallel@1.2.0:
|
||||
@@ -2659,23 +2705,23 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
vite@7.3.6(@types/node@20.19.43)(yaml@2.9.0):
|
||||
vite@7.3.6(@types/node@20.19.43)(yaml@2.9.1):
|
||||
dependencies:
|
||||
esbuild: 0.28.2
|
||||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.28
|
||||
rollup: 4.63.1
|
||||
rollup: 4.63.4
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
fsevents: 2.3.3
|
||||
yaml: 2.9.0
|
||||
yaml: 2.9.1
|
||||
|
||||
vitest@4.1.11(@types/node@20.19.43)(@vitest/ui@4.1.11)(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.0)):
|
||||
vitest@4.1.11(@types/node@20.19.43)(@vitest/ui@4.1.11)(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.1)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.11
|
||||
'@vitest/mocker': 4.1.11(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.0))
|
||||
'@vitest/mocker': 4.1.11(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.1))
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
'@vitest/runner': 4.1.11
|
||||
'@vitest/snapshot': 4.1.11
|
||||
@@ -2692,7 +2738,7 @@ snapshots:
|
||||
tinyexec: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.1
|
||||
vite: 7.3.6(@types/node@20.19.43)(yaml@2.9.0)
|
||||
vite: 7.3.6(@types/node@20.19.43)(yaml@2.9.1)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
@@ -2711,10 +2757,10 @@ snapshots:
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
yaml@2.9.1: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yoctocolors@2.2.0: {}
|
||||
|
||||
zod@4.5.4: {}
|
||||
zod@4.6.5: {}
|
||||
|
||||
@@ -210,6 +210,13 @@ artifacts:
|
||||
that task's checkbox description. Use a separate verification task only
|
||||
when it checks broader integration or system behavior that spans
|
||||
multiple implementation tasks.
|
||||
- Each task group MUST land the tests and documentation its own work
|
||||
calls for. Do NOT collect testing or documentation into a final group -
|
||||
when a late group first exercises work from an early one, the failures
|
||||
cascade back through every group in between and force rework. A group
|
||||
whose work calls for neither, such as scaffolding or dependency setup,
|
||||
carries neither. A final group is for integration checks only, not for
|
||||
the tests and docs an earlier group owed.
|
||||
|
||||
Example:
|
||||
```
|
||||
@@ -224,6 +231,7 @@ artifacts:
|
||||
|
||||
- [ ] 2.1 Implement data export function and verify the export test passes
|
||||
- [ ] 2.2 Add CSV formatting utilities and verify unit tests cover quoting and delimiters
|
||||
- [ ] 2.3 Document the export API in docs/export.md and verify the documented command runs as written
|
||||
```
|
||||
|
||||
Reference specs for what needs to be built, design for how to build it.
|
||||
|
||||
@@ -10,18 +10,34 @@
|
||||
// `changeset publish` triggers `prepublishOnly` (also builds here). This
|
||||
// means an explicit build is not strictly necessary for the guard.
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
function log(msg) {
|
||||
if (process.env.CI) return; // keep CI logs quiet by default
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
// cross-spawn, not execFileSync: on Windows `npm` is npm.cmd, which execFile
|
||||
// cannot resolve without a shell. Keeps the argv form, so no shell is involved.
|
||||
function run(cmd, args, opts = {}) {
|
||||
return execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
|
||||
const result = spawn.sync(cmd, args, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
...opts,
|
||||
});
|
||||
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr || '').trim();
|
||||
throw new Error(
|
||||
`${cmd} ${args.join(' ')} exited with ${result.status}${stderr ? `: ${stderr}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function npmPack() {
|
||||
|
||||
@@ -85,20 +85,27 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
Run `openspec list --json` with the same selected-root flags and find the
|
||||
entry in `changes` whose `name` exactly matches the selected change.
|
||||
Require exactly one match and nonnegative integer `totalTasks` and
|
||||
`completedTasks`, with `completedTasks <= totalTasks`. The CLI resolves
|
||||
the schema's tracked task files, including custom artifact names, output
|
||||
paths, and globs.
|
||||
Incomplete tasks = `totalTasks - completedTasks`.
|
||||
|
||||
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.
|
||||
Do not infer task completion from artifact status or the absence of a
|
||||
top-level `tasks.md`. If the lookup fails, returns invalid JSON, omits or
|
||||
duplicates the selected change, or returns invalid counts, report the problem
|
||||
and stop before syncing or archiving.
|
||||
The CLI counts only `x`/`X` checkbox markers as complete;
|
||||
other markers, including unfamiliar ones, remain incomplete.
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Ask the user to confirm they want to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
**If `totalTasks` is zero:** Proceed without a task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
2. **Prompt for change selection**
|
||||
|
||||
Ask the user to choose changes (multi-select):
|
||||
- Show each change with its schema
|
||||
- Show each change name and task status from the list output
|
||||
- Include an option for "All changes"
|
||||
- Allow any number of selections (1+ works, 2+ is the typical use case)
|
||||
|
||||
@@ -74,17 +74,24 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
|
||||
3. **Batch validation - gather status for all selected changes**
|
||||
|
||||
Run `openspec list --json` once with the same selected-root flags for task
|
||||
progress. If the lookup fails, returns invalid JSON, or omits any selected
|
||||
change, contains a duplicate selected change, or returns invalid counts,
|
||||
report the problem and stop before syncing or archiving the batch.
|
||||
|
||||
For each selected change, collect:
|
||||
|
||||
a. **Artifact status** - Run `openspec status --change "<name>" --json`
|
||||
- Parse `schemaName`, `artifacts`, `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`
|
||||
- Note which artifacts are `done` vs other states
|
||||
|
||||
b. **Task completion** - Read `artifactPaths.tasks.existingOutputPaths` from status JSON
|
||||
- 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"
|
||||
b. **Task completion** - Find the `changes` entry from the list response whose `name` exactly matches this change
|
||||
- Require nonnegative integer `totalTasks` and `completedTasks`, with `completedTasks <= totalTasks`
|
||||
- Incomplete tasks = `totalTasks - completedTasks`
|
||||
- The CLI resolves the schema's tracked task files, including custom artifact names, output paths, and globs
|
||||
- Do not infer task completion from artifact status, an artifact id of `tasks`, or the absence of a top-level `tasks.md`
|
||||
- The CLI counts only `x`/`X` checkbox markers as complete; other markers remain incomplete
|
||||
- If `totalTasks` is zero, note as "No tasks"
|
||||
|
||||
c. **Delta specs** - Check `artifactPaths.specs.existingOutputPaths` from status JSON
|
||||
- List which capability specs exist
|
||||
|
||||
@@ -37,7 +37,6 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
|
||||
When prompting, present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from `lastModified` field)
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ openspec list --json
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- Their names and task status
|
||||
- What the user might be working on
|
||||
|
||||
That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
|
||||
|
||||
@@ -91,7 +91,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
- Dependencies are enablers, not gates: if a required artifact is still `blocked` only because you skipped a conditional dependency, write it anyway
|
||||
- Stop when every artifact in the required set is `done`, `skipped`, or was deliberately skipped
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
c. **If an artifact requires user input** (critically unclear context):
|
||||
- Ask the user to clarify
|
||||
- Then continue with creation
|
||||
|
||||
|
||||
@@ -378,7 +378,7 @@ Save to the `resolvedOutputPath` from `openspec instructions design --change "<n
|
||||
|
||||
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
|
||||
|
||||
These should be small, clear, and in logical order.
|
||||
These should be small, clear, and in logical order. Each group carries the tests and documentation for its own work - the last group is only for integration checks.
|
||||
```
|
||||
|
||||
**DO:** Generate tasks based on specs and design:
|
||||
@@ -401,12 +401,17 @@ Here are the implementation tasks:
|
||||
|
||||
---
|
||||
|
||||
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
|
||||
Each checkbox becomes a unit of work in the apply phase. Does this task breakdown look right?
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user to confirm they're ready to implement.
|
||||
**PAUSE** - Wait for user approval/feedback.
|
||||
|
||||
Save to the `resolvedOutputPath` from `openspec instructions tasks --change "<name>" --json`.
|
||||
After approval, save to the `resolvedOutputPath` from `openspec instructions tasks --change "<name>" --json`.
|
||||
|
||||
Then ask:
|
||||
> "Tasks are saved. Ready to implement?"
|
||||
|
||||
**PAUSE** - Wait for user to confirm before implementation.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ This workflow revises artifacts that already exist; `/openspec-continue-change`
|
||||
|
||||
When prompting, present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from `lastModified` field)
|
||||
|
||||
@@ -69,7 +68,13 @@ This workflow revises artifacts that already exist; `/openspec-continue-change`
|
||||
- Read the artifact(s) the request touches and the change's other existing artifacts.
|
||||
- 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.
|
||||
- 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.
|
||||
- Propose revisions to files that already exist (`existingOutputPaths`). If an artifact has no existing output files and status `ready` or `blocked`, note it and point the user to `/openspec-continue-change` to create them. Leave `skipped` artifacts untouched; do not treat them as missing or defer them to the continue workflow.
|
||||
- A glob artifact (e.g. `specs/**/*.md`) is marked `done` after at least one file matches, and the continue workflow only handles `ready` artifacts. When reconciliation identifies a missing file for a glob artifact whose `existingOutputPaths` is non-empty:
|
||||
1. Run `openspec instructions "<artifact-id>" --change "<name>" --json` and use its `instruction` and `template`. Treat `context` and `rules` as constraints; do not copy them into the file. If instructions report `skipped: true`, do not create the file. Read current dependency files from disk; if a required non-skipped dependency is missing, stop and ask the user to restore it first.
|
||||
2. Choose a concrete path inside `changeRoot` that matches `artifactPaths.<id>.outputPath` and does not already exist. Verify it remains inside `changeRoot` after resolving any symlinked parent directories. The glob `resolvedOutputPath` is not a valid target.
|
||||
3. Include the new file in step 5's proposed revisions and create it only after the user confirms.
|
||||
4. After confirmation, immediately before creation, refresh status and instructions. Verify the artifact is still in scope, not skipped, and partially populated; repeat the concrete-path checks above.
|
||||
5. Use a create operation that fails if the target already exists. If `instruction` delegates creation to another skill or command, invoke it only if it can honor the confirmed path and these guardrails; otherwise stop. If any check fails or the confirmed draft is no longer valid, stop and reconcile with the user rather than replacing existing content or choosing a different path.
|
||||
- If the change is already coherent, say so and propose no revisions.
|
||||
|
||||
5. **Confirm and apply, one artifact at a time**
|
||||
@@ -82,7 +87,7 @@ This workflow revises artifacts that already exist; `/openspec-continue-change`
|
||||
```
|
||||
|
||||
6. **Point to the next step (guidance only - NEVER act on it)**
|
||||
- Artifacts still missing -> suggest `/openspec-continue-change` to create them.
|
||||
- Artifacts with empty `existingOutputPaths` and status `ready` or `blocked` -> suggest `/openspec-continue-change` to create them.
|
||||
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/openspec-apply-change` to carry the delta into code.
|
||||
- Everything done and implemented -> suggest `/openspec-archive-change`.
|
||||
|
||||
@@ -90,13 +95,14 @@ This workflow revises artifacts that already exist; `/openspec-continue-change`
|
||||
|
||||
After each invocation, show:
|
||||
- Which artifacts were revised (and which proposed revisions were rejected)
|
||||
- Anything deferred to `/openspec-continue-change` (not-yet-created artifacts or files)
|
||||
- Any file created under a glob artifact that was already partially populated
|
||||
- Anything deferred to `/openspec-continue-change` (artifacts with no files yet and status `ready` or `blocked`, never `skipped` artifacts)
|
||||
- Where the change stands and the recommended next command
|
||||
|
||||
**Guardrails**
|
||||
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/openspec-apply-change`.
|
||||
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
|
||||
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
|
||||
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/openspec-continue-change`'s job.
|
||||
- Do not advance the build frontier: if an artifact has empty `existingOutputPaths` and status `ready` or `blocked`, that is `/openspec-continue-change`'s job. Leave `skipped` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose `existingOutputPaths` is non-empty.
|
||||
- Confirm every edit with the user before writing.
|
||||
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic).
|
||||
|
||||
@@ -35,7 +35,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and ask the user to select one
|
||||
|
||||
When prompting, show changes that have implementation tasks (tasks artifact exists).
|
||||
When prompting, show all active changes returned by the list, including changes with `status: "no-tasks"`.
|
||||
Include the schema used for each change if available.
|
||||
Mark changes with incomplete tasks as "(In Progress)".
|
||||
|
||||
@@ -56,7 +56,9 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns the change directory and `contextFiles` (artifact ID -> array of concrete file paths). Read all available artifacts from `contextFiles`.
|
||||
This returns the change directory, `contextFiles` (artifact ID -> array of concrete file paths), `taskTrackingConfigured`, and top-level `tasks` and `progress` aggregated from every concrete file matched by the schema's `apply.tracks` configuration that could be read. Read all available artifacts from `contextFiles`.
|
||||
|
||||
Treat apply `state` and `instruction` as context, not a verification verdict. Do not implement tasks or archive the change during verification.
|
||||
|
||||
4. **Initialize verification report structure**
|
||||
|
||||
@@ -67,32 +69,59 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
|
||||
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
|
||||
|
||||
Verification is advisory. Respect intentional omissions such as `skip_specs: true`, optional design documents, and schemas without task tracking. Do not require or invent optional or intentionally omitted artifacts to obtain a clean report. `Not verified` describes a limit of this report, not a new archive prerequisite. Archive retains its own checks and user-confirmation behavior.
|
||||
|
||||
Mark checks the schema does not define, or artifacts the status reports as intentionally skipped, as **Not applicable**. The correctness checks of a change whose readable delta specs contain REMOVED or RENAMED requirements but no ADDED or MODIFIED requirements are also **Not applicable** (see step 6). Exclude them from skipped-check counts and the archive-readiness assessment. Reserve **Not verified** for applicable checks whose evidence is missing or unusable.
|
||||
|
||||
If only task evidence is available for applicable checks, verify task completion only and mark the remaining applicable checks, including **Code Pattern Consistency**, as not verified with the reason "Only task evidence available".
|
||||
|
||||
If artifacts cannot be read or contain no usable requirements, scenarios, or design decisions, mark the affected checks as not verified with the specific reason. Continue checks supported by the remaining evidence, but a partially checked input set is not a fully verified check. Missing requirements affect Spec Coverage and Requirement Implementation Mapping; missing scenarios affect Scenario Coverage; missing design decisions affect Design Adherence.
|
||||
|
||||
5. **Verify Completeness**
|
||||
|
||||
**Task Completion**:
|
||||
- If `contextFiles.tasks` exists, read every file path in it
|
||||
- Parse checkboxes: 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
|
||||
- If `taskTrackingConfigured` is false, report **Task Completion** as not applicable. Do not treat empty `tasks` as missing evidence.
|
||||
- Otherwise, use the top-level `tasks` and `progress` fields. They already aggregate every readable concrete file matched by `apply.tracks`, regardless of the tracked artifact's ID; do not infer tracking from a `contextFiles` key.
|
||||
- If `unavailableTrackingFiles` is nonempty, mark **Task Completion** as not verified and include every unavailable path and reason. Continue using any readable task evidence, but do not infer completion from the partial `tasks` and `progress` fields.
|
||||
- If `taskTrackingConfigured` is true and `tasks` is empty, mark **Task Completion** as not verified and record the reason from apply `state` and `instruction`. Nonzero totals alone do not establish evaluable task descriptions.
|
||||
- Report complete vs total tasks from `progress`.
|
||||
- If `progress.remaining` is greater than 0:
|
||||
- Add CRITICAL issue for each listed incomplete task. If the remaining count exceeds the listed incomplete tasks, also report the incomplete checkboxes without descriptions and recommend adding descriptions and completing them. Do not infer completion from the listed tasks alone.
|
||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
||||
|
||||
**Spec Coverage**:
|
||||
- If status marks the spec artifact skipped by `skip_specs: true`, or the schema defines no spec artifact, report the spec-dependent checks as not applicable.
|
||||
- Otherwise, `contextFiles` is keyed by artifact id, and artifact ids come from the active schema. If `contextFiles.specs` is absent or empty, mark **Spec Coverage**, **Requirement Implementation Mapping**, and **Scenario Coverage** as not verified; do not treat any of them as clean.
|
||||
- If delta specs exist in `contextFiles.specs`:
|
||||
- Extract all requirements (marked with "### Requirement:")
|
||||
- For each requirement:
|
||||
- Extract all requirements (marked with "### Requirement:", or listed as `FROM:`/`TO:` pairs under `## RENAMED Requirements`) and note the delta section each one sits under: `## ADDED`, `## MODIFIED`, `## REMOVED`, or `## RENAMED Requirements`. The section decides what the check looks for.
|
||||
- For each ADDED or MODIFIED requirement (for MODIFIED, check the text in the delta, not the old wording):
|
||||
- Search codebase for keywords related to the requirement
|
||||
- Assess if implementation likely exists
|
||||
- If requirements appear unimplemented:
|
||||
- If ADDED or MODIFIED requirements appear unimplemented:
|
||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
||||
- Recommendation: "Implement requirement X: <description>"
|
||||
- For each REMOVED requirement, the change asks for the behavior to be gone, so invert the check:
|
||||
- Search codebase for the removed behavior. Matches in `openspec/` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
||||
- Finding no implementation is the expected result. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
||||
- If the behavior is still present:
|
||||
- Add CRITICAL issue: "Removed requirement still implemented: <requirement name>"
|
||||
- Recommendation: "Remove the remaining implementation at <file>:<lines>, following the requirement's Migration note if it has one"
|
||||
- For each RENAMED entry (`FROM:`/`TO:`), the name changes but the behavior stays, so check the TO requirement for that unchanged behavior:
|
||||
- Do not report the FROM name as missing, and do not require code symbols, identifiers, or file names to be renamed.
|
||||
- If the TO name also appears under MODIFIED, its behavior is checked there against the MODIFIED text; skip it here.
|
||||
- Otherwise, read the baseline requirement in the main spec at `<planningHome.root>/openspec/specs/<capability-path>/spec.md`, using the same capability path as the delta spec: the requirement under the FROM name, or under the TO name only when the FROM name is absent because the main spec is already synced. Its body and scenarios are the evidence for the behavior the TO requirement keeps.
|
||||
- Search codebase for that behavior and assess if it is still implemented.
|
||||
- If it appears unimplemented:
|
||||
- Add CRITICAL issue: "Renamed requirement not found: <TO name>"
|
||||
- Recommendation: "Restore the behavior of <TO name> (renamed from <FROM name>); a rename must not change behavior"
|
||||
- If the baseline requirement cannot be found or read, mark **Spec Coverage** as not verified for that entry with the reason. Never count an unchecked rename as passing.
|
||||
|
||||
6. **Verify Correctness**
|
||||
|
||||
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each RENAMED entry is checked there against its baseline behavior), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
||||
|
||||
**Requirement Implementation Mapping**:
|
||||
- For each requirement from delta specs:
|
||||
- For each ADDED or MODIFIED requirement from delta specs (REMOVED entries, and RENAMED entries without a MODIFIED block, were settled under Spec Coverage):
|
||||
- Search codebase for implementation evidence
|
||||
- If found, note file paths and line ranges
|
||||
- Assess if implementation matches requirement intent
|
||||
@@ -101,26 +130,29 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
- Recommendation: "Review <file>:<lines> against requirement X"
|
||||
|
||||
**Scenario Coverage**:
|
||||
- For each scenario in delta specs (marked with "#### Scenario:"):
|
||||
- For each scenario under an ADDED or MODIFIED requirement in delta specs (marked with "#### Scenario:"):
|
||||
- Check if conditions are handled in code
|
||||
- Check if tests exist covering the scenario
|
||||
- If scenario appears uncovered:
|
||||
- Add WARNING: "Scenario not covered: <scenario name>"
|
||||
- Recommendation: "Add test or implementation for scenario: <description>"
|
||||
- Skip scenarios under a REMOVED requirement; that behavior is meant to be gone.
|
||||
|
||||
7. **Verify Coherence**
|
||||
|
||||
**Design Adherence**:
|
||||
- If the schema defines no design artifact, report **Design Adherence** as not applicable.
|
||||
- If `contextFiles.design` exists:
|
||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
||||
- Verify implementation follows those decisions
|
||||
- If contradiction detected:
|
||||
- Add WARNING: "Design decision not followed: <decision>"
|
||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
||||
- If no design.md: Skip design adherence check, note "No design.md to verify against"
|
||||
- Otherwise, if `contextFiles.design` is absent or empty: mark **Design Adherence** as not verified. With other supporting artifacts, **Code Pattern Consistency** still runs; the task-only case remains limited to task completion.
|
||||
|
||||
**Code Pattern Consistency**:
|
||||
- Review new code for consistency with project patterns
|
||||
- If implementation changes cannot be identified, mark **Code Pattern Consistency** as not verified and explain the missing evidence.
|
||||
- Otherwise, review new code for consistency with project patterns
|
||||
- Check file naming, directory structure, coding style
|
||||
- If significant deviations found:
|
||||
- Add SUGGESTION: "Code pattern deviation: <details>"
|
||||
@@ -140,11 +172,15 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
| Coherence | Followed/Issues |
|
||||
```
|
||||
|
||||
In each Status cell, report the results of checks that ran and `Not verified (<reason>)` for every skipped check. If all checks in a dimension were skipped, start the cell with `Not verified`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified"). For a change that only removes or renames requirements, the Correctness cell reads `Not applicable (no ADDED or MODIFIED requirements)`.
|
||||
|
||||
**Issues by Priority**:
|
||||
|
||||
1. **CRITICAL** (Must fix before archive):
|
||||
- Incomplete tasks
|
||||
- Missing requirement implementations
|
||||
- Removed requirements still implemented
|
||||
- Renamed requirements whose behavior is no longer implemented
|
||||
- Each with specific, actionable recommendation
|
||||
|
||||
2. **WARNING** (Should fix):
|
||||
@@ -158,9 +194,12 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
- Each with specific recommendation
|
||||
|
||||
**Final Assessment**:
|
||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
|
||||
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If all clear: "All checks passed. Ready for archive."
|
||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving." If any check was skipped, also name every skipped check and its reason.
|
||||
- If no CRITICAL issues, one or more warnings, and no checks were skipped: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If only suggestions and no checks were skipped: "No critical issues or warnings. Z suggestion(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If no issues and no checks were skipped: "All checks passed. Ready for archive."
|
||||
- If any check was skipped and there are no CRITICAL issues: do not claim readiness. Say "No critical issues found in the checks that ran. <check(s)> not verified: <reason>." Include the warning count when nonzero.
|
||||
- Include the suggestion count when nonzero in every final assessment.
|
||||
|
||||
**Verification Heuristics**
|
||||
|
||||
@@ -170,13 +209,6 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
|
||||
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
|
||||
|
||||
**Graceful Degradation**
|
||||
|
||||
- If only tasks.md exists: verify task completion only, skip spec/design checks
|
||||
- If tasks + specs exist: verify completeness and correctness, skip design
|
||||
- If full artifacts: verify all three dimensions
|
||||
- Always note which checks were skipped and why
|
||||
|
||||
**Output Format**
|
||||
|
||||
Use clear markdown with:
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
loadChangeContext,
|
||||
generateInstructions,
|
||||
resolveSchema,
|
||||
resolveArtifactOutputPath,
|
||||
resolveArtifactOutputs,
|
||||
type ArtifactInstructions,
|
||||
} from '../../core/artifact-graph/index.js';
|
||||
@@ -569,15 +568,27 @@ export async function generateApplyInstructions(
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tasks if tracking file exists
|
||||
// Parse every concrete file matched by apply.tracks. A tracking path may be
|
||||
// a glob owned by an artifact with any ID, so treating it as one literal
|
||||
// path loses task evidence for valid custom schemas.
|
||||
let parsedTasks: ParsedTask[] = [];
|
||||
const unavailableTrackingFiles: Array<{ path: string; reason: string }> = [];
|
||||
let tracksFileExists = false;
|
||||
if (tracksFile) {
|
||||
const tracksPath = resolveArtifactOutputPath(changeDir, tracksFile);
|
||||
tracksFileExists = fs.existsSync(tracksPath);
|
||||
if (tracksFileExists) {
|
||||
const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
|
||||
parsedTasks = parseTaskLines(tasksContent);
|
||||
const tracksPaths = resolveArtifactOutputs(changeDir, tracksFile);
|
||||
tracksFileExists = tracksPaths.length > 0;
|
||||
for (const tracksPath of tracksPaths) {
|
||||
try {
|
||||
const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
|
||||
parsedTasks.push(...parseTaskLines(tasksContent));
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
unavailableTrackingFiles.push({
|
||||
path: tracksPath,
|
||||
reason: code && !message.includes(code) ? `${code}: ${message}` : message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const tasks = toTaskItems(parsedTasks);
|
||||
@@ -615,6 +626,9 @@ export async function generateApplyInstructions(
|
||||
instruction =
|
||||
`The ${tracksFilename} file is missing and must be created.` +
|
||||
`\n${describeArtifactRemedy(changeName, findArtifactIdFor(schema, tracksFile))}`;
|
||||
} else if (tracksFile && unavailableTrackingFiles.length > 0 && tasks.length === 0) {
|
||||
state = 'blocked';
|
||||
instruction = 'No readable task descriptions are available.';
|
||||
} else if (tracksFile && tracksFileExists && tasks.length === 0) {
|
||||
// Tracking file exists but lists nothing an agent can work on: either no
|
||||
// checkboxes at all, or only checkboxes with no text after them.
|
||||
@@ -623,7 +637,12 @@ export async function generateApplyInstructions(
|
||||
instruction =
|
||||
`The ${tracksFilename} file exists but contains no tasks to work on.` +
|
||||
`\nAdd tasks to ${tracksFilename}, or rebuild it: ${describeArtifactRemedy(changeName, findArtifactIdFor(schema, tracksFile))}`;
|
||||
} else if (tracksFile && remaining === 0 && total > 0) {
|
||||
} else if (
|
||||
tracksFile &&
|
||||
unavailableTrackingFiles.length === 0 &&
|
||||
remaining === 0 &&
|
||||
total > 0
|
||||
) {
|
||||
state = 'all_done';
|
||||
instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.';
|
||||
} else if (!tracksFile) {
|
||||
@@ -635,6 +654,13 @@ 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.';
|
||||
}
|
||||
|
||||
if (unavailableTrackingFiles.length > 0) {
|
||||
const unavailableDetails = unavailableTrackingFiles
|
||||
.map((file) => `- ${file.path}: ${file.reason}`)
|
||||
.join('\n');
|
||||
instruction += `\nTask completion is not verified because tracking evidence was unavailable:\n${unavailableDetails}`;
|
||||
}
|
||||
|
||||
const warnings = await collectApplyWarnings({
|
||||
state,
|
||||
schema,
|
||||
@@ -650,6 +676,8 @@ export async function generateApplyInstructions(
|
||||
contextFiles,
|
||||
progress: { total, complete, remaining },
|
||||
tasks,
|
||||
taskTrackingConfigured: tracksFile !== null,
|
||||
...(unavailableTrackingFiles.length > 0 ? { unavailableTrackingFiles } : {}),
|
||||
state,
|
||||
missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined,
|
||||
...(missingPrerequisites.length > 0 ? { missingPrerequisites } : {}),
|
||||
|
||||
@@ -45,6 +45,11 @@ export interface ApplyInstructions {
|
||||
remaining: number;
|
||||
};
|
||||
tasks: TaskItem[];
|
||||
taskTrackingConfigured: boolean;
|
||||
unavailableTrackingFiles?: Array<{
|
||||
path: string;
|
||||
reason: string;
|
||||
}>;
|
||||
state: 'blocked' | 'all_done' | 'ready';
|
||||
missingArtifacts?: string[];
|
||||
/**
|
||||
|
||||
+305
-79
@@ -21,6 +21,7 @@ import {
|
||||
writeUpdatedSpec,
|
||||
retireSpec,
|
||||
finalizeRetiredSpec,
|
||||
pruneEmptyDirs,
|
||||
type SpecUpdate,
|
||||
} from './specs-apply.js';
|
||||
import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
|
||||
@@ -378,6 +379,17 @@ async function copyDirContents(src: string, dest: string): Promise<void> {
|
||||
await fs.chmod(dest, sourceStat.mode & 0o7777);
|
||||
}
|
||||
|
||||
type TreeEntry = { relative: string; kind: 'directory' | 'file' | 'symlink' };
|
||||
|
||||
/** SHA-256 of one file's bytes, streamed so a large file is never held whole. */
|
||||
async function fingerprintFileContents(filePath: string): Promise<Buffer> {
|
||||
const fileHash = createHash('sha256');
|
||||
for await (const chunk of createReadStream(filePath)) {
|
||||
fileHash.update(chunk);
|
||||
}
|
||||
return fileHash.digest();
|
||||
}
|
||||
|
||||
async function fingerprintDirectoryContents(root: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
const updateHashField = (label: string, value: string | Buffer): void => {
|
||||
@@ -390,13 +402,7 @@ async function fingerprintDirectoryContents(root: string): Promise<string> {
|
||||
hash.update(labelBuffer);
|
||||
hash.update(valueBuffer);
|
||||
};
|
||||
const fingerprintFile = async (filePath: string): Promise<Buffer> => {
|
||||
const fileHash = createHash('sha256');
|
||||
for await (const chunk of createReadStream(filePath)) {
|
||||
fileHash.update(chunk);
|
||||
}
|
||||
return fileHash.digest();
|
||||
};
|
||||
const fingerprintFile = fingerprintFileContents;
|
||||
|
||||
const visit = async (dir: string, relativeDir: string): Promise<void> => {
|
||||
const before = await fs.lstat(dir, { bigint: true });
|
||||
@@ -470,14 +476,225 @@ async function assertCopiedDirectoryUnchanged(
|
||||
|
||||
/**
|
||||
* Move a directory from src to dest. On Windows, fs.rename() can fail with
|
||||
* EPERM, and cross-device moves fail with EXDEV. When the source can first be
|
||||
* renamed to a private sibling, fall back to a verified copy-then-remove. A
|
||||
* source that cannot be staged is left untouched rather than copied and deleted
|
||||
* through a path another process may still be editing.
|
||||
* EPERM, and cross-device moves fail with EXDEV. Prefer renaming the source
|
||||
* to a private sibling first, then copy-then-remove. When that staging rename
|
||||
* also fails with EPERM/EXDEV — the usual Windows case for a directory that
|
||||
* still has children, because a watcher holds a directory-enumeration handle —
|
||||
* copy from the original source instead. Fingerprints still abort if the tree
|
||||
* changes mid-copy. A staging failure that is not EPERM/EXDEV still leaves
|
||||
* the source untouched rather than copying through a path we could not claim.
|
||||
*/
|
||||
class MoveDestinationRetainedError extends Error {}
|
||||
class RetirementBackupsRetainedError extends Error {}
|
||||
|
||||
function isFallbackRenameCode(code: string | undefined): boolean {
|
||||
return code === 'EPERM' || code === 'EXDEV';
|
||||
}
|
||||
|
||||
/**
|
||||
* Every entry under `root`, deepest first, as paths relative to it.
|
||||
*
|
||||
* The listing is what bounds the removal below. Anything that appears after it
|
||||
* is simply not in the set, so it cannot be deleted by the cleanup.
|
||||
*/
|
||||
async function listTreeEntriesDeepestFirst(
|
||||
root: string
|
||||
): Promise<TreeEntry[]> {
|
||||
const entries: TreeEntry[] = [];
|
||||
const visit = async (dir: string, relativeDir: string): Promise<void> => {
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const relative = relativeDir === '' ? entry.name : path.join(relativeDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await visit(path.join(dir, entry.name), relative);
|
||||
entries.push({ relative, kind: 'directory' });
|
||||
} else {
|
||||
// A symlink to a directory is not a directory here, and its content is
|
||||
// its target, not bytes to read - reading one raises EISDIR. Record the
|
||||
// kind so cleanup compares each entry the way the copy wrote it.
|
||||
entries.push({ relative, kind: entry.isSymbolicLink() ? 'symlink' : 'file' });
|
||||
}
|
||||
}
|
||||
};
|
||||
await visit(root, '');
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove exactly the entries that were copied and verified, deepest first.
|
||||
*
|
||||
* The move is only safe to finish by deleting the source, and the source of the
|
||||
* unstaged fallback is still the live change directory: the archive claim
|
||||
* covers the destination, not it. A recursive remove would delete whatever is
|
||||
* there at that moment, including a file a concurrent writer added after the
|
||||
* final fingerprint - data that never reached the destination.
|
||||
*
|
||||
* Removing a named set instead means a late arrival is never in it. It is left
|
||||
* on disk, and the `rmdir` of its parent fails with ENOTEMPTY, which the caller
|
||||
* reports as a retained destination. The move does not complete silently.
|
||||
*
|
||||
* `entries` must be listed before the final fingerprint, so that an arrival is
|
||||
* either caught by that fingerprint or absent from the set.
|
||||
*
|
||||
* An edit to a file that is already in the set is covered by claiming each file
|
||||
* before reading it: `rename` is atomic, so once a file is under its claim name
|
||||
* the bytes there are ours. A writer that rewrites the file by path after that
|
||||
* point creates a new file at the original path, which is not in `entries`, is
|
||||
* never deleted, and makes the parent `rmdir` fail with ENOTEMPTY - reported as
|
||||
* a retained destination. A writer that got there first is caught by comparing
|
||||
* the claimed bytes against the copy: on a mismatch the file is put back and
|
||||
* the move is abandoned with both trees intact, because the destination holds
|
||||
* the older content and deleting the source would lose the newer.
|
||||
*
|
||||
* What remains outside this, as for any copy, is a writer holding an open
|
||||
* descriptor that writes through it after the comparison. Staging is still
|
||||
* preferred whenever the rename is permitted at all.
|
||||
*/
|
||||
/**
|
||||
* A claim suffix no entry in this move can already carry.
|
||||
*
|
||||
* A fixed suffix collides with a source file that legitimately ends in it:
|
||||
* claiming `x` would rename it over a real `x.openspec-claim`, and that file's
|
||||
* own turn would then fail with ENOENT after part of the live source had
|
||||
* already been removed. So draw a fresh suffix per move and prove it against
|
||||
* the very set being removed - if no entry ends with the suffix, no claim of
|
||||
* one entry can land on another.
|
||||
*/
|
||||
function makeClaimSuffix(entries: TreeEntry[]): string {
|
||||
const names = entries.map((entry) => entry.relative);
|
||||
for (;;) {
|
||||
const suffix = `.openspec-claim-${randomUUID()}`;
|
||||
if (!names.some((name) => name.endsWith(suffix))) return suffix;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the entry holds now, for comparison against the copy. */
|
||||
async function readEntryIdentity(
|
||||
entryPath: string,
|
||||
kind: 'file' | 'symlink'
|
||||
): Promise<string> {
|
||||
return kind === 'symlink'
|
||||
? `symlink:${await fs.readlink(entryPath)}`
|
||||
: `file:${(await fingerprintFileContents(entryPath)).toString('hex')}`;
|
||||
}
|
||||
|
||||
async function removeVerifiedTree(
|
||||
root: string,
|
||||
entries: TreeEntry[],
|
||||
destination: string
|
||||
): Promise<void> {
|
||||
const claimSuffix = makeClaimSuffix(entries);
|
||||
for (const entry of entries) {
|
||||
const target = path.join(root, entry.relative);
|
||||
if (entry.kind === 'directory') {
|
||||
await fs.rmdir(target);
|
||||
continue;
|
||||
}
|
||||
const claimed = target + claimSuffix;
|
||||
await fs.rename(target, claimed);
|
||||
let claimedIdentity: string;
|
||||
let copiedIdentity: string;
|
||||
try {
|
||||
claimedIdentity = await readEntryIdentity(claimed, entry.kind);
|
||||
copiedIdentity = await readEntryIdentity(
|
||||
path.join(destination, entry.relative),
|
||||
entry.kind
|
||||
);
|
||||
} catch (error) {
|
||||
await fs.rename(claimed, target).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
if (claimedIdentity !== copiedIdentity) {
|
||||
await fs.rename(claimed, target).catch(() => undefined);
|
||||
throw new Error(
|
||||
`${target} changed after it was verified, so the copy at ${destination} ` +
|
||||
'does not hold its current content.'
|
||||
);
|
||||
}
|
||||
await fs.rm(claimed, { force: true });
|
||||
}
|
||||
await fs.rmdir(root);
|
||||
}
|
||||
|
||||
async function copyThenRemoveDirectory(
|
||||
source: string,
|
||||
dest: string,
|
||||
options: {
|
||||
verifyCopiedDestination?: (copiedSource: string) => Promise<void>;
|
||||
},
|
||||
restoreSource?: () => Promise<void>
|
||||
): Promise<void> {
|
||||
let destIsOurs = false;
|
||||
let sourceFingerprint: string;
|
||||
try {
|
||||
sourceFingerprint = await fingerprintDirectoryContents(source);
|
||||
await fs.mkdir(dest, { mode: 0o700 });
|
||||
destIsOurs = true;
|
||||
await copyDirContents(source, dest);
|
||||
await options.verifyCopiedDestination?.(source);
|
||||
await assertCopiedDirectoryUnchanged(source, dest, sourceFingerprint);
|
||||
} catch (copyError) {
|
||||
if (destIsOurs) {
|
||||
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
if (restoreSource) {
|
||||
try {
|
||||
await restoreSource();
|
||||
} catch (restoreError) {
|
||||
throw new Error(
|
||||
`${copyError instanceof Error ? copyError.message : String(copyError)} ` +
|
||||
`Could not restore the staged source at ${source} ` +
|
||||
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
if ((copyError as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
throw new ArchiveBlockedError(
|
||||
'archive_target_exists',
|
||||
`Archive '${path.basename(dest)}' already exists.`
|
||||
);
|
||||
}
|
||||
throw copyError;
|
||||
}
|
||||
let verifiedEntries: TreeEntry[];
|
||||
try {
|
||||
// Listed before the verification, not after it. A file that arrives before
|
||||
// the fingerprint changes it and aborts the move; one that arrives after is
|
||||
// not in this set. Listing afterwards would leave a window in which an
|
||||
// arrival is both unverified and deletable.
|
||||
verifiedEntries = await listTreeEntriesDeepestFirst(source);
|
||||
await options.verifyCopiedDestination?.(source);
|
||||
await assertCopiedDirectoryUnchanged(source, dest, sourceFingerprint);
|
||||
} catch (verificationError) {
|
||||
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
||||
if (restoreSource) {
|
||||
try {
|
||||
await restoreSource();
|
||||
} catch (restoreError) {
|
||||
throw new Error(
|
||||
`${verificationError instanceof Error ? verificationError.message : String(verificationError)} ` +
|
||||
`Could not restore the staged source at ${source} ` +
|
||||
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
throw verificationError;
|
||||
}
|
||||
try {
|
||||
await removeVerifiedTree(source, verifiedEntries, dest);
|
||||
} catch (cleanupError) {
|
||||
// Removal may already have deleted part of the source, or stopped on an
|
||||
// entry that appeared after verification. The destination is now the only
|
||||
// complete copy, so never erase it while trying to make this failed move
|
||||
// look atomic.
|
||||
throw new MoveDestinationRetainedError(
|
||||
`Copied ${source} to ${dest}, but could not remove the source at ` +
|
||||
`${source} completely ` +
|
||||
`(${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}). ` +
|
||||
'The complete destination was retained for recovery.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveDirectory(
|
||||
src: string,
|
||||
dest: string,
|
||||
@@ -497,76 +714,25 @@ async function moveDirectory(
|
||||
`Archive '${path.basename(dest)}' already exists.`
|
||||
);
|
||||
}
|
||||
if (code === 'EPERM' || code === 'EXDEV') {
|
||||
if (isFallbackRenameCode(code)) {
|
||||
const stagedSource = path.join(path.dirname(src), `.openspec-move-${randomUUID()}`);
|
||||
try {
|
||||
await fs.rename(src, stagedSource);
|
||||
} catch (stageError) {
|
||||
const stageCode = (stageError as NodeJS.ErrnoException)?.code;
|
||||
if (isFallbackRenameCode(stageCode)) {
|
||||
await copyThenRemoveDirectory(src, dest, options);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Could not safely stage ${src} before the fallback archive copy ` +
|
||||
`(${stageError instanceof Error ? stageError.message : String(stageError)}). ` +
|
||||
'No fallback copy was attempted.'
|
||||
);
|
||||
}
|
||||
let destIsOurs = false;
|
||||
let stagedFingerprint: string;
|
||||
try {
|
||||
stagedFingerprint = await fingerprintDirectoryContents(stagedSource);
|
||||
await fs.mkdir(dest, { mode: 0o700 });
|
||||
destIsOurs = true;
|
||||
await copyDirContents(stagedSource, dest);
|
||||
await options.verifyCopiedDestination?.(stagedSource);
|
||||
await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint);
|
||||
} catch (copyError) {
|
||||
if (destIsOurs) {
|
||||
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
try {
|
||||
await fs.rename(stagedSource, src);
|
||||
} catch (restoreError) {
|
||||
throw new Error(
|
||||
`${copyError instanceof Error ? copyError.message : String(copyError)} ` +
|
||||
`Could not restore the staged source at ${stagedSource} ` +
|
||||
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`
|
||||
);
|
||||
}
|
||||
if ((copyError as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
throw new ArchiveBlockedError(
|
||||
'archive_target_exists',
|
||||
`Archive '${path.basename(dest)}' already exists.`
|
||||
);
|
||||
}
|
||||
throw copyError;
|
||||
}
|
||||
try {
|
||||
await options.verifyCopiedDestination?.(stagedSource);
|
||||
await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint);
|
||||
} catch (verificationError) {
|
||||
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
||||
try {
|
||||
await fs.rename(stagedSource, src);
|
||||
} catch (restoreError) {
|
||||
throw new Error(
|
||||
`${verificationError instanceof Error ? verificationError.message : String(verificationError)} ` +
|
||||
`Could not restore the staged source at ${stagedSource} ` +
|
||||
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`
|
||||
);
|
||||
}
|
||||
throw verificationError;
|
||||
}
|
||||
try {
|
||||
await fs.rm(stagedSource, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
// Recursive removal may already have deleted part of the source. The
|
||||
// destination is now the only complete copy, so never erase it while
|
||||
// trying to make this failed move look atomic.
|
||||
throw new MoveDestinationRetainedError(
|
||||
`Copied ${src} to ${dest}, but could not remove the staged source at ` +
|
||||
`${stagedSource} completely ` +
|
||||
`(${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}). ` +
|
||||
'The complete destination was retained for recovery.'
|
||||
);
|
||||
}
|
||||
await copyThenRemoveDirectory(stagedSource, dest, options, async () => {
|
||||
await fs.rename(stagedSource, src);
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
@@ -598,6 +764,21 @@ interface ArchiveClaim {
|
||||
contents: string;
|
||||
}
|
||||
|
||||
interface ArchiveClaimFileIdentity {
|
||||
dev: bigint;
|
||||
ino: bigint;
|
||||
}
|
||||
|
||||
function isSameArchiveClaimFile(
|
||||
first: ArchiveClaimFileIdentity,
|
||||
second: ArchiveClaimFileIdentity
|
||||
): boolean {
|
||||
return (
|
||||
first.ino === second.ino &&
|
||||
(first.dev === second.dev || first.dev === 0n || second.dev === 0n)
|
||||
);
|
||||
}
|
||||
|
||||
async function releaseArchiveClaim(
|
||||
claim: ArchiveClaim,
|
||||
claimPath: string
|
||||
@@ -613,10 +794,8 @@ async function releaseArchiveClaim(
|
||||
const contents = await fs.readFile(claimPath, 'utf8');
|
||||
const currentAfterRead = await fs.lstat(claimPath, { bigint: true });
|
||||
if (
|
||||
current.dev === owned.dev &&
|
||||
current.ino === owned.ino &&
|
||||
current.dev === currentAfterRead.dev &&
|
||||
current.ino === currentAfterRead.ino &&
|
||||
isSameArchiveClaimFile(current, owned) &&
|
||||
isSameArchiveClaimFile(current, currentAfterRead) &&
|
||||
contents === claim.contents
|
||||
) {
|
||||
await fs.unlink(claimPath);
|
||||
@@ -660,6 +839,14 @@ async function claimArchiveDestination(
|
||||
interface SpecSnapshot {
|
||||
target: string;
|
||||
existed: boolean;
|
||||
/**
|
||||
* The deepest directory at or above the target's parent that already existed
|
||||
* before the mutation. Rollback prunes up to but never past it, so a
|
||||
* capability directory the user already had keeps its permissions and ACLs -
|
||||
* including an intermediate one under a nested capability id, where only the
|
||||
* leaf was created by this write.
|
||||
*/
|
||||
pruneBoundary?: string;
|
||||
outcome: 'write' | 'retire';
|
||||
expectedContent?: Buffer;
|
||||
content?: Buffer;
|
||||
@@ -849,7 +1036,31 @@ async function assertDistinctMutationTargets(mutations: SpecMutation[]): Promise
|
||||
}
|
||||
}
|
||||
|
||||
async function captureSpecSnapshots(mutations: SpecMutation[]): Promise<SpecSnapshot[]> {
|
||||
/**
|
||||
* The deepest directory at or above `dir` that exists, never going above
|
||||
* `boundaryDir`. Used as the floor for a rollback prune: everything below it
|
||||
* was created by the write being undone, and it was not.
|
||||
*/
|
||||
async function deepestExistingAncestor(dir: string, boundaryDir: string): Promise<string> {
|
||||
let current = dir;
|
||||
for (;;) {
|
||||
if (current === boundaryDir || !current.startsWith(boundaryDir + path.sep)) {
|
||||
return boundaryDir;
|
||||
}
|
||||
try {
|
||||
await fs.lstat(current);
|
||||
return current;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureSpecSnapshots(
|
||||
mutations: SpecMutation[],
|
||||
mainSpecsDir: string
|
||||
): Promise<SpecSnapshot[]> {
|
||||
return Promise.all(
|
||||
mutations.map(async ({ update, outcome, rebuilt }) => {
|
||||
try {
|
||||
@@ -895,6 +1106,10 @@ async function captureSpecSnapshots(mutations: SpecMutation[]): Promise<SpecSnap
|
||||
target: update.target,
|
||||
existed: false,
|
||||
outcome,
|
||||
pruneBoundary: await deepestExistingAncestor(
|
||||
path.dirname(update.target),
|
||||
mainSpecsDir
|
||||
),
|
||||
...(outcome === 'write' ? { expectedContent: Buffer.from(rebuilt) } : {}),
|
||||
};
|
||||
}
|
||||
@@ -904,7 +1119,10 @@ async function captureSpecSnapshots(mutations: SpecMutation[]): Promise<SpecSnap
|
||||
);
|
||||
}
|
||||
|
||||
async function restoreSpecSnapshots(snapshots: SpecSnapshot[]): Promise<void> {
|
||||
async function restoreSpecSnapshots(
|
||||
snapshots: SpecSnapshot[],
|
||||
mainSpecsDir: string
|
||||
): Promise<void> {
|
||||
const errors: Error[] = [];
|
||||
for (const snapshot of [...snapshots].reverse()) {
|
||||
try {
|
||||
@@ -995,6 +1213,13 @@ async function restoreSpecSnapshots(snapshots: SpecSnapshot[]): Promise<void> {
|
||||
|
||||
if (!snapshot.existed) {
|
||||
await fs.rm(snapshot.target, { force: true });
|
||||
// Only a capability directory this write created is ours to take back.
|
||||
// One the user already had stays, empty or not, with its own mode -
|
||||
// pruneEmptyDirs never removes its boundary.
|
||||
await pruneEmptyDirs(
|
||||
path.dirname(snapshot.target),
|
||||
snapshot.pruneBoundary ?? mainSpecsDir
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (snapshot.symlink !== undefined) {
|
||||
@@ -1743,7 +1968,7 @@ export class ArchiveCommand {
|
||||
);
|
||||
}
|
||||
}
|
||||
const specSnapshots = await captureSpecSnapshots(mutations);
|
||||
const specSnapshots = await captureSpecSnapshots(mutations, mainSpecsDir);
|
||||
const specSnapshotsByTarget = new Map(
|
||||
specSnapshots.map((snapshot) => [snapshot.target, snapshot])
|
||||
);
|
||||
@@ -2015,7 +2240,8 @@ export class ArchiveCommand {
|
||||
const rollbackErrors: Error[] = [];
|
||||
try {
|
||||
await restoreSpecSnapshots(
|
||||
specSnapshots.filter(({ target }) => mutationAttempts.has(target))
|
||||
specSnapshots.filter(({ target }) => mutationAttempts.has(target)),
|
||||
mainSpecsDir
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
rollbackErrors.push(
|
||||
|
||||
@@ -3,11 +3,31 @@ import * as path from 'node:path';
|
||||
import fg from 'fast-glob';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
|
||||
const EXTGLOB_RE = /[!*+?@]\([^(]*\)/u;
|
||||
const BRACE_EXPANSION_SEPARATORS_RE = /,|\.\./u;
|
||||
|
||||
function hasBraceExpansion(pattern: string): boolean {
|
||||
const openings: number[] = [];
|
||||
for (let index = 0; index < pattern.length; index += 1) {
|
||||
if (pattern[index] === '{') openings.push(index);
|
||||
if (pattern[index] !== '}') continue;
|
||||
const opening = openings.pop();
|
||||
if (opening !== undefined && BRACE_EXPANSION_SEPARATORS_RE.test(pattern.slice(opening, index))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path contains glob pattern characters.
|
||||
* Recognizes artifact globs while preserving literal output filenames.
|
||||
*/
|
||||
export function isGlobPattern(pattern: string): boolean {
|
||||
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
|
||||
// Keep the original wildcard rules and recognize brace expansions and extglobs.
|
||||
// Its full dynamic predicate also reinterprets literal !, parentheses, and backslashes.
|
||||
const normalized = FileSystemUtils.toPosixPath(pattern);
|
||||
return normalized.includes('*') || normalized.includes('?') || normalized.includes('[')
|
||||
|| EXTGLOB_RE.test(normalized) || hasBraceExpansion(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,20 +128,30 @@ export function resolveArtifactOutputs(changeDir: string, generates: string): st
|
||||
}
|
||||
|
||||
const normalizedPattern = FileSystemUtils.toPosixPath(generates);
|
||||
assertGlobDirectoryTraversal(
|
||||
changeDir,
|
||||
changeDir,
|
||||
normalizedPattern.split('/').slice(0, -1)
|
||||
);
|
||||
const globOptions = {
|
||||
cwd: changeDir,
|
||||
onlyFiles: true,
|
||||
absolute: true,
|
||||
// Preserve linked artifact directories; confine traversal and concrete matches.
|
||||
followSymbolicLinks: true,
|
||||
};
|
||||
// Task generation expands braces without accessing the filesystem. Validate
|
||||
// every task base before globbing, including paths introduced by expansion.
|
||||
const tasks = fg.generateTasks(normalizedPattern, globOptions);
|
||||
for (const task of tasks) {
|
||||
FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, task.base));
|
||||
}
|
||||
for (const task of tasks) {
|
||||
for (const positivePattern of task.positive) {
|
||||
assertGlobDirectoryTraversal(
|
||||
changeDir,
|
||||
changeDir,
|
||||
positivePattern.split('/').slice(0, -1)
|
||||
);
|
||||
}
|
||||
}
|
||||
const matches = fg
|
||||
.sync(normalizedPattern, {
|
||||
cwd: changeDir,
|
||||
onlyFiles: true,
|
||||
absolute: true,
|
||||
// Preserve existing support for linked artifact directories. Every
|
||||
// concrete match is canonically confined below before it is returned.
|
||||
followSymbolicLinks: true,
|
||||
})
|
||||
.sync(normalizedPattern, globOptions)
|
||||
.map((match) => {
|
||||
const normalizedMatch = path.normalize(match);
|
||||
FileSystemUtils.assertPathWithin(changeDir, normalizedMatch);
|
||||
|
||||
@@ -21,12 +21,16 @@ export const continueAdapter: ToolCommandAdapter = {
|
||||
},
|
||||
|
||||
formatFile(content: CommandContent): string {
|
||||
// Continue injects an invoked prompt into the model context. Smaller local
|
||||
// models can otherwise mistake the workflow name for a tool to call (#925).
|
||||
return `---
|
||||
name: ${escapeYamlValue(`opsx-${content.id}`)}
|
||||
description: ${escapeYamlValue(content.description)}
|
||||
invokable: true
|
||||
---
|
||||
|
||||
This workflow prompt is already active. Follow its instructions directly. Do not call a tool named after this workflow.
|
||||
|
||||
${content.body}
|
||||
`;
|
||||
},
|
||||
|
||||
@@ -10,14 +10,14 @@ import type { CommandContent, ToolCommandAdapter } from '../types.js';
|
||||
|
||||
/**
|
||||
* Kilo Code adapter for command generation.
|
||||
* File path: .kilocode/workflows/opsx-<id>.md
|
||||
* File path: .kilo/command/opsx-<id>.md
|
||||
* Format: Plain markdown without frontmatter
|
||||
*/
|
||||
export const kilocodeAdapter: ToolCommandAdapter = {
|
||||
toolId: 'kilocode',
|
||||
|
||||
getFilePath(commandId: string): string {
|
||||
return path.join('.kilocode', 'workflows', `opsx-${commandId}.md`);
|
||||
return path.join('.kilo', 'command', `opsx-${commandId}.md`);
|
||||
},
|
||||
|
||||
formatFile(content: CommandContent): string {
|
||||
|
||||
+7
-3
@@ -1360,9 +1360,13 @@ export class InitCommand {
|
||||
// Tools with no slash surface (e.g. Rovo Dev) reference skills as
|
||||
// prose ("the openspec-propose skill"); phrase the hint so it reads
|
||||
// as an instruction rather than a dead command with an argument.
|
||||
hint = usesNaturalLanguageSkillReferences(tool.value)
|
||||
? `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`
|
||||
: `Start your first change: ${skillReference} "your idea"`;
|
||||
if (usesNaturalLanguageSkillReferences(tool.value)) {
|
||||
hint = `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`;
|
||||
} else if (tool.value === 'codex') {
|
||||
hint = `Start your first change: ${skillReference} "your idea" (Codex CLI or IDE); in the Codex desktop app, select ${skillReference.slice(1)} from Skills in the sidebar`;
|
||||
} else {
|
||||
hint = `Start your first change: ${skillReference} "your idea"`;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { promises as fs } from 'fs';
|
||||
import { promises as fs, constants as fsConstants } from 'fs';
|
||||
import type { FileHandle } from 'fs/promises';
|
||||
import chalk from 'chalk';
|
||||
import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js';
|
||||
import { OPENSPEC_MARKERS } from './config.js';
|
||||
import type { WorkflowId } from './profiles.js';
|
||||
import { ALL_WORKFLOWS, type WorkflowId } from './profiles.js';
|
||||
|
||||
/**
|
||||
* Legacy config file names from the old ToolRegistry.
|
||||
@@ -29,6 +30,14 @@ export const LEGACY_CONFIG_FILES = [
|
||||
/** The three commands the old SlashCommandRegistry wrote into each directory. */
|
||||
const LEGACY_DIRECTORY_COMMAND_FILES = ['proposal.md', 'apply.md', 'archive.md'] as const;
|
||||
|
||||
/** Exact Kilo workflow files written by OpenSpec before the command path moved. */
|
||||
const LEGACY_KILOCODE_COMMAND_FILES = [
|
||||
...ALL_WORKFLOWS.map(workflow => `.kilocode/workflows/opsx-${workflow}.md`),
|
||||
'.kilocode/workflows/openspec-proposal.md',
|
||||
'.kilocode/workflows/openspec-apply.md',
|
||||
'.kilocode/workflows/openspec-archive.md',
|
||||
];
|
||||
|
||||
/**
|
||||
* Legacy slash command patterns from the old SlashCommandRegistry.
|
||||
* These map toolId to the path pattern where legacy commands were created.
|
||||
@@ -54,7 +63,12 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
|
||||
// belong to `devin` — the id Windsurf became. Only `.windsurf/` is listed:
|
||||
// `.devin/` postdates the opsx rename and never held `openspec-*` files.
|
||||
'devin': { type: 'files', pattern: '.windsurf/workflows/openspec-*.md' },
|
||||
'kilocode': { type: 'files', pattern: '.kilocode/workflows/openspec-*.md' },
|
||||
// Kilo now writes commands under `.kilo/command/`. Clean up both generations
|
||||
// of OpenSpec workflows from Kilo's legacy `.kilocode/workflows/` folder.
|
||||
'kilocode': {
|
||||
type: 'files',
|
||||
pattern: LEGACY_KILOCODE_COMMAND_FILES,
|
||||
},
|
||||
'kiro': { type: 'files', pattern: '.kiro/prompts/openspec-*.prompt.md' },
|
||||
'github-copilot': { type: 'files', pattern: '.github/prompts/openspec-*.prompt.md' },
|
||||
'amazon-q': { type: 'files', pattern: '.amazonq/prompts/openspec-*.md' },
|
||||
@@ -443,13 +457,24 @@ async function settleLegacyCommandDir(
|
||||
* a same-named file without them is the user's.
|
||||
*/
|
||||
async function isGeneratedLegacyCommand(filePath: string): Promise<boolean> {
|
||||
// Judge the opened handle, not the path, so the file checked is the file
|
||||
// read. O_NOFOLLOW refuses a link and O_NONBLOCK keeps a FIFO from hanging;
|
||||
// Windows has neither flag, so a link is refused there by lstat instead.
|
||||
const { O_RDONLY, O_NOFOLLOW, O_NONBLOCK } = fsConstants;
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
if (!(await fs.lstat(filePath)).isFile()) {
|
||||
handle = await fs.open(filePath, O_RDONLY | (O_NOFOLLOW ?? 0) | (O_NONBLOCK ?? 0));
|
||||
if (O_NOFOLLOW === undefined && (await fs.lstat(filePath)).isSymbolicLink()) {
|
||||
return false;
|
||||
}
|
||||
return hasOpenSpecMarkers(await fs.readFile(filePath, 'utf-8'));
|
||||
if (!(await handle.stat()).isFile()) {
|
||||
return false;
|
||||
}
|
||||
return hasOpenSpecMarkers(await handle.readFile('utf-8'));
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -519,37 +519,97 @@ interface ScenarioBlock {
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/** Both directions of the scenario-name comparison, plus the two totals. */
|
||||
export interface ScenarioNameDiff {
|
||||
/** Names the current block has that the incoming block does not cover. */
|
||||
missing: string[];
|
||||
/** Names the incoming block introduces that the current block does not have. */
|
||||
added: string[];
|
||||
/** Level-4 headers in the current block. */
|
||||
currentCount: number;
|
||||
/** Level-4 headers in the incoming block. */
|
||||
incomingCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the scenario names of a current requirement block and an incoming
|
||||
* (MODIFIED) one, in both directions.
|
||||
*
|
||||
* `missing` is the loss the guard exists to catch: a MODIFIED requirement
|
||||
* replaces the whole block, so every name there would be dropped from the main
|
||||
* spec. `added` and the two counts are reported alongside it, because they are
|
||||
* the first thing a reader checks once it fires (#1697) - a block that omits
|
||||
* two names and introduces two is shaped like a rename, one that omits two and
|
||||
* introduces none is shaped like a truncation. Neither is proof, and intent is
|
||||
* not recoverable from structure, so this decides nothing and only says what
|
||||
* the two blocks contain.
|
||||
*/
|
||||
export function diffScenarioNames(
|
||||
current: RequirementBlock,
|
||||
incoming: RequirementBlock
|
||||
): ScenarioNameDiff {
|
||||
const currentNames = parseScenarioBlocks(current.raw).map((scenario) => scenario.name);
|
||||
const incomingNames = parseScenarioBlocks(incoming.raw).map((scenario) => scenario.name);
|
||||
|
||||
// Multiplicity-aware: a name present N times on one side and M times on the
|
||||
// other leaves max(0, N - M) instances unmatched. Set membership would treat
|
||||
// N>M as fully covered and let archive silently drop duplicates (residual
|
||||
// #1246 / duplicate-scenario-name blind spot).
|
||||
const unmatched = (names: readonly string[], against: readonly string[]): string[] => {
|
||||
const remaining = new Map<string, number>();
|
||||
for (const name of against) remaining.set(name, (remaining.get(name) ?? 0) + 1);
|
||||
|
||||
const out: string[] = [];
|
||||
for (const name of names) {
|
||||
const left = remaining.get(name) ?? 0;
|
||||
if (left > 0) remaining.set(name, left - 1);
|
||||
else out.push(name);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return {
|
||||
missing: unmatched(currentNames, incomingNames),
|
||||
added: unmatched(incomingNames, currentNames),
|
||||
currentCount: currentNames.length,
|
||||
incomingCount: incomingNames.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario names the current requirement block has and the incoming
|
||||
* (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
|
||||
* so every name reported here would be dropped from the main spec.
|
||||
*
|
||||
* Shared by archive (which refuses to apply the block) and validate (which
|
||||
* reports the same loss at authoring time, #1477), so the two cannot disagree
|
||||
* about what counts as a dropped scenario.
|
||||
* The `missing` half of diffScenarioNames, which archive (refusing to apply
|
||||
* the block) and validate (reporting the same loss at authoring time, #1477)
|
||||
* both go through, so the two cannot disagree about what counts as a dropped
|
||||
* scenario.
|
||||
*/
|
||||
export function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[] {
|
||||
// Multiplicity-aware: a name present N times in current and M times in
|
||||
// incoming means max(0, N - M) instances are missing. Set membership would
|
||||
// treat N>M as fully covered and let archive silently drop duplicates
|
||||
// (residual #1246 / duplicate-scenario-name blind spot).
|
||||
const remainingIncoming = new Map<string, number>();
|
||||
for (const scenario of parseScenarioBlocks(incoming.raw)) {
|
||||
const name = scenario.name;
|
||||
remainingIncoming.set(name, (remainingIncoming.get(name) ?? 0) + 1);
|
||||
}
|
||||
return diffScenarioNames(current, incoming).missing;
|
||||
}
|
||||
|
||||
const missing: string[] = [];
|
||||
for (const scenario of parseScenarioBlocks(current.raw)) {
|
||||
const name = scenario.name;
|
||||
const remaining = remainingIncoming.get(name) ?? 0;
|
||||
if (remaining > 0) {
|
||||
remainingIncoming.set(name, remaining - 1);
|
||||
} else {
|
||||
missing.push(name);
|
||||
}
|
||||
/** At most this many added names are listed before the rest are counted. */
|
||||
const MAX_LISTED_ADDED_SCENARIOS = 3;
|
||||
|
||||
/**
|
||||
* The one sentence archive and validate both append when the guard fires, so
|
||||
* the counts a reader sees cannot differ between the two commands.
|
||||
*/
|
||||
export function describeScenarioBalance(diff: ScenarioNameDiff): string {
|
||||
const count = (value: number) => `${value} ${value === 1 ? 'scenario' : 'scenarios'}`;
|
||||
const scale = `The modified block has ${count(diff.incomingCount)}; the current spec has ${count(diff.currentCount)}.`;
|
||||
if (diff.added.length === 0) {
|
||||
return `${scale} It adds none.`;
|
||||
}
|
||||
return missing;
|
||||
const listed = diff.added
|
||||
.slice(0, MAX_LISTED_ADDED_SCENARIOS)
|
||||
.map((name) => `"${name}"`)
|
||||
.join(', ');
|
||||
const rest = diff.added.length - MAX_LISTED_ADDED_SCENARIOS;
|
||||
const names = rest > 0 ? `${listed} and ${rest} more` : listed;
|
||||
return `${scale} It adds ${count(diff.added.length)} not in the current spec: ${names}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+21
-6
@@ -11,7 +11,8 @@ import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
extractRequirementsSection,
|
||||
findMissingCurrentScenarios,
|
||||
diffScenarioNames,
|
||||
describeScenarioBalance,
|
||||
foldRequirementName,
|
||||
parseDeltaSpec,
|
||||
normalizeRequirementName,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from './validation/constants.js';
|
||||
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { matchLineEnding } from '../utils/line-endings.js';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -537,10 +539,10 @@ export async function buildUpdatedSpec(
|
||||
`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`
|
||||
);
|
||||
}
|
||||
const missingScenarios = findMissingCurrentScenarios(currentBlock, mod);
|
||||
if (missingScenarios.length > 0) {
|
||||
const scenarioDiff = diffScenarioNames(currentBlock, mod);
|
||||
if (scenarioDiff.missing.length > 0) {
|
||||
throw new Error(
|
||||
`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${missingScenarios.map(name => `"${name}"`).join(', ')}. Refresh the change spec before archiving to avoid dropping scenarios.`
|
||||
`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${scenarioDiff.missing.map(name => `"${name}"`).join(', ')}. ${describeScenarioBalance(scenarioDiff)} Refresh the change spec before archiving to avoid dropping scenarios.`
|
||||
);
|
||||
}
|
||||
// Identical content means the modification was already synced to the
|
||||
@@ -1189,7 +1191,7 @@ async function isInsideRealDir(realPath: string, dir: string): Promise<boolean>
|
||||
* needs fd-relative syscalls Node does not expose, and it requires local write
|
||||
* access to `openspec/specs` during an archive.
|
||||
*/
|
||||
async function pruneEmptyDirs(startDir: string, boundaryDir: string): Promise<void> {
|
||||
export async function pruneEmptyDirs(startDir: string, boundaryDir: string): Promise<void> {
|
||||
let boundary: string;
|
||||
try {
|
||||
boundary = await fs.realpath(boundaryDir);
|
||||
@@ -1243,11 +1245,24 @@ export async function writeUpdatedSpec(
|
||||
// Create target directory if needed
|
||||
const targetDir = path.dirname(update.target);
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
// The parsers normalize CRLF to LF on read, so `rebuilt` is always LF. Write
|
||||
// it back with the convention the file already used, or a Windows checkout
|
||||
// (core.autocrlf=true) sees every line of the spec change when one
|
||||
// requirement moved. A spec that does not exist yet stays LF.
|
||||
// Only a missing file means "no convention to match". Swallowing every error
|
||||
// would read an existing but unreadable spec as absent and rewrite it as LF.
|
||||
const previous = await fs.readFile(update.target, 'utf-8').catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
});
|
||||
const toWrite = previous === undefined ? rebuilt : matchLineEnding(rebuilt, previous);
|
||||
|
||||
await options.beforeMutate?.();
|
||||
// Preserve the established in-place write semantics: symlink referents,
|
||||
// hard-linked specs, ACLs, extended attributes, and filesystems without hard
|
||||
// links must continue to behave as they did before capability retirement.
|
||||
await fs.writeFile(update.target, rebuilt);
|
||||
await fs.writeFile(update.target, toWrite);
|
||||
if (options.silent) return;
|
||||
|
||||
const specName = update.id;
|
||||
|
||||
@@ -99,20 +99,27 @@ ${PROJECT_ROOT_GUARD}
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically \`tasks.md\`) to check for incomplete tasks.
|
||||
Run \`openspec list --json\` with the same selected-root flags and find the
|
||||
entry in \`changes\` whose \`name\` exactly matches the selected change.
|
||||
Require exactly one match and nonnegative integer \`totalTasks\` and
|
||||
\`completedTasks\`, with \`completedTasks <= totalTasks\`. The CLI resolves
|
||||
the schema's tracked task files, including custom artifact names, output
|
||||
paths, and globs.
|
||||
Incomplete tasks = \`totalTasks - completedTasks\`.
|
||||
|
||||
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.
|
||||
Do not infer task completion from artifact status or the absence of a
|
||||
top-level \`tasks.md\`. If the lookup fails, returns invalid JSON, omits or
|
||||
duplicates the selected change, or returns invalid counts, report the problem
|
||||
and stop before syncing or archiving.
|
||||
The CLI counts only \`x\`/\`X\` checkbox markers as complete;
|
||||
other markers, including unfamiliar ones, remain incomplete.
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Ask the user to confirm they want to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
**If \`totalTasks\` is zero:** Proceed without a task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
@@ -293,20 +300,27 @@ ${PROJECT_ROOT_GUARD}
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically \`tasks.md\`) to check for incomplete tasks.
|
||||
Run \`openspec list --json\` with the same selected-root flags and find the
|
||||
entry in \`changes\` whose \`name\` exactly matches the selected change.
|
||||
Require exactly one match and nonnegative integer \`totalTasks\` and
|
||||
\`completedTasks\`, with \`completedTasks <= totalTasks\`. The CLI resolves
|
||||
the schema's tracked task files, including custom artifact names, output
|
||||
paths, and globs.
|
||||
Incomplete tasks = \`totalTasks - completedTasks\`.
|
||||
|
||||
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.
|
||||
Do not infer task completion from artifact status or the absence of a
|
||||
top-level \`tasks.md\`. If the lookup fails, returns invalid JSON, omits or
|
||||
duplicates the selected change, or returns invalid counts, report the problem
|
||||
and stop before syncing or archiving.
|
||||
The CLI counts only \`x\`/\`X\` checkbox markers as complete;
|
||||
other markers, including unfamiliar ones, remain incomplete.
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
**If \`totalTasks\` is zero:** Proceed without a task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
2. **Prompt for change selection**
|
||||
|
||||
Ask the user to choose changes (multi-select):
|
||||
- Show each change with its schema
|
||||
- Show each change name and task status from the list output
|
||||
- Include an option for "All changes"
|
||||
- Allow any number of selections (1+ works, 2+ is the typical use case)
|
||||
|
||||
@@ -88,17 +88,24 @@ ${PROJECT_ROOT_GUARD}
|
||||
|
||||
3. **Batch validation - gather status for all selected changes**
|
||||
|
||||
Run \`openspec list --json\` once with the same selected-root flags for task
|
||||
progress. If the lookup fails, returns invalid JSON, or omits any selected
|
||||
change, contains a duplicate selected change, or returns invalid counts,
|
||||
report the problem and stop before syncing or archiving the batch.
|
||||
|
||||
For each selected change, collect:
|
||||
|
||||
a. **Artifact status** - Run \`openspec status --change "<name>" --json\`
|
||||
- Parse \`schemaName\`, \`artifacts\`, \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`
|
||||
- Note which artifacts are \`done\` vs other states
|
||||
|
||||
b. **Task completion** - Read \`artifactPaths.tasks.existingOutputPaths\` from status JSON
|
||||
- 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"
|
||||
b. **Task completion** - Find the \`changes\` entry from the list response whose \`name\` exactly matches this change
|
||||
- Require nonnegative integer \`totalTasks\` and \`completedTasks\`, with \`completedTasks <= totalTasks\`
|
||||
- Incomplete tasks = \`totalTasks - completedTasks\`
|
||||
- The CLI resolves the schema's tracked task files, including custom artifact names, output paths, and globs
|
||||
- Do not infer task completion from artifact status, an artifact id of \`tasks\`, or the absence of a top-level \`tasks.md\`
|
||||
- The CLI counts only \`x\`/\`X\` checkbox markers as complete; other markers remain incomplete
|
||||
- If \`totalTasks\` is zero, note as "No tasks"
|
||||
|
||||
c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON
|
||||
- List which capability specs exist
|
||||
@@ -212,7 +219,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
Process changes in the determined order (respecting conflict resolution):
|
||||
|
||||
a. **Sync included delta specs**:
|
||||
- Run the \`openspec-sync-specs\` workflow inline (agent-driven intelligent merge) only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish.
|
||||
- ${optionalWorkflow('sync', 'Run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge)', 'Perform the delta-to-main-spec merge inline yourself (agent-driven intelligent merge)')} only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish.
|
||||
- For conflicts, apply in resolved order.
|
||||
- Pass that change's fetched specs-rule snapshot into inline sync; inline
|
||||
sync must reuse it without fetching instructions again
|
||||
@@ -365,7 +372,7 @@ No active changes found. Create a new change to get started.
|
||||
- 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
|
||||
- If sync is requested, ${optionalWorkflow('sync', 'run the `openspec-sync-specs` workflow inline (agent-driven)', 'perform the delta-to-main-spec merge 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
|
||||
- Never archive a change while a spec sync is still in flight — run the sync inline and verify main specs at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` before moving \`changeRoot\`
|
||||
@@ -414,7 +421,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
2. **Prompt for change selection**
|
||||
|
||||
Ask the user to choose changes (multi-select):
|
||||
- Show each change with its schema
|
||||
- Show each change name and task status from the list output
|
||||
- Include an option for "All changes"
|
||||
- Allow any number of selections (1+ works, 2+ is the typical use case)
|
||||
|
||||
@@ -447,17 +454,24 @@ ${PROJECT_ROOT_GUARD}
|
||||
|
||||
3. **Batch validation - gather status for all selected changes**
|
||||
|
||||
Run \`openspec list --json\` once with the same selected-root flags for task
|
||||
progress. If the lookup fails, returns invalid JSON, or omits any selected
|
||||
change, contains a duplicate selected change, or returns invalid counts,
|
||||
report the problem and stop before syncing or archiving the batch.
|
||||
|
||||
For each selected change, collect:
|
||||
|
||||
a. **Artifact status** - Run \`openspec status --change "<name>" --json\`
|
||||
- Parse \`schemaName\`, \`artifacts\`, \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`
|
||||
- Note which artifacts are \`done\` vs other states
|
||||
|
||||
b. **Task completion** - Read \`artifactPaths.tasks.existingOutputPaths\` from status JSON
|
||||
- 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"
|
||||
b. **Task completion** - Find the \`changes\` entry from the list response whose \`name\` exactly matches this change
|
||||
- Require nonnegative integer \`totalTasks\` and \`completedTasks\`, with \`completedTasks <= totalTasks\`
|
||||
- Incomplete tasks = \`totalTasks - completedTasks\`
|
||||
- The CLI resolves the schema's tracked task files, including custom artifact names, output paths, and globs
|
||||
- Do not infer task completion from artifact status, an artifact id of \`tasks\`, or the absence of a top-level \`tasks.md\`
|
||||
- The CLI counts only \`x\`/\`X\` checkbox markers as complete; other markers remain incomplete
|
||||
- If \`totalTasks\` is zero, note as "No tasks"
|
||||
|
||||
c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON
|
||||
- List which capability specs exist
|
||||
|
||||
@@ -47,7 +47,6 @@ ${PROJECT_ROOT_GUARD}
|
||||
|
||||
When prompting, present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from \`schema\` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from \`lastModified\` field)
|
||||
|
||||
@@ -167,7 +166,6 @@ ${PROJECT_ROOT_GUARD}
|
||||
|
||||
When prompting, present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from \`schema\` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from \`lastModified\` field)
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ openspec list --json
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- Their names and task status
|
||||
- What the user might be working on
|
||||
|
||||
That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
|
||||
@@ -499,7 +499,7 @@ openspec list --json
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- Their names and task status
|
||||
- What the user might be working on
|
||||
|
||||
That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
|
||||
|
||||
@@ -106,7 +106,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway
|
||||
- Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
c. **If an artifact requires user input** (critically unclear context):
|
||||
- Ask the user to clarify
|
||||
- Then continue with creation
|
||||
|
||||
@@ -225,7 +225,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway
|
||||
- Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
c. **If an artifact requires user input** (critically unclear context):
|
||||
- Ask the user to clarify
|
||||
- Then continue with creation
|
||||
|
||||
|
||||
@@ -442,7 +442,7 @@ Save to the \`resolvedOutputPath\` from \`openspec instructions design --change
|
||||
|
||||
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
|
||||
|
||||
These should be small, clear, and in logical order.
|
||||
These should be small, clear, and in logical order. Each group carries the tests and documentation for its own work - the last group is only for integration checks.
|
||||
\`\`\`
|
||||
|
||||
**DO:** Generate tasks based on specs and design:
|
||||
@@ -465,12 +465,17 @@ Here are the implementation tasks:
|
||||
|
||||
---
|
||||
|
||||
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
|
||||
Each checkbox becomes a unit of work in the apply phase. Does this task breakdown look right?
|
||||
\`\`\`
|
||||
|
||||
**PAUSE** - Wait for user to confirm they're ready to implement.
|
||||
**PAUSE** - Wait for user approval/feedback.
|
||||
|
||||
Save to the \`resolvedOutputPath\` from \`openspec instructions tasks --change "<name>" --json\`.
|
||||
After approval, save to the \`resolvedOutputPath\` from \`openspec instructions tasks --change "<name>" --json\`.
|
||||
|
||||
Then ask:
|
||||
> "Tasks are saved. Ready to implement?"
|
||||
|
||||
**PAUSE** - Wait for user to confirm before implementation.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ const CONTINUE_NEXT_STEP = optionalWorkflow(
|
||||
|
||||
const CONTINUE_DEFERRED = optionalWorkflow(
|
||||
'continue',
|
||||
'Anything deferred to `/opsx:continue` (not-yet-created artifacts or files)',
|
||||
'Anything deferred because it does not exist yet (not-yet-created artifacts or files)'
|
||||
'Anything deferred to `/opsx:continue` (artifacts with no files yet and status `ready` or `blocked`, never `skipped` artifacts)',
|
||||
'Anything deferred because it does not exist yet (artifacts with no files and status `ready` or `blocked`, never `skipped` artifacts)'
|
||||
);
|
||||
|
||||
const CONTINUE_FRONTIER = optionalWorkflow(
|
||||
@@ -98,7 +98,6 @@ ${CONTINUE_SCOPE_NOTE}
|
||||
|
||||
When prompting, present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from \`schema\` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from \`lastModified\` field)
|
||||
|
||||
@@ -128,7 +127,13 @@ ${CONTINUE_SCOPE_NOTE}
|
||||
- Read the artifact(s) the request touches and the change's other existing artifacts.
|
||||
- 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.
|
||||
- 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 ${CONTINUE_CREATE_THEM}.
|
||||
- Propose revisions to files that already exist (\`existingOutputPaths\`). If an artifact has no existing output files and status \`ready\` or \`blocked\`, note it and ${CONTINUE_CREATE_THEM}. Leave \`skipped\` artifacts untouched; do not treat them as missing or defer them to the continue workflow.
|
||||
- A glob artifact (e.g. \`specs/**/*.md\`) is marked \`done\` after at least one file matches, and the continue workflow only handles \`ready\` artifacts. When reconciliation identifies a missing file for a glob artifact whose \`existingOutputPaths\` is non-empty:
|
||||
1. Run \`openspec instructions "<artifact-id>" --change "<name>" --json\` and use its \`instruction\` and \`template\`. Treat \`context\` and \`rules\` as constraints; do not copy them into the file. If instructions report \`skipped: true\`, do not create the file. Read current dependency files from disk; if a required non-skipped dependency is missing, stop and ask the user to restore it first.
|
||||
2. Choose a concrete path inside \`changeRoot\` that matches \`artifactPaths.<id>.outputPath\` and does not already exist. Verify it remains inside \`changeRoot\` after resolving any symlinked parent directories. The glob \`resolvedOutputPath\` is not a valid target.
|
||||
3. Include the new file in step 5's proposed revisions and create it only after the user confirms.
|
||||
4. After confirmation, immediately before creation, refresh status and instructions. Verify the artifact is still in scope, not skipped, and partially populated; repeat the concrete-path checks above.
|
||||
5. Use a create operation that fails if the target already exists. If \`instruction\` delegates creation to another skill or command, invoke it only if it can honor the confirmed path and these guardrails; otherwise stop. If any check fails or the confirmed draft is no longer valid, stop and reconcile with the user rather than replacing existing content or choosing a different path.
|
||||
- If the change is already coherent, say so and propose no revisions.
|
||||
|
||||
5. **Confirm and apply, one artifact at a time**
|
||||
@@ -141,7 +146,7 @@ ${CONTINUE_SCOPE_NOTE}
|
||||
\`\`\`
|
||||
|
||||
6. **Point to the next step (guidance only - NEVER act on it)**
|
||||
- Artifacts still missing -> ${CONTINUE_NEXT_STEP}.
|
||||
- Artifacts with empty \`existingOutputPaths\` and status \`ready\` or \`blocked\` -> ${CONTINUE_NEXT_STEP}.
|
||||
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; ${APPLY_DELTA_HANDOFF}.
|
||||
- Everything done and implemented -> ${ARCHIVE_HANDOFF}.
|
||||
|
||||
@@ -149,6 +154,7 @@ ${CONTINUE_SCOPE_NOTE}
|
||||
|
||||
After each invocation, show:
|
||||
- Which artifacts were revised (and which proposed revisions were rejected)
|
||||
- Any file created under a glob artifact that was already partially populated
|
||||
- ${CONTINUE_DEFERRED}
|
||||
- Where the change stands and the recommended next command
|
||||
|
||||
@@ -156,7 +162,7 @@ After each invocation, show:
|
||||
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, ${APPLY_GUARDRAIL}.
|
||||
- Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names.
|
||||
- Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`.
|
||||
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - ${CONTINUE_FRONTIER}.
|
||||
- Do not advance the build frontier: if an artifact has empty \`existingOutputPaths\` and status \`ready\` or \`blocked\`, ${CONTINUE_FRONTIER}. Leave \`skipped\` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose \`existingOutputPaths\` is non-empty.
|
||||
- Confirm every edit with the user before writing.
|
||||
- If the request changes the change's *intent* rather than refining it, ${INTENT_CHANGE_GUARDRAIL}.`,
|
||||
license: 'MIT',
|
||||
@@ -192,7 +198,6 @@ ${CONTINUE_SCOPE_NOTE}
|
||||
|
||||
When prompting, present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from \`schema\` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from \`lastModified\` field)
|
||||
|
||||
@@ -222,7 +227,13 @@ ${CONTINUE_SCOPE_NOTE}
|
||||
- Read the artifact(s) the request touches and the change's other existing artifacts.
|
||||
- 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.
|
||||
- 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 ${CONTINUE_CREATE_THEM}.
|
||||
- Propose revisions to files that already exist (\`existingOutputPaths\`). If an artifact has no existing output files and status \`ready\` or \`blocked\`, note it and ${CONTINUE_CREATE_THEM}. Leave \`skipped\` artifacts untouched; do not treat them as missing or defer them to the continue workflow.
|
||||
- A glob artifact (e.g. \`specs/**/*.md\`) is marked \`done\` after at least one file matches, and the continue workflow only handles \`ready\` artifacts. When reconciliation identifies a missing file for a glob artifact whose \`existingOutputPaths\` is non-empty:
|
||||
1. Run \`openspec instructions "<artifact-id>" --change "<name>" --json\` and use its \`instruction\` and \`template\`. Treat \`context\` and \`rules\` as constraints; do not copy them into the file. If instructions report \`skipped: true\`, do not create the file. Read current dependency files from disk; if a required non-skipped dependency is missing, stop and ask the user to restore it first.
|
||||
2. Choose a concrete path inside \`changeRoot\` that matches \`artifactPaths.<id>.outputPath\` and does not already exist. Verify it remains inside \`changeRoot\` after resolving any symlinked parent directories. The glob \`resolvedOutputPath\` is not a valid target.
|
||||
3. Include the new file in step 5's proposed revisions and create it only after the user confirms.
|
||||
4. After confirmation, immediately before creation, refresh status and instructions. Verify the artifact is still in scope, not skipped, and partially populated; repeat the concrete-path checks above.
|
||||
5. Use a create operation that fails if the target already exists. If \`instruction\` delegates creation to another skill or command, invoke it only if it can honor the confirmed path and these guardrails; otherwise stop. If any check fails or the confirmed draft is no longer valid, stop and reconcile with the user rather than replacing existing content or choosing a different path.
|
||||
- If the change is already coherent, say so and propose no revisions.
|
||||
|
||||
5. **Confirm and apply, one artifact at a time**
|
||||
@@ -235,7 +246,7 @@ ${CONTINUE_SCOPE_NOTE}
|
||||
\`\`\`
|
||||
|
||||
6. **Point to the next step (guidance only - NEVER act on it)**
|
||||
- Artifacts still missing -> ${CONTINUE_NEXT_STEP}.
|
||||
- Artifacts with empty \`existingOutputPaths\` and status \`ready\` or \`blocked\` -> ${CONTINUE_NEXT_STEP}.
|
||||
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; ${APPLY_DELTA_HANDOFF}.
|
||||
- Everything done and implemented -> ${ARCHIVE_HANDOFF}.
|
||||
|
||||
@@ -243,6 +254,7 @@ ${CONTINUE_SCOPE_NOTE}
|
||||
|
||||
After each invocation, show:
|
||||
- Which artifacts were revised (and which proposed revisions were rejected)
|
||||
- Any file created under a glob artifact that was already partially populated
|
||||
- ${CONTINUE_DEFERRED}
|
||||
- Where the change stands and the recommended next command
|
||||
|
||||
@@ -250,7 +262,7 @@ After each invocation, show:
|
||||
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, ${APPLY_GUARDRAIL}.
|
||||
- Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names.
|
||||
- Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`.
|
||||
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - ${CONTINUE_FRONTIER}.
|
||||
- Do not advance the build frontier: if an artifact has empty \`existingOutputPaths\` and status \`ready\` or \`blocked\`, ${CONTINUE_FRONTIER}. Leave \`skipped\` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose \`existingOutputPaths\` is non-empty.
|
||||
- Confirm every edit with the user before writing.
|
||||
- If the request changes the change's *intent* rather than refining it, ${INTENT_CHANGE_GUARDRAIL}.`
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run \`openspec list --json\` to get available changes and ask the user to select one
|
||||
|
||||
When prompting, show changes that have implementation tasks (tasks artifact exists).
|
||||
When prompting, show all active changes returned by the list, including changes with \`status: "no-tasks"\`.
|
||||
Include the schema used for each change if available.
|
||||
Mark changes with incomplete tasks as "(In Progress)".
|
||||
|
||||
@@ -50,7 +50,9 @@ ${PROJECT_ROOT_GUARD}
|
||||
openspec instructions apply --change "<name>" --json
|
||||
\`\`\`
|
||||
|
||||
This returns the change directory and \`contextFiles\` (artifact ID -> array of concrete file paths). Read all available artifacts from \`contextFiles\`.
|
||||
This returns the change directory, \`contextFiles\` (artifact ID -> array of concrete file paths), \`taskTrackingConfigured\`, and top-level \`tasks\` and \`progress\` aggregated from every concrete file matched by the schema's \`apply.tracks\` configuration that could be read. Read all available artifacts from \`contextFiles\`.
|
||||
|
||||
Treat apply \`state\` and \`instruction\` as context, not a verification verdict. Do not implement tasks or archive the change during verification.
|
||||
|
||||
4. **Initialize verification report structure**
|
||||
|
||||
@@ -61,32 +63,59 @@ ${PROJECT_ROOT_GUARD}
|
||||
|
||||
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
|
||||
|
||||
Verification is advisory. Respect intentional omissions such as \`skip_specs: true\`, optional design documents, and schemas without task tracking. Do not require or invent optional or intentionally omitted artifacts to obtain a clean report. \`Not verified\` describes a limit of this report, not a new archive prerequisite. Archive retains its own checks and user-confirmation behavior.
|
||||
|
||||
Mark checks the schema does not define, or artifacts the status reports as intentionally skipped, as **Not applicable**. The correctness checks of a change whose readable delta specs contain REMOVED or RENAMED requirements but no ADDED or MODIFIED requirements are also **Not applicable** (see step 6). Exclude them from skipped-check counts and the archive-readiness assessment. Reserve **Not verified** for applicable checks whose evidence is missing or unusable.
|
||||
|
||||
If only task evidence is available for applicable checks, verify task completion only and mark the remaining applicable checks, including **Code Pattern Consistency**, as not verified with the reason "Only task evidence available".
|
||||
|
||||
If artifacts cannot be read or contain no usable requirements, scenarios, or design decisions, mark the affected checks as not verified with the specific reason. Continue checks supported by the remaining evidence, but a partially checked input set is not a fully verified check. Missing requirements affect Spec Coverage and Requirement Implementation Mapping; missing scenarios affect Scenario Coverage; missing design decisions affect Design Adherence.
|
||||
|
||||
5. **Verify Completeness**
|
||||
|
||||
**Task Completion**:
|
||||
- If \`contextFiles.tasks\` exists, read every file path in it
|
||||
- Parse checkboxes: 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
|
||||
- If \`taskTrackingConfigured\` is false, report **Task Completion** as not applicable. Do not treat empty \`tasks\` as missing evidence.
|
||||
- Otherwise, use the top-level \`tasks\` and \`progress\` fields. They already aggregate every readable concrete file matched by \`apply.tracks\`, regardless of the tracked artifact's ID; do not infer tracking from a \`contextFiles\` key.
|
||||
- If \`unavailableTrackingFiles\` is nonempty, mark **Task Completion** as not verified and include every unavailable path and reason. Continue using any readable task evidence, but do not infer completion from the partial \`tasks\` and \`progress\` fields.
|
||||
- If \`taskTrackingConfigured\` is true and \`tasks\` is empty, mark **Task Completion** as not verified and record the reason from apply \`state\` and \`instruction\`. Nonzero totals alone do not establish evaluable task descriptions.
|
||||
- Report complete vs total tasks from \`progress\`.
|
||||
- If \`progress.remaining\` is greater than 0:
|
||||
- Add CRITICAL issue for each listed incomplete task. If the remaining count exceeds the listed incomplete tasks, also report the incomplete checkboxes without descriptions and recommend adding descriptions and completing them. Do not infer completion from the listed tasks alone.
|
||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
||||
|
||||
**Spec Coverage**:
|
||||
- If status marks the spec artifact skipped by \`skip_specs: true\`, or the schema defines no spec artifact, report the spec-dependent checks as not applicable.
|
||||
- Otherwise, \`contextFiles\` is keyed by artifact id, and artifact ids come from the active schema. If \`contextFiles.specs\` is absent or empty, mark **Spec Coverage**, **Requirement Implementation Mapping**, and **Scenario Coverage** as not verified; do not treat any of them as clean.
|
||||
- If delta specs exist in \`contextFiles.specs\`:
|
||||
- Extract all requirements (marked with "### Requirement:")
|
||||
- For each requirement:
|
||||
- Extract all requirements (marked with "### Requirement:", or listed as \`FROM:\`/\`TO:\` pairs under \`## RENAMED Requirements\`) and note the delta section each one sits under: \`## ADDED\`, \`## MODIFIED\`, \`## REMOVED\`, or \`## RENAMED Requirements\`. The section decides what the check looks for.
|
||||
- For each ADDED or MODIFIED requirement (for MODIFIED, check the text in the delta, not the old wording):
|
||||
- Search codebase for keywords related to the requirement
|
||||
- Assess if implementation likely exists
|
||||
- If requirements appear unimplemented:
|
||||
- If ADDED or MODIFIED requirements appear unimplemented:
|
||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
||||
- Recommendation: "Implement requirement X: <description>"
|
||||
- For each REMOVED requirement, the change asks for the behavior to be gone, so invert the check:
|
||||
- Search codebase for the removed behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
||||
- Finding no implementation is the expected result. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
||||
- If the behavior is still present:
|
||||
- Add CRITICAL issue: "Removed requirement still implemented: <requirement name>"
|
||||
- Recommendation: "Remove the remaining implementation at <file>:<lines>, following the requirement's Migration note if it has one"
|
||||
- For each RENAMED entry (\`FROM:\`/\`TO:\`), the name changes but the behavior stays, so check the TO requirement for that unchanged behavior:
|
||||
- Do not report the FROM name as missing, and do not require code symbols, identifiers, or file names to be renamed.
|
||||
- If the TO name also appears under MODIFIED, its behavior is checked there against the MODIFIED text; skip it here.
|
||||
- Otherwise, read the baseline requirement in the main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\`, using the same capability path as the delta spec: the requirement under the FROM name, or under the TO name only when the FROM name is absent because the main spec is already synced. Its body and scenarios are the evidence for the behavior the TO requirement keeps.
|
||||
- Search codebase for that behavior and assess if it is still implemented.
|
||||
- If it appears unimplemented:
|
||||
- Add CRITICAL issue: "Renamed requirement not found: <TO name>"
|
||||
- Recommendation: "Restore the behavior of <TO name> (renamed from <FROM name>); a rename must not change behavior"
|
||||
- If the baseline requirement cannot be found or read, mark **Spec Coverage** as not verified for that entry with the reason. Never count an unchecked rename as passing.
|
||||
|
||||
6. **Verify Correctness**
|
||||
|
||||
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each RENAMED entry is checked there against its baseline behavior), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
||||
|
||||
**Requirement Implementation Mapping**:
|
||||
- For each requirement from delta specs:
|
||||
- For each ADDED or MODIFIED requirement from delta specs (REMOVED entries, and RENAMED entries without a MODIFIED block, were settled under Spec Coverage):
|
||||
- Search codebase for implementation evidence
|
||||
- If found, note file paths and line ranges
|
||||
- Assess if implementation matches requirement intent
|
||||
@@ -95,26 +124,29 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Recommendation: "Review <file>:<lines> against requirement X"
|
||||
|
||||
**Scenario Coverage**:
|
||||
- For each scenario in delta specs (marked with "#### Scenario:"):
|
||||
- For each scenario under an ADDED or MODIFIED requirement in delta specs (marked with "#### Scenario:"):
|
||||
- Check if conditions are handled in code
|
||||
- Check if tests exist covering the scenario
|
||||
- If scenario appears uncovered:
|
||||
- Add WARNING: "Scenario not covered: <scenario name>"
|
||||
- Recommendation: "Add test or implementation for scenario: <description>"
|
||||
- Skip scenarios under a REMOVED requirement; that behavior is meant to be gone.
|
||||
|
||||
7. **Verify Coherence**
|
||||
|
||||
**Design Adherence**:
|
||||
- If the schema defines no design artifact, report **Design Adherence** as not applicable.
|
||||
- If \`contextFiles.design\` exists:
|
||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
||||
- Verify implementation follows those decisions
|
||||
- If contradiction detected:
|
||||
- Add WARNING: "Design decision not followed: <decision>"
|
||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
||||
- If no design.md: Skip design adherence check, note "No design.md to verify against"
|
||||
- Otherwise, if \`contextFiles.design\` is absent or empty: mark **Design Adherence** as not verified. With other supporting artifacts, **Code Pattern Consistency** still runs; the task-only case remains limited to task completion.
|
||||
|
||||
**Code Pattern Consistency**:
|
||||
- Review new code for consistency with project patterns
|
||||
- If implementation changes cannot be identified, mark **Code Pattern Consistency** as not verified and explain the missing evidence.
|
||||
- Otherwise, review new code for consistency with project patterns
|
||||
- Check file naming, directory structure, coding style
|
||||
- If significant deviations found:
|
||||
- Add SUGGESTION: "Code pattern deviation: <details>"
|
||||
@@ -134,11 +166,15 @@ ${PROJECT_ROOT_GUARD}
|
||||
| Coherence | Followed/Issues |
|
||||
\`\`\`
|
||||
|
||||
In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
|
||||
|
||||
**Issues by Priority**:
|
||||
|
||||
1. **CRITICAL** (Must fix before archive):
|
||||
- Incomplete tasks
|
||||
- Missing requirement implementations
|
||||
- Removed requirements still implemented
|
||||
- Renamed requirements whose behavior is no longer implemented
|
||||
- Each with specific, actionable recommendation
|
||||
|
||||
2. **WARNING** (Should fix):
|
||||
@@ -152,9 +188,12 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Each with specific recommendation
|
||||
|
||||
**Final Assessment**:
|
||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
|
||||
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If all clear: "All checks passed. Ready for archive."
|
||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving." If any check was skipped, also name every skipped check and its reason.
|
||||
- If no CRITICAL issues, one or more warnings, and no checks were skipped: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If only suggestions and no checks were skipped: "No critical issues or warnings. Z suggestion(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If no issues and no checks were skipped: "All checks passed. Ready for archive."
|
||||
- If any check was skipped and there are no CRITICAL issues: do not claim readiness. Say "No critical issues found in the checks that ran. <check(s)> not verified: <reason>." Include the warning count when nonzero.
|
||||
- Include the suggestion count when nonzero in every final assessment.
|
||||
|
||||
**Verification Heuristics**
|
||||
|
||||
@@ -164,13 +203,6 @@ ${PROJECT_ROOT_GUARD}
|
||||
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
|
||||
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
|
||||
|
||||
**Graceful Degradation**
|
||||
|
||||
- If only tasks.md exists: verify task completion only, skip spec/design checks
|
||||
- If tasks + specs exist: verify completeness and correctness, skip design
|
||||
- If full artifacts: verify all three dimensions
|
||||
- Always note which checks were skipped and why
|
||||
|
||||
**Output Format**
|
||||
|
||||
Use clear markdown with:
|
||||
@@ -208,7 +240,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run \`openspec list --json\` to get available changes and ask the user to select one
|
||||
|
||||
When prompting, show changes that have implementation tasks (tasks artifact exists).
|
||||
When prompting, show all active changes returned by the list, including changes with \`status: "no-tasks"\`.
|
||||
Include the schema used for each change if available.
|
||||
Mark changes with incomplete tasks as "(In Progress)".
|
||||
|
||||
@@ -229,7 +261,9 @@ ${PROJECT_ROOT_GUARD}
|
||||
openspec instructions apply --change "<name>" --json
|
||||
\`\`\`
|
||||
|
||||
This returns the change directory and \`contextFiles\` (artifact ID -> array of concrete file paths). Read all available artifacts from \`contextFiles\`.
|
||||
This returns the change directory, \`contextFiles\` (artifact ID -> array of concrete file paths), \`taskTrackingConfigured\`, and top-level \`tasks\` and \`progress\` aggregated from every concrete file matched by the schema's \`apply.tracks\` configuration that could be read. Read all available artifacts from \`contextFiles\`.
|
||||
|
||||
Treat apply \`state\` and \`instruction\` as context, not a verification verdict. Do not implement tasks or archive the change during verification.
|
||||
|
||||
4. **Initialize verification report structure**
|
||||
|
||||
@@ -240,32 +274,58 @@ ${PROJECT_ROOT_GUARD}
|
||||
|
||||
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
|
||||
|
||||
Verification is advisory. Respect intentional omissions such as \`skip_specs: true\`, optional design documents, and schemas without task tracking. Do not require or invent optional or intentionally omitted artifacts to obtain a clean report. \`Not verified\` describes a limit of this report, not a new archive prerequisite. Archive retains its own checks and user-confirmation behavior.
|
||||
Mark checks the schema does not define, or artifacts the status reports as intentionally skipped, as **Not applicable**. The correctness checks of a change whose readable delta specs contain REMOVED or RENAMED requirements but no ADDED or MODIFIED requirements are also **Not applicable** (see step 6). Exclude them from skipped-check counts and the archive-readiness assessment. Reserve **Not verified** for applicable checks whose evidence is missing or unusable.
|
||||
|
||||
If only task evidence is available for applicable checks, verify task completion only and mark the remaining applicable checks, including **Code Pattern Consistency**, as not verified with the reason "Only task evidence available".
|
||||
|
||||
If artifacts cannot be read or contain no usable requirements, scenarios, or design decisions, mark the affected checks as not verified with the specific reason. Continue checks supported by the remaining evidence, but a partially checked input set is not a fully verified check. Missing requirements affect Spec Coverage and Requirement Implementation Mapping; missing scenarios affect Scenario Coverage; missing design decisions affect Design Adherence.
|
||||
|
||||
5. **Verify Completeness**
|
||||
|
||||
**Task Completion**:
|
||||
- If \`contextFiles.tasks\` exists, read every file path in it
|
||||
- Parse checkboxes: 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
|
||||
- If \`taskTrackingConfigured\` is false, report **Task Completion** as not applicable. Do not treat empty \`tasks\` as missing evidence.
|
||||
- Otherwise, use the top-level \`tasks\` and \`progress\` fields. They already aggregate every readable concrete file matched by \`apply.tracks\`, regardless of the tracked artifact's ID; do not infer tracking from a \`contextFiles\` key.
|
||||
- If \`unavailableTrackingFiles\` is nonempty, mark **Task Completion** as not verified and include every unavailable path and reason. Continue using any readable task evidence, but do not infer completion from the partial \`tasks\` and \`progress\` fields.
|
||||
- If \`taskTrackingConfigured\` is true and \`tasks\` is empty, mark **Task Completion** as not verified and record the reason from apply \`state\` and \`instruction\`. Nonzero totals alone do not establish evaluable task descriptions.
|
||||
- Report complete vs total tasks from \`progress\`.
|
||||
- If \`progress.remaining\` is greater than 0:
|
||||
- Add CRITICAL issue for each listed incomplete task. If the remaining count exceeds the listed incomplete tasks, also report the incomplete checkboxes without descriptions and recommend adding descriptions and completing them. Do not infer completion from the listed tasks alone.
|
||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
||||
|
||||
**Spec Coverage**:
|
||||
- If status marks the spec artifact skipped by \`skip_specs: true\`, or the schema defines no spec artifact, report the spec-dependent checks as not applicable.
|
||||
- Otherwise, \`contextFiles\` is keyed by artifact id, and artifact ids come from the active schema. If \`contextFiles.specs\` is absent or empty, mark **Spec Coverage**, **Requirement Implementation Mapping**, and **Scenario Coverage** as not verified; do not treat any of them as clean.
|
||||
- If delta specs exist in \`contextFiles.specs\`:
|
||||
- Extract all requirements (marked with "### Requirement:")
|
||||
- For each requirement:
|
||||
- Extract all requirements (marked with "### Requirement:", or listed as \`FROM:\`/\`TO:\` pairs under \`## RENAMED Requirements\`) and note the delta section each one sits under: \`## ADDED\`, \`## MODIFIED\`, \`## REMOVED\`, or \`## RENAMED Requirements\`. The section decides what the check looks for.
|
||||
- For each ADDED or MODIFIED requirement (for MODIFIED, check the text in the delta, not the old wording):
|
||||
- Search codebase for keywords related to the requirement
|
||||
- Assess if implementation likely exists
|
||||
- If requirements appear unimplemented:
|
||||
- If ADDED or MODIFIED requirements appear unimplemented:
|
||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
||||
- Recommendation: "Implement requirement X: <description>"
|
||||
- For each REMOVED requirement, the change asks for the behavior to be gone, so invert the check:
|
||||
- Search codebase for the removed behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
||||
- Finding no implementation is the expected result. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
||||
- If the behavior is still present:
|
||||
- Add CRITICAL issue: "Removed requirement still implemented: <requirement name>"
|
||||
- Recommendation: "Remove the remaining implementation at <file>:<lines>, following the requirement's Migration note if it has one"
|
||||
- For each RENAMED entry (\`FROM:\`/\`TO:\`), the name changes but the behavior stays, so check the TO requirement for that unchanged behavior:
|
||||
- Do not report the FROM name as missing, and do not require code symbols, identifiers, or file names to be renamed.
|
||||
- If the TO name also appears under MODIFIED, its behavior is checked there against the MODIFIED text; skip it here.
|
||||
- Otherwise, read the baseline requirement in the main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\`, using the same capability path as the delta spec: the requirement under the FROM name, or under the TO name only when the FROM name is absent because the main spec is already synced. Its body and scenarios are the evidence for the behavior the TO requirement keeps.
|
||||
- Search codebase for that behavior and assess if it is still implemented.
|
||||
- If it appears unimplemented:
|
||||
- Add CRITICAL issue: "Renamed requirement not found: <TO name>"
|
||||
- Recommendation: "Restore the behavior of <TO name> (renamed from <FROM name>); a rename must not change behavior"
|
||||
- If the baseline requirement cannot be found or read, mark **Spec Coverage** as not verified for that entry with the reason. Never count an unchecked rename as passing.
|
||||
|
||||
6. **Verify Correctness**
|
||||
|
||||
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each RENAMED entry is checked there against its baseline behavior), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
||||
|
||||
**Requirement Implementation Mapping**:
|
||||
- For each requirement from delta specs:
|
||||
- For each ADDED or MODIFIED requirement from delta specs (REMOVED entries, and RENAMED entries without a MODIFIED block, were settled under Spec Coverage):
|
||||
- Search codebase for implementation evidence
|
||||
- If found, note file paths and line ranges
|
||||
- Assess if implementation matches requirement intent
|
||||
@@ -274,26 +334,29 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Recommendation: "Review <file>:<lines> against requirement X"
|
||||
|
||||
**Scenario Coverage**:
|
||||
- For each scenario in delta specs (marked with "#### Scenario:"):
|
||||
- For each scenario under an ADDED or MODIFIED requirement in delta specs (marked with "#### Scenario:"):
|
||||
- Check if conditions are handled in code
|
||||
- Check if tests exist covering the scenario
|
||||
- If scenario appears uncovered:
|
||||
- Add WARNING: "Scenario not covered: <scenario name>"
|
||||
- Recommendation: "Add test or implementation for scenario: <description>"
|
||||
- Skip scenarios under a REMOVED requirement; that behavior is meant to be gone.
|
||||
|
||||
7. **Verify Coherence**
|
||||
|
||||
**Design Adherence**:
|
||||
- If the schema defines no design artifact, report **Design Adherence** as not applicable.
|
||||
- If \`contextFiles.design\` exists:
|
||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
||||
- Verify implementation follows those decisions
|
||||
- If contradiction detected:
|
||||
- Add WARNING: "Design decision not followed: <decision>"
|
||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
||||
- If no design.md: Skip design adherence check, note "No design.md to verify against"
|
||||
- Otherwise, if \`contextFiles.design\` is absent or empty: mark **Design Adherence** as not verified. With other supporting artifacts, **Code Pattern Consistency** still runs; the task-only case remains limited to task completion.
|
||||
|
||||
**Code Pattern Consistency**:
|
||||
- Review new code for consistency with project patterns
|
||||
- If implementation changes cannot be identified, mark **Code Pattern Consistency** as not verified and explain the missing evidence.
|
||||
- Otherwise, review new code for consistency with project patterns
|
||||
- Check file naming, directory structure, coding style
|
||||
- If significant deviations found:
|
||||
- Add SUGGESTION: "Code pattern deviation: <details>"
|
||||
@@ -313,11 +376,15 @@ ${PROJECT_ROOT_GUARD}
|
||||
| Coherence | Followed/Issues |
|
||||
\`\`\`
|
||||
|
||||
In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
|
||||
|
||||
**Issues by Priority**:
|
||||
|
||||
1. **CRITICAL** (Must fix before archive):
|
||||
- Incomplete tasks
|
||||
- Missing requirement implementations
|
||||
- Removed requirements still implemented
|
||||
- Renamed requirements whose behavior is no longer implemented
|
||||
- Each with specific, actionable recommendation
|
||||
|
||||
2. **WARNING** (Should fix):
|
||||
@@ -331,9 +398,12 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Each with specific recommendation
|
||||
|
||||
**Final Assessment**:
|
||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
|
||||
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If all clear: "All checks passed. Ready for archive."
|
||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving." If any check was skipped, also name every skipped check and its reason.
|
||||
- If no CRITICAL issues, one or more warnings, and no checks were skipped: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If only suggestions and no checks were skipped: "No critical issues or warnings. Z suggestion(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If no issues and no checks were skipped: "All checks passed. Ready for archive."
|
||||
- If any check was skipped and there are no CRITICAL issues: do not claim readiness. Say "No critical issues found in the checks that ran. <check(s)> not verified: <reason>." Include the warning count when nonzero.
|
||||
- Include the suggestion count when nonzero in every final assessment.
|
||||
|
||||
**Verification Heuristics**
|
||||
|
||||
@@ -343,13 +413,6 @@ ${PROJECT_ROOT_GUARD}
|
||||
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
|
||||
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
|
||||
|
||||
**Graceful Degradation**
|
||||
|
||||
- If only tasks.md exists: verify task completion only, skip spec/design checks
|
||||
- If tasks + specs exist: verify completeness and correctness, skip design
|
||||
- If full artifacts: verify all three dimensions
|
||||
- Always note which checks were skipped and why
|
||||
|
||||
**Output Format**
|
||||
|
||||
Use clear markdown with:
|
||||
|
||||
+24
-6
@@ -89,6 +89,7 @@ const { version: OPENSPEC_VERSION } = require('../../package.json');
|
||||
type LegacyUpgradeResult = {
|
||||
newlyConfiguredTools: string[];
|
||||
workflowOverrides: Partial<Record<string, readonly (typeof ALL_WORKFLOWS)[number][]>>;
|
||||
failedTools?: ToolFailure[];
|
||||
deferredGlobalCleanup?: LegacyDetectionResult;
|
||||
/**
|
||||
* Tools whose skill generation was skipped because another tool already owns
|
||||
@@ -98,6 +99,14 @@ type LegacyUpgradeResult = {
|
||||
skippedSharedSkillTools?: string[];
|
||||
};
|
||||
|
||||
type ToolFailure = { name: string; error: string };
|
||||
|
||||
function throwIfUpdateFailed(failedTools: readonly ToolFailure[]): void {
|
||||
if (failedTools.length > 0) {
|
||||
throw new Error(`OpenSpec update failed for: ${failedTools.map((tool) => tool.name).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout artifacts that are not real content drift: a UTF-8 BOM and the CRLF
|
||||
* line endings a Windows clone with `core.autocrlf` reintroduces on every
|
||||
@@ -185,6 +194,7 @@ export class UpdateCommand {
|
||||
newlyConfiguredTools,
|
||||
workflowOverrides: legacyWorkflowOverrides,
|
||||
deferredGlobalCleanup,
|
||||
failedTools: legacyUpgradeFailures = [],
|
||||
} = legacyUpgrade;
|
||||
|
||||
// 5. Find configured tools
|
||||
@@ -195,6 +205,7 @@ export class UpdateCommand {
|
||||
if (deferredGlobalCleanup) {
|
||||
await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup);
|
||||
}
|
||||
throwIfUpdateFailed(legacyUpgradeFailures);
|
||||
if (declinedMigrations.length > 0) {
|
||||
// Not an unconfigured project — a configured one the user chose to
|
||||
// leave in its former directory. Saying "run init" would be wrong.
|
||||
@@ -269,6 +280,7 @@ export class UpdateCommand {
|
||||
this.detectNewTools(resolvedProjectPath, configuredTools);
|
||||
this.displayProfileNotes(resolvedProjectPath, configuredTools, desiredWorkflows, profile, delivery);
|
||||
this.displaySetupNotes(configuredTools);
|
||||
throwIfUpdateFailed(legacyUpgradeFailures);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -299,7 +311,7 @@ export class UpdateCommand {
|
||||
);
|
||||
const updatedTools: string[] = [];
|
||||
const updatedToolIds: string[] = [];
|
||||
const failedTools: Array<{ name: string; error: string }> = [];
|
||||
const failedTools: ToolFailure[] = [...legacyUpgradeFailures];
|
||||
const skillsInvocableCommandSkips: string[] = [];
|
||||
const zeroArtifactTools: string[] = [];
|
||||
let removedCommandCount = 0;
|
||||
@@ -526,9 +538,7 @@ export class UpdateCommand {
|
||||
if (restartHint) {
|
||||
console.log(chalk.dim(restartHint));
|
||||
}
|
||||
if (failedTools.length > 0) {
|
||||
throw new Error(`OpenSpec update failed for: ${failedTools.map((tool) => tool.name).join(', ')}`);
|
||||
}
|
||||
throwIfUpdateFailed(failedTools);
|
||||
}
|
||||
|
||||
private async syncCopilotCloudFiles(projectPath: string, configuredTools: string[]): Promise<void> {
|
||||
@@ -1277,6 +1287,7 @@ export class UpdateCommand {
|
||||
// Create skills/commands for selected tools using effective profile+delivery.
|
||||
const newlyConfigured: string[] = [];
|
||||
const skippedSharedSkillTools: string[] = [];
|
||||
const failedTools: ToolFailure[] = [];
|
||||
const workflowOverrides: LegacyUpgradeResult['workflowOverrides'] = {};
|
||||
const arbitrationTools = [...new Set([...configuredTools, ...selectedTools])]
|
||||
.map((toolId) => AI_TOOLS.find((tool) => tool.value === toolId))
|
||||
@@ -1377,7 +1388,9 @@ export class UpdateCommand {
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to set up ${tool.name}`);
|
||||
console.log(chalk.red(` ${error instanceof Error ? error.message : String(error)}`));
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(chalk.red(` ${message}`));
|
||||
failedTools.push({ name: tool.name, error: message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1385,6 +1398,11 @@ export class UpdateCommand {
|
||||
console.log();
|
||||
}
|
||||
|
||||
return { newlyConfiguredTools: newlyConfigured, workflowOverrides, skippedSharedSkillTools };
|
||||
return {
|
||||
newlyConfiguredTools: newlyConfigured,
|
||||
workflowOverrides,
|
||||
skippedSharedSkillTools,
|
||||
failedTools,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,15 +39,35 @@ export interface PurposePlaceholderIssue {
|
||||
}
|
||||
|
||||
/**
|
||||
* A `TBD` or `TODO` opening the Purpose. The lookahead keeps it off a longer
|
||||
* word that merely begins with those letters, like "TBDs" or "TODOs", while
|
||||
* still allowing the punctuation a marker is usually written with: `TODO:`,
|
||||
* `TBD -`. It rejects any letter, digit or combining mark rather than only the
|
||||
* ASCII ones `\b` knows about, because a Purpose is prose and prose is not
|
||||
* always written in Latin script - `TBD` followed by an Arabic-Indic digit is
|
||||
* as much a longer word as `TBDs` is.
|
||||
* A `TBD` or `TODO` opening the Purpose.
|
||||
*
|
||||
* `WORD_END` keeps the marker off a longer word that merely begins with those
|
||||
* letters, like "TBDs" or "TODOs", while still allowing the punctuation a
|
||||
* marker is usually written with: `TODO:`, `TBD -`. It rejects any letter,
|
||||
* digit or combining mark rather than only the ASCII ones `\b` knows about,
|
||||
* because a Purpose is prose and prose is not always written in Latin script -
|
||||
* `TBD` followed by an Arabic-Indic digit is as much a longer word as `TBDs`.
|
||||
*
|
||||
* Case is what separates the marker from the word. `TODO` shouted in capitals
|
||||
* is the marker whatever follows it, so `TODO write this later` is still an
|
||||
* unwritten Purpose. Written in any other case it is only a marker when
|
||||
* punctuation or the end of the line says so, because `todo` is an extremely
|
||||
* frequent sentence opener in Spanish ("Todo el...") and Portuguese ("Todo
|
||||
* o..."), and ordinary prose in those languages is not a placeholder. That
|
||||
* keeps the lowercase forms an agent really does leave behind - `todo - write
|
||||
* this later`, `tbd.` - reported, without reading a Spanish sentence as one.
|
||||
*/
|
||||
const LEADING_MARKER = /^(?:TBD|TODO)(?![\p{L}\p{N}\p{M}_])/iu;
|
||||
const WORD_END = '(?![\\p{L}\\p{N}\\p{M}_])';
|
||||
const MARKER_PUNCTUATION = '(?=[ \\t]*(?:$|\\n|[:\\-\u2013\u2014.,;()\\[\\]{}]))';
|
||||
|
||||
/** `TBD`/`TODO` in capitals: the marker, whatever follows it. */
|
||||
const LEADING_MARKER_SHOUTED = new RegExp(`^(?:TBD|TODO)${WORD_END}`, 'u');
|
||||
|
||||
/** Any other case: a marker only when punctuation or the line end says so. */
|
||||
const LEADING_MARKER_PUNCTUATED = new RegExp(
|
||||
`^(?:TBD|TODO)${WORD_END}${MARKER_PUNCTUATION}`,
|
||||
'iu'
|
||||
);
|
||||
|
||||
const PURPOSE_HEADER = /^ {0,3}##(?!#)[ \t]+Purpose[ \t]*$/i;
|
||||
const TOP_LEVEL_HEADER = /^ {0,3}#{1,2}(?!#)[ \t]+/;
|
||||
@@ -104,7 +124,8 @@ export function findPurposePlaceholderIssue(
|
||||
// that is nothing but a fenced block reduces to the same empty text here, and
|
||||
// is left to the brevity and empty-Purpose rules for the same reason.
|
||||
const prose = unfencedLines(overview).join('\n').trim();
|
||||
const leading = LEADING_MARKER.test(prose);
|
||||
const leading =
|
||||
LEADING_MARKER_SHOUTED.test(prose) || LEADING_MARKER_PUNCTUATED.test(prose);
|
||||
if (!leading && generatedPlaceholderPrefixIndex(prose) === undefined) return null;
|
||||
// Which rule matched decides where the placeholder is, so the locator is told.
|
||||
// When both match the leading marker wins: it sits at or above the generated
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
foldRequirementName,
|
||||
normalizeRequirementName,
|
||||
extractRequirementsSection,
|
||||
findMissingCurrentScenarios,
|
||||
diffScenarioNames,
|
||||
describeScenarioBalance,
|
||||
type RequirementBlock,
|
||||
} from '../parsers/requirement-blocks.js';
|
||||
import {
|
||||
@@ -739,15 +740,16 @@ export class Validator {
|
||||
if (renamedAway.has(key)) continue;
|
||||
const current = currentBlockFor(key);
|
||||
if (!current) continue;
|
||||
const missing = findMissingCurrentScenarios(current, block);
|
||||
if (missing.length === 0) continue;
|
||||
const diff = diffScenarioNames(current, block);
|
||||
if (diff.missing.length === 0) continue;
|
||||
issues.push({
|
||||
level: 'ERROR',
|
||||
path: entryPath,
|
||||
message:
|
||||
`MODIFIED "${block.name}" omits scenario(s) the current spec still has: ` +
|
||||
`${missing.map(name => `"${name}"`).join(', ')}. ` +
|
||||
'Copy them into the MODIFIED block (a MODIFIED requirement replaces the whole block, so archive refuses to drop them).',
|
||||
`${diff.missing.map(name => `"${name}"`).join(', ')}. ` +
|
||||
`${describeScenarioBalance(diff)} ` +
|
||||
'Copy the omitted scenarios into the MODIFIED block (a MODIFIED requirement replaces the whole block, so archive refuses to drop them).',
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as nodeFs from 'fs';
|
||||
import path from 'path';
|
||||
import { detectLineEnding, matchLineEnding } from './line-endings.js';
|
||||
|
||||
const fs = nodeFs.promises;
|
||||
const { constants: fsConstants } = nodeFs;
|
||||
@@ -325,10 +326,16 @@ export class FileSystemUtils {
|
||||
endMarker: string
|
||||
): Promise<void> {
|
||||
let existingContent = '';
|
||||
|
||||
// The managed block is composed with '\n', so splicing it into a CRLF file
|
||||
// would leave mixed endings behind. bash reports a stray '\r' in .bashrc as
|
||||
// "$'\r': command not found", so settle the whole file on the convention it
|
||||
// already used. A file that does not exist yet stays LF.
|
||||
let originalContent: string | undefined;
|
||||
|
||||
if (await this.fileExists(filePath)) {
|
||||
existingContent = await this.readFile(filePath);
|
||||
|
||||
originalContent = existingContent;
|
||||
|
||||
const startIndex = findMarkerIndex(existingContent, startMarker);
|
||||
const endIndex = startIndex !== -1
|
||||
? findMarkerIndex(existingContent, endMarker, startIndex + startMarker.length)
|
||||
@@ -352,8 +359,13 @@ export class FileSystemUtils {
|
||||
} else {
|
||||
existingContent = startMarker + '\n' + content + '\n' + endMarker;
|
||||
}
|
||||
|
||||
await this.writeFile(filePath, existingContent);
|
||||
|
||||
await this.writeFile(
|
||||
filePath,
|
||||
originalContent === undefined
|
||||
? existingContent
|
||||
: matchLineEnding(existingContent, originalContent)
|
||||
);
|
||||
}
|
||||
|
||||
static async ensureWritePermissions(dirPath: string): Promise<boolean> {
|
||||
@@ -439,14 +451,23 @@ export function removeMarkerBlock(
|
||||
const before = content.substring(0, lineStart);
|
||||
const after = content.substring(lineEnd);
|
||||
|
||||
// The file's own newline, used for every ending this function writes. The
|
||||
// blank-line collapse below rebuilds the separator it matched, so spelling it
|
||||
// '\n' would leave a CRLF file with a mixed pair wherever a run was collapsed
|
||||
// - the stray '\r' that bash reports as "$'\r': command not found".
|
||||
//
|
||||
// Dominant rather than "contains a CRLF anywhere", so that one stray CRLF in
|
||||
// an otherwise-LF file does not pull the whole rewrite to CRLF. This is the
|
||||
// same reading matchLineEnding uses, so both write paths agree.
|
||||
const newline = detectLineEnding(content) ?? '\n';
|
||||
|
||||
// Clean up double blank lines (handle both Unix \n and Windows \r\n)
|
||||
let result = before + after;
|
||||
result = result.replace(/(\r?\n){3,}/g, '\n\n');
|
||||
result = result.replace(/(\r?\n){3,}/g, newline + newline);
|
||||
|
||||
// Trim trailing whitespace but preserve leading whitespace and original newline style
|
||||
if (result.trimEnd() === '') {
|
||||
return '';
|
||||
}
|
||||
const newline = content.includes('\r\n') ? '\r\n' : '\n';
|
||||
return result.trimEnd() + newline;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Line-ending helpers.
|
||||
*
|
||||
* Every parser in this codebase normalizes CRLF to LF on the way in, so all
|
||||
* serialization logic can assume '\n'. That leaves the write side responsible
|
||||
* for restoring whatever convention the file already used: without it, editing
|
||||
* one requirement in a CRLF spec rewrites every line of the file and buries the
|
||||
* real change in the diff.
|
||||
*/
|
||||
|
||||
export type LineEnding = '\n' | '\r\n';
|
||||
|
||||
/**
|
||||
* The dominant line ending in `content`, or undefined when it holds no line
|
||||
* break to judge from.
|
||||
*
|
||||
* Mixed files resolve to whichever ending is more common, with CRLF winning a
|
||||
* tie: a file that is mostly CRLF is a CRLF file that picked up a stray LF, and
|
||||
* settling the whole file on one ending is what keeps later diffs small.
|
||||
*/
|
||||
export function detectLineEnding(content: string): LineEnding | undefined {
|
||||
const crlf = content.match(/\r\n/g)?.length ?? 0;
|
||||
// Count LFs not preceded by CR, so CRLF is never also counted as LF.
|
||||
const lf = content.match(/(?<!\r)\n/g)?.length ?? 0;
|
||||
|
||||
if (crlf === 0 && lf === 0) return undefined;
|
||||
return crlf >= lf ? '\r\n' : '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-apply `ending` to LF-normalized `content`.
|
||||
*
|
||||
* Normalizes to LF first, so the result is uniform even if the caller passed
|
||||
* content that already contained CRLF.
|
||||
*/
|
||||
export function applyLineEnding(content: string, ending: LineEnding): string {
|
||||
const normalized = content.replace(/\r\n/g, '\n');
|
||||
return ending === '\n' ? normalized : normalized.replace(/\n/g, '\r\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite `content` to match the convention of `original`.
|
||||
*
|
||||
* When `original` has no line break to learn from, `content` is left as LF —
|
||||
* the portable default this project writes new files with.
|
||||
*/
|
||||
export function matchLineEnding(content: string, original: string): string {
|
||||
const ending = detectLineEnding(original);
|
||||
return ending === undefined ? content : applyLineEnding(content, ending);
|
||||
}
|
||||
@@ -153,6 +153,55 @@ describe('openspec CLI e2e basics', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ tasks: '', completedTasks: 0, totalTasks: 0, status: 'no-tasks' },
|
||||
{ tasks: '- [x] Done\n- [ ] Pending\n', completedTasks: 1, totalTasks: 2, status: 'in-progress' },
|
||||
{ tasks: '- [x] Done\n', completedTasks: 1, totalTasks: 1, status: 'complete' },
|
||||
])('lists custom-schema task status ($status) without inventing a schema field', async (expected) => {
|
||||
const projectDir = await prepareFixture('tmp-init');
|
||||
const schemaDir = path.join(projectDir, 'openspec', 'schemas', 'custom-workflow');
|
||||
await fs.mkdir(schemaDir, { recursive: true });
|
||||
await fs.writeFile(path.join(schemaDir, 'schema.yaml'), [
|
||||
'name: custom-workflow',
|
||||
'version: 1',
|
||||
'description: Custom tracked work',
|
||||
'artifacts:',
|
||||
' - id: work',
|
||||
' generates: work.md',
|
||||
' description: Implementation work',
|
||||
' template: work.md',
|
||||
' requires: []',
|
||||
'apply:',
|
||||
' requires: [work]',
|
||||
' tracks: work.md',
|
||||
'',
|
||||
].join('\n'));
|
||||
const changeDir = path.join(projectDir, 'openspec', 'changes', 'custom-change');
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: custom-workflow\n');
|
||||
await fs.writeFile(path.join(changeDir, 'work.md'), expected.tasks);
|
||||
|
||||
const listResult = await runCLI(['list', '--json'], { cwd: projectDir });
|
||||
expectJsonOnlyOutput(listResult);
|
||||
const change = JSON.parse(listResult.stdout).changes.find(
|
||||
(entry: { name: string }) => entry.name === 'custom-change'
|
||||
);
|
||||
expect(change).toMatchObject({
|
||||
name: 'custom-change',
|
||||
completedTasks: expected.completedTasks,
|
||||
totalTasks: expected.totalTasks,
|
||||
status: expected.status,
|
||||
lastModified: expect.any(String),
|
||||
});
|
||||
expect(Number.isFinite(Date.parse(change.lastModified))).toBe(true);
|
||||
expect(change).not.toHaveProperty('schema');
|
||||
expect(change).not.toHaveProperty('schemaName');
|
||||
|
||||
const statusResult = await runCLI(['status', '--change', 'custom-change', '--json'], { cwd: projectDir });
|
||||
expectJsonOnlyOutput(statusResult);
|
||||
expect(JSON.parse(statusResult.stdout).schemaName).toBe('custom-workflow');
|
||||
});
|
||||
|
||||
it('keeps schemas --json free of spinner output', async () => {
|
||||
const projectDir = await prepareFixture('tmp-init');
|
||||
const result = await runCLI(['schemas', '--json'], { cwd: projectDir });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
@@ -27,6 +27,7 @@ describe('generateApplyInstructions task list', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -34,6 +35,162 @@ describe('generateApplyInstructions task list', () => {
|
||||
fs.writeFileSync(path.join(changeDir, 'tasks.md'), content);
|
||||
}
|
||||
|
||||
function writeGlobTasksSchema(): void {
|
||||
const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'glob-tasks');
|
||||
fs.mkdirSync(schemaDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(schemaDir, 'schema.yaml'),
|
||||
`name: glob-tasks
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: implementation
|
||||
generates: "**/tasks.md"
|
||||
description: Implementation checklists
|
||||
template: tasks.md
|
||||
requires: []
|
||||
apply:
|
||||
requires: [implementation]
|
||||
tracks: "**/tasks.md"
|
||||
`
|
||||
);
|
||||
fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: glob-tasks\n');
|
||||
}
|
||||
|
||||
it.each([true, false])('resolves custom tracking configuration (enabled: %s)', async (tracked) => {
|
||||
const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'custom');
|
||||
fs.mkdirSync(schemaDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(schemaDir, 'schema.yaml'),
|
||||
`name: custom
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: implementation
|
||||
generates: checklist.md
|
||||
description: Implementation checklist
|
||||
template: checklist.md
|
||||
requires: []
|
||||
apply:
|
||||
requires: [implementation]
|
||||
${tracked ? ' tracks: checklist.md\n' : ''}`
|
||||
);
|
||||
fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: custom\n');
|
||||
const checklist = path.join(changeDir, 'checklist.md');
|
||||
fs.writeFileSync(checklist, '- [x] Finished task\n- [ ] Pending task\n');
|
||||
|
||||
const instructions = await generateApplyInstructions(tempDir, 'my-change');
|
||||
|
||||
expect(instructions.contextFiles).toEqual({ implementation: [fs.realpathSync.native(checklist)] });
|
||||
expect(instructions.taskTrackingConfigured).toBe(tracked);
|
||||
expect(instructions.tasks).toEqual(tracked ? [
|
||||
{ id: '1', description: 'Finished task', done: true },
|
||||
{ id: '2', description: 'Pending task', done: false },
|
||||
] : []);
|
||||
expect(instructions.progress).toEqual(tracked
|
||||
? { total: 2, complete: 1, remaining: 1 }
|
||||
: { total: 0, complete: 0, remaining: 0 });
|
||||
expect(instructions.state).toBe('ready');
|
||||
});
|
||||
|
||||
it.each(['missing', 'empty'])('returns no task evidence for a %s tracking file', async (kind) => {
|
||||
if (kind === 'empty') writeTasks('');
|
||||
|
||||
const instructions = await generateApplyInstructions(tempDir, 'my-change');
|
||||
|
||||
expect(instructions.tasks).toEqual([]);
|
||||
expect(instructions.progress).toEqual({ total: 0, complete: 0, remaining: 0 });
|
||||
expect(instructions.state).toBe('blocked');
|
||||
expect(instructions.instruction).toContain(kind === 'missing' ? 'Missing artifacts: tasks' : 'contains no tasks');
|
||||
});
|
||||
|
||||
it('aggregates a tracking glob owned by an artifact not named tasks', async () => {
|
||||
writeGlobTasksSchema();
|
||||
const backendTasks = path.join(changeDir, 'backend', 'tasks.md');
|
||||
const frontendTasks = path.join(changeDir, 'frontend', 'tasks.md');
|
||||
fs.mkdirSync(path.dirname(backendTasks), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(frontendTasks), { recursive: true });
|
||||
fs.writeFileSync(backendTasks, '- [x] Finished backend task\n');
|
||||
fs.writeFileSync(frontendTasks, '- [x] Finished frontend task\n- [ ] Pending frontend task\n');
|
||||
|
||||
const instructions = await generateApplyInstructions(tempDir, 'my-change');
|
||||
const listProgress = await getTaskProgressForChange(
|
||||
path.join(tempDir, 'openspec', 'changes'),
|
||||
'my-change',
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(instructions.contextFiles.implementation).toEqual([
|
||||
fs.realpathSync.native(backendTasks),
|
||||
fs.realpathSync.native(frontendTasks),
|
||||
]);
|
||||
expect(instructions.tasks).toEqual([
|
||||
{ id: '1', description: 'Finished backend task', done: true },
|
||||
{ id: '2', description: 'Finished frontend task', done: true },
|
||||
{ id: '3', description: 'Pending frontend task', done: false },
|
||||
]);
|
||||
expect(instructions.progress).toEqual({ total: 3, complete: 2, remaining: 1 });
|
||||
expect(instructions.state).toBe('ready');
|
||||
expect(listProgress).toEqual({ total: 3, completed: 2 });
|
||||
});
|
||||
|
||||
it('retains partial task evidence when a tracked file is unreadable', async () => {
|
||||
writeGlobTasksSchema();
|
||||
const backendTasks = path.join(changeDir, 'backend', 'tasks.md');
|
||||
const frontendTasks = path.join(changeDir, 'frontend', 'tasks.md');
|
||||
fs.mkdirSync(path.dirname(backendTasks), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(frontendTasks), { recursive: true });
|
||||
fs.writeFileSync(backendTasks, '- [x] Finished backend task\n');
|
||||
fs.writeFileSync(frontendTasks, '- [x] Finished frontend task\n');
|
||||
vi.spyOn(fs.promises, 'readFile').mockRejectedValueOnce(
|
||||
Object.assign(new Error('permission denied'), { code: 'EACCES' })
|
||||
);
|
||||
|
||||
const instructions = await generateApplyInstructions(tempDir, 'my-change');
|
||||
|
||||
expect(instructions.tasks).toEqual([
|
||||
{ id: '1', description: 'Finished frontend task', done: true },
|
||||
]);
|
||||
expect(instructions.progress).toEqual({ total: 1, complete: 1, remaining: 0 });
|
||||
expect(instructions.unavailableTrackingFiles).toEqual([
|
||||
{ path: fs.realpathSync.native(backendTasks), reason: 'EACCES: permission denied' },
|
||||
]);
|
||||
expect(instructions.state).toBe('ready');
|
||||
expect(instructions.instruction).toContain('Task completion is not verified');
|
||||
expect(instructions.instruction).toContain(fs.realpathSync.native(backendTasks));
|
||||
});
|
||||
|
||||
it('reports tracking evidence that disappears after glob resolution', async () => {
|
||||
writeTasks('- [x] Finished task\n');
|
||||
const tasksPath = fs.realpathSync.native(path.join(changeDir, 'tasks.md'));
|
||||
vi.spyOn(fs.promises, 'readFile').mockRejectedValueOnce(
|
||||
Object.assign(new Error('no such file or directory'), { code: 'ENOENT' })
|
||||
);
|
||||
|
||||
const instructions = await generateApplyInstructions(tempDir, 'my-change');
|
||||
|
||||
expect(instructions.tasks).toEqual([]);
|
||||
expect(instructions.progress).toEqual({ total: 0, complete: 0, remaining: 0 });
|
||||
expect(instructions.unavailableTrackingFiles).toEqual([
|
||||
{ path: tasksPath, reason: 'ENOENT: no such file or directory' },
|
||||
]);
|
||||
expect(instructions.state).toBe('blocked');
|
||||
expect(instructions.instruction).toContain('Task completion is not verified');
|
||||
expect(instructions.instruction).toContain(tasksPath);
|
||||
});
|
||||
|
||||
it('returns existing spec and design paths even when their files contain no evidence', async () => {
|
||||
writeTasks('- [x] Finished task\n');
|
||||
const spec = path.join(changeDir, 'specs', 'demo', 'spec.md');
|
||||
const design = path.join(changeDir, 'design.md');
|
||||
fs.writeFileSync(spec, '');
|
||||
fs.writeFileSync(design, '');
|
||||
|
||||
const instructions = await generateApplyInstructions(tempDir, 'my-change');
|
||||
|
||||
expect(instructions.contextFiles.specs).toEqual([fs.realpathSync.native(spec)]);
|
||||
expect(instructions.contextFiles.design).toEqual([fs.realpathSync.native(design)]);
|
||||
expect(instructions.state).toBe('all_done');
|
||||
});
|
||||
|
||||
it('lists indented sub-tasks alongside their parents', async () => {
|
||||
writeTasks(
|
||||
[
|
||||
@@ -84,6 +241,7 @@ describe('generateApplyInstructions task list', () => {
|
||||
// As before the shared parser: apply points at regenerating the file
|
||||
// rather than listing a blank row an agent cannot act on.
|
||||
expect(instructions.tasks).toEqual([]);
|
||||
expect(instructions.progress).toEqual({ total: 1, complete: 1, remaining: 0 });
|
||||
expect(instructions.state).toBe('blocked');
|
||||
expect(instructions.instruction).toContain('contains no tasks');
|
||||
});
|
||||
|
||||
@@ -340,6 +340,17 @@ describe('artifact-workflow CLI commands', () => {
|
||||
expect(status.artifacts.find((artifact: any) => artifact.id === 'specs')?.status).toBe(
|
||||
'skipped'
|
||||
);
|
||||
expect(status.artifactPaths.specs.existingOutputPaths).toEqual([]);
|
||||
const instructionsResult = await runCLI(
|
||||
['instructions', 'specs', '--change', 'skip-specs-change', '--json'],
|
||||
{ cwd: tempDir }
|
||||
);
|
||||
expect(instructionsResult.exitCode).toBe(0);
|
||||
expect(JSON.parse(instructionsResult.stdout)).toMatchObject({
|
||||
skipped: true,
|
||||
existingOutputPaths: [],
|
||||
warning: expect.stringContaining('Do not create spec files'),
|
||||
});
|
||||
await expect(fs.stat(path.join(changeDir, 'specs'))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
@@ -450,6 +461,122 @@ describe('artifact-workflow CLI commands', () => {
|
||||
});
|
||||
|
||||
describe('instructions command', () => {
|
||||
it('keeps instructions available for missing companion outputs after a glob artifact is done', async () => {
|
||||
const schemaName = 'companion-outputs';
|
||||
const schemaDir = path.join(tempDir, 'openspec', 'schemas', schemaName);
|
||||
const outputPath = 'reviews/*/notes.md';
|
||||
const template = '# Review\n\n## Findings\n';
|
||||
await fs.mkdir(path.join(schemaDir, 'templates'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(schemaDir, 'schema.yaml'),
|
||||
`name: ${schemaName}
|
||||
version: 1
|
||||
artifacts:
|
||||
- id: brief
|
||||
generates: brief.md
|
||||
description: Review brief
|
||||
template: brief.md
|
||||
requires: []
|
||||
- id: assessments
|
||||
generates: ${outputPath}
|
||||
description: Component assessments
|
||||
template: review.md
|
||||
instruction: Write an assessment for each affected component.
|
||||
requires: [brief]
|
||||
- id: signoff
|
||||
generates: signoff.md
|
||||
description: Review signoff
|
||||
template: signoff.md
|
||||
requires: [assessments]
|
||||
`
|
||||
);
|
||||
await fs.writeFile(path.join(schemaDir, 'templates', 'brief.md'), '# Brief\n');
|
||||
await fs.writeFile(path.join(schemaDir, 'templates', 'review.md'), template);
|
||||
await fs.writeFile(path.join(schemaDir, 'templates', 'signoff.md'), '# Signoff\n');
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'openspec', 'config.yaml'),
|
||||
`schema: ${schemaName}
|
||||
context: Review both the API and UI components.
|
||||
rules:
|
||||
assessments:
|
||||
- Preserve existing findings when adding a companion assessment.
|
||||
`
|
||||
);
|
||||
const changeName = 'companion-review';
|
||||
const changeDir = path.join(changesDir, changeName);
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), `schema: ${schemaName}\n`);
|
||||
const briefPath = path.join(changeDir, 'brief.md');
|
||||
await fs.writeFile(briefPath, '# Brief\nReview the API and UI.\n');
|
||||
const apiPath = path.join(changeDir, 'reviews', 'api', 'notes.md');
|
||||
const uiPath = path.join(changeDir, 'reviews', 'ui', 'notes.md');
|
||||
|
||||
async function readJson(args: string[]) {
|
||||
const result = await runCLI([...args, '--change', changeName, '--json'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
const empty = await readJson(['status']);
|
||||
expect(empty.artifacts).toMatchObject([
|
||||
{ id: 'brief', status: 'done' },
|
||||
{ id: 'assessments', status: 'ready' },
|
||||
{ id: 'signoff', status: 'blocked', missingDeps: ['assessments'] },
|
||||
]);
|
||||
expect(empty.artifactPaths.assessments.existingOutputPaths).toEqual([]);
|
||||
|
||||
// Fixture writes simulate authored outputs; the CLI only reports their state.
|
||||
const existingContent = '# Review\n\n## Findings\nKeep this API finding.\n';
|
||||
await fs.mkdir(path.dirname(apiPath), { recursive: true });
|
||||
await fs.writeFile(apiPath, existingContent);
|
||||
const partial = await readJson(['status']);
|
||||
expect(partial.artifacts).toMatchObject([
|
||||
{ id: 'brief', status: 'done' },
|
||||
{ id: 'assessments', status: 'done' },
|
||||
{ id: 'signoff', status: 'ready' },
|
||||
]);
|
||||
expect(partial.artifactPaths.assessments.existingOutputPaths.map(canonical)).toEqual([
|
||||
canonical(apiPath),
|
||||
]);
|
||||
const instructions = await readJson(['instructions', 'assessments']);
|
||||
expect(instructions).toMatchObject({
|
||||
artifactId: 'assessments',
|
||||
outputPath,
|
||||
instruction: 'Write an assessment for each affected component.',
|
||||
context: 'Review both the API and UI components.',
|
||||
rules: ['Preserve existing findings when adding a companion assessment.'],
|
||||
template,
|
||||
dependencies: [{ id: 'brief', done: true, path: 'brief.md' }],
|
||||
});
|
||||
expect(canonical(instructions.changeDir)).toBe(canonical(changeDir));
|
||||
expect(instructions.resolvedOutputPath).toBe(path.join(instructions.changeDir, outputPath));
|
||||
expect(instructions.existingOutputPaths.map(canonical)).toEqual([canonical(apiPath)]);
|
||||
expect(instructions.skipped).toBeUndefined();
|
||||
await expect(fs.stat(uiPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
|
||||
await fs.mkdir(path.dirname(uiPath), { recursive: true });
|
||||
await fs.writeFile(uiPath, '# Review\n\n## Findings\nNew UI finding.\n');
|
||||
const expanded = await readJson(['status']);
|
||||
expect(expanded.artifacts).toEqual(partial.artifacts);
|
||||
expect(expanded.nextSteps).toEqual(partial.nextSteps);
|
||||
expect(expanded.artifactPaths.assessments.existingOutputPaths.map(canonical)).toEqual(
|
||||
[apiPath, uiPath].map(canonical).sort()
|
||||
);
|
||||
expect(await fs.readFile(apiPath, 'utf-8')).toBe(existingContent);
|
||||
await expect(fs.stat(path.join(changeDir, 'signoff.md'))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
|
||||
await fs.unlink(briefPath);
|
||||
const missingInput = await readJson(['status']);
|
||||
expect(missingInput.artifacts.find((artifact: any) => artifact.id === 'assessments')).toMatchObject({
|
||||
status: 'done',
|
||||
requires: ['brief'],
|
||||
});
|
||||
const missingInputInstructions = await readJson(['instructions', 'assessments']);
|
||||
expect(missingInputInstructions.dependencies).toMatchObject([
|
||||
{ id: 'brief', done: false, path: 'brief.md' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows instructions for proposal on scaffolded change', async () => {
|
||||
// Create empty change directory (no proposal.md)
|
||||
const changeDir = path.join(changesDir, 'scaffolded-change');
|
||||
|
||||
@@ -613,24 +613,35 @@ describe('openspec workset (7.1)', () => {
|
||||
});
|
||||
|
||||
describe('opener config', () => {
|
||||
it('adds a new workspace-file tool from config', async () => {
|
||||
writeOpenersConfig({ zed: { style: 'workspace-file' } });
|
||||
await createPlatform(['--tool', 'zed']);
|
||||
const fakeZed = createFakeTool(tempDir, 'zed');
|
||||
it.each([
|
||||
{ tool: 'code-insiders', args: [] },
|
||||
{ tool: 'code', args: ['--new-window'] },
|
||||
])('launches the documented $tool opener configuration', async ({ tool, args }) => {
|
||||
delete process.env.OPENSPEC_ENABLE_CLI_AGENT_OPENERS;
|
||||
writeOpenersConfig({
|
||||
'code-insiders': { style: 'workspace-file', label: 'VS Code Insiders' },
|
||||
code: { args: ['--new-window'] },
|
||||
});
|
||||
const created = await createPlatform(['--tool', tool]);
|
||||
expect(created.exitCode).toBe(0);
|
||||
const fakeEditor = createFakeTool(tempDir, tool);
|
||||
|
||||
const result = await runCLI(['workset', 'open', 'platform'], {
|
||||
cwd: tempDir,
|
||||
env: envWithFakeTools(env, [fakeZed]),
|
||||
env: envWithFakeTools(env, [fakeEditor]),
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(readLaunchLog(fakeZed.logPath).args).toEqual([
|
||||
getWorksetCodeWorkspacePath('platform', pathOptions()),
|
||||
]);
|
||||
expect(readLaunchLog(fakeEditor.logPath)).toEqual({
|
||||
cwd: memberA,
|
||||
args: [...args, getWorksetCodeWorkspacePath('platform', pathOptions())],
|
||||
});
|
||||
});
|
||||
|
||||
it('renaming an attach flag is a one-line local fix', async () => {
|
||||
writeOpenersConfig({ claude: { attach_flag: '--dir' } });
|
||||
it('passes configured args before configured attach pairs', async () => {
|
||||
writeOpenersConfig({
|
||||
claude: { args: ['--model', 'opus'], attach_flag: '--dir' },
|
||||
});
|
||||
await createPlatform(['--tool', 'claude']);
|
||||
const fakeClaude = createFakeTool(tempDir, 'claude');
|
||||
|
||||
@@ -641,6 +652,8 @@ describe('openspec workset (7.1)', () => {
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(readLaunchLog(fakeClaude.logPath).args).toEqual([
|
||||
'--model',
|
||||
'opus',
|
||||
'--dir',
|
||||
memberA,
|
||||
'--dir',
|
||||
|
||||
+471
-4
@@ -2243,10 +2243,61 @@ New feature description.
|
||||
await expect(fs.access(claimPath)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('releases its archive claim when the path stat has no Windows device id', async () => {
|
||||
const changeName = 'windows-archive-claim-release';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
const archiveName = `${formatLocalDate()}-${changeName}`;
|
||||
const claimPath = archiveClaimPath(archiveName);
|
||||
const realLstat = fs.lstat.bind(fs);
|
||||
// Match the claim by file name rather than by full path. The command
|
||||
// stats the resolved real path, so a literal comparison against the
|
||||
// temp-dir path misses on macOS (/var -> /private/var) and on Windows
|
||||
// short paths, leaving the mock inert and the regression unexercised.
|
||||
let maskedDeviceIds = 0;
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
vi.spyOn(fs, 'lstat').mockImplementation(async (target, options) => {
|
||||
const stats = await realLstat(target, options as any);
|
||||
if (path.basename(String(target)) !== '.openspec-archive.lock') {
|
||||
return stats;
|
||||
}
|
||||
maskedDeviceIds += 1;
|
||||
return { ...stats, dev: 0n };
|
||||
});
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, skipSpecs: true });
|
||||
|
||||
// Guards the assertion below: without this the test passes even when the
|
||||
// mock never intercepts, which is how it originally went vacuous.
|
||||
expect(maskedDeviceIds).toBeGreaterThan(0);
|
||||
await expect(fs.access(claimPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('keeps a claim when its inode changes after reading on a zero-device stat', async () => {
|
||||
const changeName = 'changed-archive-claim-identity';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
const claimPath = archiveClaimPath(`${formatLocalDate()}-${changeName}`);
|
||||
const realLstat = fs.lstat.bind(fs);
|
||||
let claimStats = 0;
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
vi.spyOn(fs, 'lstat').mockImplementation(async (target, options) => {
|
||||
const stats = await realLstat(target, options as any);
|
||||
if (path.basename(String(target)) !== '.openspec-archive.lock') return stats;
|
||||
claimStats += 1;
|
||||
return { ...stats, dev: 0n, ino: claimStats === 2 ? stats.ino + 1n : stats.ino };
|
||||
});
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, skipSpecs: true });
|
||||
|
||||
expect(claimStats).toBe(2);
|
||||
await expect(fs.access(claimPath)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
// Windows defers deletion of an open file until its original handle closes,
|
||||
// so unlink-and-recreate cannot model a persistent replacement there.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'does not unlink a claim entry replaced by another process',
|
||||
'does not unlink a replaced claim when path stats omit the device id',
|
||||
async () => {
|
||||
const changeName = 'replaced-archive-claim';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
@@ -2254,7 +2305,14 @@ New feature description.
|
||||
const archiveName = `${formatLocalDate()}-${changeName}`;
|
||||
const claimPath = archiveClaimPath(archiveName);
|
||||
const realRename = fs.rename.bind(fs);
|
||||
const realLstat = fs.lstat.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
vi.spyOn(fs, 'lstat').mockImplementation(async (target, options) => {
|
||||
const stats = await realLstat(target, options as any);
|
||||
return path.basename(String(target)) === '.openspec-archive.lock'
|
||||
? { ...stats, dev: 0n }
|
||||
: stats;
|
||||
});
|
||||
let replaced = false;
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => {
|
||||
if (
|
||||
@@ -6711,11 +6769,14 @@ The system SHALL provide a replacement behavior.
|
||||
const realRename = fs.rename.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => {
|
||||
const src = String(source);
|
||||
const dest = String(destination);
|
||||
if (
|
||||
String(source).endsWith(
|
||||
`${path.sep}openspec${path.sep}changes${path.sep}${changeName}`
|
||||
)
|
||||
src.endsWith(`${path.sep}openspec${path.sep}changes${path.sep}${changeName}`)
|
||||
) {
|
||||
if (dest.includes(`${path.sep}.openspec-move-`)) {
|
||||
throw Object.assign(new Error('staging denied'), { code: 'EACCES' });
|
||||
}
|
||||
throw Object.assign(new Error('directory is busy'), { code: 'EPERM' });
|
||||
}
|
||||
return realRename(source, destination);
|
||||
@@ -6749,6 +6810,412 @@ The system SHALL provide a replacement behavior.
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not leave an empty capability directory when a create is rolled back', async () => {
|
||||
const changeName = 'eperm-create-rollback-prunes';
|
||||
const changeDir = await createChange(
|
||||
changeName,
|
||||
'write-feedback',
|
||||
`## ADDED Requirements
|
||||
|
||||
### Requirement: Write feedback is captured
|
||||
The system SHALL capture write feedback.
|
||||
|
||||
#### Scenario: Feedback is stored
|
||||
- **WHEN** write feedback arrives
|
||||
- **THEN** it is stored
|
||||
`
|
||||
);
|
||||
const capabilityDir = path.join(tempDir, 'openspec', 'specs', 'write-feedback');
|
||||
|
||||
const realRename = fs.rename.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => {
|
||||
const src = String(source);
|
||||
const dest = String(destination);
|
||||
if (
|
||||
src.endsWith(`${path.sep}openspec${path.sep}changes${path.sep}${changeName}`)
|
||||
) {
|
||||
if (dest.includes(`${path.sep}.openspec-move-`)) {
|
||||
throw Object.assign(new Error('staging denied'), { code: 'EACCES' });
|
||||
}
|
||||
throw Object.assign(new Error('directory is busy'), { code: 'EPERM' });
|
||||
}
|
||||
return realRename(source, destination);
|
||||
});
|
||||
|
||||
await expect(
|
||||
archiveCommand.execute(changeName, { yes: true })
|
||||
).rejects.toThrow(/Could not safely stage/);
|
||||
|
||||
await expect(fs.access(path.join(capabilityDir, 'spec.md'))).rejects.toThrow();
|
||||
await expect(fs.access(capabilityDir)).rejects.toThrow();
|
||||
await expect(fs.access(changeDir)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('archives a source that already contains a claim-suffixed filename', async () => {
|
||||
// A fixed claim suffix collided with a real source file ending in it:
|
||||
// claiming `collision` renamed it over `collision.openspec-claim`, and
|
||||
// that file's own turn then failed with ENOENT after part of the live
|
||||
// source was gone. The suffix is drawn per move and checked against the
|
||||
// entries being removed, so a valid tree like this archives normally.
|
||||
const changeName = 'eperm-claim-suffix-collision';
|
||||
const changeDir = await createChange(
|
||||
changeName,
|
||||
'collision-feedback',
|
||||
`## ADDED Requirements
|
||||
|
||||
### Requirement: Collision feedback is captured
|
||||
The system SHALL capture collision feedback.
|
||||
|
||||
#### Scenario: Feedback is stored
|
||||
- **WHEN** collision feedback arrives
|
||||
- **THEN** it is stored
|
||||
`
|
||||
);
|
||||
await fs.writeFile(path.join(changeDir, 'collision'), 'plain entry\n');
|
||||
await fs.writeFile(
|
||||
path.join(changeDir, 'collision.openspec-claim'),
|
||||
'entry that looks like a claim\n'
|
||||
);
|
||||
|
||||
const realRename = fs.rename.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => {
|
||||
if (
|
||||
String(source).endsWith(
|
||||
`${path.sep}openspec${path.sep}changes${path.sep}${changeName}`
|
||||
)
|
||||
) {
|
||||
throw Object.assign(new Error('directory is busy'), { code: 'EPERM' });
|
||||
}
|
||||
return realRename(source, destination);
|
||||
});
|
||||
|
||||
await expect(
|
||||
archiveCommand.execute(changeName, { yes: true })
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// The source is gone and both files made it into the archive intact.
|
||||
await expect(fs.access(changeDir)).rejects.toThrow();
|
||||
const archived = path.join(
|
||||
tempDir,
|
||||
'openspec',
|
||||
'changes',
|
||||
'archive',
|
||||
`${formatLocalDate()}-${changeName}`
|
||||
);
|
||||
await expect(fs.readFile(path.join(archived, 'collision'), 'utf-8')).resolves.toBe(
|
||||
'plain entry\n'
|
||||
);
|
||||
await expect(
|
||||
fs.readFile(path.join(archived, 'collision.openspec-claim'), 'utf-8')
|
||||
).resolves.toBe('entry that looks like a claim\n');
|
||||
});
|
||||
|
||||
it('keeps an edit to an already-verified file, and retains the destination', async () => {
|
||||
// The window alfred flagged: the copy and both fingerprints are behind
|
||||
// us, and an editor rewrites a file that is already in the verified set.
|
||||
// The destination holds the older bytes, so removing that file would
|
||||
// delete the only copy of the newer ones and still report success.
|
||||
// Cleanup claims each file by renaming it before reading, then compares
|
||||
// the claimed bytes against the copy, so this aborts instead.
|
||||
const changeName = 'eperm-late-edit-preserved';
|
||||
const changeDir = await createChange(
|
||||
changeName,
|
||||
'edit-feedback',
|
||||
`## ADDED Requirements
|
||||
|
||||
### Requirement: Edit feedback is captured
|
||||
The system SHALL capture edit feedback.
|
||||
|
||||
#### Scenario: Feedback is stored
|
||||
- **WHEN** edit feedback arrives
|
||||
- **THEN** it is stored
|
||||
`
|
||||
);
|
||||
const deltaPath = path.join(changeDir, 'specs', 'edit-feedback', 'spec.md');
|
||||
const newBytes = '# Rewritten while the move was finishing.\n';
|
||||
|
||||
const realRename = fs.rename.bind(fs);
|
||||
const realWriteFile = fs.writeFile.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
|
||||
let edited = false;
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => {
|
||||
const from = String(source);
|
||||
if (
|
||||
from.endsWith(`${path.sep}openspec${path.sep}changes${path.sep}${changeName}`)
|
||||
) {
|
||||
throw Object.assign(new Error('directory is busy'), { code: 'EPERM' });
|
||||
}
|
||||
// The claim rename for the delta: land the edit just before it, so the
|
||||
// bytes we claim are the new ones and the copy still holds the old.
|
||||
if (!edited && from.endsWith(`${path.sep}spec.md`) && from.includes(changeName)) {
|
||||
edited = true;
|
||||
await realWriteFile(from, newBytes);
|
||||
}
|
||||
return realRename(source, destination);
|
||||
});
|
||||
|
||||
await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow(
|
||||
/could not remove the source|retained for recovery/i
|
||||
);
|
||||
|
||||
expect(edited).toBe(true);
|
||||
// The newer bytes are still on disk, under their own path.
|
||||
await expect(fs.readFile(deltaPath, 'utf-8')).resolves.toBe(newBytes);
|
||||
// No claim file is left behind, whatever suffix this move drew.
|
||||
await expect(
|
||||
fs.readdir(path.dirname(deltaPath))
|
||||
).resolves.toEqual(['spec.md']);
|
||||
// The complete copy is retained for recovery.
|
||||
await expect(
|
||||
fs.access(
|
||||
path.join(
|
||||
tempDir,
|
||||
'openspec',
|
||||
'changes',
|
||||
'archive',
|
||||
`${formatLocalDate()}-${changeName}`,
|
||||
'specs',
|
||||
'edit-feedback',
|
||||
'spec.md'
|
||||
)
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps a file added after verification, and retains the destination', async () => {
|
||||
// The unstaged fallback copies from the live change directory: the
|
||||
// archive claim covers the destination, not the source. Cleanup must
|
||||
// therefore delete only the entries it verified, never whatever happens
|
||||
// to be there when it runs.
|
||||
const changeName = 'eperm-late-write-preserved';
|
||||
const changeDir = await createChange(
|
||||
changeName,
|
||||
'write-feedback',
|
||||
`## ADDED Requirements
|
||||
|
||||
### Requirement: Write feedback is captured
|
||||
The system SHALL capture write feedback.
|
||||
|
||||
#### Scenario: Feedback is stored
|
||||
- **WHEN** write feedback arrives
|
||||
- **THEN** it is stored
|
||||
`
|
||||
);
|
||||
const lateFile = path.join(changeDir, 'late-arrival.md');
|
||||
|
||||
const realRename = fs.rename.bind(fs);
|
||||
const realRmdir = fs.rmdir.bind(fs);
|
||||
// archive works in realpaths, which on macOS carry a /private prefix the
|
||||
// temp dir does not.
|
||||
const resolvedChangeDir = await fs.realpath(changeDir);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => {
|
||||
if (
|
||||
String(source).endsWith(
|
||||
`${path.sep}openspec${path.sep}changes${path.sep}${changeName}`
|
||||
)
|
||||
) {
|
||||
throw Object.assign(new Error('directory is busy'), { code: 'EPERM' });
|
||||
}
|
||||
return realRename(source, destination);
|
||||
});
|
||||
|
||||
// Land the write in the window the listing has already closed: the
|
||||
// verified entries are being removed, so the copy and both fingerprint
|
||||
// checks are already behind us. rmdir of a subdirectory only happens
|
||||
// inside that removal.
|
||||
let arrived = false;
|
||||
vi.spyOn(fs, 'rmdir').mockImplementation(async (target, options) => {
|
||||
const t = String(target);
|
||||
if (
|
||||
!arrived &&
|
||||
(t === resolvedChangeDir || t.startsWith(resolvedChangeDir + path.sep))
|
||||
) {
|
||||
arrived = true;
|
||||
await fs.writeFile(lateFile, 'Written while the move was finishing.\n');
|
||||
}
|
||||
return realRmdir(target, options);
|
||||
});
|
||||
|
||||
await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow(
|
||||
/could not remove the source|retained for recovery/i
|
||||
);
|
||||
|
||||
expect(arrived).toBe(true);
|
||||
// The late write survives, and the complete copy is still there.
|
||||
await expect(fs.readFile(lateFile, 'utf-8')).resolves.toContain(
|
||||
'Written while the move was finishing.'
|
||||
);
|
||||
await expect(
|
||||
fs.access(
|
||||
path.join(
|
||||
tempDir,
|
||||
'openspec',
|
||||
'changes',
|
||||
'archive',
|
||||
`${formatLocalDate()}-${changeName}`,
|
||||
'specs',
|
||||
'write-feedback',
|
||||
'spec.md'
|
||||
)
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps a capability directory that already existed when a create is rolled back', async () => {
|
||||
// Pruning is only ever taking back a directory this write created. One
|
||||
// the user already had carries their own mode and ACLs.
|
||||
const changeName = 'eperm-create-rollback-keeps-existing-dir';
|
||||
await createChange(
|
||||
changeName,
|
||||
'write-feedback',
|
||||
`## ADDED Requirements
|
||||
|
||||
### Requirement: Write feedback is captured
|
||||
The system SHALL capture write feedback.
|
||||
|
||||
#### Scenario: Feedback is stored
|
||||
- **WHEN** write feedback arrives
|
||||
- **THEN** it is stored
|
||||
`
|
||||
);
|
||||
const capabilityDir = path.join(tempDir, 'openspec', 'specs', 'write-feedback');
|
||||
await fs.mkdir(capabilityDir, { recursive: true });
|
||||
|
||||
const realRename = fs.rename.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => {
|
||||
const src = String(source);
|
||||
const dest = String(destination);
|
||||
if (src.endsWith(`${path.sep}openspec${path.sep}changes${path.sep}${changeName}`)) {
|
||||
if (dest.includes(`${path.sep}.openspec-move-`)) {
|
||||
throw Object.assign(new Error('staging denied'), { code: 'EACCES' });
|
||||
}
|
||||
throw Object.assign(new Error('directory is busy'), { code: 'EPERM' });
|
||||
}
|
||||
return realRename(source, destination);
|
||||
});
|
||||
|
||||
await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow(
|
||||
/Could not safely stage/
|
||||
);
|
||||
|
||||
// The spec the rollback undid is gone; the directory the user had stays.
|
||||
await expect(fs.access(path.join(capabilityDir, 'spec.md'))).rejects.toThrow();
|
||||
await expect(fs.access(capabilityDir)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps a pre-existing ancestor when a nested capability create is rolled back', async () => {
|
||||
// `platform/` already existed and `platform/session-layout/` did not.
|
||||
// Only the leaf is ours to take back; walking up to the specs root would
|
||||
// delete the user's directory too.
|
||||
const changeName = 'eperm-nested-rollback-keeps-ancestor';
|
||||
await createChange(
|
||||
changeName,
|
||||
'platform/session-layout',
|
||||
`## ADDED Requirements
|
||||
|
||||
### Requirement: Session layout is described
|
||||
The system SHALL describe the session layout.
|
||||
|
||||
#### Scenario: Layout is read
|
||||
- **WHEN** the layout is requested
|
||||
- **THEN** it is returned
|
||||
`
|
||||
);
|
||||
const ancestorDir = path.join(tempDir, 'openspec', 'specs', 'platform');
|
||||
const capabilityDir = path.join(ancestorDir, 'session-layout');
|
||||
await fs.mkdir(ancestorDir, { recursive: true });
|
||||
|
||||
const realRename = fs.rename.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => {
|
||||
const src = String(source);
|
||||
const dest = String(destination);
|
||||
if (src.endsWith(`${path.sep}openspec${path.sep}changes${path.sep}${changeName}`)) {
|
||||
if (dest.includes(`${path.sep}.openspec-move-`)) {
|
||||
throw Object.assign(new Error('staging denied'), { code: 'EACCES' });
|
||||
}
|
||||
throw Object.assign(new Error('directory is busy'), { code: 'EPERM' });
|
||||
}
|
||||
return realRename(source, destination);
|
||||
});
|
||||
|
||||
await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow(
|
||||
/Could not safely stage/
|
||||
);
|
||||
|
||||
// The leaf this write created is gone; the ancestor the user had stays.
|
||||
await expect(fs.access(capabilityDir)).rejects.toThrow();
|
||||
await expect(fs.access(ancestorDir)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('archives via copy when EPERM prevents both dest rename and staging', async () => {
|
||||
const changeName = 'eperm-copy-without-staging';
|
||||
const changeDir = await createChange(
|
||||
changeName,
|
||||
'write-feedback',
|
||||
`## ADDED Requirements
|
||||
|
||||
### Requirement: Write feedback is captured
|
||||
The system SHALL capture write feedback.
|
||||
|
||||
#### Scenario: Feedback is stored
|
||||
- **WHEN** write feedback arrives
|
||||
- **THEN** it is stored
|
||||
`
|
||||
);
|
||||
const target = path.join(
|
||||
tempDir,
|
||||
'openspec',
|
||||
'specs',
|
||||
'write-feedback',
|
||||
'spec.md'
|
||||
);
|
||||
|
||||
const realRename = fs.rename.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => {
|
||||
if (
|
||||
String(source).endsWith(
|
||||
`${path.sep}openspec${path.sep}changes${path.sep}${changeName}`
|
||||
)
|
||||
) {
|
||||
throw Object.assign(new Error('directory is busy'), { code: 'EPERM' });
|
||||
}
|
||||
return realRename(source, destination);
|
||||
});
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true });
|
||||
|
||||
await expect(fs.access(changeDir)).rejects.toThrow();
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toContain(
|
||||
'### Requirement: Write feedback is captured'
|
||||
);
|
||||
await expect(
|
||||
fs.access(
|
||||
path.join(
|
||||
tempDir,
|
||||
'openspec',
|
||||
'changes',
|
||||
'archive',
|
||||
`${formatLocalDate()}-${changeName}`,
|
||||
'specs',
|
||||
'write-feedback',
|
||||
'spec.md'
|
||||
)
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
expect(
|
||||
(await fs.readdir(path.dirname(path.dirname(changeDir)))).some((entry) =>
|
||||
entry.startsWith('.openspec-move-')
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps applied specs when fallback retains a complete archive copy', async () => {
|
||||
const changeName = 'retained-copy-keeps-specs';
|
||||
const changeDir = await createChange(
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fg from 'fast-glob';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { FileSystemUtils } from '../../../src/utils/file-system.js';
|
||||
import {
|
||||
artifactOutputExists,
|
||||
isGlobPattern,
|
||||
isSpecsArtifactPath,
|
||||
resolveArtifactOutputs,
|
||||
} from '../../../src/core/artifact-graph/outputs.js';
|
||||
@@ -34,6 +36,36 @@ describe('artifact-graph/outputs', () => {
|
||||
expect(isSpecsArtifactPath(generates)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['specs/**/*.md', true],
|
||||
['specs/foo*.md', true],
|
||||
['specs/a?.md', true],
|
||||
['specs/[ab].md', true],
|
||||
['review-{api,ui}.md', true],
|
||||
['file-{1..3}.md', true],
|
||||
['report-{draft}-{api,ui}.md', true],
|
||||
['report-{draft}-{1..3}.md', true],
|
||||
['report-{draft,{api,ui}}.md', true],
|
||||
['report-{{draft},api}.md', true],
|
||||
['report-{draft}-{final}.md', false],
|
||||
['@(proposal|design).md', true],
|
||||
['+(proposal|design).md', true],
|
||||
['!(proposal|design).md', true],
|
||||
['*(proposal|design).md', true],
|
||||
['?(proposal|design).md', true],
|
||||
['!*.md', true],
|
||||
['file[.md', true],
|
||||
[String.raw`specs\review-{api,ui}.md`, true],
|
||||
['!review.md', false],
|
||||
['(proposal|design).md', false],
|
||||
[String.raw`specs\auth\spec.md`, false],
|
||||
['review-{api}.md', false],
|
||||
['proposal.md', false],
|
||||
['specs/auth/spec.md', false],
|
||||
])('classifies glob pattern %s as %s', (pattern, expected) => {
|
||||
expect(isGlobPattern(pattern)).toBe(expected);
|
||||
});
|
||||
|
||||
it('resolves a direct file path when it exists', () => {
|
||||
const filePath = path.join(tempDir, 'proposal.md');
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
@@ -50,6 +82,108 @@ describe('artifact-graph/outputs', () => {
|
||||
expect(artifactOutputExists(tempDir, 'proposal.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves a literal filename with a leading exclamation mark', () => {
|
||||
const filePath = path.join(tempDir, '!review.md');
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, '!review.md')).toEqual([canonical(filePath)]);
|
||||
expect(artifactOutputExists(tempDir, '!review.md')).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32').each([
|
||||
'(proposal|design).md',
|
||||
String.raw`foo\bar.md`,
|
||||
])('preserves the literal filename %s', (filename) => {
|
||||
const filePath = path.join(tempDir, filename);
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'other');
|
||||
fs.mkdirSync(path.join(tempDir, 'foo'));
|
||||
fs.writeFileSync(path.join(tempDir, 'foo', 'bar.md'), 'other');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, filename)).toEqual([canonical(filePath)]);
|
||||
});
|
||||
|
||||
it('resolves a negative extglob to files outside its alternatives', () => {
|
||||
const notesPath = path.join(tempDir, 'notes.md');
|
||||
for (const filename of ['proposal.md', 'design.md', 'notes.md']) {
|
||||
fs.writeFileSync(path.join(tempDir, filename), 'content');
|
||||
}
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, '!(proposal|design).md')).toEqual([
|
||||
canonical(notesPath),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'content/{safe,linked}/review.md',
|
||||
'content/@(safe|linked)/review.md',
|
||||
String.raw`content\{safe,linked}\review.md`,
|
||||
'{content/safe,content/linked/deep}/review.md',
|
||||
'{content/{safe,linked/deep},other}/review.md',
|
||||
])('confines directory pattern %s even without matching files', (pattern) => {
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-outside-'));
|
||||
fs.mkdirSync(path.join(tempDir, 'content', 'safe'), { recursive: true });
|
||||
fs.symlinkSync(outsideDir, path.join(tempDir, 'content', 'linked'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir');
|
||||
try {
|
||||
expect(() => resolveArtifactOutputs(tempDir, pattern)).toThrow(
|
||||
/outside the allowed directory/u
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])('rejects brace-expanded parent traversal before globbing (file exists: %s)', (exists) => {
|
||||
const outsideDir = fs.mkdtempSync(path.join(path.dirname(tempDir), 'openspec-outside-'));
|
||||
const pattern = `{safe,../${path.basename(outsideDir)}}/review.md`;
|
||||
if (exists) fs.writeFileSync(path.join(outsideDir, 'review.md'), 'private');
|
||||
const glob = vi.spyOn(fg, 'sync');
|
||||
try {
|
||||
expect(() => resolveArtifactOutputs(tempDir, pattern)).toThrow(
|
||||
/outside the allowed directory/u
|
||||
);
|
||||
expect(glob).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
glob.mockRestore();
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
'report-{draft}-{api,ui}.md',
|
||||
'report-{draft}-{{api},ui}.md',
|
||||
])('resolves later and nested brace expansions in %s', (pattern) => {
|
||||
const filePath = path.join(tempDir,
|
||||
pattern.includes('{{api}') ? 'report-{draft}-{api}.md' : 'report-{draft}-api.md');
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
expect(resolveArtifactOutputs(tempDir, pattern)).toEqual([canonical(filePath)]);
|
||||
});
|
||||
|
||||
it('resolves a brace range after a literal brace group', () => {
|
||||
const filenames = [1, 2, 3, 4].map((index) => `report-{draft}-${index}.md`);
|
||||
for (const filename of filenames) {
|
||||
fs.writeFileSync(path.join(tempDir, filename), 'content');
|
||||
}
|
||||
fs.writeFileSync(path.join(tempDir, 'report-draft-1.md'), 'other');
|
||||
|
||||
const pattern = 'report-{draft}-{1..3}.md';
|
||||
expect(resolveArtifactOutputs(tempDir, pattern)).toEqual(
|
||||
filenames.slice(0, 3).map((filename) => canonical(path.join(tempDir, filename)))
|
||||
);
|
||||
expect(artifactOutputExists(tempDir, pattern)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'{content/safe,other/deep}/review.md',
|
||||
String.raw`{content\safe,other\deep}\review.md`,
|
||||
])('resolves confined cross-directory braces in %s', (pattern) => {
|
||||
const filePath = path.join(tempDir, 'content', 'safe', 'review.md');
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
expect(resolveArtifactOutputs(tempDir, pattern)).toEqual([canonical(filePath)]);
|
||||
});
|
||||
|
||||
it('resolves single-star nested globs to concrete files', () => {
|
||||
const nestedDir = path.join(tempDir, 'specs', 'change-a');
|
||||
const filePath = path.join(nestedDir, 'spec.md');
|
||||
@@ -96,6 +230,66 @@ describe('artifact-graph/outputs', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports brace alternative glob patterns', () => {
|
||||
const apiPath = path.join(tempDir, 'review-api.md');
|
||||
const uiPath = path.join(tempDir, 'review-ui.md');
|
||||
fs.writeFileSync(apiPath, 'content');
|
||||
fs.writeFileSync(uiPath, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, 'review-{api,ui}.md')).toEqual([
|
||||
canonical(apiPath),
|
||||
canonical(uiPath),
|
||||
]);
|
||||
expect(artifactOutputExists(tempDir, 'review-{api,ui}.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves a brace glob with Windows-style separators', () => {
|
||||
const specsDir = path.join(tempDir, 'specs');
|
||||
fs.mkdirSync(specsDir);
|
||||
const apiPath = path.join(specsDir, 'review-api.md');
|
||||
const uiPath = path.join(specsDir, 'review-ui.md');
|
||||
fs.writeFileSync(apiPath, 'content');
|
||||
fs.writeFileSync(uiPath, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, String.raw`specs\review-{api,ui}.md`)).toEqual([
|
||||
canonical(apiPath),
|
||||
canonical(uiPath),
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports brace range glob patterns', () => {
|
||||
const file1 = path.join(tempDir, 'file-1.md');
|
||||
const file2 = path.join(tempDir, 'file-2.md');
|
||||
const file4 = path.join(tempDir, 'file-4.md');
|
||||
fs.writeFileSync(file1, 'content');
|
||||
fs.writeFileSync(file2, 'content');
|
||||
fs.writeFileSync(file4, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, 'file-{1..3}.md')).toEqual([
|
||||
canonical(file1),
|
||||
canonical(file2),
|
||||
]);
|
||||
expect(artifactOutputExists(tempDir, 'file-{1..3}.md')).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['@(proposal|design).md', '+(proposal|design).md'])('supports extglob %s', (pattern) => {
|
||||
const proposalPath = path.join(tempDir, 'proposal.md');
|
||||
fs.writeFileSync(proposalPath, 'content');
|
||||
fs.writeFileSync(path.join(tempDir, 'readme.md'), 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, pattern)).toEqual([
|
||||
canonical(proposalPath),
|
||||
]);
|
||||
expect(artifactOutputExists(tempDir, pattern)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty list when dynamic brace or extglob pattern has no matches', () => {
|
||||
expect(resolveArtifactOutputs(tempDir, 'review-{api,ui}.md')).toEqual([]);
|
||||
expect(artifactOutputExists(tempDir, 'review-{api,ui}.md')).toBe(false);
|
||||
expect(resolveArtifactOutputs(tempDir, '@(proposal|design).md')).toEqual([]);
|
||||
expect(artifactOutputExists(tempDir, '@(proposal|design).md')).toBe(false);
|
||||
});
|
||||
|
||||
it('canonicalizes resolved paths when the change directory is accessed through an alias', () => {
|
||||
const rootDir = path.join(tempDir, 'workspace');
|
||||
const realChangeDir = path.join(rootDir, 'real-change');
|
||||
|
||||
@@ -169,5 +169,63 @@ describe('artifact-graph/state', () => {
|
||||
expect(completed.has('design')).toBe(false);
|
||||
expect(completed.has('tasks')).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect brace expansion pattern as complete and unblock dependent artifact', () => {
|
||||
const schema = createSchema([
|
||||
{
|
||||
id: 'review',
|
||||
generates: 'review-{api,ui}.md',
|
||||
description: 'Review',
|
||||
template: 't.md',
|
||||
requires: [],
|
||||
},
|
||||
{
|
||||
id: 'signoff',
|
||||
generates: 'signoff.md',
|
||||
description: 'Signoff',
|
||||
template: 't.md',
|
||||
requires: ['review'],
|
||||
},
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
fs.writeFileSync(path.join(tempDir, 'review-api.md'), 'content');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
expect(completed.has('review')).toBe(true);
|
||||
expect(completed.has('signoff')).toBe(false);
|
||||
|
||||
const next = graph.getNextArtifacts(completed);
|
||||
expect(next).toEqual(['signoff']);
|
||||
});
|
||||
|
||||
it('should detect extglob pattern as complete and unblock dependent artifact', () => {
|
||||
const schema = createSchema([
|
||||
{
|
||||
id: 'spec',
|
||||
generates: '@(proposal|design).md',
|
||||
description: 'Spec',
|
||||
template: 't.md',
|
||||
requires: [],
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
generates: 'tasks.md',
|
||||
description: 'Tasks',
|
||||
template: 't.md',
|
||||
requires: ['spec'],
|
||||
},
|
||||
]);
|
||||
const graph = ArtifactGraph.fromSchema(schema);
|
||||
|
||||
fs.writeFileSync(path.join(tempDir, 'proposal.md'), 'content');
|
||||
|
||||
const completed = detectCompleted(graph, tempDir);
|
||||
expect(completed.has('spec')).toBe(true);
|
||||
expect(completed.has('tasks')).toBe(false);
|
||||
|
||||
const next = graph.getNextArtifacts(completed);
|
||||
expect(next).toEqual(['tasks']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -397,6 +397,9 @@ describe('command-generation/adapters', () => {
|
||||
expect(output).toContain('name: "opsx-explore"');
|
||||
expect(output).toContain('description: "Enter explore mode for thinking"');
|
||||
expect(output).toContain('invokable: true');
|
||||
expect(output).toContain(
|
||||
'---\n\nThis workflow prompt is already active. Follow its instructions directly. Do not call a tool named after this workflow.\n\nThis is the command body.'
|
||||
);
|
||||
expect(output).toContain('---\n\n');
|
||||
expect(output).toContain('This is the command body.');
|
||||
});
|
||||
@@ -580,7 +583,7 @@ describe('command-generation/adapters', () => {
|
||||
|
||||
it('should generate correct file path', () => {
|
||||
const filePath = kilocodeAdapter.getFilePath('explore');
|
||||
expect(filePath).toBe(path.join('.kilocode', 'workflows', 'opsx-explore.md'));
|
||||
expect(filePath).toBe(path.join('.kilo', 'command', 'opsx-explore.md'));
|
||||
});
|
||||
|
||||
it('should format file without frontmatter', () => {
|
||||
|
||||
+52
-22
@@ -1238,7 +1238,7 @@ describe('InitCommand', () => {
|
||||
const proposeFiles = [
|
||||
path.join(testDir, '.factory', 'commands', 'opsx-propose.md'),
|
||||
path.join(testDir, '.cursor', 'commands', 'opsx-propose.md'),
|
||||
path.join(testDir, '.kilocode', 'workflows', 'opsx-propose.md'),
|
||||
path.join(testDir, '.kilo', 'command', 'opsx-propose.md'),
|
||||
path.join(testDir, '.pi', 'prompts', 'opsx-propose.md'),
|
||||
path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md'),
|
||||
];
|
||||
@@ -1581,6 +1581,9 @@ describe('InitCommand', () => {
|
||||
const content = await fs.readFile(cmdFile, 'utf-8');
|
||||
expect(content).toContain('name: "opsx-explore"');
|
||||
expect(content).toContain('invokable: true');
|
||||
expect(content).toContain(
|
||||
'---\n\nThis workflow prompt is already active. Follow its instructions directly. Do not call a tool named after this workflow.\n\nEnter explore mode.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate Cline workflow files', async () => {
|
||||
@@ -1591,12 +1594,20 @@ describe('InitCommand', () => {
|
||||
expect(await fileExists(cmdFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate GitHub Copilot prompt files', async () => {
|
||||
it('should generate GitHub Copilot prompt and skill files with default delivery', async () => {
|
||||
const initCommand = new InitCommand({ tools: 'github-copilot', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const cmdFile = path.join(testDir, '.github', 'prompts', 'opsx-explore.prompt.md');
|
||||
const skillFile = path.join(
|
||||
testDir,
|
||||
'.github',
|
||||
'skills',
|
||||
'openspec-explore',
|
||||
'SKILL.md'
|
||||
);
|
||||
expect(await fileExists(cmdFile)).toBe(true);
|
||||
expect(await fileExists(skillFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail GitHub Copilot setup without partially creating cloud files', async () => {
|
||||
@@ -1772,6 +1783,18 @@ describe('InitCommand - profile and detection features', () => {
|
||||
expect(proposeCommand).toContain('**Provided arguments**: $ARGUMENTS');
|
||||
});
|
||||
|
||||
it('should replace legacy Kilo workflows with commands in the canonical directory', async () => {
|
||||
const legacyDir = path.join(testDir, '.kilocode', 'workflows');
|
||||
await fs.mkdir(legacyDir, { recursive: true });
|
||||
await fs.writeFile(path.join(legacyDir, 'opsx-propose.md'), 'legacy content');
|
||||
|
||||
const initCommand = new InitCommand({ tools: 'kilocode' });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
expect(await fileExists(path.join(legacyDir, 'opsx-propose.md'))).toBe(false);
|
||||
expect(await fileExists(path.join(testDir, '.kilo', 'command', 'opsx-propose.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove managed global Codex prompts in non-interactive mode', async () => {
|
||||
const promptDir = path.join(process.env.CODEX_HOME!, 'prompts');
|
||||
const legacyPrompt = path.join(promptDir, 'opsx-apply.md');
|
||||
@@ -2288,29 +2311,33 @@ describe('InitCommand - profile and detection features', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should print the $-prefixed skill hint for codex (skills-invocable, no slash surface)', async () => {
|
||||
// Codex has no slash-command surface: it invokes skills as $<name>, so the
|
||||
// hint - and the generated skills - must use that form, never /opsx:*
|
||||
const initCommand = new InitCommand({ tools: 'codex', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
it.each(['both', 'skills', 'commands'] as const)(
|
||||
'should print the Codex skill hint with delivery=%s',
|
||||
async (delivery) => {
|
||||
saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery });
|
||||
// Codex has no slash-command surface: it invokes skills as $<name>, so the
|
||||
// hint - and the generated skills - must use that form, never /opsx:*
|
||||
const initCommand = new InitCommand({ tools: 'codex', force: true });
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const skillFile = path.join(testDir, '.agents', 'skills', 'openspec-apply-change', 'SKILL.md');
|
||||
expect(await fileExists(skillFile)).toBe(true);
|
||||
const skillContent = await fs.readFile(skillFile, 'utf-8');
|
||||
expect(skillContent).not.toContain('/opsx:');
|
||||
expect(skillContent).toContain('$openspec-');
|
||||
const skillFile = path.join(testDir, '.agents', 'skills', 'openspec-apply-change', 'SKILL.md');
|
||||
expect(await fileExists(skillFile)).toBe(true);
|
||||
const skillContent = await fs.readFile(skillFile, 'utf-8');
|
||||
expect(skillContent).not.toContain('/opsx:');
|
||||
expect(skillContent).toContain('$openspec-');
|
||||
|
||||
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
|
||||
const startHint = logCalls.find((entry) => entry.includes('Start your first change'));
|
||||
expect(startHint).toContain('$openspec-propose');
|
||||
expect(startHint).not.toContain('/openspec-propose');
|
||||
expect(startHint).not.toContain('/opsx:propose');
|
||||
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
|
||||
const startHints = logCalls.filter((entry) => entry.includes('Start your first change'));
|
||||
expect(startHints).toEqual([
|
||||
' Start your first change: $openspec-propose "your idea" (Codex CLI or IDE); in the Codex desktop app, select openspec-propose from Skills in the sidebar',
|
||||
]);
|
||||
|
||||
// Codex is a CLI tool: its skills load as soon as the files exist, with no
|
||||
// IDE process to restart, so the restart line must not appear at all (#1067).
|
||||
const restartHint = logCalls.find((entry) => entry.includes('Restart your IDE'));
|
||||
expect(restartHint).toBeUndefined();
|
||||
});
|
||||
// Codex is a CLI tool: its skills load as soon as the files exist, with no
|
||||
// IDE process to restart, so the restart line must not appear at all (#1067).
|
||||
const restartHint = logCalls.find((entry) => entry.includes('Restart your IDE'));
|
||||
expect(restartHint).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it('should print the @-prefixed prompt hint for amazon-q (prompt library, no slash surface)', async () => {
|
||||
// Amazon Q loads .amazonq/prompts/opsx-<id>.md into its prompt library,
|
||||
@@ -2352,8 +2379,11 @@ describe('InitCommand - profile and detection features', () => {
|
||||
const codexHint = startHints.find((entry) => entry.includes('(Codex)'));
|
||||
const vibeHint = startHints.find((entry) => entry.includes('Mistral Vibe'));
|
||||
expect(codexHint).toContain('$openspec-propose');
|
||||
expect(codexHint).toContain('(Codex CLI or IDE)');
|
||||
expect(codexHint).toContain('Skills in the sidebar');
|
||||
expect(codexHint).not.toContain('/openspec-propose');
|
||||
expect(vibeHint).toContain('/openspec-propose');
|
||||
expect(vibeHint).not.toContain('Skills in the sidebar');
|
||||
for (const hint of startHints) {
|
||||
expect(hint).not.toContain('/opsx:');
|
||||
}
|
||||
|
||||
@@ -391,6 +391,21 @@ ${OPENSPEC_MARKERS.end}`);
|
||||
expect(result.files).toContain('.opencode/command/openspec-new.md');
|
||||
});
|
||||
|
||||
it('should detect Kilo workflows from the legacy command directory', async () => {
|
||||
const dirPath = path.join(testDir, '.kilocode', 'workflows');
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
await fs.writeFile(path.join(dirPath, 'opsx-propose.md'), 'content');
|
||||
await fs.writeFile(path.join(dirPath, 'openspec-apply.md'), 'content');
|
||||
await fs.writeFile(path.join(dirPath, 'opsx-custom.md'), 'user content');
|
||||
await fs.writeFile(path.join(dirPath, 'openspec-custom.md'), 'user content');
|
||||
|
||||
const result = await detectLegacySlashCommands(testDir);
|
||||
expect(result.files).toContain('.kilocode/workflows/opsx-propose.md');
|
||||
expect(result.files).toContain('.kilocode/workflows/openspec-apply.md');
|
||||
expect(result.files).not.toContain('.kilocode/workflows/opsx-custom.md');
|
||||
expect(result.files).not.toContain('.kilocode/workflows/openspec-custom.md');
|
||||
});
|
||||
|
||||
it('should detect legacy CoStrict command files without claiming their directory', async () => {
|
||||
const dirPath = path.join(testDir, '.cospec', 'openspec', 'commands');
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
@@ -1174,6 +1189,16 @@ ${OPENSPEC_MARKERS.end}`);
|
||||
type: 'files',
|
||||
pattern: '.windsurf/workflows/openspec-*.md',
|
||||
});
|
||||
|
||||
expect(LEGACY_SLASH_COMMAND_PATHS['kilocode']).toEqual({
|
||||
type: 'files',
|
||||
pattern: [
|
||||
...ALL_WORKFLOWS.map(workflow => `.kilocode/workflows/opsx-${workflow}.md`),
|
||||
'.kilocode/workflows/openspec-proposal.md',
|
||||
'.kilocode/workflows/openspec-apply.md',
|
||||
'.kilocode/workflows/openspec-archive.md',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should only include legacy tool IDs with a command surface capability', () => {
|
||||
|
||||
@@ -268,16 +268,16 @@ describe('legacy command directories and the files users keep in them', () => {
|
||||
|
||||
// Swap in the user's file right after cleanup's own directory scan has
|
||||
// read the generated one, so only a check just before the unlink catches it.
|
||||
const realReadFile = fs.readFile.bind(fs);
|
||||
const realOpen = fs.open.bind(fs);
|
||||
let opens = 0;
|
||||
let replaced = false;
|
||||
const spy = vi.spyOn(fs, 'readFile').mockImplementation((async (file: any, options?: any) => {
|
||||
const content = await realReadFile(file, options);
|
||||
if (!replaced && file === proposalPath) {
|
||||
const spy = vi.spyOn(fs, 'open').mockImplementation((async (file: any, ...rest: any[]) => {
|
||||
if (file === proposalPath && ++opens === 2) {
|
||||
replaced = true;
|
||||
await fs.writeFile(proposalPath, 'my own proposal command\n');
|
||||
}
|
||||
return content;
|
||||
}) as typeof fs.readFile);
|
||||
return realOpen(file, ...rest);
|
||||
}) as typeof fs.open);
|
||||
let result;
|
||||
try {
|
||||
result = await cleanupLegacyArtifacts(testDir, detection);
|
||||
@@ -294,6 +294,21 @@ describe('legacy command directories and the files users keep in them', () => {
|
||||
});
|
||||
|
||||
// Creating symlinks on Windows needs elevated rights.
|
||||
it.skipIf(process.platform === 'win32')('never follows a symlinked legacy command file', async () => {
|
||||
const shared = path.join(testDir, 'shared-proposal.md');
|
||||
await fs.writeFile(shared, generatedContent('proposal.md'));
|
||||
await writeFiles(CLAUDE_DIR, ['apply.md', 'archive.md']);
|
||||
await fs.symlink(shared, inProject(CLAUDE_DIR, 'proposal.md'));
|
||||
|
||||
const detection = await detectLegacyArtifacts(testDir);
|
||||
const result = await cleanupLegacyArtifacts(testDir, detection);
|
||||
|
||||
expect(await fs.readFile(shared, 'utf-8')).toBe(generatedContent('proposal.md'));
|
||||
expect((await fs.lstat(inProject(CLAUDE_DIR, 'proposal.md'))).isSymbolicLink()).toBe(true);
|
||||
expect(await exists(inProject(CLAUDE_DIR, 'apply.md'))).toBe(false);
|
||||
expect(result.keptFiles).toEqual([`${CLAUDE_DIR}/proposal.md`]);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')('never follows a symlinked legacy command folder', async () => {
|
||||
const shared = path.join(testDir, 'shared-commands');
|
||||
await fs.mkdir(shared, { recursive: true });
|
||||
|
||||
@@ -71,6 +71,17 @@ describe('openers core', () => {
|
||||
expect(claude?.style).toBe('attach-dirs');
|
||||
});
|
||||
|
||||
it.each([{ args: [] }, { args: ['--model', 'custom model'] }])(
|
||||
'replaces built-in args with $args without changing omitted fields',
|
||||
({ args }) => {
|
||||
const builtin = findOpener([...BUILTIN_OPENERS], 'codex')!;
|
||||
const table = mergeOpenerTable({ codex: { args } }, CONFIG_PATH);
|
||||
|
||||
expect(findOpener(table, 'codex')).toEqual({ ...builtin, args });
|
||||
expect(builtin.args).toEqual(['--sandbox', 'workspace-write']);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an unknown style naming the two valid styles', () => {
|
||||
try {
|
||||
mergeOpenerTable({ vim: { style: 'tabs' } }, CONFIG_PATH);
|
||||
@@ -98,6 +109,9 @@ describe('openers core', () => {
|
||||
expect(() =>
|
||||
mergeOpenerTable({ zed: { style: 'workspace-file', extra: 1 } }, CONFIG_PATH)
|
||||
).toThrowError(/Invalid openers config/);
|
||||
expect(() =>
|
||||
mergeOpenerTable({ code: { args: ['--wait', 1] } }, CONFIG_PATH)
|
||||
).toThrowError(/Invalid openers config/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
extractRequirementsSection,
|
||||
parseDeltaSpec,
|
||||
findMissingCurrentScenarios,
|
||||
diffScenarioNames,
|
||||
describeScenarioBalance,
|
||||
type RequirementBlock,
|
||||
} from '../../../src/core/parsers/requirement-blocks.js';
|
||||
|
||||
@@ -244,3 +246,93 @@ describe('findMissingCurrentScenarios: level-4 header parity', () => {
|
||||
expect(findMissingCurrentScenarios(current, incoming)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffScenarioNames: what the block adds, alongside what it drops', () => {
|
||||
const block = (raw: string): RequirementBlock => ({ headerLine: raw.split('\n')[0], name: '', raw });
|
||||
const req = (...names: string[]) =>
|
||||
block(
|
||||
[
|
||||
'### Requirement: Widget state',
|
||||
'The system SHALL report it.',
|
||||
'',
|
||||
...names.flatMap((name) => [`#### Scenario: ${name}`, '- **WHEN** a', '- **THEN** b', '']),
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
it('reports both directions and both totals', () => {
|
||||
const diff = diffScenarioNames(req('Kept', 'Dropped'), req('Kept', 'Fresh'));
|
||||
expect(diff).toEqual({
|
||||
missing: ['Dropped'],
|
||||
added: ['Fresh'],
|
||||
currentCount: 2,
|
||||
incomingCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts added names by multiplicity, mirroring missing', () => {
|
||||
// A name twice in the incoming block and once in the current leaves one
|
||||
// instance unmatched, the same rule the loss half already applies.
|
||||
const diff = diffScenarioNames(req('Edge case'), req('Edge case', 'Edge case'));
|
||||
expect(diff.missing).toEqual([]);
|
||||
expect(diff.added).toEqual(['Edge case']);
|
||||
});
|
||||
|
||||
it('agrees with findMissingCurrentScenarios, which is its missing half', () => {
|
||||
const current = req('Kept', 'Dropped');
|
||||
const incoming = req('Kept');
|
||||
expect(findMissingCurrentScenarios(current, incoming)).toEqual(
|
||||
diffScenarioNames(current, incoming).missing
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a fenced #### on either side, like the loss half', () => {
|
||||
const current = block(
|
||||
['### Requirement: Widget state', '', '#### Scenario: Real', '- **WHEN** a'].join('\n')
|
||||
);
|
||||
const incoming = block(
|
||||
[
|
||||
'### Requirement: Widget state',
|
||||
'',
|
||||
'#### Scenario: Real',
|
||||
'- **WHEN** a',
|
||||
'',
|
||||
'```markdown',
|
||||
'#### Scenario: Only an example',
|
||||
'```',
|
||||
].join('\n')
|
||||
);
|
||||
expect(diffScenarioNames(current, incoming).added).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeScenarioBalance: the sentence archive and validate share', () => {
|
||||
const diff = (over: Partial<ReturnType<typeof diffScenarioNames>>) => ({
|
||||
missing: [],
|
||||
added: [],
|
||||
currentCount: 0,
|
||||
incomingCount: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('names what a block adds, which is what separates a rename from a loss', () => {
|
||||
expect(
|
||||
describeScenarioBalance(diff({ added: ['Fresh'], currentCount: 2, incomingCount: 2 }))
|
||||
).toBe(
|
||||
'The modified block has 2 scenarios; the current spec has 2 scenarios. It adds 1 scenario not in the current spec: "Fresh".'
|
||||
);
|
||||
});
|
||||
|
||||
it('says so when a block adds nothing, which reads as a truncation', () => {
|
||||
expect(describeScenarioBalance(diff({ currentCount: 5, incomingCount: 2 }))).toBe(
|
||||
'The modified block has 2 scenarios; the current spec has 5 scenarios. It adds none.'
|
||||
);
|
||||
});
|
||||
|
||||
it('caps the listing so a wholesale rewrite cannot flood the message', () => {
|
||||
const message = describeScenarioBalance(
|
||||
diff({ added: ['A', 'B', 'C', 'D', 'E'], currentCount: 1, incomingCount: 5 })
|
||||
);
|
||||
expect(message).toContain('adds 5 scenarios not in the current spec: "A", "B", "C" and 2 more.');
|
||||
expect(message).not.toContain('"D"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +164,43 @@ describe('findPurposePlaceholderIssue', () => {
|
||||
expect(findPurposePlaceholderIssue(' \n ', specWith(''))).toBeNull();
|
||||
});
|
||||
|
||||
it('does not report the Spanish word "Todo" opening authored prose', () => {
|
||||
// `todo` is an extremely frequent sentence opener in Spanish ("Todo
|
||||
// el…") and Portuguese ("Todo o…"). A marker is written `TODO:`,
|
||||
// `TODO -`, or alone on its line - never `Todo` followed by prose.
|
||||
for (const purpose of [
|
||||
'Todo el conocimiento del producto vive del otro lado, en el repo hermano.',
|
||||
'Todo o catálogo é carregado a partir do repositório irmão.',
|
||||
]) {
|
||||
expect(findPurposePlaceholderIssue(purpose, specWith(purpose))).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('still reports a marker alone on its line above placeholder prose', () => {
|
||||
const purpose = 'TODO\nfill this in once the capability settles down.';
|
||||
expect(findPurposePlaceholderIssue(purpose, specWith(purpose))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('still reports a shouted TODO opening placeholder prose without punctuation', () => {
|
||||
// Case is what separates the marker from the Spanish word. In capitals
|
||||
// it is the marker whatever follows it, so requiring punctuation must
|
||||
// not let the plainest unwritten Purpose of all through.
|
||||
for (const purpose of [
|
||||
'TODO write this once the capability settles down.',
|
||||
'TBD pending the design review that has not happened yet.',
|
||||
]) {
|
||||
expect(findPurposePlaceholderIssue(purpose, specWith(purpose))).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not report lowercase Spanish prose either', () => {
|
||||
// The reporter is the capitals, not the position: `todo` uncapitalised
|
||||
// opens a sentence just as often, and is just as much authored prose.
|
||||
const purpose =
|
||||
'todo el conocimiento del producto vive del otro lado, en el repo hermano.';
|
||||
expect(findPurposePlaceholderIssue(purpose, specWith(purpose))).toBeNull();
|
||||
});
|
||||
|
||||
it('does not report an ordinary short Purpose, which PURPOSE_TOO_BRIEF covers', () => {
|
||||
expect(findPurposePlaceholderIssue('Does stuff.', specWith('Does stuff.'))).toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildUpdatedSpec,
|
||||
findSpecUpdates,
|
||||
writeUpdatedSpec,
|
||||
} from '../../src/core/specs-apply.js';
|
||||
|
||||
/**
|
||||
* A spec written by a Windows editor, or checked out with core.autocrlf=true,
|
||||
* arrives with CRLF endings. Applying a delta must not silently convert the
|
||||
* whole file to LF: that turns a one-requirement change into a diff touching
|
||||
* every line, which is unreviewable.
|
||||
*/
|
||||
|
||||
const ORIGINAL_REQUIREMENT = [
|
||||
'### Requirement: Existing behavior',
|
||||
'The project SHALL expose the original behavior.',
|
||||
'',
|
||||
'#### Scenario: Existing path',
|
||||
'- **WHEN** the behavior is exercised',
|
||||
'- **THEN** it SHALL remain available',
|
||||
].join('\n');
|
||||
|
||||
const UPDATED_REQUIREMENT = [
|
||||
'### Requirement: Existing behavior',
|
||||
'The project SHALL expose the updated behavior.',
|
||||
'',
|
||||
'#### Scenario: Existing path',
|
||||
'- **WHEN** the behavior is exercised',
|
||||
'- **THEN** it SHALL remain available',
|
||||
].join('\n');
|
||||
|
||||
const BASE_SPEC = [
|
||||
'# demo Specification',
|
||||
'',
|
||||
'## Purpose',
|
||||
'Demonstrates line-ending preservation.',
|
||||
'',
|
||||
'## Requirements',
|
||||
ORIGINAL_REQUIREMENT,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const DELTA_SPEC = ['## MODIFIED Requirements', '', UPDATED_REQUIREMENT, ''].join('\n');
|
||||
|
||||
function countEndings(content: string): { crlf: number; loneLf: number } {
|
||||
const crlf = content.match(/\r\n/g)?.length ?? 0;
|
||||
const loneLf = content.match(/(?<!\r)\n/g)?.length ?? 0;
|
||||
return { crlf, loneLf };
|
||||
}
|
||||
|
||||
describe('spec line-ending preservation', () => {
|
||||
let tempDir: string;
|
||||
let changeDir: string;
|
||||
let mainSpecsDir: string;
|
||||
let source: string;
|
||||
let target: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-spec-eol-'));
|
||||
changeDir = path.join(tempDir, 'openspec', 'changes', 'eol-test');
|
||||
mainSpecsDir = path.join(tempDir, 'openspec', 'specs');
|
||||
source = path.join(changeDir, 'specs', 'demo', 'spec.md');
|
||||
target = path.join(mainSpecsDir, 'demo', 'spec.md');
|
||||
await fs.mkdir(path.dirname(source), { recursive: true });
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(source, DELTA_SPEC);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function applyTo(targetContent: string): Promise<string> {
|
||||
await fs.writeFile(target, targetContent);
|
||||
const [update] = await findSpecUpdates(changeDir, mainSpecsDir);
|
||||
const built = await buildUpdatedSpec(update, 'eol-test', { silent: true });
|
||||
await writeUpdatedSpec(update, built.rebuilt, built.counts, { silent: true });
|
||||
return fs.readFile(target, 'utf-8');
|
||||
}
|
||||
|
||||
it('keeps a CRLF spec on CRLF', async () => {
|
||||
const written = await applyTo(BASE_SPEC.replaceAll('\n', '\r\n'));
|
||||
|
||||
expect(written).toContain('updated behavior.');
|
||||
const { crlf, loneLf } = countEndings(written);
|
||||
expect(loneLf).toBe(0);
|
||||
expect(crlf).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps an LF spec on LF', async () => {
|
||||
const written = await applyTo(BASE_SPEC);
|
||||
|
||||
expect(written).toContain('updated behavior.');
|
||||
const { crlf } = countEndings(written);
|
||||
expect(crlf).toBe(0);
|
||||
});
|
||||
|
||||
it('writes a brand-new spec with LF', async () => {
|
||||
// No existing target to take a convention from; LF is the portable default.
|
||||
// A new spec only accepts ADDED requirements.
|
||||
await fs.writeFile(
|
||||
source,
|
||||
['## ADDED Requirements', '', ORIGINAL_REQUIREMENT, ''].join('\n')
|
||||
);
|
||||
await fs.rm(target, { force: true });
|
||||
|
||||
const [update] = await findSpecUpdates(changeDir, mainSpecsDir);
|
||||
const built = await buildUpdatedSpec(update, 'eol-test', { silent: true });
|
||||
await writeUpdatedSpec(update, built.rebuilt, built.counts, { silent: true });
|
||||
|
||||
const written = await fs.readFile(target, 'utf-8');
|
||||
expect(countEndings(written).crlf).toBe(0);
|
||||
});
|
||||
|
||||
it('normalizes a mixed-ending spec to its dominant ending', async () => {
|
||||
const mixed = BASE_SPEC.replaceAll('\n', '\r\n').replace('## Purpose\r\n', '## Purpose\n');
|
||||
const written = await applyTo(mixed);
|
||||
|
||||
// CRLF dominates the input, so the output should settle on CRLF throughout
|
||||
// rather than preserving the stray LF.
|
||||
const { crlf, loneLf } = countEndings(written);
|
||||
expect(crlf).toBeGreaterThan(0);
|
||||
expect(loneLf).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
generateSkillContent,
|
||||
getCommandTemplates,
|
||||
getSkillTemplates,
|
||||
} from '../../../src/core/shared/skill-generation.js';
|
||||
import { getGlobalDataDir, registerStore } from '../../../src/core/index.js';
|
||||
import { runCLI } from '../../helpers/run-cli.js';
|
||||
|
||||
// Go through getSkillTemplates/getCommandTemplates rather than the raw
|
||||
// templates: these workflows carry optional-workflow blocks, and only these
|
||||
// entry points resolve them against an installed set. Building from the raw
|
||||
// template leaves `[[opsx:if-workflow ...]]` in the text, which skill
|
||||
// generation rejects. The set names sync so the installed branch is chosen,
|
||||
// which is the wording these assertions are about.
|
||||
const WORKFLOWS = ['archive', 'bulk-archive', 'sync'];
|
||||
const skill = (workflowId: string): string =>
|
||||
generateSkillContent(
|
||||
getSkillTemplates(WORKFLOWS).find((entry) => entry.workflowId === workflowId)!.template,
|
||||
'test'
|
||||
);
|
||||
const command = (id: string): string =>
|
||||
getCommandTemplates(WORKFLOWS).find((entry) => entry.id === id)!.template.content;
|
||||
|
||||
const surfaces = [
|
||||
['archive skill', skill('archive')],
|
||||
['archive command', command('archive')],
|
||||
['bulk archive skill', skill('bulk-archive')],
|
||||
['bulk archive command', command('bulk-archive')],
|
||||
] as const;
|
||||
|
||||
describe('archive task discovery uses schema-resolved CLI progress', () => {
|
||||
let root: string;
|
||||
let callerRoot: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
const storeId = 'task-store';
|
||||
|
||||
async function write(relative: string, content: string) {
|
||||
const file = path.join(root, relative);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, content);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-archive-task-guidance-'));
|
||||
env = {
|
||||
XDG_DATA_HOME: path.join(root, 'data'),
|
||||
XDG_CONFIG_HOME: path.join(root, 'config'),
|
||||
};
|
||||
await write('openspec/config.yaml', 'schema: custom\n');
|
||||
await fs.mkdir(path.join(root, 'openspec/specs'), { recursive: true });
|
||||
// Include another change so callers must match the selected name, not
|
||||
// take the first list entry or aggregate progress across the whole root.
|
||||
await write('openspec/changes/other/tasks.md', '- [x] Unrelated completed work\n');
|
||||
await registerStore({ id: storeId, localPath: root, globalDataDir: getGlobalDataDir({ env }) });
|
||||
|
||||
// The caller's nearest root contains the same change name, but its tasks
|
||||
// are all done. Omitting --store must therefore produce different progress.
|
||||
callerRoot = path.join(root, 'caller');
|
||||
await write('caller/openspec/config.yaml', 'schema: spec-driven\n');
|
||||
await write('caller/openspec/changes/selected/tasks.md', '- [x] Local work is done\n');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
for (const [surface, content] of surfaces) {
|
||||
it.each([
|
||||
['custom output', 'planning/work-items.md', ['planning/work-items.md']],
|
||||
['multiple outputs', 'work/*.md', ['work/backend.md', 'work/frontend.md']],
|
||||
] as const)(`${surface}: counts unfinished tasks in %s`, async (_label, generates, files) => {
|
||||
await write('openspec/schemas/custom/schema.yaml', [
|
||||
'name: custom',
|
||||
'version: 1',
|
||||
'artifacts:',
|
||||
' - id: implementation',
|
||||
` generates: "${generates}"`,
|
||||
' description: Implementation checklist',
|
||||
' template: checklist.md',
|
||||
' requires: []',
|
||||
'apply:',
|
||||
' requires: [implementation]',
|
||||
` tracks: "${generates}"`,
|
||||
].join('\n'));
|
||||
await write('openspec/changes/selected/.openspec.yaml', 'schema: custom\n');
|
||||
for (const file of files) {
|
||||
await write(`openspec/changes/selected/${file}`, '- [ x ] Finished\n- [~] Pending\n- [ ] Pending\n');
|
||||
}
|
||||
|
||||
// Execute the lookup actually taught in the task-checking step. The old
|
||||
// single workflow read tasks.md, and bulk assumed an artifact id "tasks";
|
||||
// neither can find this schema's implementation checklist.
|
||||
const step = content.split('3. **')[1].split('4. **')[0];
|
||||
const command = step.match(/`openspec (list[^`]*)`/);
|
||||
expect(command, surface).not.toBeNull();
|
||||
expect(step).toContain('same selected-root flags');
|
||||
expect(step).toMatch(/name` exactly matches/);
|
||||
expect(step).toContain('totalTasks - completedTasks');
|
||||
expect(step).toContain('nonnegative integer');
|
||||
expect(step).toContain('completedTasks <= totalTasks');
|
||||
expect(step).toMatch(/other markers.*remain incomplete/s);
|
||||
expect(step).not.toContain('artifactPaths.tasks');
|
||||
expect(step).not.toContain('If no tasks file exists');
|
||||
|
||||
const args = command![1].trim().split(/\s+/);
|
||||
expect(args).toEqual(['list', '--json']);
|
||||
const storeFlag = content.match(/then pass `(--store) <id>`/);
|
||||
expect(storeFlag).not.toBeNull();
|
||||
expect(content).toContain('Every unscoped example of those commands below is shorthand: before running it, append the flag');
|
||||
const scopedArgs = [...args, storeFlag![1], storeId];
|
||||
expect(scopedArgs).toEqual(['list', '--json', '--store', storeId]);
|
||||
|
||||
const unscoped = await runCLI(args, { cwd: callerRoot, env });
|
||||
expect(unscoped.exitCode, unscoped.stderr).toBe(0);
|
||||
expect(JSON.parse(unscoped.stdout).changes).toEqual([
|
||||
expect.objectContaining({ name: 'selected', totalTasks: 1, completedTasks: 1 }),
|
||||
]);
|
||||
|
||||
const result = await runCLI(scopedArgs, { cwd: callerRoot, env });
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const report = JSON.parse(result.stdout);
|
||||
expect(report.root).toMatchObject({ source: 'store', store_id: storeId });
|
||||
const changes = report.changes;
|
||||
expect(changes).toHaveLength(2);
|
||||
expect(changes.find((change: { name: string }) => change.name === 'selected')).toMatchObject({
|
||||
totalTasks: files.length * 3,
|
||||
completedTasks: files.length,
|
||||
status: 'in-progress',
|
||||
});
|
||||
await expect(fs.access(path.join(root, 'openspec/changes/selected/tasks.md'))).rejects.toThrow();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getBulkArchiveChangeSkillTemplate,
|
||||
getContinueChangeSkillTemplate,
|
||||
getExploreSkillTemplate,
|
||||
getOpsxBulkArchiveCommandTemplate,
|
||||
getOpsxContinueCommandTemplate,
|
||||
getOpsxExploreCommandTemplate,
|
||||
getOpsxUpdateCommandTemplate,
|
||||
getUpdateChangeSkillTemplate,
|
||||
} from '../../../src/core/templates/skill-templates.js';
|
||||
import { getCommandTemplates, getSkillTemplates } from '../../../src/core/shared/skill-generation.js';
|
||||
|
||||
describe('workflow list --json field usage', () => {
|
||||
it('does not invent schema labels in update and continue pickers', () => {
|
||||
const bodies = [
|
||||
getUpdateChangeSkillTemplate().instructions,
|
||||
getOpsxUpdateCommandTemplate().content,
|
||||
getContinueChangeSkillTemplate().instructions,
|
||||
getOpsxContinueCommandTemplate().content,
|
||||
];
|
||||
|
||||
for (const body of bodies) {
|
||||
const picker = body.slice(body.indexOf('1. **Select the change**'), body.indexOf('2. **'));
|
||||
expect(picker).toContain('openspec list --json');
|
||||
expect(picker).toContain('- Change name');
|
||||
expect(picker).toContain('- Status');
|
||||
expect(picker).toContain('`lastModified`');
|
||||
expect(picker).not.toMatch(/schema/i);
|
||||
expect(picker).not.toContain('openspec status');
|
||||
|
||||
const status = body.slice(body.indexOf('2. **'), body.indexOf('3. **'));
|
||||
expect(status).toContain('openspec status --change "<name>" --json');
|
||||
expect(status).toContain('`schemaName`');
|
||||
}
|
||||
});
|
||||
|
||||
it('limits bulk archive selection to list fields', () => {
|
||||
const bodies = [
|
||||
getBulkArchiveChangeSkillTemplate().instructions,
|
||||
getOpsxBulkArchiveCommandTemplate().content,
|
||||
];
|
||||
|
||||
for (const body of bodies) {
|
||||
const picker = body.slice(body.indexOf('2. **'), body.indexOf('3. **'));
|
||||
expect(picker).toContain('Show each change name and task status from the list output');
|
||||
expect(picker).not.toMatch(/schema/i);
|
||||
expect(picker).not.toContain('openspec status');
|
||||
|
||||
const status = body.slice(body.indexOf('3. **'), body.indexOf('4. **'));
|
||||
expect(status).toContain('openspec status --change "<name>" --json');
|
||||
expect(status).toContain('`schemaName`');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not claim explore receives schemas from list output', () => {
|
||||
const bodies = [
|
||||
getExploreSkillTemplate().instructions,
|
||||
getOpsxExploreCommandTemplate().content,
|
||||
];
|
||||
|
||||
for (const body of bodies) {
|
||||
expect(body).toContain('Their names and task status');
|
||||
expect(body).not.toContain('Their names, schemas, and status');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps bulk archive sync available with and without the sync workflow', () => {
|
||||
const variants = [
|
||||
[
|
||||
getSkillTemplates(['bulk-archive', 'sync']).find((entry) => entry.workflowId === 'bulk-archive')!.template.instructions,
|
||||
getSkillTemplates(['bulk-archive'])[0].template.instructions,
|
||||
'openspec-sync-specs',
|
||||
],
|
||||
[
|
||||
getCommandTemplates(['bulk-archive', 'sync']).find((entry) => entry.id === 'bulk-archive')!.template.content,
|
||||
getCommandTemplates(['bulk-archive'])[0].template.content,
|
||||
'/opsx:sync',
|
||||
],
|
||||
] as const;
|
||||
|
||||
for (const [withSync, withoutSync, workflow] of variants) {
|
||||
const syncStep = (text: string) => text.slice(
|
||||
text.indexOf('a. **Sync included delta specs**'),
|
||||
text.indexOf('b. **Verify included delta specs')
|
||||
);
|
||||
|
||||
expect(syncStep(withSync)).toContain(workflow);
|
||||
expect(syncStep(withoutSync)).not.toContain(workflow);
|
||||
expect(syncStep(withoutSync)).toContain('Perform the delta-to-main-spec merge inline yourself');
|
||||
expect(syncStep(withoutSync)).toContain('`includedDeltas`');
|
||||
expect(syncStep(withoutSync)).toContain('`excludedDeltas`');
|
||||
expect(withoutSync).toContain('If sync is requested, perform the delta-to-main-spec merge inline');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -98,14 +98,53 @@ describe('default task guidance', () => {
|
||||
const example = tasks!.instruction.match(/```\s*([\s\S]*?)```/)?.[1];
|
||||
expect(example).toBeDefined();
|
||||
const numberedTasks = example!.split('\n').filter(line => /^- \[ \] \d+\.\d+ /.test(line));
|
||||
expect(numberedTasks).toHaveLength(4);
|
||||
expect(numberedTasks).toHaveLength(5);
|
||||
expect(numberedTasks.every(line => /\bverify\b/i.test(line))).toBe(true);
|
||||
expect(numberedTasks[0]).toContain('expected files are present');
|
||||
expect(numberedTasks[1]).toContain('package installation succeeds');
|
||||
expect(numberedTasks[2]).toContain('export test passes');
|
||||
expect(numberedTasks[3]).toContain('unit tests cover quoting and delimiters');
|
||||
expect(numberedTasks[4]).toContain('Document the export API');
|
||||
expect(example).not.toMatch(/^- \[ \] \d+\.\d+ (?:verify|run (?:the )?verification)\b/im);
|
||||
});
|
||||
|
||||
// #1952: agents parked testing and documentation in one trailing group, so a
|
||||
// failure seeded in group 1 only surfaced at the end and cascaded into rework.
|
||||
it('keeps tests and documentation inside the group that does the work (#1952)', () => {
|
||||
const tasks = defaultSchema.artifacts.find(artifact => artifact.id === 'tasks');
|
||||
expect(tasks).toBeDefined();
|
||||
expect(tasks!.instruction).toMatch(
|
||||
/Each task group MUST land the tests and documentation its own work\s+calls for/
|
||||
);
|
||||
expect(tasks!.instruction).toMatch(
|
||||
/Do NOT collect testing or documentation into a final group/
|
||||
);
|
||||
// The rule is scoped to what a group's work actually needs, so the worked
|
||||
// example's scaffolding group can carry no tests or docs without
|
||||
// contradicting it.
|
||||
expect(tasks!.instruction).toMatch(
|
||||
/A group\s+whose work calls for neither, such as scaffolding or dependency setup,\s+carries neither/
|
||||
);
|
||||
expect(tasks!.instruction).toMatch(
|
||||
/A final group is for integration checks only, not for\s+the tests and docs an earlier group owed/
|
||||
);
|
||||
|
||||
// The worked example has to show a docs task inside the implementation
|
||||
// group, not a trailing "testing and documentation" group of its own.
|
||||
const example = tasks!.instruction.match(/```\s*([\s\S]*?)```/)?.[1];
|
||||
expect(example).toBeDefined();
|
||||
const headings = example!
|
||||
.split('\n')
|
||||
.filter(line => /^## /.test(line.trim()))
|
||||
.map(line => line.trim());
|
||||
expect(headings).toHaveLength(2);
|
||||
expect(headings.some(heading => /\b(test|testing|documentation|docs)\b/i.test(heading))).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
const lastGroup = example!.slice(example!.lastIndexOf(headings[headings.length - 1]));
|
||||
expect(lastGroup).toMatch(/^- \[ \] \d+\.\d+ Document the export API in docs\/export\.md/im);
|
||||
});
|
||||
});
|
||||
|
||||
describe('propose project context', () => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseSchema } from '../../../src/core/artifact-graph/schema.js';
|
||||
|
||||
// The published schema reference quotes every `spec-driven` instruction
|
||||
// verbatim ("The instruction sent to the agent when it drafts this
|
||||
// artifact"), so a reader can see exactly what their agent is told. Nothing
|
||||
// regenerated that page, and it drifted: the `specs` block predated the
|
||||
// store-aware main-spec paths (#1703) and the `tasks` block still taught the
|
||||
// pre-#1660 rules, so the site contradicted the shipped instruction. This
|
||||
// keeps the quoted blocks byte-identical to schema.yaml.
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..', '..');
|
||||
const SCHEMA_DIR = path.join(REPO_ROOT, 'schemas', 'spec-driven');
|
||||
const DOC_PATH = path.join(
|
||||
REPO_ROOT,
|
||||
'docs-lab',
|
||||
'reference',
|
||||
'schemas',
|
||||
'spec-driven',
|
||||
'index.md'
|
||||
);
|
||||
|
||||
const normalize = (value: string): string => value.replace(/\r\n?/g, '\n').trim();
|
||||
|
||||
// The fence length varies per block because an instruction may contain its own
|
||||
// ``` example, so the outer fence has to be longer.
|
||||
const QUOTED_INSTRUCTION =
|
||||
/### Instructions\n\n[^\n]*\n\n(`{3,})md\n([\s\S]*?)\n\1\n/g;
|
||||
|
||||
describe('published schema reference', () => {
|
||||
it('quotes every spec-driven instruction verbatim (#1952)', () => {
|
||||
const schema = parseSchema(
|
||||
fs.readFileSync(path.join(SCHEMA_DIR, 'schema.yaml'), 'utf-8')
|
||||
);
|
||||
const doc = fs.readFileSync(DOC_PATH, 'utf-8').replace(/\r\n?/g, '\n');
|
||||
|
||||
const quoted = [...doc.matchAll(QUOTED_INSTRUCTION)].map(match => match[2]);
|
||||
const expected: Array<[string, string]> = [
|
||||
...schema.artifacts.map(
|
||||
(artifact): [string, string] => [artifact.id, artifact.instruction ?? '']
|
||||
),
|
||||
['apply', schema.apply?.instruction ?? ''],
|
||||
];
|
||||
|
||||
expect(quoted).toHaveLength(expected.length);
|
||||
expected.forEach(([id, instruction], index) => {
|
||||
expect(instruction, `${id} has no instruction to quote`).not.toBe('');
|
||||
expect(normalize(quoted[index]), `${id} instruction is stale in ${path.basename(DOC_PATH)}`).toBe(
|
||||
normalize(instruction)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the tasks guidance on the published page (#1952)', () => {
|
||||
const doc = fs.readFileSync(DOC_PATH, 'utf-8').replace(/\r\n?/g, '\n');
|
||||
expect(doc).toContain(
|
||||
'Each task group MUST land the tests and documentation its own work'
|
||||
);
|
||||
expect(doc).toContain('Each task MUST state how to verify completion');
|
||||
});
|
||||
});
|
||||
@@ -76,46 +76,46 @@ function specDrivenTitles(): Record<string, string> {
|
||||
}
|
||||
|
||||
const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
|
||||
getExploreSkillTemplate: 'b17a409b5634b5e48864a87f038f2111a74c2442288e9f4cb7a704f86b6d75e7',
|
||||
getExploreSkillTemplate: 'c1fddb294758004936add586f5826694cb06175cff935b75fd3a8d92332332e6',
|
||||
getNewChangeSkillTemplate: '0e5035b7b42198afc430206a1dbc9579096650ef0813d85e837d5a6cd0b98a85',
|
||||
getContinueChangeSkillTemplate: '550dc22bc8e0921b1ca5cef867379c4f370c5f1902c420bf9fa3bbfa75cea933',
|
||||
getContinueChangeSkillTemplate: 'c2c8a0ba7f8c8fc7b174793832cd50f7c404eb8e1f7d49c47000993d621633b6',
|
||||
getApplyChangeSkillTemplate: '04ae407c97b5f9cb0cc15199fe877ccc7cd1eff78bfe10ad70c16a112b10a661',
|
||||
getFfChangeSkillTemplate: '6fb5492e78b9ceec068949080ec9f2e0d2a8baff75a2fe33d07ad33ffe542b65',
|
||||
getFfChangeSkillTemplate: 'd091600476a815ba99f69b446bcd46af5bf73d1c2810215a0c6196937d019cf6',
|
||||
getSyncSpecsSkillTemplate: 'bc80fe9b07eaa289e5eb8a3ce65eb7df722a16d864e37283c678220712e4f230',
|
||||
getOnboardSkillTemplate: '7d92756ffc0b30053838716005610daf3f65c3fa011f3f4d29b6488f303f9cfb',
|
||||
getOpsxExploreCommandTemplate: 'f6cf22825643281d653355745623a6c1a4566db46cc2f262d2282243c6d8169a',
|
||||
getOnboardSkillTemplate: '84258a06c0ca88de708a23dd74e9a17efe11eff63a071b3864c781dcd5a0a4b7',
|
||||
getOpsxExploreCommandTemplate: '5d11f8ecb4c457140a3e874a8bf7aa72674e922e698c208832b1f34d3c617719',
|
||||
getOpsxNewCommandTemplate: '6d504fef1e0d4ced7c423f4cc9d9d2cee11b1a6224edf685e06a3f0757e0ebff',
|
||||
getOpsxContinueCommandTemplate: 'ace5c9cc239c12b57dc86fd9a1c02a6ca467cb8e1245127340c07ab1b9d37c11',
|
||||
getOpsxContinueCommandTemplate: '241c50f97d5d681412d456d6b982743c3a5babeb77017fc8099c418bcf0d92df',
|
||||
getOpsxApplyCommandTemplate: 'd70cecce3b7d1dd4dbd5fd1fc2bccb538f5e61f5b43d520e4beca896e3f9e6b3',
|
||||
getOpsxFfCommandTemplate: '04cb49b0bf3ebe364b45268a283564ee4fd50b78b01ec1d3f975bcae68179d2d',
|
||||
getArchiveChangeSkillTemplate: '8447a2489240bf0c27f863065d61453dd0264842d1dabafe27b577d6bff96eb3',
|
||||
getBulkArchiveChangeSkillTemplate: 'f17399959921ff98c7798e4591c8888825b7c9a83b0a90f09d98c7e0984ab793',
|
||||
getOpsxFfCommandTemplate: '743a7304c7efc84aa87f556154c034e1e0e561c276c51870a30ada58f33eb9af',
|
||||
getArchiveChangeSkillTemplate: '71715f9d5899498942af03e182e6d1ac2c95952dde967950c2a9161084a53a8b',
|
||||
getBulkArchiveChangeSkillTemplate: '2a6ec08fea0f942158b4abe9c8d1af9622038e0c4dc684e7c73dad2fb8379a54',
|
||||
getOpsxSyncCommandTemplate: '60550b7bb9829421656d6324a9e4c951bc912f48f88882d1a07ce7f78397a5e7',
|
||||
getVerifyChangeSkillTemplate: '2e069a277dac23818b13bb50b66e806ab405bc3b7f535400e1ebf81b84153699',
|
||||
getOpsxArchiveCommandTemplate: '980109e5f8362610872c70fe0a0f1d48d3d2692275b2b17e2f4c91c3de89c2fd',
|
||||
getOpsxOnboardCommandTemplate: '9cad751f7b938eea039b0ba207247776269c81bec5923eb335bee468f515f244',
|
||||
getOpsxBulkArchiveCommandTemplate: '3db03eadb764abd74c8c180656c3f64a8b9a4971056c91624d38df3209d7b446',
|
||||
getOpsxVerifyCommandTemplate: '938f52f20fb9a3b811ea47314baac1034cd550e8ab363ae878ccba4b6329348f',
|
||||
getVerifyChangeSkillTemplate: 'eecb063792075191b613978dec45f9f2fee247d2ff3003f2ebf17d632e54352e',
|
||||
getOpsxArchiveCommandTemplate: '3d2a330b46043fbb9f220831aa42ebbb62f411e9597b2bb491ad1ac1fa2d0873',
|
||||
getOpsxOnboardCommandTemplate: '0cf66e164c0e14c916c6d1ebb5d80ded07d7fb8e55d4eb34eba43e8ca9c28558',
|
||||
getOpsxBulkArchiveCommandTemplate: '4e2e39c4d634074f4a1ed67f076d5c4d0ead8b998f4d75218c33cdc6173719be',
|
||||
getOpsxVerifyCommandTemplate: 'f47bc0c30cfa8e93b5e42026e9417636c5f15bd8505fb9138872e34af8906abb',
|
||||
getOpsxProposeSkillTemplate: '1aa2f2eb9c8cbc4dcab9d777bf8832b92ca04f9ef91d0494f1224a566aefdfe8',
|
||||
getOpsxProposeCommandTemplate: '3b7090ce5e79e879ab9b5bdaf4ff2b52e3c02211f71188838772d36ac337f96c',
|
||||
getFeedbackSkillTemplate: 'dabeb5e825b9349abc8156c3e7b8608f27987912a6d9bf47ef29addde6138133',
|
||||
getUpdateChangeSkillTemplate: 'f4c38adf3c82b3e0af7c460de97b72740d69a8966b5426b259f7c2cb6dc11d3d',
|
||||
getOpsxUpdateCommandTemplate: 'a3156c2c3b4a429fed56545f315f66a7cc25bc9f8822c5fe30a60ccd87159a0f',
|
||||
getUpdateChangeSkillTemplate: '8380139769cf9b247cb64089c07628e73923405fca4704052d52a67f35526fe8',
|
||||
getOpsxUpdateCommandTemplate: 'ec6c8b7f3f366d65a216c4ba423bc41b8dbd18974a432186ad44e60c1891340b',
|
||||
};
|
||||
|
||||
const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = {
|
||||
'openspec-explore': '8b02eb77ae87a4374a43ad33930e5ea19cbaf1c8a7624d5b613c03d7ee1c5f14',
|
||||
'openspec-explore': '7d80caf9cd25a2565ba190b1297f1631c7f2c2db5e614597b4284abc0118ea70',
|
||||
'openspec-new-change': '27e09d43785953827efc9a98bb9d6cf06db48fe6abe7e1c049409fe5b5061323',
|
||||
'openspec-continue-change': '182f015de6a1a114c79a6106c0565fd71f368d629641d0ad088de54bd871b52f',
|
||||
'openspec-continue-change': '1f92fad53022270e96f8ea34de75f7c12c08225edd5a9e8f4e864b63b5ef79c5',
|
||||
'openspec-apply-change': 'f3e92c229fab8d77df9f0a77dcb117cf46279b53a208d53aed89bfe0bab2ac09',
|
||||
'openspec-ff-change': '8ffad1b1a2deea5f097eb7294fb8b9474d5dfb1c31ee2fd3311d9a9d78259323',
|
||||
'openspec-ff-change': 'a7ab656d46f04d45dff0c8888df4a126a2e62288b7336f7445bce4d1715055f5',
|
||||
'openspec-sync-specs': '3909936a236a21a9a6d5bf495f90b396b3b68fc9220d7b2c1894668653beb2e4',
|
||||
'openspec-archive-change': '305a21a9c76a925055f3bdbaac504f208660ef6948d78f73928de166250609bf',
|
||||
'openspec-bulk-archive-change': '4bd638a50111d2ee3a667752a2355ed513f770695b137b93fc28848ca7bf60d2',
|
||||
'openspec-verify-change': 'ad8a3098bd27d852721687c47a12db7107ed8b8dfc7f071406bb19961652e7ee',
|
||||
'openspec-onboard': 'd4c5f3e24c19c8e389950544ea0d1844027753def14748c9684210ae4c6cd5e5',
|
||||
'openspec-archive-change': 'd01d9eeb06223ee89708b7963e82c5ebc11719c5b2dc62d4abb268ee016fcb7b',
|
||||
'openspec-bulk-archive-change': '10f050ad5ef77084dc55a202427988b23903ee122f00985238a4eb9354a5dc3c',
|
||||
'openspec-verify-change': '62c2d471a1ebc4be38df0d06393eb94d3d8b803719b6349b8a1d8e9231448275',
|
||||
'openspec-onboard': '6993eff867d97d485e080078f9dfb80e968e242f3b17a924eeb077715fd548fa',
|
||||
'openspec-propose': '66e3395adf9f2d93a09e8ef1d20e4efb010e5e8d4811f2d42a9316e4d1ca5a8b',
|
||||
'openspec-update-change': '19163b8c1b40ccdc0840019aa8005877a90a3a1cd9f7aadb87f76ccce1342f19',
|
||||
'openspec-update-change': '5f4ea19aa732b33d87a2120ec393ee34578e70678d97e8c3bb10f988c00cb4d3',
|
||||
};
|
||||
|
||||
// Intentionally excludes getFeedbackSkillTemplate: this list only models templates
|
||||
@@ -156,6 +156,51 @@ function hash(value: string): string {
|
||||
}
|
||||
|
||||
describe('skill templates split parity', () => {
|
||||
it('uses one clarification threshold in fast-forward guidance (#1837)', () => {
|
||||
const variants: Array<[string, string]> = [
|
||||
['ff skill', getFfChangeSkillTemplate().instructions],
|
||||
['ff command', getOpsxFfCommandTemplate().content],
|
||||
];
|
||||
|
||||
for (const [variant, content] of variants) {
|
||||
expect(content, variant).toContain(
|
||||
'**If an artifact requires user input** (critically unclear context)'
|
||||
);
|
||||
expect(content, variant).not.toContain(
|
||||
'**If an artifact requires user input** (unclear context)'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('approves onboarding tasks before saving or offering implementation (#1837)', () => {
|
||||
const variants: Array<[string, string]> = [
|
||||
['onboard skill', getOnboardSkillTemplate().instructions],
|
||||
['onboard command', getOpsxOnboardCommandTemplate().content],
|
||||
];
|
||||
|
||||
for (const [variant, content] of variants) {
|
||||
expect(content, variant).toContain('Does this task breakdown look right?');
|
||||
expect(content, variant).not.toContain(
|
||||
'Each checkbox becomes a unit of work in the apply phase. Ready to implement?'
|
||||
);
|
||||
expect(content, variant).toContain(
|
||||
'**PAUSE** - Wait for user approval/feedback.\n\n' +
|
||||
'After approval, save to the `resolvedOutputPath` from `openspec instructions tasks --change "<name>" --json`.'
|
||||
);
|
||||
expect(content, variant).toContain('> "Tasks are saved. Ready to implement?"');
|
||||
expect(content, variant).toContain(
|
||||
'**PAUSE** - Wait for user to confirm before implementation.'
|
||||
);
|
||||
|
||||
const saveAt = content.indexOf('After approval, save to the `resolvedOutputPath`');
|
||||
const implementationChoiceAt = content.indexOf('> "Tasks are saved. Ready to implement?"');
|
||||
const implementationAt = content.indexOf('## Phase 9: Apply (Implementation)');
|
||||
expect(saveAt, variant).toBeGreaterThanOrEqual(0);
|
||||
expect(implementationChoiceAt, variant).toBeGreaterThan(saveAt);
|
||||
expect(implementationAt, variant).toBeGreaterThan(implementationChoiceAt);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves all template function payloads exactly', () => {
|
||||
const functionFactories: Record<string, () => unknown> = {
|
||||
getExploreSkillTemplate,
|
||||
@@ -454,6 +499,25 @@ describe('skill templates split parity', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// #1952: the onboarding walkthrough is where a user first meets task groups,
|
||||
// so it has to say the same thing the tasks instruction does - tests and docs
|
||||
// belong to the group that did the work, not to a trailing catch-up group.
|
||||
it('teaches per-group tests and docs in the onboarding walkthrough (#1952)', () => {
|
||||
const variants: Array<[string, string]> = [
|
||||
['onboard skill', generateSkillContent(asDeployed(getOnboardSkillTemplate()), 'PARITY-BASELINE')],
|
||||
['onboard command', getOpsxOnboardCommandTemplate().content],
|
||||
];
|
||||
|
||||
for (const [label, content] of variants) {
|
||||
expect(content, label).toContain(
|
||||
'Each group carries the tests and documentation for its own work - the last group is only for integration checks.'
|
||||
);
|
||||
// The trailing group stays integration-only; it must not be renamed back
|
||||
// into a general testing/documentation bucket.
|
||||
expect(content, label).toContain('## 2. Integration Verification');
|
||||
}
|
||||
});
|
||||
|
||||
it('generates no workspace-planning residue in any workflow template (4.1)', () => {
|
||||
const allSkills: Array<[string, () => SkillTemplate]> = [
|
||||
['openspec-apply-change', getApplyChangeSkillTemplate],
|
||||
|
||||
@@ -241,21 +241,72 @@ describe('update-change templates', () => {
|
||||
expect(body, label).toContain('NEVER edit implementation code');
|
||||
expect(body, label).toContain('stop and point to `/opsx:apply`');
|
||||
expect(body, label).toContain('Do not advance the build frontier');
|
||||
expect(body, label).toContain('Do NOT create artifacts that don\'t exist yet');
|
||||
expect(body, label).toContain(
|
||||
'no existing output files and status `ready` or `blocked`, note it and point the user to `/opsx:continue`'
|
||||
);
|
||||
expect(body, label).toContain(
|
||||
'empty `existingOutputPaths` and status `ready` or `blocked`, that is `/opsx:continue`\'s job'
|
||||
);
|
||||
expect(body, label).toContain('Leave `skipped` artifacts untouched');
|
||||
expect(body, label).toContain('do not treat them as missing or defer them to the continue workflow');
|
||||
}
|
||||
});
|
||||
|
||||
it('fills a gap under an already-satisfied glob artifact instead of deferring it (3.3a)', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain('is marked `done` after at least one file matches');
|
||||
expect(body, label).toContain('the continue workflow only handles `ready` artifacts');
|
||||
expect(body, label).toContain('whose `existingOutputPaths` is non-empty');
|
||||
expect(body, label).toContain(
|
||||
'use its `instruction` and `template`'
|
||||
);
|
||||
expect(body, label).toContain('Treat `context` and `rules` as constraints; do not copy them into the file');
|
||||
expect(body, label).toContain('If instructions report `skipped: true`, do not create the file');
|
||||
expect(body, label).toContain('Read current dependency files from disk');
|
||||
expect(body, label).toContain('if a required non-skipped dependency is missing, stop and ask the user to restore it first');
|
||||
expect(body, label).toContain('If `instruction` delegates creation to another skill or command');
|
||||
expect(body, label).toContain('only if it can honor the confirmed path and these guardrails; otherwise stop');
|
||||
expect(body, label).toContain(
|
||||
'inside `changeRoot` that matches `artifactPaths.<id>.outputPath`'
|
||||
);
|
||||
expect(body, label).toContain('create it only after the user confirms');
|
||||
expect(body, label).toContain('does not already exist');
|
||||
expect(body, label).toContain('after resolving any symlinked parent directories');
|
||||
}
|
||||
});
|
||||
|
||||
it('rechecks new-file scope after confirmation and refuses concurrent overwrites', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
const confirmation = body.indexOf('create it only after the user confirms');
|
||||
const recheck = body.indexOf('After confirmation, immediately before creation');
|
||||
const create = body.indexOf('Use a create operation that fails if the target already exists');
|
||||
|
||||
expect(confirmation, label).toBeGreaterThanOrEqual(0);
|
||||
expect(recheck, label).toBeGreaterThan(confirmation);
|
||||
expect(create, label).toBeGreaterThan(recheck);
|
||||
const writeGuard = body.slice(recheck, create);
|
||||
expect(writeGuard, label).toContain('refresh status and instructions');
|
||||
expect(writeGuard, label).toContain('still in scope, not skipped, and partially populated');
|
||||
expect(writeGuard, label).toContain('repeat the concrete-path checks above');
|
||||
expect(body, label).toContain('stop and reconcile with the user rather than replacing existing content or choosing a different path');
|
||||
}
|
||||
});
|
||||
|
||||
it('writes to existingOutputPaths, never to a glob resolvedOutputPath (3.4)', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain('artifactPaths.<id>.existingOutputPaths');
|
||||
expect(body, label).toContain('Do NOT write to `resolvedOutputPath`');
|
||||
expect(body, label).toContain('still the glob pattern, not a real file');
|
||||
expect(body, label).toContain('it is still the glob pattern');
|
||||
expect(body, label).toContain('The glob `resolvedOutputPath` is not a valid target');
|
||||
expect(body, label).toContain('The only new-file scope');
|
||||
}
|
||||
});
|
||||
|
||||
it('ends with next-step guidance and never acts on it (3.5)', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain('guidance only - NEVER act on it');
|
||||
expect(body, label).toContain(
|
||||
'Artifacts with empty `existingOutputPaths` and status `ready` or `blocked` -> suggest `/opsx:continue`'
|
||||
);
|
||||
expect(body, label).toContain('suggest `/opsx:continue`');
|
||||
expect(body, label).toContain('suggest `/opsx:apply`');
|
||||
expect(body, label).toContain('suggest `/opsx:archive`');
|
||||
@@ -296,6 +347,9 @@ describe('update-change templates', () => {
|
||||
|
||||
it('confirms every edit and redirects intent changes to /opsx:new when installed', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
const reconciliation = body.slice(body.indexOf('4. **Read and reconcile**'), body.indexOf('5. **Confirm and apply'));
|
||||
expect(reconciliation, label).toContain('Draft the requested edit in the conversation, not in files');
|
||||
expect(reconciliation, label).not.toContain('Apply the requested edit');
|
||||
expect(body, label).toContain('Write only after the user confirms');
|
||||
expect(body, label).toContain('If the user rejects a revision, do not write it');
|
||||
expect(body, label).toContain('recommend starting fresh with `/opsx:new`');
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getVerifyChangeSkillTemplate,
|
||||
getOpsxVerifyCommandTemplate,
|
||||
} from '../../../src/core/templates/skill-templates.js';
|
||||
|
||||
// #1959: verify treated every "### Requirement:" in a change's delta specs as
|
||||
// behavior that must exist, whichever section it sat under. A REMOVED
|
||||
// requirement that was removed correctly came back as CRITICAL "Requirement not
|
||||
// found" with the recommendation to implement it, so an agent following the
|
||||
// report restored what the change had just deleted.
|
||||
const bodies: Array<[string, string]> = [
|
||||
['skill', getVerifyChangeSkillTemplate().instructions],
|
||||
['command', getOpsxVerifyCommandTemplate().content],
|
||||
// The committed skills.sh mirror is what `npx skills add` installs.
|
||||
[
|
||||
'committed skill file',
|
||||
readFileSync(new URL('../../../skills/openspec-verify-change/SKILL.md', import.meta.url), 'utf8'),
|
||||
],
|
||||
];
|
||||
|
||||
function section(body: string, start: string, end: string, label: string): string {
|
||||
const from = body.indexOf(start);
|
||||
const to = body.indexOf(end, from + start.length);
|
||||
expect(from, `${label}: "${start}" not found`).toBeGreaterThanOrEqual(0);
|
||||
expect(to, `${label}: "${end}" not found after "${start}"`).toBeGreaterThan(from);
|
||||
return body.slice(from, to);
|
||||
}
|
||||
|
||||
describe('verify checks each requirement by its delta operation', () => {
|
||||
it.each(bodies)('%s: classifies requirements by delta section before checking them', (label, body) => {
|
||||
const coverage = section(body, '**Spec Coverage**', '6. **Verify Correctness**', label);
|
||||
|
||||
// RENAMED entries carry no "### Requirement:" heading, so a rename-only
|
||||
// delta must not read as empty.
|
||||
expect(coverage, label).toContain('or listed as `FROM:`/`TO:` pairs under `## RENAMED Requirements`');
|
||||
for (const header of ['## ADDED', '## MODIFIED', '## REMOVED', '## RENAMED Requirements']) {
|
||||
expect(coverage, label).toContain(header);
|
||||
}
|
||||
// The unscoped loop is what produced the bug.
|
||||
expect(coverage, label).not.toMatch(/^\s*- For each requirement:$/m);
|
||||
});
|
||||
|
||||
it.each(bodies)('%s: reports a missing requirement only for ADDED or MODIFIED', (label, body) => {
|
||||
const coverage = section(body, '**Spec Coverage**', '6. **Verify Correctness**', label);
|
||||
const addedOrModified = section(coverage, '- For each ADDED or MODIFIED requirement', '- For each REMOVED requirement', label);
|
||||
|
||||
expect(addedOrModified, label).toContain('Add CRITICAL issue: "Requirement not found: <requirement name>"');
|
||||
});
|
||||
|
||||
it.each(bodies)('%s: inverts the check for a REMOVED requirement', (label, body) => {
|
||||
const coverage = section(body, '**Spec Coverage**', '6. **Verify Correctness**', label);
|
||||
const removed = section(coverage, '- For each REMOVED requirement', '- For each RENAMED entry', label);
|
||||
|
||||
expect(removed, label).toContain('Finding no implementation is the expected result.');
|
||||
expect(removed, label).toContain('Never report a REMOVED requirement as "Requirement not found"');
|
||||
expect(removed, label).toContain('Add CRITICAL issue: "Removed requirement still implemented: <requirement name>"');
|
||||
expect(removed, label).not.toContain('Recommendation: "Implement');
|
||||
});
|
||||
|
||||
it.each(bodies)('%s: does not report the old name of a RENAMED requirement as missing', (label, body) => {
|
||||
const renamed = section(body, '- For each RENAMED entry', '6. **Verify Correctness**', label);
|
||||
|
||||
expect(renamed, label).toContain('Do not report the FROM name as missing');
|
||||
expect(renamed, label).toContain('do not require code symbols, identifiers, or file names to be renamed');
|
||||
});
|
||||
|
||||
// A rename keeps behavior, so a rename-only change must still prove the
|
||||
// behavior exists before verify can call it ready. Its evidence is the
|
||||
// baseline requirement in the main spec, not the RENAMED entry itself.
|
||||
it.each(bodies)('%s: verifies the unchanged behavior of a RENAMED requirement against its baseline', (label, body) => {
|
||||
const renamed = section(body, '- For each RENAMED entry', '6. **Verify Correctness**', label);
|
||||
|
||||
expect(renamed, label).toContain('check the TO requirement for that unchanged behavior');
|
||||
expect(renamed, label).toContain('`<planningHome.root>/openspec/specs/<capability-path>/spec.md`');
|
||||
expect(renamed, label).toContain('the requirement under the FROM name, or under the TO name only when the FROM name is absent because the main spec is already synced.');
|
||||
expect(renamed, label).toContain('Its body and scenarios are the evidence for the behavior the TO requirement keeps.');
|
||||
expect(renamed, label).toContain('Search codebase for that behavior and assess if it is still implemented.');
|
||||
expect(renamed, label).toContain('Add CRITICAL issue: "Renamed requirement not found: <TO name>"');
|
||||
expect(body, label).toContain('- Renamed requirements whose behavior is no longer implemented');
|
||||
expect(renamed, label).toContain('If the TO name also appears under MODIFIED, its behavior is checked there');
|
||||
});
|
||||
|
||||
it.each(bodies)('%s: never counts an unchecked rename as passing', (label, body) => {
|
||||
const renamed = section(body, '- For each RENAMED entry', '6. **Verify Correctness**', label);
|
||||
|
||||
expect(renamed, label).toContain('If the baseline requirement cannot be found or read, mark **Spec Coverage** as not verified for that entry');
|
||||
expect(renamed, label).toContain('Never count an unchecked rename as passing.');
|
||||
expect(body, label).toContain('(each RENAMED entry is checked there against its baseline behavior)');
|
||||
});
|
||||
|
||||
it.each(bodies)('%s: maps implementation and scenarios only for ADDED or MODIFIED requirements', (label, body) => {
|
||||
const correctness = section(body, '6. **Verify Correctness**', '7. **Verify Coherence**', label);
|
||||
|
||||
expect(correctness, label).toContain('- For each ADDED or MODIFIED requirement from delta specs');
|
||||
expect(correctness, label).toContain('- For each scenario under an ADDED or MODIFIED requirement in delta specs');
|
||||
expect(correctness, label).toContain('Skip scenarios under a REMOVED requirement');
|
||||
expect(correctness, label).not.toContain('- For each requirement from delta specs:');
|
||||
expect(correctness, label).not.toContain('- For each scenario in delta specs');
|
||||
});
|
||||
// With #1732's "Not verified" rule, a change with nothing to add or modify
|
||||
// left both correctness checks empty, which read as unverified and withheld
|
||||
// readiness. That is the exact case #1959 reports.
|
||||
it.each(bodies)('%s: treats the correctness checks of a removal-only change as not applicable', (label, body) => {
|
||||
const correctness = section(body, '6. **Verify Correctness**', '**Requirement Implementation Mapping**:', label);
|
||||
|
||||
expect(correctness, label).toContain('If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements');
|
||||
// An empty or unparseable delta must not pass as a removal-only change.
|
||||
expect(correctness, label).toContain('A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.');
|
||||
expect(correctness, label).toContain('report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**');
|
||||
expect(correctness, label).toContain('do not mark these two checks as not verified');
|
||||
expect(body, label).toContain('The correctness checks of a change whose readable delta specs contain REMOVED or RENAMED requirements but no ADDED or MODIFIED requirements are also **Not applicable** (see step 6).');
|
||||
});
|
||||
|
||||
it.each(bodies)('%s: does not treat artifacts or replacement code as the removed behavior', (label, body) => {
|
||||
const removed = section(body, '- For each REMOVED requirement', '- For each RENAMED entry', label);
|
||||
|
||||
expect(removed, label).toContain('Matches in `openspec/` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves.');
|
||||
expect(removed, label).toContain('Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.');
|
||||
});
|
||||
|
||||
it.each(bodies)('%s: counts removals separately from covered requirements', (label, body) => {
|
||||
expect(body, label).toContain('Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately');
|
||||
expect(body, label).toContain('the Correctness cell reads `Not applicable (no ADDED or MODIFIED requirements)`');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getOpsxVerifyCommandTemplate,
|
||||
getVerifyChangeSkillTemplate,
|
||||
} from '../../../src/core/templates/skill-templates.js';
|
||||
|
||||
const skill = getVerifyChangeSkillTemplate();
|
||||
const command = getOpsxVerifyCommandTemplate();
|
||||
|
||||
const bodies: Array<[string, string]> = [
|
||||
['skill', skill.instructions],
|
||||
['command', command.content],
|
||||
];
|
||||
|
||||
describe('verify-change templates', () => {
|
||||
it('keeps active no-task changes eligible for ambiguous selection', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain('show all active changes returned by the list');
|
||||
expect(body, label).toContain('including changes with `status: "no-tasks"`');
|
||||
expect(body, label).not.toContain(
|
||||
'show changes that have implementation tasks (tasks artifact exists)'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers schema-aware apply task fields without assuming a tasks artifact id', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain('top-level `tasks` and `progress`');
|
||||
expect(body, label).toContain("schema's `apply.tracks` configuration");
|
||||
expect(body, label).toContain('aggregated from every concrete file matched');
|
||||
expect(body, label).toContain("regardless of the tracked artifact's ID");
|
||||
expect(body, label).toContain('do not infer tracking from a `contextFiles` key');
|
||||
}
|
||||
});
|
||||
|
||||
it('marks partial tracking evidence as not verified', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain('If `unavailableTrackingFiles` is nonempty');
|
||||
expect(body, label).toContain('include every unavailable path and reason');
|
||||
expect(body, label).toContain('do not infer completion from the partial `tasks` and `progress` fields');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it('does not lose incomplete checkboxes omitted from the task list', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain('If `progress.remaining` is greater than 0');
|
||||
expect(body, label).toContain('incomplete checkboxes without descriptions');
|
||||
expect(body, label).toContain('Do not infer completion from the listed tasks alone');
|
||||
}
|
||||
});
|
||||
|
||||
it('requires usable evidence rather than just existing artifact paths', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain('cannot be read or contain no usable requirements, scenarios, or design decisions');
|
||||
expect(body, label).toContain('Continue checks supported by the remaining evidence');
|
||||
expect(body, label).toContain('a partially checked input set is not a fully verified check');
|
||||
expect(body, label).toContain('If implementation changes cannot be identified, mark **Code Pattern Consistency** as not verified');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not mistake apply readiness for verification or execute apply instructions', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain('Treat apply `state` and `instruction` as context, not a verification verdict');
|
||||
expect(body, label).toContain('Do not implement tasks or archive the change during verification');
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves optional artifacts and the existing archive workflow', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain('Verification is advisory');
|
||||
expect(body, label).toContain('`skip_specs: true`');
|
||||
expect(body, label).toContain('schemas without task tracking');
|
||||
expect(body, label).toContain('Do not require or invent optional or intentionally omitted artifacts');
|
||||
expect(body, label).toContain('Mark checks the schema does not define, or artifacts the status reports as intentionally skipped, as **Not applicable**');
|
||||
expect(body, label).toContain('Exclude them from skipped-check counts and the archive-readiness assessment');
|
||||
expect(body, label).toContain('If `taskTrackingConfigured` is false, report **Task Completion** as not applicable');
|
||||
expect(body, label).toContain('If `taskTrackingConfigured` is true and `tasks` is empty, mark **Task Completion** as not verified');
|
||||
expect(body, label).toContain('`Not verified` describes a limit of this report, not a new archive prerequisite');
|
||||
expect(body, label).toContain('Archive retains its own checks and user-confirmation behavior');
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves task-only verification without dropping checks supported by other artifacts', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain('If only task evidence is available for applicable checks, verify task completion only');
|
||||
expect(body, label).toContain('including **Code Pattern Consistency**, as not verified');
|
||||
expect(body, label).toContain('With other supporting artifacts, **Code Pattern Consistency** still runs');
|
||||
}
|
||||
});
|
||||
|
||||
it('covers warning and suggestion outcomes without claiming all checks passed', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain('If no CRITICAL issues, one or more warnings, and no checks were skipped');
|
||||
expect(body, label).toContain('If only suggestions and no checks were skipped');
|
||||
expect(body, label).toContain('Include the suggestion count when nonzero');
|
||||
}
|
||||
});
|
||||
|
||||
it('maps missing supporting artifacts to every check they prevent', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain(
|
||||
'mark **Spec Coverage**, **Requirement Implementation Mapping**, and **Scenario Coverage** as not verified'
|
||||
);
|
||||
expect(body, label).toContain('mark **Design Adherence** as not verified');
|
||||
expect(body, label).toContain('**Code Pattern Consistency** still runs');
|
||||
}
|
||||
});
|
||||
|
||||
it('never reports a skipped check as passing or archive-ready', () => {
|
||||
for (const [label, body] of bodies) {
|
||||
expect(body, label).toContain('`Not verified (<reason>)` for every skipped check');
|
||||
expect(body, label).toContain('Never score a skipped check as passing');
|
||||
expect(body, label).toContain('Treat every not verified or partially verified check as skipped in the final assessment');
|
||||
expect(body, label).toContain('If any check was skipped and there are no CRITICAL issues');
|
||||
expect(body, label).toContain(
|
||||
'If any check was skipped, also name every skipped check and its reason'
|
||||
);
|
||||
expect(body, label).toContain('do not claim readiness');
|
||||
expect(body, label).toContain('If no issues and no checks were skipped');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,23 @@ import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
|
||||
const { confirmMock, searchableMultiSelectMock, interactiveState } = vi.hoisted(() => ({
|
||||
confirmMock: vi.fn(),
|
||||
searchableMultiSelectMock: vi.fn(),
|
||||
interactiveState: { value: false },
|
||||
}));
|
||||
|
||||
vi.mock('@inquirer/prompts', () => ({ confirm: confirmMock }));
|
||||
|
||||
vi.mock('../../src/prompts/searchable-multi-select.js', () => ({
|
||||
searchableMultiSelect: searchableMultiSelectMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/utils/interactive.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/utils/interactive.js')>();
|
||||
return { ...actual, isInteractive: () => interactiveState.value };
|
||||
});
|
||||
|
||||
// Shared mutable mock config state
|
||||
const mockState = {
|
||||
config: {
|
||||
@@ -45,6 +62,23 @@ async function markCodexTarget(skillsDir: string): Promise<void> {
|
||||
await fs.writeFile(path.join(skillsDir, '.openspec-target'), 'codex\n');
|
||||
}
|
||||
|
||||
async function createLegacyCodexPrompt(): Promise<string> {
|
||||
const prompt = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md');
|
||||
await fs.mkdir(path.dirname(prompt), { recursive: true });
|
||||
await fs.writeFile(prompt, 'legacy prompt');
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function failCodexSkillWrites(): void {
|
||||
const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils);
|
||||
vi.spyOn(FileSystemUtils, 'writeFile').mockImplementation(async (filePath, content) => {
|
||||
if (filePath.includes(`${path.sep}.agents${path.sep}`) && filePath.endsWith('SKILL.md')) {
|
||||
throw new Error('EACCES: permission denied');
|
||||
}
|
||||
return originalWriteFile(filePath, content);
|
||||
});
|
||||
}
|
||||
|
||||
describe('UpdateCommand', () => {
|
||||
let testDir: string;
|
||||
let updateCommand: UpdateCommand;
|
||||
@@ -66,6 +100,9 @@ describe('UpdateCommand', () => {
|
||||
|
||||
// Reset mock config to defaults
|
||||
resetMockConfig();
|
||||
interactiveState.value = false;
|
||||
confirmMock.mockReset();
|
||||
searchableMultiSelectMock.mockReset();
|
||||
|
||||
// Clear all mocks before each test
|
||||
vi.restoreAllMocks();
|
||||
@@ -1203,6 +1240,29 @@ metadata:
|
||||
await expect(fs.access(path.join(testDir, '.agents'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should migrate Kilo commands without deleting unrelated workflows', async () => {
|
||||
const skillsDir = path.join(testDir, '.kilocode', 'skills');
|
||||
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
|
||||
'old content'
|
||||
);
|
||||
|
||||
const legacyDir = path.join(testDir, '.kilocode', 'workflows');
|
||||
await fs.mkdir(legacyDir, { recursive: true });
|
||||
await fs.writeFile(path.join(legacyDir, 'opsx-explore.md'), 'old OpenSpec command');
|
||||
await fs.writeFile(path.join(legacyDir, 'opsx-custom.md'), 'user workflow');
|
||||
|
||||
await new UpdateCommand({ force: true }).execute(testDir);
|
||||
|
||||
expect(await FileSystemUtils.fileExists(path.join(legacyDir, 'opsx-explore.md'))).toBe(false);
|
||||
expect(await fs.readFile(path.join(legacyDir, 'opsx-custom.md'), 'utf-8')).toBe('user workflow');
|
||||
|
||||
const commandFile = path.join(testDir, '.kilo', 'command', 'opsx-explore.md');
|
||||
expect(await FileSystemUtils.fileExists(commandFile)).toBe(true);
|
||||
expect(await fs.readFile(commandFile, 'utf-8')).toContain('Enter explore mode');
|
||||
});
|
||||
|
||||
it('should update core profile opsx commands when tool is configured', async () => {
|
||||
// Set up a configured tool
|
||||
const skillsDir = path.join(testDir, '.claude', 'skills');
|
||||
@@ -1688,6 +1748,84 @@ metadata:
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should report a failed legacy-only Codex bootstrap to automation', async () => {
|
||||
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' });
|
||||
|
||||
const prompt = await createLegacyCodexPrompt();
|
||||
failCodexSkillWrites();
|
||||
|
||||
await expect(new UpdateCommand({ force: true }).execute(testDir)).rejects.toThrow(
|
||||
'OpenSpec update failed for: Codex'
|
||||
);
|
||||
await expect(fs.access(prompt)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.access(path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md'))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should refresh configured tools after a legacy Codex bootstrap fails', async () => {
|
||||
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' });
|
||||
|
||||
await createLegacyCodexPrompt();
|
||||
|
||||
const cursorCommand = path.join(testDir, '.cursor', 'commands', 'opsx-explore.md');
|
||||
await fs.mkdir(path.dirname(cursorCommand), { recursive: true });
|
||||
await fs.writeFile(cursorCommand, 'old');
|
||||
|
||||
failCodexSkillWrites();
|
||||
|
||||
await expect(new UpdateCommand({ force: true }).execute(testDir)).rejects.toThrow(
|
||||
'OpenSpec update failed for: Codex'
|
||||
);
|
||||
expect(await fs.readFile(cursorCommand, 'utf-8')).not.toBe('old');
|
||||
});
|
||||
|
||||
it('should report a failed bootstrap when configured tools are already current', async () => {
|
||||
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' });
|
||||
await new InitCommand({ tools: 'cursor', force: true }).execute(testDir);
|
||||
|
||||
await createLegacyCodexPrompt();
|
||||
|
||||
interactiveState.value = true;
|
||||
confirmMock.mockResolvedValue(true);
|
||||
searchableMultiSelectMock.mockResolvedValue(['codex']);
|
||||
|
||||
failCodexSkillWrites();
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await expect(new UpdateCommand().execute(testDir)).rejects.toThrow(
|
||||
'OpenSpec update failed for: Codex'
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('All 1 tool(s) up to date'));
|
||||
});
|
||||
|
||||
it('should report a failed bootstrap after declining an unrelated migration', async () => {
|
||||
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' });
|
||||
|
||||
const legacySkill = path.join(
|
||||
testDir,
|
||||
'.windsurf',
|
||||
'skills',
|
||||
'openspec-explore',
|
||||
'SKILL.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(legacySkill), { recursive: true });
|
||||
await fs.writeFile(legacySkill, 'legacy skill');
|
||||
|
||||
await createLegacyCodexPrompt();
|
||||
|
||||
interactiveState.value = true;
|
||||
confirmMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||
searchableMultiSelectMock.mockResolvedValue(['codex']);
|
||||
|
||||
failCodexSkillWrites();
|
||||
|
||||
await expect(new UpdateCommand().execute(testDir)).rejects.toThrow(
|
||||
'OpenSpec update failed for: Codex'
|
||||
);
|
||||
expect(await fs.readFile(legacySkill, 'utf-8')).toBe('legacy skill');
|
||||
});
|
||||
|
||||
it('should preserve legacy Codex skills and prompts when canonical generation fails', async () => {
|
||||
const legacySkill = path.join(
|
||||
testDir,
|
||||
|
||||
@@ -446,4 +446,39 @@ describe('validate: MODIFIED blocks that would drop a main-spec scenario (#1477)
|
||||
expect(lossIssue(report)).toBeUndefined();
|
||||
expect(report.issues.map((i) => i.message).join('\n')).toContain('MODIFIED references old name from RENAMED');
|
||||
});
|
||||
it('reports both new headings when one current scenario is replaced by two (#1697)', async () => {
|
||||
await writeMainSpec(
|
||||
'widgets',
|
||||
mainSpec(`### Requirement: Widget state\nThe system SHALL report the widget state.\n\n#### Scenario: Existing scenario\n- **WHEN** queried\n- **THEN** the state is reported`)
|
||||
);
|
||||
const widened = `## MODIFIED Requirements\n\n### Requirement: Widget state\nThe system SHALL report the widget state.\n\n#### Scenario: Existing scenario, first branch\n- **WHEN** queried in the first case\n- **THEN** the first state is reported\n\n#### Scenario: Existing scenario, second branch\n- **WHEN** queried in the second case\n- **THEN** the second state is reported\n`;
|
||||
const changeDir = await writeChange('widen-scenario', 'widgets', widened);
|
||||
|
||||
const report = await validate(changeDir);
|
||||
const issue = lossIssue(report);
|
||||
|
||||
// The guard still fires: a widened title is a dropped name, and nothing
|
||||
// here decides whether that was deliberate.
|
||||
expect(report.valid).toBe(false);
|
||||
expect(issue?.message).toContain('"Existing scenario"');
|
||||
expect(issue?.message).toContain(
|
||||
'The modified block has 2 scenarios; the current spec has 1 scenario. It adds 2 scenarios not in the current spec: "Existing scenario, first branch", "Existing scenario, second branch".'
|
||||
);
|
||||
// Parity: archive refuses the same change and prints the same sentence.
|
||||
expect(await archiveError(changeDir)).toContain(
|
||||
'It adds 2 scenarios not in the current spec: "Existing scenario, first branch", "Existing scenario, second branch".'
|
||||
);
|
||||
});
|
||||
|
||||
it('says the block adds none when scenarios are only dropped (#1697)', async () => {
|
||||
await writeMainSpec('widgets', mainSpec(TWO_SCENARIO_REQUIREMENT));
|
||||
const changeDir = await writeChange('drop-scenario', 'widgets', DELTA_KEEPING_ONE);
|
||||
|
||||
const issue = lossIssue(await validate(changeDir));
|
||||
|
||||
expect(issue?.message).toContain(
|
||||
'The modified block has 1 scenario; the current spec has 2 scenarios. It adds none.'
|
||||
);
|
||||
expect(await archiveError(changeDir)).toContain('It adds none.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,9 @@ const FORBIDDEN: readonly RegExp[] = [
|
||||
const ALLOW = [
|
||||
// docs/commands.md and docs/troubleshooting.md quote this CLI message.
|
||||
/"No artifacts ready"/,
|
||||
// docs/commands.md describes which artifacts `/opsx:update` leaves to
|
||||
// `/opsx:continue`. "no files yet" is about an update target, not explore.
|
||||
/Artifacts with no files yet remain with/,
|
||||
];
|
||||
|
||||
const TEST_FILE = 'test/explore-docs-claims.test.ts';
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { claudeAdapter } from '../src/core/command-generation/adapters/claude.js';
|
||||
import { AI_TOOLS } from '../src/core/config.js';
|
||||
import { CORE_WORKFLOWS } from '../src/core/profiles.js';
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SETUP = fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'docs-lab', 'start', 'setup.md'),
|
||||
'utf-8'
|
||||
);
|
||||
const PROFILES = fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'docs-lab', 'customize', 'profiles.md'),
|
||||
'utf-8'
|
||||
);
|
||||
const CORE_SECTION = PROFILES.split('## The core set')[1].split(
|
||||
'## Expanding the set: optional workflows'
|
||||
)[0];
|
||||
|
||||
describe('setup documentation', () => {
|
||||
it('keeps the Claude Code paths and recovery commands aligned with OpenSpec', () => {
|
||||
const claude = AI_TOOLS.find((tool) => tool.value === 'claude');
|
||||
const claudeCommandPath = claudeAdapter.getFilePath('<id>').split(path.sep).join('/');
|
||||
|
||||
expect(claude?.skillsDir).toBeDefined();
|
||||
expect(SETUP).toContain(`\`${claude?.skillsDir}/skills/openspec-*/SKILL.md\``);
|
||||
expect(SETUP).toContain(`\`${claudeCommandPath}\``);
|
||||
expect(SETUP).toContain('openspec config set delivery both');
|
||||
expect(SETUP).toContain('openspec update');
|
||||
expect(SETUP).toContain('`/openspec-propose`');
|
||||
});
|
||||
|
||||
it('lists every workflow in the core profile', () => {
|
||||
for (const workflow of CORE_WORKFLOWS) {
|
||||
expect(CORE_SECTION).toContain(`\`${workflow}\``);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyLineEnding,
|
||||
detectLineEnding,
|
||||
matchLineEnding,
|
||||
} from '../../src/utils/line-endings.js';
|
||||
|
||||
describe('detectLineEnding', () => {
|
||||
it('reports LF for an LF file', () => {
|
||||
expect(detectLineEnding('a\nb\nc')).toBe('\n');
|
||||
});
|
||||
|
||||
it('reports CRLF for a CRLF file', () => {
|
||||
expect(detectLineEnding('a\r\nb\r\nc')).toBe('\r\n');
|
||||
});
|
||||
|
||||
it('reports undefined when there is no line break', () => {
|
||||
expect(detectLineEnding('single line')).toBeUndefined();
|
||||
expect(detectLineEnding('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not count a CRLF as an LF', () => {
|
||||
// Two CRLF and no lone LF: a naive /\n/ count would see 2 of each and tie.
|
||||
expect(detectLineEnding('a\r\nb\r\nc')).toBe('\r\n');
|
||||
});
|
||||
|
||||
it('picks the dominant ending in a mixed file', () => {
|
||||
expect(detectLineEnding('a\r\nb\r\nc\r\nd\ne')).toBe('\r\n');
|
||||
expect(detectLineEnding('a\nb\nc\nd\r\ne')).toBe('\n');
|
||||
});
|
||||
|
||||
it('breaks a tie toward CRLF', () => {
|
||||
expect(detectLineEnding('a\r\nb\nc')).toBe('\r\n');
|
||||
});
|
||||
|
||||
it('handles a lone CR without treating it as a line ending', () => {
|
||||
// A bare CR is not a line break this project emits; it must not be
|
||||
// mistaken for CRLF.
|
||||
expect(detectLineEnding('a\rb')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyLineEnding', () => {
|
||||
it('converts LF to CRLF', () => {
|
||||
expect(applyLineEnding('a\nb\n', '\r\n')).toBe('a\r\nb\r\n');
|
||||
});
|
||||
|
||||
it('leaves LF alone when LF is requested', () => {
|
||||
expect(applyLineEnding('a\nb\n', '\n')).toBe('a\nb\n');
|
||||
});
|
||||
|
||||
it('is idempotent on already-CRLF content', () => {
|
||||
expect(applyLineEnding('a\r\nb\r\n', '\r\n')).toBe('a\r\nb\r\n');
|
||||
});
|
||||
|
||||
it('collapses mixed content to the requested ending', () => {
|
||||
expect(applyLineEnding('a\r\nb\nc', '\r\n')).toBe('a\r\nb\r\nc');
|
||||
expect(applyLineEnding('a\r\nb\nc', '\n')).toBe('a\nb\nc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchLineEnding', () => {
|
||||
it('restores CRLF from a CRLF original', () => {
|
||||
expect(matchLineEnding('x\ny\n', 'a\r\nb\r\n')).toBe('x\r\ny\r\n');
|
||||
});
|
||||
|
||||
it('keeps LF from an LF original', () => {
|
||||
expect(matchLineEnding('x\ny\n', 'a\nb\n')).toBe('x\ny\n');
|
||||
});
|
||||
|
||||
it('defaults to LF when the original has no line break', () => {
|
||||
expect(matchLineEnding('x\ny\n', 'single line')).toBe('x\ny\n');
|
||||
expect(matchLineEnding('x\ny\n', '')).toBe('x\ny\n');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user