Compare commits

...
59 Commits
Author SHA1 Message Date
openspec-release-bot[bot]andgithub-actions[bot] 2826b8889e Version Packages (#1629)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-13 15:22:19 +00:00
Clay GoodandClaude Opus 4.8 610b78f655 chore(changeset): add catch-up changesets for 6 untracked fixes (#1640)
Six user-facing fixes merged after v1.8.0 without a changeset, so they
would ship in v1.9.0 with no changelog entry and their authors uncredited.
All are patch fixes; the release target stays at 1.9.0.

Covers: #1637, #1607, #1632, #1616, #1612, #1523.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 16:40:35 +00:00
tech_ren 0221ac3d46 fix(archive): preserve blank lines around ## Requirements when syncing specs (#1637) 2026-08-12 16:05:17 +00:00
6d031f12f7 chore(deps): bump the website-dependencies group in /website with 2 updates (#1636)
* chore(deps): bump the website-dependencies group

Bumps the website-dependencies group in /website with 2 updates: [fumadocs-core](https://github.com/fuma-nama/fumadocs) and [fumadocs-ui](https://github.com/fuma-nama/fumadocs).


Updates `fumadocs-core` from 16.12.1 to 16.14.0
- [Release notes](https://github.com/fuma-nama/fumadocs/releases)
- [Commits](https://github.com/fuma-nama/fumadocs/compare/fumadocs@16.12.1...fumadocs@16.14.0)

Updates `fumadocs-ui` from 16.12.1 to 16.14.0
- [Release notes](https://github.com/fuma-nama/fumadocs/releases)
- [Commits](https://github.com/fuma-nama/fumadocs/compare/fumadocs@16.12.1...fumadocs@16.14.0)

---
updated-dependencies:
- dependency-name: fumadocs-core
  dependency-version: 16.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: website-dependencies
- dependency-name: fumadocs-ui
  dependency-version: 16.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: website-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(website): migrate search to ZBSearch for fumadocs 16.14

fumadocs 16.14.0 replaced the Orama search engine with ZBSearch, which
broke the custom static search dialog's type-check (Orama instance no
longer assignable to the ZBSearch client) and failed the Cloudflare
Pages build.

Switch components/search.tsx to the new `staticClient` API and drop the
now-optional client-side DB init — ZBSearch restores the tokenizer from
the exported static data. Remove the direct `@orama/orama` dependency,
which is no longer imported anywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Clay Good <hi@claygood.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 00:08:05 +00:00
8127c7b7cc fix(schema): preserve YAML formatting when forking a schema (#1607)
* fix(schema): preserve YAML formatting when forking a schema

Rename a forked schema via yaml's Document API (parseDocument + doc.set)
instead of round-tripping through parseSchema/stringifyYaml, so block
scalars, comments, and key order in the source schema.yaml survive the
fork. Keep the structural parseSchema validation before the document
mutation so an invalid source is still rejected (addresses PR #1130
review). Adds fork-level regression coverage for both formatting
preservation and invalid-source rejection.

Co-Authored-By: JinzeLin <linjinze999@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* harden(schema): clean up partial fork when validation fails

If the source schema is structurally invalid, parseSchema throws after
copyDirRecursive has already created the destination directory, leaving
a broken half-schema on disk that made the next fork report "already
exists". Wrap the read/validate/rename in a try/catch that removes the
just-created destination on any failure and rethrows so the original
error still drives the JSON/exit-code reporting. The cleanup can only
ever delete a directory this run created: the no-force existing-dest
path returns before the copy, and the --force path removes the prior
directory first. This also closes a mid-write truncation window for free.

Adds regression coverage: cleanup + retryability on invalid source, the
pre-existing-destination-is-never-touched invariant, and a lock-in that
YAML-ambiguous names (true/false/null/off) round-trip as strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* harden(schema): validate fork source up front; never mask fork errors

Second hardening pass on the fork command, from an adversarial review of
the previously-added cleanup.

1. Atomicity: validate the source's schema.yaml up front, immediately
   after assertSchemaTreeCanBeCopied and BEFORE the --force removal of an
   existing destination. Previously the source was validated only after
   the copy, so `fork --force <invalid-source> <existing-valid-dest>`
   destroyed the existing destination and then failed, leaving nothing.
   This matches `schema init`, which already validates before it
   overwrites. Behavior is unchanged for valid sources, and the redundant
   post-copy validation is dropped.

2. Never mask the real error: the failure-cleanup rmSync is now wrapped
   in its own try/catch. fs.rmSync's `force` only suppresses ENOENT, not
   EPERM/EBUSY/ENOTEMPTY (e.g. a locked file on Windows or a concurrent
   process), so a failed cleanup could previously replace the real
   "Invalid schema" diagnostic with a confusing filesystem error. The
   original error is now always rethrown.

Adds regression coverage: --force with an invalid source leaves a valid
destination intact; the pre-existing-destination test now uses a valid
source so it exercises the no-force "already exists" guard directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* harden(schema): reject self-fork and stage fork before replacing destination

Two data-loss defects in `schema fork --force` (per alfred-openspec review):

1. Self-fork: forking a schema onto itself removed the destination (which IS
   the source) before the copy, so the copy then read a directory it had just
   deleted — destroying the only copy. Now rejected up front by comparing the
   real (symlink-resolved) source and destination paths before any removal.

2. Non-atomic replacement: an existing destination was removed before the new
   fork was fully copied and name-updated, so a mid-copy failure left the user
   with nothing. The fork is now staged in a temporary sibling directory and
   only swapped into place once complete; any failure while staging leaves both
   the source and the existing destination untouched.

Adds regressions: self-fork is rejected with the source intact; a forced fork
whose copy fails leaves the existing destination byte-identical with no staging
leftovers.

Co-Authored-By: JinzeLin <linjinze999@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* harden(schema): back up destination before installing fork so a failed final move restores

The stage-then-swap still removed the destination and then renamed staging into
place; if that final rename failed (e.g. a Windows lock) the destination was gone
with no restore. Now, when a destination exists, `fork --force` moves it to a
sibling backup, installs the staged fork, and only then discards the backup. If
the install rename throws, the backup is moved back so the original destination
is never lost. Non-existing destinations keep the simple staging rename.

Adds a regression: forcing the final staging->destination move to fail leaves the
pre-existing destination byte-identical with no staging/backup leftovers.

Co-Authored-By: JinzeLin <linjinze999@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* harden(schema): surface unrecoverable fork restore + hide fork temp dirs from discovery

Two more edge cases from alfred's review:

1. A failed backup->destination restore was silently swallowed, so if the final
   install AND the restore both failed the user lost the destination with no clue
   the backup existed. Now that case throws an error naming the backup directory
   and how to move it back, with the original install error attached as cause.

2. The transient `.fork-staging-*` / `<name>.fork-backup-*` directories live
   inside the schemas dir, so a concurrent scan could surface them as real
   schemas. isSchemaDir (the single discovery chokepoint) now excludes them;
   real schema names are kebab-case (no dots) so this can never hide a schema.

Adds regressions: an unrecoverable restore surfaces the backup path (and the
rescued content is really there); fork temp dirs are excluded from listSchemas
and listSchemasWithInfo.

Co-Authored-By: JinzeLin <linjinze999@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* harden(schema): fingerprint fork destination to abort on concurrent edits

A concurrent process could edit an existing fork destination between the moment
--force authorized the overwrite and the moment the destructive swap ran, and
those edits were silently destroyed (reproduced by alfred: mutate destination
schema.yaml during copy; --force completed and deleted the newer content).

Now, when overwriting an existing destination, the fork:
- fingerprints the authorized destination (SHA-256 over every file's relative
  path and bytes) BEFORE staging;
- re-fingerprints and compares immediately before moving the destination aside;
  on mismatch it ABORTS without touching the destination, preserving the
  concurrent changes and telling the user to re-run;
- re-fingerprints the backup before discarding it on the success path; if it
  changed during the install window it is kept, not deleted, and its location is
  surfaced.

All prior guarantees remain: self-fork rejection, stage-then-swap, backup/restore
on failed install with the backup path surfaced, and the temp-dir discovery
filter.

Adds regressions: a destination edited concurrently during staging aborts the
fork and preserves the edit; a backup modified during the install window is kept
and its location surfaced.

Co-Authored-By: JinzeLin <linjinze999@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(schema): avoid stat-then-read in fork fingerprint (CodeQL js/file-system-race)

fingerprintDir called fs.lstatSync then fs.readFileSync on the same path,
which CodeQL flags as a file-system race (the file may change between the
check and the read). Use the Dirent type already returned by readdirSync
({ withFileTypes: true }) instead of a separate lstat, and read files
directly, deriving the size from the bytes read. Behavior is unchanged
(13/13 fork-fidelity tests, incl. the concurrent-edit race regressions,
still pass); one fewer syscall per entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* harden(schema): validate the completed staged fork before any destructive step

The up-front parseSchema only checks the SOURCE, but copyDirRecursive reads
source files that can change mid-copy, so the staged result can be invalid even
though the source was valid at the pre-check (reproduced by alfred: mutate source
schema.yaml to invalid inside copyFileSync; --force installed the invalid fork
and deleted the valid destination).

Now, after copying and the Document-API name edit, the fork validates the
COMPLETED staged schema.yaml (the exact bytes about to be installed) with
parseSchema BEFORE any destination displacement. On failure it aborts, cleans up
staging, and rethrows a clear error ("the staged fork of '<source>' is not a
valid schema ...; aborted, '<dest>' was not modified") chaining the parse error.
The up-front source parseSchema stays as a fail-fast; this is the authoritative
gate. Order before the swap: validate staged -> fingerprint-revalidate dest ->
rename dest->backup -> rename staging->dest -> revalidate+rm backup.

Adds a regression: a source that becomes structurally invalid during staging
aborts the fork and leaves the valid destination byte-identical, no leftovers.

Co-Authored-By: JinzeLin <linjinze999@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: JinzeLin <linjinze999@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 23:36:45 +00:00
Clay GoodandClaude Opus 4.8 3281f1f068 fix(deps): patch js-yaml and nanoid advisories via pnpm overrides (#1635)
Resolve all three open Dependabot alerts (all high severity):

- GHSA-5p4m-2wfm-xmqj — js-yaml quadratic-CPU !!omap DoS (#96, #97).
  Root tree carried js-yaml 3.15.0 (via read-yaml-file) and 4.3.0 (via
  @changesets/parse). Dev-only; never in the published CLI, which uses
  `yaml`, not `js-yaml`. Pinned to >=3.15.1 / >=4.3.1.
- GHSA-2v37-7h3g-55p8 / CVE-2026-67213 — nanoid size=0 infinite loop (#99).
  Present in both root (dev, via postcss<-vitest) and website (build-time,
  via postcss<-next) trees. Pinned to >=3.3.17 (resolves to 3.3.18).

Overrides added to all four override surfaces (pnpm-workspace.yaml +
package.json, root and website) to keep them in sync, each YAML entry
annotated with its advisory id and removal condition.

flake.nix pnpmDeps FOD hash regenerated for the root lockfile change
(verified via nix build; hash-mismatch-count 0). dependabot.yml gains a
note documenting the two surfaces Dependabot cannot manage (pnpm
overrides + the Nix flake).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 23:12:15 +00:00
Clay GoodandClaude Opus 4.8 b96b3e85cd chore(deps): bump safe website-dependencies subset (#1634)
* chore(deps): bump safe website-dependencies subset (defer fumadocs 16.14 / ZBSearch migration)

Ships the non-breaking bumps from dependabot PR #1631, holding back the
fumadocs 16.14 major that requires a website source migration.

Bumped:
- fumadocs-mdx  ^15.2.1 -> ^15.2.2 (resolves 15.2.3; builds cleanly)
- lucide-react  ^1.27.0 -> ^1.28.0 (resolves 1.31.0)
- next          16.2.12 -> 16.3.0
- postcss       ^8.5.25 -> ^8.5.26 (override ^8.5.22 governs resolution)

Held (defer to a dedicated migration PR):
- fumadocs-core ^16.12.1 (16.14 replaces Orama with ZBSearch)
- fumadocs-ui   ^16.12.1 (pairs with core)

fumadocs-mdx 15.2.3 does NOT pull core 16.14 transitively; type-check
and next build both pass. esbuild stays 0.28.1, so allowBuilds is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(deps): apply the postcss bump for real (align override + lock to 8.5.26)

The manifest declared postcss ^8.5.26 but the pnpm override stayed ^8.5.22,
so the importer resolved 8.5.25 and the declared bump had no effect. Raise
the override (website/pnpm-workspace.yaml + website/package.json pnpm.overrides)
to ^8.5.26 and re-lock so postcss resolves 8.5.26 everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 23:12:11 +00:00
Clay GoodandClaude Opus 4.8 207f3cc515 fix(config): label the update workflow in the picker; drop "expanded-profile" wording (#1632)
* fix(config): label the update workflow in the picker and drop "expanded-profile" wording

The config workflow picker builds each row's label from WORKFLOW_PROMPT_META
in src/commands/config.ts. The table had entries for 11 of the 12 workflows
but not `update`, so `openspec config` rendered that row as the raw id
`update` with a `Workflow: update` placeholder description. Since `update` is
one of the six core workflows, every user who opens the picker saw it.

Add the missing `update` entry so the row reads "Update change / Revise the
planning artifacts of an existing change".

Also reword the update-change workflow template, which called `/opsx:continue`
and `/opsx:new` "expanded-profile" workflows. There is no "expanded" profile;
the only profile values the product stores are `core` and `custom`. They are
now described as "optional" workflows. Regenerated the committed skills.sh
mirror and parity hashes accordingly.

Harden with a regression test asserting every ALL_WORKFLOWS id has real picker
metadata (no raw-id name, no "Workflow:" placeholder), so a future workflow
addition can't silently reintroduce the fallback.

Closes #1627

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: regenerate skills and parity hashes after rebase onto main

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 22:41:44 +00:00
Clay GoodandClaude Opus 4.8 4b114aade9 chore(deps-dev): bump development-dependencies group + refresh flake hash (#1633)
* chore(deps-dev): bump development-dependencies group + refresh flake pnpmDeps hash

Supersedes #1630. Dependabot's PR bumps two dev dependencies within their
existing package.json semver ranges (eslint 10.8.0 -> 10.8.1, typescript-eslint
8.65.0 -> 8.66.0), touching only pnpm-lock.yaml. That lockfile change
invalidates the flake's fixed-output pnpmDeps.hash, so #1630 fails Nix Flake
Validation ("pnpm failed to install dependencies"). Dependabot cannot update the
Nix FOD hash, so this PR carries the same bump together with the refreshed hash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(nix): set pnpmDeps hash for updated lockfile

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 22:24:01 +00:00
8364428661 fix(schemas): honor canonical root selection (#1616)
* docs(openspec): propose schemas root selection fix

* fix(schemas): honor canonical root selection

* test(schemas): assert complete JSON schema shape

* docs(stores): drop view from the cwd-only, no --store list

view already accepts --store <id> (registered in src/cli/index.ts), so
listing it among the commands that act on the current directory only was
incorrect. Remove it; templates and the deprecated noun forms remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: regenerate skills and parity hashes after rebase onto main

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Clay Good <hi@claygood.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 22:04:03 +00:00
Clay GoodandClaude Opus 4.8 804427b6ff fix(telemetry): suppress first-run notice in --json mode (#1609)
* fix(telemetry): suppress first-run notice in --json mode

The first-run telemetry disclosure notice was written to stdout from the
global preAction hook. On a user's first-ever command with --json this
polluted stdout and could break JSON parsers. Read the executing command's
--json flag (actionCommand.opts().json) and, when set, skip the notice and
leave noticeSeen unset so the disclosure is deferred to the first later
non-JSON run rather than lost.

Spinner suppression, new-change --json output, and structured JSON errors
already landed on main (#960, #1190); this closes the one remaining stdout
writer in --json mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* harden: detect --json from argv to cover all invocation forms

The preAction guard read actionCommand.opts().json, which only sees a
declared leaf option. That missed two supported --json forms that emit a
single JSON document to stdout:
  - openspec store --json  (permissive group reads --json from residual args;
    never declares the option, so opts().json is undefined)
  - openspec workset --json <sub>  (--json on the parent group, consumed
    before the leaf; leaf opts().json is undefined)
Both would still print the first-run telemetry notice ahead of their JSON.

Detect --json from process.argv instead: it covers leaf, parent, and
residual-arg forms uniformly. Suppressing is always safe (the disclosure
defers to the next non-JSON run, never lost), so a broad argv check is the
correct, conservative signal.

Also add a direct assertion that noticeSeen stays unset after a silent run,
and note the pre-existing raw-stdout commands (completion generate, config
get/path, __complete) as out of scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: derive --json from parsed command state + regression test

Replace the process.argv check with isJsonRun(command), an exported pure
helper that reads Commander's parsed state: optsWithGlobals().json (leaf and
parent-group forms) OR command.args (residual --json on permissive bare
groups like store). This is tied to the actually-parsed command rather than
raw args, and — unlike process.argv — is unit-testable in-process.

Add test/core/cli-is-json-run.test.ts: a synthetic program reproducing all
three registration patterns proves isJsonRun returns true for status --json,
store --json, workset --json list, and workset list --json, and false
otherwise. This locks in the store/workset coverage against future
regressions (an e2e test can't: telemetry is disabled under CI, so the notice
never fires there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(spec): qualify first-command notice scenario as non-JSON

The generic 'First command execution' scenario asserted the notice
displays on every first command, contradicting the JSON scenario that
says it does not. Qualify it as 'without --json' so the required
behavior is unambiguous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 21:52:54 +00:00
Clay GoodandClaude Opus 4.8 17581c11ed fix(init): only show 'Restart your IDE' hint for IDE-embedded tools (#1610)
Reconstructed on current main (the original branch predated the Codex
.agents rename, Command Code, Rovo Dev, Antigravity, Zoo Code, and the
Kimi/Windsurf changes, so a direct rebase conflicted heavily in
config.ts/init.ts/init.test.ts).

Adds requiresIdeRestart to AIToolOption and gates the success-screen
restart hint so it shows only when an IDE-resident tool actually
received a surface. Wording follows that tool's own surface. Closes #1067.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 21:52:39 +00:00
1a10dd5820 docs(opsx): clarify /opsx:sync description and add usage section (#1606)
* docs(opsx): fix /opsx:sync description and add detailed documentation

- Changed description from 'Sync delta specs to main' to 'Merge delta specs into main specs'

- Added detailed Usage section for /opsx:sync command

- Now consistent with commands.md and migration-guide.md

- Improves documentation completeness and clarity

* docs(opsx): harden Sync delta specs section for accuracy and house style

Fold the /opsx:sync usage entry into a single prose paragraph to match
the six sibling Usage sections (heading -> fence -> paragraph), and fix
two accuracy issues found against src/core/templates/workflows/sync-specs.ts:

- Drop the invented "changes see each other's specs" and "test
  integration" use cases (no cross-change propagation or test step exists).
- State that sync applies the whole delta -- a REMOVED requirement is
  deleted from the main spec and a RENAMED one retitled -- so the section
  no longer reads as additive-only.
- Use the file's spaced em-dash convention.

Docs-site build verified: sync-docs + fumadocs next build compile and
render /docs/opsx end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Howard <yhwelcome1981@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 21:23:38 +00:00
Clay Good 137404b423 fix(cli): reject missing roots for list and validate (#1612)
* fix(cli): reject missing roots for list and validate

* test(cli): cover legacy list root fallback
2026-08-11 21:23:28 +00:00
Clay Good 144901ca74 chore(dependabot): ignore unsupported major updates (#1623) 2026-08-11 21:23:21 +00:00
dependabot[bot] 89169627e0 ci: bump pnpm/action-setup in the github-actions group (#1618)
Bumps the github-actions group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `pnpm/action-setup` from 6.0.9 to 6.0.10
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0ebf47130e4866e96fce0953f49152a61190b271...0977fd99725f1db4007ccb2928dbb4e90d06cc86)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 21:23:11 +00:00
942589741d fix(core): canonicalize rebuilt spec EOF (#1528)
* fix(core): canonicalize rebuilt spec EOF

* chore(changeset): add patch changeset for spec EOF canonicalization

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Clay Good <hi@claygood.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 21:22:59 +00:00
Clay GoodandClaude Opus 4.8 c751b3da52 fix(validate): count every level-4 header as a scenario in the loss guard (#1521)
* fix(validate): count every level-4 header as a scenario in the loss guard

The scenario-loss guard (#1482) recognized only `#### Scenario:` headers, but
the spec path (SCENARIO_HEADER / countScenarios) counts every `#### ` child of
a requirement as a scenario. A MODIFIED block that dropped a differently-labeled
level-4 child (e.g. `#### Edge case`) therefore passed validate and was silently
deleted by archive. Align parseScenarioBlocks with the spec path so both agree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(validate): guard scenario-header parity; reuse SCENARIO_HEADER

Harden the scenario-loss parity fix after a multi-agent review:
- Export SCENARIO_HEADER from requirement-text.ts and reuse it in the delta
  path (scenarioHeaderAt/scenarioNameAt) so parity is guaranteed by
  construction, not two matching literals plus a comment.
- Add boundary tests for the widened matcher: a level-5 (#####) header must
  not count, an unlabeled #### inside a fence must not count, an optional
  Scenario: label normalizes (relabel is not a loss), and unlabeled scenarios
  are counted by multiplicity. Plus an integration case: a dropped labeled
  scenario is caught even when an unlabeled sibling is kept (validate/archive
  parity, both directions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(validate): harden scenario-name folding + incoming-fence parity

Adversarial review of the scenario-loss guard surfaced one over-strict nit
and one untested symmetry:

- scenarioNameAt now also strips a CommonMark closing `#` run, so `#### Foo`
  and `#### Foo ####` fold to the same scenario name. Without this, relabeling
  a scenario's header on one side (ATX-open vs ATX-closed) read as a dropped
  scenario — a false-abort. Safe direction only: a genuine drop still lowers a
  folded name's count and is caught.
- Add unit tests for the untested incoming-side fence mask (a fenced `####` in
  the MODIFIED block must not satisfy a real scenario), lowercase `scenario:`
  label normalization, and the ATX-closed header fold.

Behavior for conventional `#### Scenario:` headers is unchanged; parser,
validation, and archive suites stay green (269 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(validate): match CommonMark whitespace in ATX-close strip; changeset nit

Second adversarial-review round follow-ups:

- scenarioNameAt's ATX-closing-sequence strip now matches only a space/tab
  before the trailing `#` run (`[ \t]` not `\s`), exactly as CommonMark defines
  a closing sequence. A looser `\s` could strip a `#` run after an exotic space
  (e.g. NBSP) that CommonMark keeps rendered, folding two distinct scenario
  names into one and masking a real loss. Correct-direction hardening for a
  data-loss guard; no behavior change for real space/tab-authored headers.
- Changeset: describe the header whitespace outside the code span to satisfy
  markdownlint MD038 (no trailing space inside `#### `). Resolves CodeRabbit.

Parser/validation/archive suites green (243 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 21:22:45 +00:00
Clay GoodandClaude Opus 4.8 07dea6ed2f fix(update): don't hijack the agents target on legacy Codex upgrade (#1522)
* fix(update): don't hijack the agents target on legacy Codex upgrade

Codex and the vendor-neutral `agents` target share `.agents/skills`. In
upgradeLegacyTools, a Codex install inferred only from global ~/.codex/prompts
wrote Codex skills into `.agents` and flipped the ownership marker
agents -> codex, silently rewriting an existing agents-owned tree. The main
generation path reconciles shared-target ownership first; this legacy-upgrade
path did not. Add sharedSkillRootOwnedByOther() and skip generation when a
different tool already owns the shared root (marker or existing tree), while
still allowing a genuine first-time Codex upgrade with no `.agents` yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(update): cover the hijack guard end-to-end; name the owner on skip

Harden the agents-target ownership fix after a multi-agent review:
- Add an integration test that runs the real update flow for the bug
  scenario (agents-owned .agents + a legacy global Codex prompt) and asserts
  the marker stays `agents` and skills keep generic `/openspec-` syntax. A
  unit test of the predicate can't catch a future refactor that stops calling
  it; this can.
- Name the owning tool in the skip message ("...managed by another tool
  (Shared .agents skills)") via a new sharedSkillRootOwner() helper that
  sharedSkillRootOwnedByOther now delegates to.
- Add a unit case for the ambiguous-tree branch (existing skills, no marker,
  no inferable syntax) and one asserting sharedSkillRootOwner names agents.
- Document the known, harmless re-offer tradeoff (a skipped tool isn't
  recorded as configured, so a persistent legacy prompt re-offers it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(update): preserve skipped tool's legacy files; add upgrade-path tests

Adversarial review of the shared-root ownership guard surfaced one real
integration defect and the review asks from alfred/CodeRabbit.

Defect: when the guard skips a legacy Codex upgrade because the `.agents`
root is owned by another tool, the caller's immediate legacy cleanup still
deleted Codex's repo-local `.codex/prompts/openspec-*.md`. That violates the
cleanup contract (remove X only because replacement Y was written): no
replacement is written for a skipped tool, so its legacy files must stay.

`upgradeLegacyTools` now reports `skippedSharedSkillTools`, and
`performImmediateLegacyCleanup` exempts those tools' repo-local artifacts via
a new `omitToolLegacyArtifacts` helper. Refactored the per-artifact tool
matching out of `getToolsFromLegacyArtifacts` so both share one matcher.

Tests (addressing the review + the defect):
- update.test.ts: hijack test now asserts Codex is absent from the persisted
  configured-tool set and that the skip names the established owner.
- update.test.ts: inverse no-root case proves a first-time Codex upgrade still
  writes the `codex` marker via the real UpdateCommand path.
- update.test.ts: a skipped tool's repo-local `.codex/prompts` is preserved.
- legacy-cleanup.test.ts: unit coverage for omitToolLegacyArtifacts.
- shared-skill-target.test.ts: assert sharedSkillRootOwner resolves 'agents'.

Docs + changeset updated to describe the preserve-on-skip behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(update): lock in legacy-prompt preservation on skipped Codex upgrade

Address the outstanding CodeRabbit review notes on #1522. The fix itself
is confirmed correct by three independent adversarial reviews — these are
test-only hardening that locks in the guarantees the fix promises:

- Assert the global ~/.codex/prompts survives (byte-for-byte) in the
  hijack scenario. Previously the test set the prompt up but never
  checked it was preserved; on unfixed code Codex would be generated,
  its 'explore' workflow would read as installed, and the deferred
  global cleanup would delete the prompt — so this assertion fails
  without the fix.
- Assert the repo-local .codex/prompts is preserved by content, not
  mere existence (distinguishes 'left untouched' from 'deleted+rewritten').
- Restore the stdout/stderr spies in a finally so a throw can't swallow
  output for the rest of the suite.
- Cover backslash-delimited (Windows) paths in omitToolLegacyArtifacts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 21:22:32 +00:00
Clay GoodandClaude Opus 4.8 bf5099e39f fix(apply): surface deferred scope instead of silently simplifying tasks (#1530)
* fix(apply): surface deferred scope instead of silently simplifying tasks

The /opsx:apply guidance told agents to keep going through tasks but never
told them what to do when a task turned out harder than the spec assumed.
Agents absorbed the extra scope silently — narrowing, deferring, or
declaring partial work done — and marked the task complete anyway (#1529).

Add a pause trigger and two guardrails to the shared apply instructions
(rendered identically by the skill and command surfaces): surface the added
scope and ask rather than simplify to fit, and mark a task complete only
when it is fully implemented as specified. Regenerate the static skill and
parity-hash pins. Guidance text only — no behavioral code paths change.

Fixes #1529

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(apply): anchor deferred-scope guidance to spec scope, not effort

Adversarial review flagged that "more complex than the spec assumed" could
be read as "takes more effort than I guessed," which would make an agent
pause on nearly every task. Retie the pause trigger and guardrail to a
change in scope — work beyond what the spec/tasks describe, or dropping /
narrowing / deferring specified behavior — so normal implementation effort
does not trip it. Regenerate the static skill and parity pins; update the
regression test and changeset to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(apply): name the "accept exceptions" pattern in deferred-scope guidance

Issue #1529's concrete example is an agent that found three exceptions to a
"zero writes on the main thread" task, declared them "accepted," and moved
on. Add "accept exceptions to" to the pause trigger's verb list so the
guidance names that exact failure mode, not just drop/narrow/defer. Behavior
is otherwise unchanged; regenerate the static skill and parity pins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(apply): assert the deferred-scope guidance requires pausing

CodeRabbit noted the guardrail test checked that added scope is surfaced but
not that the agent pauses, so it could pass if the workflow reported scope and
kept going. Assert the exact "surface the added scope and pause" phrasing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:53:53 +00:00
Clay GoodandClaude Opus 4.8 9ae75c86ef fix(archive): don't write ANSI escape codes to a redirected (non-TTY) stdout (#1603)
* fix(archive): stop non-TTY confirm prompts from writing ANSI escapes to stdout

`openspec archive` asks up to three yes/no questions through @inquirer's
`confirm`, which renders by writing ANSI cursor-movement escape sequences —
and emits them even when stdout is not a TTY. When archive runs with its
output captured to a file or pipe (an agent's background task, CI), those
escapes are noise, and in some non-TTY hosts the render loop never settles
and repeats `ESC[NNG` moves until the disk fills (reporter hit 19.8 GB).

Add `confirmPrompt` in interactive.ts: a real terminal (stdin AND stdout
TTY) still gets @inquirer's rich prompt; every other case reads one plain
line via node:readline with `terminal:false`, emitting no escapes. Parsing
mirrors @inquirer/confirm exactly (prefix match on y/yes and n/no, else the
default), and an unreadable stdin rejects with an ExitPromptError-shaped
error so the existing #1479 "rerun with --yes" guidance is unchanged.
archive's confirmOrBlock now calls confirmPrompt.

Closes #1526

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(interactive): cover Windows CRLF and drained-stdin paths; doc note

Adds two regression tests surfaced by adversarial review of the #1526 fix:
- Windows CRLF piped input (`y\r\n`) parses as a clean yes with no ANSI —
  the reporter's platform, previously untested (all inputs used `\n`).
- A second prompt after stdin was already drained blocks with an
  ExitPromptError instead of hanging, exercising the readableEnded guard.

Also documents in troubleshooting.md that a redirected/agent archive run
that pipes an answer no longer writes terminal escape codes into the capture.

Refs #1526

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(interactive): align non-interactive classification and handle readline errors

Addresses two review findings on the #1526 confirm-prompt fix:

- confirmPrompt drops to the plain reader whenever either stream is not a
  TTY, but isNonInteractivePromptError only checked stdin. A stdin-TTY /
  stdout-redirected run that hit EOF leaked the raw ExitPromptError instead
  of the #1479 "rerun with --yes" guidance. Classification now also counts a
  redirected stdout, matching how the prompt mode is chosen. (isInteractive,
  used broadly elsewhere, is left untouched.)

- readYesNo never listened for the readline/input 'error' event, so a stdin
  error would hang the promise (and go unhandled). It now settles with the
  underlying fault, guarded so the promise resolves or rejects exactly once.

Tests: TTY-stdin/redirected-stdout EOF is classified non-interactive; an
erroring input stream rejects instead of hanging; the archive usable-terminal
test now models a full terminal (both streams TTY).

Refs #1526

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(archive): gate the change picker on a TTY and tidy the reader

Follow-ups from a second review round:

- selectChange (the no-argument change picker) called @inquirer's `select`
  unconditionally. `select` writes ANSI escapes to stdout even when redirected
  — the same #1526 mechanism the confirm prompts were fixed for — so
  `openspec archive > log.txt` with no change name still spewed cursor moves
  into the capture before blocking. Refuse before rendering when either stream
  is not a TTY, with the same "pass a change name / --yes" guidance the caught
  ExitPromptError already gives. A new test asserts the picker is never
  reached in a non-terminal run.

- readYesNo now removes its input-stream 'error' listener on every settle path
  (it lives on the long-lived process.stdin) and closes the readline interface
  on error too, so nothing accumulates across archive's sequential prompts.

- troubleshooting.md now notes the picker also stays clean.

Refs #1526

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(changeset): add patch changeset for the archive non-TTY fix (#1526)

User-facing patch note for the archive ANSI/disk-fill fix. Also drops an
unnecessary optional-chain on the non-nullable readline handle in readYesNo
(the listener is only attached after the interface exists).

Refs #1526
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:53:41 +00:00
Clay GoodandClaude Opus 4.8 83be9d113e feat(validate): add --archived to lint task completion of archived changes (#1604)
* feat(validate): add --archived to lint task completion of archived changes

`openspec validate --archived` scans every change under changes/archive/
and fails (exit 1) if any has unchecked tasks in tasks.md. This catches
changes archived with unfinished work — which the normal validate flow
never sees, since it only looks at active changes — and is meant for a
pre-commit or CI hook.

It is a standalone, opt-in scope: it returns before any existing bulk
path, so no current `validate` invocation changes behavior, and it does
not re-validate already-applied spec deltas. Reuses getTaskProgressForChange
(the same counter status/list/archive use) so task counting never forks,
and reads root.archiveDir so it is store-aware.

Closes #205

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(validate): fail loudly on archive read errors and unreadable task files

Address adversarial + CodeRabbit review of `validate --archived`:

- listArchivedChangeIds now returns [] only for ENOENT (missing archive
  dir) and rethrows permission/I/O/ENOTDIR errors, so a real archive-read
  failure exits 1 instead of silently reading as "no archived changes".
- Add getTaskProgressDetailForChange, which reports task files that exist
  but cannot be read; --archived turns those into an ERROR (naming the
  file) rather than silently counting them as zero tasks. The shared
  getTaskProgressForChange now wraps it and drops the detail, so
  status/list/archive totals are byte-identical.
- Start the spinner after listing so a thrown listing error never leaves
  a spinner running.

Adds regression tests (archive path is a file; archived tasks.md is
unreadable) and unit tests for the new detail variant. Docs: align the
--archived table verb and add a troubleshooting one-liner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(validate): address CodeRabbit nits on archived-task tests

- Build the unreadable-fixture path from separate path.join components
  instead of a hard-coded Unix-separator string.
- Assert the reported unreadable path (canonicalized with
  realpathSync.native), not just the count, so a wrong path can't pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(validate): address round-3 review of --archived

From three fresh adversarial reviews (scale/perf, flag/output-shape,
filesystem/security):

- perf: memoize schema→glob resolution across archived changes via a
  run-scoped SchemaGlobCache, so the same schema.yaml isn't re-parsed
  once per change (the archive is append-only and can hold thousands).
  Threaded as an optional arg; existing callers are unchanged. Loop stays
  sequential by design (per-change work is synchronous) — now documented.
- output shape: issue `path` now follows validate's convention —
  'tasks.md' for incomplete tasks, and the POSIX root-relative file path
  for an unreadable file (one issue per file) instead of the bare 'tasks'.
- plain output: print `change/<id>` (matching the JSON `type` and bulk
  validation) instead of `archived/<id>`.
- docs: correct the "Never throws" docstrings (glob resolution can throw
  on a malformed/unsafe schema; the caller guards it) and note the
  load-bearing projectRoot override for the archive path depth.

Store-mode resolution confirmed correct by review. Tests updated + a memo
regression test added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:53:06 +00:00
Clay GoodandClaude Opus 4.8 59c16a4461 feat(tools): add Command Code command adapter for /opsx-* commands (#1622)
* feat(tools): add Command Code command adapter for /opsx-* commands

Command Code documents custom slash commands under
`.commandcode/commands/`, where the command name is the markdown
filename without its `.md` extension (see
https://commandcode.ai/docs/reference/slash-commands). That is the same
flat naming Cursor and OpenCode use, so a standard flat adapter writing
`.commandcode/commands/opsx-<id>.md` registers `/opsx-<id>`.

Registering the adapter flips Command Code from `none` to
`adapter-backed`, so with the default `both` delivery `openspec init`
now generates OpenSpec commands alongside the skills it already installs
under `.commandcode/skills/`.

Builds on #1613, which registered Command Code as a skills-only tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tools): preserve Command Code command arguments

* test(command-code): cover commands-only delivery and openspec update

Addresses review: prove the Command Code adapter survives both the
commands-only init path and the update path, not just default delivery.

- init: delivery=commands generates .commandcode/commands/opsx-explore.md
  and installs no skills.
- update: a detected .commandcode install regenerates the flat
  opsx-<id>.md command (plain Markdown, $ARGUMENTS injected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:52:25 +00:00
Ángel PeñaandCommandCodeBot 42d7f673bc feat(tools): add Command Code support as a skills-only tool (#1613)
* feat(tools): add Command Code support as a skills-only tool

Register Command Code in AI_TOOLS with skillsDir `.commandcode`, so
`openspec init`/`update` install the OpenSpec skills where Command Code
discovers them (.commandcode/skills/<name>/SKILL.md) and reference them
with the `/openspec-*` invocations its skill surface registers.

No command adapter: Command Code has no slash-command files, so the
skills-only target behaves like other adapterless tools and reports
"Commands skipped for: command-code ^(no adapter^)".

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>

* docs(supported-tools): add Command Code to skills-only invocation table

Keeps the How-To-Invoke table consistent with the Tool Directory
Reference row added for Command Code.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>

---------

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-11 20:26:16 +00:00
aliouswe e50bd0983d fix(validate): warn on ambiguous task numbering (#1523)
* fix(validate): warn on ambiguous task numbering

* fix(validate): honor task numbering review boundaries
2026-08-07 13:10:54 +00:00
openspec-release-bot[bot]andgithub-actions[bot] d57889664c Version Packages (#1488)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-05 20:55:58 +00:00
Clay GoodandClaude Opus 4.8 568e56c672 chore(release): add catch-up changeset for Rovo, Codex dir, status (#1518)
* chore(release): add catch-up changeset for Rovo, Codex dir, status

Cover three user-facing PRs that merged without changesets so they
appear in the v1.8.0 CHANGELOG:

- #1516 Atlassian Rovo Dev CLI (new tool)
- #1511 Codex skills move to shared .agents directory
- #1505 openspec status separates planning from implementation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(release): correct isPlanningComplete wording in changeset

Skipped planning artifacts count as satisfied without being written; say
"every non-skipped planning artifact exists" to match the CLI and
agent-contract docs (alfred/CodeRabbit review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 20:38:36 +00:00
Clay GoodandClaude Opus 4.8 73207a6f2c feat(copilot): make cloud coding-agent files opt-in (#1517)
* feat(copilot): make cloud coding-agent files opt-in

Selecting the `github-copilot` tool auto-generated a GitHub Actions
workflow (.github/workflows/copilot-setup-steps.yml) plus an agent file.
Writing into a user's CI on init/update is invasive, benefits only the
narrow set of Copilot *cloud* coding-agent users, and couples us to
GitHub's externally-owned custom-agent format.

Cloud files are now opt-in:
- `openspec init` prompts before generating them (default No) and records
  the choice in openspec/config.yaml (`githubCopilot.cloudAgent`).
- `--copilot-cloud` / `--no-copilot-cloud` decide non-interactively.
- `openspec update` never prompts; it only refreshes files for projects
  that opted in, or that already have generated cloud files (so existing
  setups keep working — the migration path).

The pre-existing content-matching guarantees are unchanged and now proven
by regression tests: a user-customized cloud file is never overwritten or
deleted. Opt-in state is persisted via the YAML document model so the
user's hand-authored config comments and formatting survive untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(copilot): polish the cloud opt-in — safety, UX, and docs

Follow-up hardening driven by a five-agent review swarm over the opt-in.

Correctness:
- persistCopilotCloudOptIn no longer throws on a scalar/`null` config file
  (reproduced crash); it starts a fresh map while preserving comment-only
  and empty files.
- Explicit opt-out (`--no-copilot-cloud` / `cloudAgent: false`) now removes
  OpenSpec-managed cloud files on both init and update, instead of orphaning
  them. Customized files are still never touched.
- `--copilot-cloud` / `--no-copilot-cloud` warns when github-copilot isn't
  among the selected tools, instead of silently no-opping.

UX / discoverability:
- init prints whether cloud files were written or, when skipped for want of
  a signal, how to enable them (`--copilot-cloud`).
- When the user opts in but already has their own copilot-setup-steps.yml or
  agent file, init/update say it was left untouched and that the OpenSpec
  install step must be added by hand — the direct answer to "will this affect
  my existing Copilot cloud agent?".
- Clearer interactive prompt (names both files; distinguishes the GitHub-hosted
  cloud agent from Copilot in the editor); a dim, interactive-only, decision-
  gated hint on `openspec update`; tightened flag help text.

Docs (the feature was undocumented): new "GitHub Copilot cloud coding agent"
section in supported-tools.md; init flags in cli.md; the githubCopilot.cloudAgent
key in customization.md.

Tests: interactive prompt (accept/decline), opt-out removal + customized-file
preservation, config.yml variant, scalar-config regression, collision
reporting, flag-ignored warning, re-init honoring persisted opt-in, and the
config parse/warn branches. 2763 tests pass; the only failures are pre-existing
and unrelated (completion mocks, adapters loader, one config-profile PATH case,
one experimental-alias case), verified identical on clean main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(copilot): make init cloud-file output honest; harden config guard

Final hardening pass (adversarial review of the opt-in polish).

- init's success line listed both cloud-file paths from the *decision* to
  write, not from what was written — so it claimed files that a write
  skipped (user already owns them) or that the alternate-agent path removed.
  It now lists only OpenSpec-managed files that actually exist after the
  write (listManagedCloudFiles), keeps the "left untouched" caveat for
  user-owned files, and reports opt-out removals in the normal output block.
- persistCopilotCloudOptIn's non-map guard used isCollection, which is also
  true for sequences, so a YAML list at the config root still made setIn
  throw. Gate on isMap so scalars AND sequences fall back to a fresh
  document; empty/comment-only files still round-trip with comments intact.
- Fixed a misleading catch comment on the opt-out removal path.

Tests: success-line accuracy over a user-owned file, sequence-root config
regression, and listManagedCloudFiles coverage. 318 tests pass across the
touched suites; build + lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(copilot): replace a non-map githubCopilot node before setIn

Addresses alfred review on #1517. The prior guard only fixed a non-map
config *root*; a valid top-level map whose `githubCopilot` value is itself a
scalar/null/sequence (`githubCopilot: false`, `null`, or a list) still made
`setIn(['githubCopilot','cloudAgent'], ...)` throw, which init swallowed —
so the explicit opt-in/out was never saved. Now the intermediate node is
replaced with an empty map before descending, keeping the rest of the config
and its comments intact.

Regression covers all three reproduced cases (false/null/sequence). Full
suite: 2770 pass; only the pre-existing unrelated failures remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(copilot): never throw persisting into an unparseable config

Deeper pass on persistCopilotCloudOptIn (the function alfred flagged), driven
by an exhaustive input-shape check. Two malformed inputs still threw at
toString(): a multi-document YAML stream and a tab-indented (syntactically
invalid) file. Such a file can't be edited without corrupting it, so persist
now detects parse errors and leaves it untouched (no throw, no clobber) — it
is already invalid, so readProjectConfig ignores it regardless.

With this the function is throw-free across every shape exercised: empty,
comment-only, scalar/sequence root, a non-map githubCopilot value, anchors,
CRLF, BOM, and the two malformed cases (now skipped byte-identical).

Regression added for the multi-document case. Touched suites: 314 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 18:51:26 +00:00
Clay GoodandClaude Opus 4.8 13e213e00f feat(tools): add Atlassian Rovo Dev CLI as a first-class tool (#1516)
* feat(tools): add Atlassian Rovo Dev CLI as a first-class tool

Rovo Dev CLI loads project Agent Skills from `.rovodev/skills/<name>/SKILL.md`
(Atlassian docs), the same SKILL.md format OpenSpec generates. It was usable
only via the generic "Shared .agents skills" fallback; this makes it a named,
selectable target in `openspec init`.

Rovo has no slash-command surface, so it is registered as an adapterless
skills-only tool (like CodeArts/ForgeCode/Hermes) — no command adapter.

Closes #212

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tools): reference Rovo skills by natural language, not dead slash commands

Rovo Dev CLI has no slash-command surface — it matches skills
automatically or by prompt, and `/skills` only manages them. The
generated skills and the getting-started hint still advertised
`/openspec-*` slash commands (18 references across the skill bodies plus
the "Start your first change" hint), so every one was a dead command.

Adds a natural-language skill-reference path for no-slash tools:
`/opsx:<id>` now renders as "the openspec-<skill> skill" for rovodev, in
both skill bodies and the init hint. Other tools are unchanged.

- src/utils/command-references.ts: NATURAL_LANGUAGE_SKILL_TOOLS +
  usesNaturalLanguageSkillReferences(); getSkillReferenceTransformer
  returns the prose transformer for rovodev.
- src/core/init.ts: phrase the skills-only hint as an instruction for
  no-slash tools ("ask Rovo Dev CLI to use the openspec-propose skill…").
- docs/supported-tools.md: correct the Rovo row (was "use skill-based
  /openspec-* invocations").
- tests: assert generated Rovo skills contain no /openspec-* or /opsx
  slash tokens, the hint advertises no dead command, and the transformer
  emits prose.

Addresses alfred-openspec review on #1516.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 17:11:21 +00:00
Clay GoodandClaude Opus 4.8 96a6548664 refactor(templates): share one apply instruction body across skill and command (#1515)
* refactor(templates): share one apply instruction body across skill and command

The apply skill and command templates each carried a full ~150-line copy of
the same instruction body, differing in exactly one line (the `contextFiles`
note). Two near-identical copies invite silent drift.

Author the body once in `getApplyInstructions(contextFilesNote)` and render it
per surface, passing each surface's own note. The single intentional wording
difference stays explicit as a named constant, and further per-surface
parameters can be added here as the surfaces evolve — the skill and command
remain distinct templates.

Pure refactor: the generated skill and command output is byte-identical to
before (SKILL.md and all parity hashes unchanged). Added a contract test that
fails both if the shared body drifts between surfaces and if the intentional
contextFiles difference is flattened away.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(templates): unify apply instruction body into one shared core

Builds on the shared-core extraction: the apply skill and command still each
carried a slightly different `contextFiles` note (skill spelled out example
artifact sets, command said only "varies by schema"). That difference was
long-standing accidental drift between the two copies, not an intentional
surface distinction — the surfaces are meant to differ only in how they are
invoked, which the generation transformers already handle downstream by
rewriting `/opsx:<id>` tokens per surface.

Resolve the drift by unifying on the more informative note, so both surfaces
render one shared `getApplyInstructions()` body with no per-surface text.
Skill output is unchanged; the command's contextFiles note gains the example
artifact sets. Updated the contract test to assert both surfaces render the
shared core (no silent template-level drift), and regenerated the command
function hash accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 15:50:11 +00:00
aliousweandClay Good d9bcc18582 docs(stores): add multi-repo implementation flow (#1491)
* docs(stores): add multi-repo implementation flow

* docs(stores): qualify project pointer precedence

---------

Co-authored-by: Clay Good <hi@claygood.com>
2026-08-05 15:25:12 +00:00
FasterPHPandMarcus Don 622c509a13 fix(telemetry): honor telemetry.enabled in global config (#1513)
* fix(telemetry): honor telemetry.enabled in global config

Honor the documented global config opt-out while preserving environment and
CI overrides. Keep runtime-managed telemetry identity fields intact and apply
the same privacy setting to update checks.

AI: agentic

* docs(telemetry): address automated review feedback

Document the full opt-out behavior in the changeset and describe the new
test helper so automated documentation coverage meets the project threshold.

AI: agentic

---------

Co-authored-by: Marcus Don <marcus.don@team.blue>
2026-08-05 15:23:41 +00:00
Clay GoodandClaude Opus 4.8 06b310bf57 fix(templates): restore intentional apply skill/command separation (#1514)
* fix(templates): restore intentional apply skill/command separation

Revert the deduplication from #1153. Skills and commands are different
ways to invoke the apply workflow: commands reference /opsx:*, while
skills reference other skills by name and avoid /opsx: (a skill may be
installed without the commands). Teams choose skills-only, commands-only,
or both through profiles, so generating both is intentional, not drift.

#1153 collapsed getApplyChangeSkillTemplate() and getOpsxApplyCommandTemplate()
into one shared body and added a test asserting they are byte-identical,
erasing four deliberate differences (change-name example, contextFiles
note, blocked-state pointer, and completion hint). This restores the two
separate templates and removes the identical-body assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(templates): keep apply skill invocations transformable per target

Address alfred's review on #1514. A plain revert of #1153 restored the
skill template's bare `openspec-continue-change` prose and dropped the
archive/input invocations. The generator only rewrites canonical
`/opsx:<id>` tokens, so bare prose is dead text for skills-only targets:
skills.sh, Codex, and Kimi lost valid continue/apply/archive invocations.

Keep the skill and command templates split (no shared constant, no
identical-body assertion — the design separation #1153 erased stays
reverted), but author the skill's three invocation references as
transformable `/opsx:*` tokens. The generator now emits the correct
per-target skill invocation: `/openspec-continue-change` (default),
`$openspec-continue-change` (Codex), `/skill:openspec-continue-change`
(Kimi) — i.e. "invoked as skills," spelled for each tool.

Regenerated the static SKILL.md and parity hashes, and added
default/Codex/Kimi generation regressions that pin the apply skill's
per-target invocations so this break can't recur silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 12:59:33 +00:00
Clay Good 59bfb27a76 fix(codex): install skills in canonical agents directory (#1511)
* fix(codex): install skills in canonical agents directory

* fix(codex): preserve shared agents compatibility

* fix(codex): harden shared skill migration

* fix(codex): preserve customized legacy skills

* fix(codex): reject malformed generated versions
2026-08-05 01:43:49 +00:00
161f9454a3 feat: add MiniMax Code skills support (#1214)
* feat: add MiniMax Code skills support to OpenSpec

* fix: separate init skill and command output summaries

* feat(minimax): add global skills support

---------

Co-authored-by: showms <showms@users.noreply.github.com>
Co-authored-by: Clay Good <hi@claygood.com>
2026-08-05 01:01:36 +00:00
SHASHANK DWIVEDIandClay Good 0b233efb86 fix(templates): deduplicate apply skill and command instructions (#1153)
* fix(templates): deduplicate apply skill and command instructions

Extract shared APPLY_INSTRUCTIONS constant so skill and command
templates reference the same string. Eliminates content drift
reported in #1139.

* fix(templates): update parity hashes after parameterizing apply instructions

* test(templates): add normalized body parity assertion for apply skill vs command

* docs(templates): add JSDoc to getApplyInstructions

* docs(templates): add JSDoc to all functions in apply-change

* fix(templates): parameterize /opsx:apply examples and add regression tests for skill /opsx: references

---------

Co-authored-by: Clay Good <hi@claygood.com>
2026-08-05 00:48:30 +00:00
NicoAvanzDevandClay Good 7a4a745d80 feat: generate Copilot coding agent files on openspec init (github-copilot) (#1274)
* feat: generate copilot cloud agent files when github-copilot tool is selected

When `openspec init` or `openspec update` is run with the github-copilot
tool selected, two additional files are now generated in the user's project:

1. `.github/workflows/copilot-setup-steps.yml` - A GitHub Actions workflow
   that pre-installs the OpenSpec CLI in the Copilot coding agent's
   ephemeral environment (required for the agent to use `openspec` commands).

2. `.github/agents/openspec.agent.md` - A custom agent definition that
   instructs the GitHub Copilot coding agent how to use the OpenSpec CLI,
   including all agent-compatible commands with `--json` output, workflow
   patterns, and best practices.

These files are only written if they don't already exist (to preserve
user customizations). The generation is non-fatal — if it fails, init/update
still completes successfully.

New module: src/core/github-copilot/cloud-agent.ts
Tests: test/core/github-copilot-cloud-agent.test.ts

* fix: wire up removeCopilotCloudFiles in update flow

When github-copilot is not in the configured tools during update,
remove the cloud agent files (copilot-setup-steps.yml and
openspec.agent.md) if they exist.

* fix: refresh Copilot cloud agent restore

* fix: address Copilot cloud review feedback

* fix: recognize legacy Copilot cloud files

* fix: harden Copilot legacy file matching

* fix(copilot): harden cloud agent file management

* fix(copilot): harden cloud agent file handling

---------

Co-authored-by: Clay Good <hi@claygood.com>
2026-08-05 00:41:30 +00:00
Ismet TogayandClay Good 3e50944fb0 fix(build): allow esbuild install scripts (#1196)
* Add pnpm-workspace.yaml to allow esbuild build scripts

pnpm 10+ blocks all dependency build scripts by default unless explicitly
approved via allowBuilds or onlyBuiltDependencies in pnpm-workspace.yaml.

esbuild (transitive dependency of vitest -> vite) has a postinstall script
that downloads a platform-specific native binary. Without this config,
pnpm install exits non-zero with [ERR_PNPM_IGNORED_BUILDS], breaking any
downstream packaging (AUR, Nix, Docker) or local setup using pnpm >=10.

Refs: #1195

* fix(build): declare pnpm workspace root

* fix(build): harden pnpm workspace policies

---------

Co-authored-by: Clay Good <hi@claygood.com>
2026-08-05 00:18:37 +00:00
Clay Good 02b124e6b6 fix(security): patch fast-uri, postcss, and brace-expansion advisories (#1510)
* fix(security): patch fast-uri, postcss, and brace-expansion advisories

Resolve the two open Dependabot alerts plus a third high-severity advisory
the repo's own audit surfaces but Dependabot had not filed, all via
version-ranged pnpm overrides (they lapse once the upstream tree moves past
them):

- fast-uri 3.1.4 -> 3.1.5 (website): GHSA-7p8r-x3mc-p8w7, high. Host
  confusion via backslash authority introducer. Pulled in transitively by
  ajv@8.18.0; bounded to ^3.1.5 so it stays on the 3.x line ajv expects.
- postcss 8.5.22 -> 8.5.25 (root): GHSA-fxqj-rqcc-2cmp, moderate. Arbitrary
  .map file read via attacker-controlled sourceMappingURL. Pulled in by
  vite (dev/test tooling).
- brace-expansion 5.0.8 -> 5.0.9 (website): GHSA-rgw5-rvv9-x895, high. DoS
  via unbounded recursion. The existing override capped at >=5.0.8, and
  5.0.8 is itself vulnerable under this newer advisory; the root already
  resolved to 5.0.9.

Root and website audits are clean at --audit-level high (and any-severity
for the website). Full test suite: 3662 passing.

* harden(security): bound overrides, scope release perms, add website lockfile drift check, document archive TOCTOU intent

Hardening pass over the security fixes, from a parallel review of the
dependency, CI, archive, and adjacent-code surfaces. Each item is low-risk
and verified; resolved dependency versions are unchanged.

- deps: bound the three security overrides to their current major
  (brace-expansion ">=5.0.9 <6", postcss ">=8.5.23 <9"). A bare ">=X" pin
  would take a future major on the next lockfile regen without review; the
  website already models the caret-bounded idiom.
- ci: scope release-prepare.yml permissions per job. The top-level block
  dropped "pull-requests: write"; only the "prepare" job (which opens the
  Version Packages PR) now holds it. The "beta" job only tags/releases and
  publishes via OIDC, so it inherits the narrower default (least privilege).
- ci: add a "Website Lockfile Drift" job to security.yml. The website keeps
  its own lockfile and is never installed in CI, so a website override that
  stops resolving would go unnoticed and `pnpm audit` would scan a stale
  graph. A `pnpm install --frozen-lockfile --ignore-scripts --dir website`
  fails fast on that drift (root drift is already caught in ci.yml).
- archive: add intent comments at the 7 js/file-system-race sites in
  src/core/archive.ts. The stat->read->re-stat pattern is a deliberate
  concurrent-change detector; the comments record why, so no future refactor
  (human or scanner-driven) collapses it to fd I/O and blinds the guard.

Verified: 3662 tests pass, build clean, website build clean, root+website
audits clean at --audit-level high, and the new frozen-lockfile check passes
locally.

* chore(nix): refresh pnpmDeps hash for the lockfile change

The root pnpm-lock.yaml changed (postcss + brace-expansion overrides), which
stales the fixed-output pnpmDeps hash and fails Nix Flake Validation. Repin to
the value CI computed from the new lockfile.
2026-08-04 22:52:40 +00:00
Clay Good 3d0701f871 fix(workflows): preserve nested spec paths (#1508)
* fix(workflows): preserve nested spec paths

* fix(workflows): key conflicts by capability path

* fix(workflows): preserve full paths in examples

* fix(workflows): clarify nested path inputs

* test(workflows): align parity hashes after rebase
2026-08-04 21:44:14 +00:00
Clay Good 8a3850da73 fix(explore): scaffold changes before capturing artifacts (#1503)
* fix(explore): scaffold changes before capturing artifacts

* fix(explore): harden artifact capture guidance

* fix(explore): evaluate conditional prerequisites

* fix(explore): retain store during artifact capture

* fix(explore): propagate store in follow-ups

* test(explore): align parity hashes after rebase
2026-08-04 21:25:54 +00:00
Clay Good afea111cd4 fix(status): clarify planning completion (#1505)
* fix(status): clarify planning completion

* test(status): cover skipped planning artifacts

* fix(workflows): gate archive guidance on implementation

* fix(status): clarify human completion message

* fix(status): make completion guidance stage-neutral

* test(status): align parity hashes after rebase
2026-08-04 21:02:50 +00:00
Clay Good f43fe0e7d5 fix(propose): use the requested workflow schema (#1504)
* fix(propose): honor explicit schema selection

* fix(propose): harden schema selection guidance

* fix(propose): preserve selected store

* fix(propose): respect store flag support

* fix(propose): resolve schema discovery root

* fix(propose): preserve rootless schema discovery

* test(propose): align schema parity after rebase
2026-08-04 20:39:09 +00:00
Clay Good 0b20ae3964 fix(propose): wait for explicit implementation request (#1501)
* fix(propose): stop before implementation

* fix(propose): require explicit implementation request

* fix(propose): hand implementation to apply

* test(propose): align parity hashes after rebase
2026-08-04 20:13:59 +00:00
Clay Good 26bd1d4e5c fix(templates): correct generated workflow guidance (#1500)
* fix(templates): correct generated workflow guidance

* fix(templates): address workflow review feedback

* test(templates): pin store-aware commands

* fix(templates): harden generated workflow guidance

* test(templates): align parity hashes after rebase
2026-08-04 19:47:28 +00:00
Clay Good ece8660d44 fix(validate): allow non-English requirements (#1502)
* fix(validate): allow non-English requirements

* test(validate): cover non-English change deltas

* test(validate): distinguish missing bodies from guidance
2026-08-04 19:20:25 +00:00
Clay GoodandClaude Opus 5 521ee33e6e feat(archive): let a change retire a capability it empties (#1484)
* fix(archive): retire a capability when a change removes its last requirement

A delta whose REMOVED entries cover every requirement rebuilt the main spec
empty, and an empty spec fails validation ("Spec must have at least one
requirement"), so the archive aborted with no way forward. Pre-deleting the
main spec did not help: the delta was then treated as a create and landed on
the same empty spec.

Archive now treats an emptied capability as retired. It deletes the
capability's spec.md and any directory the deletion leaves empty, stopping
short of the specs root, and reports the removals in the totals. Nothing is
deleted unless this run actually removed a requirement, so a re-applied or
already-synced delta still leaves the file alone.

Closes #1302

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): decide retirement from the validator and contain the deletion

Adversarial review found the original rule unsound. It retired whenever no
canonical `### Requirement:` blocks were left, but the validator counts
requirements differently: MarkdownParser accepts any `###` heading under
`## Requirements`, while the delta block parser indexes only canonical headers
and sweeps the rest into the preamble, which survives into the rebuilt spec. A
strict-valid spec could therefore be deleted on an archive that previously
succeeded. Retirement is now decided by putting the rebuilt spec to the
validator and retiring only when its sole error is that it has no requirements,
which makes "this spec could not have been written anyway" true by construction.

Also fixed:

- The directory prune walked string prefixes, but path.resolve does not resolve
  symlinks and readdir/rmdir both follow them, so a symlinked capability
  directory let it delete directories outside the repository. Pruning is now
  bounded by real paths and refuses to descend through a symlink.
- A spec that was already requirement-less and lost nothing this run is no
  longer skipped past validation; it aborts exactly as it did before.
- Deletions are deferred until every spec write has succeeded, so a later
  failure cannot leave a spec already deleted.
- Retirement is recorded in `warnings`, naming any other sections the deleted
  file held, so JSON consumers and humans can both see what went.
- Totals carry every applied operation; a rename applied on the way to the
  removal was being dropped.
- bulk-archive guidance, the sync/archive skill specs, and the docs that
  described archive as never deleting a spec.

Closes #1302

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): close the retirement gaps a second review round found

Five adversarial reviews, mutation testing and CodeRabbit went at the reworked
retirement. The findings, all verified by repro before fixing:

- The archive-name collision check ran AFTER the spec merge, so archiving twice
  in one day deleted the capability's spec and then failed, leaving the change
  unarchived and the file gone. The destination depends only on the change name,
  so it is now settled before any spec is written or deleted - which also closes
  the same, older window for ordinary writes.
- `--no-validate` retired too, but the whole safety argument is the validator's
  verdict, and that path produces none. It now writes the spec exactly as it did
  before this feature existed, leaving no exception to the claim that nothing
  previously working changes.
- The validator can be talked out of seeing a requirement: a stray
  `### Requirements` under Purpose captures its section lookup, so a spec still
  holding a real requirement reported "no requirements" and was deleted. Any
  `###` heading left under `## Requirements` now vetoes retirement outright - a
  reader is not fooled by the stray heading even when the parser is.
- A dangling symlink made `update.exists` false (`fs.access` follows links,
  `unlink` does not), skipping the "removed something this run" guard: a run that
  removed nothing deleted an entry and reported a removal. The no-target case is
  now an explicit branch that never deletes, instead of an ENOENT probe.
- `findOtherSections` reported `## ` headings that were inside HTML comments and
  listed duplicates; it now masks comments like every other structural scan here
  and dedupes. The warning also names the `## Purpose`, which the deletion always
  takes, and the resolved path when a symlink puts the file outside the repo.
- A failed `unlink` surfaced a bare errno; it now says what was being attempted
  and what to do.

Tests grew from 19 to 33, killing every surviving mutant the review found:
deferral proven against a failing write (not just a failing validation), the
warnings payload, the already-gone path's output, multi-level pruning, the
`+ path.sep` boundary, a symlinked specs root, two retirements in one archive,
and `isRetirableSpec` unit-tested directly - including the two-error shape that
proves `every` rather than `some`.

Agent guidance, the three living specs and the docs now state the same
conditions the CLI applies, so a sync agent cannot delete a spec archive keeps.

Closes #1302

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): make the write-failure test platform-neutral and the path note meaningful

Windows CI and CodeRabbit each caught one:

- `chmod 0o555` is not a write barrier on Windows, so the test that proves
  deletions are deferred until every write succeeds never failed a write there:
  the archive completed, the spec was retired, and the assertion blew up. It now
  puts a directory where the second spec's file belongs, which fails the write on
  every platform. Verified it still kills the reordering mutant.
- The "resolved to" note compared a canonicalized path against a merely resolved
  one, so any symlinked ancestor - the platform's own /var -> /private/var is
  enough - decorated an ordinary retirement with a path that says nothing. It now
  fires only when the spec really lived outside the specs tree, which is the fact
  the nominal path hides. Both directions are pinned by tests.

Closes #1302

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): make the residual-heading veto position-independent

A third review round, scoped to the code the earlier rounds never saw.

The veto that is supposed to stop a retirement deleting hand-written content
only worked when that content sat ABOVE the first requirement. `parts.preamble`
is by definition the text before the first `### Requirement:` header; anything
after the last one belongs to that block's raw and is discarded with it, so the
rebuilt-body scan never saw it. Identical content, different position: one
aborted, the other was deleted silently. The veto now reads the original
Requirements section - preamble plus every block - so position does not matter.

Also:

- `realpath` follows a symlinked `spec.md` but `unlink` removes the link, so the
  warning declared it had deleted a file outside the repo that was still there.
  The note is now skipped when the target is itself a symlink.
- `findHeadings` masked HTML comments before code fences, so an unterminated
  `<!--` inside a fenced example blanked the rest of the document and truncated
  the very list of sections the deletion was reporting. Fence first, then
  comments.
- Moving the collision check before the merge widened the window between it and
  the move, where a claimed destination surfaced as a raw ENOTEMPTY and degraded
  to `archive_error`. `moveDirectory` now reports that as `archive_target_exists`,
  the same diagnostic the pre-flight check gives.

And a simplification the review asked for: the overlapping `retirable` /
`deletes` / `retired` booleans are now one `decideSpecOutcome()` returning
'write' | 'delete' | 'skip'. Behavior is identical - same clauses, same order -
but the fourth state that existed only as a comment is now a visible return.
Both guards were kept: the review constructed inputs where each is the sole
thing preventing a data-losing delete.

Two tests the review found wanting are gone or rewritten: one killed no unique
mutant, and one assertion straddled two editable message fragments and could
have gone vacuously true.

Closes #1302

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(archive): canonicalize both negative path assertions

CodeRabbit caught that `expect(warnings).not.toContain(shared)` passed
vacuously: on macOS the temp root lives under /var, whose realpath is
/private/var, so the warning would print a form the assertion never compared
against. The sibling assertion on `tempDir` had the same flaw.

Both now canonicalize first, and both were confirmed to fail against a mutant -
dropping the lstat guard, and forcing the resolved-path note on - which neither
did before.

Closes #1302

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): move a retired capability's spec into the archive instead of deleting it

Retiring a capability was the first case where archiving deleted a file
under `openspec/specs/`. Nothing in the repo had ever removed spec content
before, so the blast radius of a wrong verdict was a lost file with only
the reflog to recover it.

The spec now moves instead. It is staged into the change directory, which
the archive step renames onto the archive path moments later, so it comes
to rest at `<archive>/retired-specs/<capability>/spec.md` beside the
proposal and tasks that retired it. `git` records a rename, and bringing a
capability back is a `git mv` from the archive.

Staged into the change rather than written to the archive path after the
move, because the archive path must not exist yet and the ordering is
safer: if a later step fails, the spec sits in a change that is still
active and a rerun carries it through, versus stranding the live specs
tree without a spec it still needs.

A symlinked `spec.md` is copied by content and its link removed, rather
than moved: relocating the link itself would archive a relative path that
no longer resolves from where it landed. A spec already staged by an
earlier aborted run is never overwritten - it is the only copy once the
live one moves.

The retirement verdict, its guards, and the deferral until every write has
succeeded are all unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): clean up staging directories when a retirement move fails

The staging directories are created before the move, so any failure left an
empty `retired-specs/<capability>/` behind. That folder then rode into the
archive with the change, where it reads as a retirement that never happened -
a spec was supposedly retired here, and there is nothing to show for it.

The failure path now prunes back up to the change directory. Only empty
directories go, so a capability the same run already staged next to the
failing one is untouched, and the guard that refuses to overwrite a staged
spec still stops at a non-empty destination.

Both cases are covered by tests that fail without the prune: a dangling
symlink is the reproducible post-staging failure, since lstat sees a file and
the copy then follows the link and finds nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(archive): say "moved" where the retirement path still said "deleted"

Three leftovers from the deletion version: the `residualRequirementHeadings`
comment, `pruneEmptyDirs`'s `mainSpecsDir` parameter - now a boundary that is
the change directory on the cleanup path, not the specs root - and a sentence
in writing-specs.md that used "deleted" for the requirement and then again for
the file, two lines apart.

No behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): roll back a staged copy when the live spec cannot be removed

Both non-atomic retirement routes - a symlinked main spec, and the
EXDEV/EPERM rename fallback - copy the spec into staging first and remove
the original second. A copy that landed before an `unlink` that failed left
the spec in TWO places, and the staged one then tripped the "already
staged" guard on every rerun. The error told the caller to rerun the
archive, and the rerun could never work.

Reproduced at the previous head with a symlinked `spec.md` in a read-only
capability directory: `copyFile` succeeded, `unlink` returned EACCES, and
both copies remained.

The failure path now deletes the destination this attempt created, so the
capability is left exactly as the attempt found it and the rerun works. The
rollback is gated on a flag set only after the destination is proven free,
so a spec staged by an EARLIER run is never the thing removed - the
overwrite guard still fires ahead of it and rolls nothing back. A partially
written copy is cleaned by the same call.

The message no longer promises more than it delivers: it reports that the
spec is still in place, or names the leftover copy when the rollback itself
failed.

Regression tests cover both routes and assert the rerun succeeds, not just
that the copy is gone. Both fail without the rollback. The cross-device
route injects EXDEV, which cannot be provoked inside one temp directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(archive): run the rename-fallback rollback case on Windows too

The two post-copy rollback cases shared one `skipIf(win32)`, inherited from
the symlink case, which needs privileges Windows does not grant by default.
The rename-fallback case uses regular files and spies only, and the sibling
errno it stands in for - EPERM - is the Windows case, so skipping it there
left that route untested on the platform that produces it.

Skipping is now per-case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): claim the retirement destination atomically

`fs.access` followed by a write is not an ownership claim. Two concurrent
retirements both saw the destination free and both set `destIsOurs`; one
moved the spec into staging, and the other - equally convinced the file was
its own - rolled it back out. The source and the staged copy both ended up
gone. Reproduced at the previous head in 36 of 40 iterations.

The claim and the content now arrive in one syscall: `copyFile` with
`COPYFILE_EXCL` fails with EEXIST rather than overwriting, so exactly one
caller can ever own the path. That is also the check that refuses to
clobber a spec an earlier aborted run staged, now decided atomically rather
than by a separate look beforehand.

The losing caller fails two ways, and both used to destroy the winner's
file. EEXIST is the obvious one. ENOENT is not: `copyFile` opens the source
first, so a loser that arrives after the winner removed the source fails
before creating anything - and treating that as "a partial copy of mine"
unlinked the winner's file. Neither errno now claims ownership. Fixing only
EEXIST left 4 of 40 iterations still losing both copies.

Copying rather than renaming is what makes the claim possible: `rename`
overwrites silently on every platform, so it cannot tell "I created this"
from "I destroyed someone else's". It also crosses filesystems, which
retires the EXDEV/EPERM fallback, and reads a symlink's content rather than
moving the link - so the two routes collapse into one shape.

Regression asserts the invariant over 25 rounds: exactly one caller
retires, the spec survives once and intact, and the source is gone. It
fails against the old access-then-write shape.

Not crash-safe, which is a weaker promise and now documented: a process
killed between the copy and the unlink leaves the spec in both places, and
the next run refuses rather than guessing which to keep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): take retirement ownership from an exclusive create, not an errno

Claiming the destination with `copyFile(..., COPYFILE_EXCL)` closed the
concurrent race but kept reading ownership out of a failure code, and that
cannot be made correct however the errnos are partitioned. An errno says
what went wrong, not what was created: a source-side EACCES is
indistinguishable from a partial copy of our own, so the cleanup deleted a
recovery copy an earlier run had staged - the last remaining copy of a spec
whose live file could not even be read.

Reproduced at the previous head with an unreadable `spec.md` and a
pre-existing `retired-specs/legacy/spec.md`: the staged file was destroyed.

Ownership now comes from `open(dest, 'wx')`. O_CREAT|O_EXCL returns a
handle exactly when it created the file, so the question is answered by the
syscall instead of inferred afterwards, and every failure path leaves the
flag false. EEXIST remains the refusal that protects an earlier run's copy,
now decided by the same operation. Content is written through the claimed
handle, as bytes, and the handle is closed before any rollback so Windows
can unlink it.

The regression uses real mode bits, skipped on Windows and under root: the
defect was a source-side errno being read as proof about the destination,
and stubbing a JS-level read cannot reproduce it, because the copy it has
to fool never went through one. Verified it fails against the errno-
inference version.

All three findings on this path now hold together: the pre-existing copy
survives, 0 of 120 racing iterations lose a spec, and a post-copy unlink
failure still rolls back and reruns cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): keep the staged copy when the source is already gone

The rollback exists for a copy that landed while the source survived - the
two-places state that blocks every rerun. It must not fire once the source
is gone: at that point the staged copy holds the only remaining content, and
the end state the retirement was reaching for is already reached.

An external delete landing between the read and the unlink produced exactly
that, and the rollback destroyed the spec outright - `retired: false`, no
live file, no staged copy, content gone.

`unlink` returning ENOENT is now a success rather than a failure to roll
back. Every other errno still throws: the source is still sitting there, and
leaving the staged copy beside it is the state that blocks a rerun.

Found reviewing the finished path rather than reported - the same class as
the three review findings before it, all of them the rollback reaching a
copy it should not have. Regression verified against the unconditional
unlink.

Also corrects a doc line that still credited the copy with claiming the
destination; the claim is the exclusive create.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(archive): gate retirement on a declared marker, drop retired-specs/

Reworks #1302 to follow the design that already exists instead of adding one.

The move-into-the-archive approach introduced two things OpenSpec did not
have: capability retirement as a lifecycle state, and `retired-specs/` as an
on-disk convention no schema declares - which a future unarchive command
would have to know about. Its whole justification was preserving content that
two existing mechanisms already preserve: the archived change carries the
delta naming every REMOVED requirement with its Reason and Migration, and git
carries the file. The approach even conceded the point by advertising `git mv`
as the recovery path.

The issue itself proposed neither. It asked for a delete, or an explicit
retirement marker. This does both: archive deletes the emptied spec, and only
when the change declares `retire_capabilities: true` in its `.openspec.yaml`.

`skip_specs` is the precedent. The marker reader is the same function,
parameterised by key, so the two can never drift apart on what counts as
honorable metadata - a marker in unparseable YAML, or one whose schema does
not load, is not a marker in either case. An explicit `false` is not an
unhonorable marker, it is simply undeclared.

Without the marker nothing changes: the unwritable spec aborts the archive
exactly as before, except the abort now names the marker as the way out - and
says nothing about it when retiring would not have made the spec writable
anyway, so it never sends an author after the wrong fix. Applying REMOVED
already deletes requirement content from a main spec, so deleting the spec
once nothing is left is that same operation carried to its end.

Every guard survives: the validator's verdict, the residual-heading veto,
something-removed-this-run, and never under --no-validate. What goes is the
exclusive claim, the rollback, the staging directories, and the four
data-loss windows they created across four review rounds. Net 307 lines
smaller than the move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: regenerate parity hashes over the merged sync-specs template

#1482 and this branch both edit the sync-specs template, so the merged
template needs its own hash - neither side's committed value describes it.

* docs(archive): correct claims the redesign left false, and bump to minor

Review findings, all verified before fixing:

- `pruneEmptyDirs`'s doc claimed "two callers, two boundaries", naming the
  change directory as the second. That was the staging walk from the move
  design; there is one caller. The boundary stays a parameter, and the comment
  now says why.
- Three comments still described the retirement as moving the file somewhere.
  It deletes it.
- The sync skill told agents the retirement condition includes "no other
  `###` headings or prose" and then claimed "openspec archive draws exactly
  these lines". It does not draw the prose line: a main spec with loose prose
  under `## Requirements` retires and is deleted, and the prose is not named
  in the warning, which reports `## ` sections only. Verified against the
  built CLI. The condition now states what the CLI enforces, and the template
  tells the agent to read that prose back to the user, since the CLI cannot
  see it for the agent.
- `docs/concepts.md`'s `.openspec.yaml` field list omitted the new marker -
  the one place a user goes to learn what that file may hold.
- `docs/cli.md`'s `--no-validate` row did not mention that it disables
  retirement, though the row two lines down documents retirement.
- Bumped patch -> minor. `skip_specs`, the marker this one mirrors, shipped as
  a minor change in 1.7.0 (#1399); this adds a metadata field and an archive
  outcome on the same footing.

No behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): refuse to retire a spec with a second Requirements section

Four review agents ran against this branch. Two data-loss findings, both
reproduced before fixing.

1. A spec with a SECOND `## Requirements` section was deleted even though it
   passed `validate --strict` with zero issues, and the report named only
   `Purpose`.

   `extractRequirementsSection` binds to the FIRST `## Requirements`, so
   everything after it rides through the merge untouched: the residual-heading
   veto never sees it, `findOtherSections` filters it out by title, and the
   validator's own section lookup stops there too - which is why a second
   section holding a `SHALL` with a scenario reads as valid and then died with
   the file. The earlier round made that veto position-independent WITHIN the
   section; this is the same evasion one level up.

   Retirement is now refused outright for such a spec, so the archive aborts as
   it did before #1302. The abort's marker hint takes the same conjunct, so it
   never advises a marker that would not have helped.

2. The recovery line promised `git checkout HEAD -- <path>` unconditionally,
   and the path was wrong twice over. Verified failures: an UNTRACKED spec -
   the ordinary case, since an earlier `openspec archive` creates the main spec
   and nobody has committed it yet - is deleted and the printed command errors,
   so the file is gone for good; under a store-selected root the nominal
   `openspec/specs/...` path does not exist in the caller's repo; and a
   symlinked capability directory puts the file somewhere else entirely.

   The line now names the path the file actually lived at, and is phrased as
   the condition it really is rather than a promise archive cannot keep.

Regressions for both, plus the three fail-closed branches on the deletion
authorisation path that no test observed: a marker in unparseable YAML, and a
failing unlink. Each verified against a mutation - removing the veto, restoring
the unconditional promise, swallowing the unlink error, and honouring a marker
in broken YAML each fail their test.

Also pins the sync skill's retirement guidance by content rather than by golden
hash, since a hash proves only that it matches its source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(archive): note that retiring a capability strands an in-flight MODIFIED

A capability's main spec is the base #1482's scenario-loss check compares a
MODIFIED block against. Retire the capability and that check goes silent by
design (a missing main spec is the sister-change-in-flight case), so a change
that modifies the retired capability keeps validating clean and then refuses to
archive with "target spec does not exist". Nothing is lost - there are no
scenarios left to drop - but nothing connects the refusal back to the
retirement either, so the changeset says it up front.

Found by testing this PR against the three that merged into main today.

* fix(archive): veto retirement on any heading past the merged section

A sixth data-loss defect, from a second round of review agents. Reproduced
before fixing: a `validate --strict`-clean spec was deleted with a live SHALL
requirement in it, and the report named only "Purpose".

The cause is a mask disagreement. `extractRequirementsSection` - the function
that decides where the Requirements section ENDS - masks fenced blocks only.
`findHeadings`, which both retirement vetoes were built on, masks HTML comments
as well. So a multi-line comment holding a `## ` line terminates the section for
the merge while being invisible to the scan that had to notice it: everything
below became a tail no guard could see. The round-five guard counted `##
Requirements` headings, which the same trick skins straight past.

The veto is now asked of the tail itself - does anything `###`-shaped sit past
the boundary the merge actually chose - read with the fence-only mask, so it
answers the question whatever produced that boundary. That subsumes the
multiple-Requirements-sections case it replaces and every comment variant.

Also from this round:

- The recovery command is derived from the path that was unlinked, not rebuilt
  from the capability id. On a case-insensitive filesystem the id and the real
  directory differ in case, git is case-sensitive, and the printed command was
  one git rejects.
- An absolute recovery path now says which checkout to run it in - for a
  selected store, the file is not under the directory archive was run from.
- A declared marker refused by the tail veto says why, instead of dropping the
  author who did what the docs asked back into the bare #1302 abort.
- Corrected "draws exactly these four lines" in the sync skill, a claim added
  two commits ago that was false when written: the CLI checks two more.

Both regressions are mutation-verified. Reverting the veto to the narrow
multi-section count fails the comment-boundary test.

One reported finding was NOT actioned, because its premise does not hold: a
residual `###` heading INSIDE the section still counts as a requirement to the
validator, so that spec is valid and simply gets written - there is no silent
dead end there to explain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(archive): say the marker needs the schema key beside it

`.openspec.yaml` requires `schema:`, so a file holding only
`retire_capabilities: true` is not honorable metadata and the marker does
nothing. The docs and the abort hint both described adding one line, which sends
anyone creating that file from scratch into a dead end. The message did explain
itself once you were there ("schema: Invalid input: expected string, received
undefined"), but it should not need to.

Pre-existing shared behavior - `skip_specs` has the same requirement - so this
is wording, not a behavior change.

* chore: merge main (#1483) and keep both archive test suites

#1483 landed while this branch was in review. Three conflicts:

- `archive.ts`: one import line, both sides' imports kept.
- `skill-templates-parity.test.ts`: hash constants, resolved by key-union and
  then regenerated from the merged source, which is the only authority once two
  branches have edited the same template.
- `archive.test.ts`: the trap this repo documents. Both branches appended a
  DIFFERENT describe block at the same place - `capability retirement (#1302)`
  here, `non-interactive prompts (#1479)` on main - so taking either side would
  have dropped 16 or 133 tests with a green suite. Both are kept.

The conflict boundary also cut the retirement describe's last two closing
braces, which `tsc --noEmit` accepted and only esbuild caught as "Unexpected
end of file". Restored by brace-balance against both parents.

Verified after: every one of main's 91 archive titles and 19 parity titles is
present, #1483's describe still holds its 16 tests, and its own non-interactive
repro still behaves as it does on main.

* fix(archive): only print a recovery command that would actually run

Both blockers from the last review.

The recovery line offered `git checkout HEAD -- <path>` for every retirement,
including ones where the file never lived under the directory archive was run
from: a selected store, or a symlinked capability directory. Git rejects an
absolute path from a different worktree however it is quoted, and an unquoted
path containing a space splits when pasted - a real store path reproduced both.
Those cases now say where the file was and leave recovery to the reader, rather
than handing them a command that cannot work. The ordinary case still gets the
command, quoted when the path needs it, via the portable quoting #1483 already
established for change names.

And `openspec/specs/specs-sync-skill/spec.md` still authorised deletion from the
four original conditions, with no mention of the tail-heading veto the CLI
gained - so the living spec permitted something the code refuses. It now carries
that condition, and a parity test pins it in the generated guidance so the two
cannot drift apart again.

Both fixes are mutation-verified: restoring the unconditional command fails the
escaped-path regression, and rewording the veto out of the template fails the
guidance test.

* fix(archive): retire only what the merge can account for

Replaces the tail-heading veto with a rule that does not read Markdown at all.

Six review rounds each found a different way to dress content so a heading scan
would miss it: a second `## Requirements` section, a `##` inside an HTML comment
ending the section early, a three-space indent, a setext underline. Every fix
was another regex approximating a parser, and every round found the next skin.

`extractRequirementsSection` has already split the file into the parts this
merge understands. So instead of asking "does anything here look like a
requirement" - a question a regex and a renderer answer differently - the guard
now asks where content ended up: anything non-blank between the `## Requirements`
header and the first requirement, or after the section ends, is content the merge
carried through without understanding, and a retirement that would delete the
file is refused. There is no second opinion to disagree with the first, because
there is no second parse.

The in-block heading guard stays, and its comment now says why: a `###` heading
that is not a requirement header is absorbed into the block above it, so it
never reaches the preamble or the tail. Folding that into the rule above needs a
parser that ends a block at any `###` heading, which belongs in the parser.

This narrows the feature: a spec carrying an authored section beyond Purpose can
no longer be retired automatically. That is deliberate. The abort names the
lines that stood in the way, and deleting a file whose contents this merge
cannot enumerate is exactly the case a person should decide.

Depends on #1490 for indented requirement headers, which are swallowed by the
block parser before any of this runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): account for the whole spec, not two slices of it

Defect eight, same class as the seven before it. The guard asked where content
landed, which was the right question, but it only read two of the five slices
`extractRequirementsSection` produces: the preamble and the tail. Content simply
moved somewhere nobody looked.

Reproduced: a hand-written migration runbook and a table written below a
requirement's scenarios live inside that requirement's `raw` - the block runs to
the next header the parser RECOGNISES - so removing the requirement deleted them,
and the report said "Its section(s) went with it: Purpose". Not silence: a false
statement the reader can act on. The same hole covered anything written above
the `## Requirements` section. And because the abort hint is gated on the same
checks, an unmarked run RECOMMENDED adding the marker that destroys it.

The audit now covers the whole file. Expected: the title, the `## Purpose`
section, the `## Requirements` header, and inside each block a requirement's own
parts - its header, its statement, its scenarios' bullets. Every other non-blank
line is reported and refuses the retirement. That folds in the `###`-heading
guard, which was a patch on this same leak using the technique the rewrite was
meant to abandon.

One reported shape is deliberately not a case: prose between `## Purpose` and
`## Requirements` IS the Purpose body, since the section runs to the next `##`,
and the warning already names Purpose as going with the file. The test says so.

Both regressions fail against the two-slice version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(specs): keep content absorbed into a removed requirement

A requirement block's `raw` runs to the next header the parser RECOGNISES, so a
heading it does not - one indented by the 0-3 spaces CommonMark allows, or a
plain `### Notes` - is absorbed into the requirement above it. Removing that
requirement deleted the absorbed content with it. Silently: nothing counted it,
so nothing warned, and the spec left behind still validated.

Reproducible on main with no marker and no capability retirement involved.

Anything from the first `#`/`##`/`###` heading after a removed block's own
header is now kept in place. `####` is excluded deliberately - a requirement's
`#### Scenario:` headings are its own and go with it.

This replaces an earlier attempt on this branch that widened every heading
pattern in both parsers to accept indentation. That was wrong twice over. It
reclassified content, so a spec that was valid became invalid - commented-out
and indented examples started parsing as real requirements, taking `list` from
1 requirement to 3. And it did not even fix the bug: moving the line out of the
block only meant the reconstruction dropped it at a different step, since
`rebuilt` is assembled from `before + header + kept blocks + after` and anything
skipped is simply gone.

So nothing is reclassified now. An indented heading is still not a requirement,
exactly as before; it just survives its neighbour's removal, which is all this
ever needed to do. The repo's own corpus produces byte-identical `list`,
`validate --specs --strict` and `validate --changes --strict` output.

Four regressions, each mutation-verified: removing the salvage fails the three
absorbed-content cases, and counting `####` as a boundary fails the scenario
case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(specs): keep notes absorbed into a modified or removed requirement

A slow audit of the previous commit found the fix covered one of three paths.

A requirement block absorbs anything below it that the parser does not read as
a new header - a note indented by the 0-3 spaces CommonMark allows, say - so
that content rides inside the block. The previous commit salvaged it when the
requirement was REMOVED and missed MODIFIED entirely: that path rebuilds the
block from the delta, which never carried the note, so it was dropped exactly as
before. Verified against the real CLI: main loses it on both paths.

RENAMED was the opposite trap. It rewrites the original block's header line in
place, so the note is already there - but it also deletes the original key from
the block map, which made the requirement look REMOVED to the salvage and
produced a duplicate. Tracking which operation applied is therefore not reliable
at this point in the merge, so the salvage now asks the assembled result
instead: re-insert a note only when nothing else in the rebuilt section already
carries it. That is correct for all three paths by construction.

Salvaged content also keeps its position now, next to the requirement it was
written beside, rather than being appended at the end of the section.

Six regressions, three of them mutation-verified against this logic: never
re-inserting fails four, always re-inserting duplicates on rename, and appending
at the end loses the position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(specs): decide salvage by identity, not by matching text

Another audit pass, another defect in my own fix.

Deciding whether a note survived by searching the rebuilt section for its text
is wrong when two requirements carry the same note: the first copy is found,
and the second is dropped. Reproduced - two removed requirements each followed
by an identical `### Notes`, one note destroyed.

Survival is a question about the block, not about text. An untouched block is
the same object the parser produced and still carries its note; a replaced one
is a different object and does not. The RENAMED path previously blurred that by
copying the whole raw, so it now carries only the requirement's own lines and
the salvage puts the note back like every other path. With every replacement
uniformly lacking the tail, `replacement !== block` decides it exactly, and no
text is compared at all.

Four properties, each mutation-verified: matching text instead of identity
loses the duplicate note, always re-inserting doubles an untouched block's note,
letting RENAMED keep the tail doubles it on rename, and counting `####` as a
boundary severs a requirement from its scenarios.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(specs): warn when a note absorbed into a requirement will be deleted

An adversarial review found the previous approach was worse than the bug.

Salvaging the "foreign tail" out of a requirement block relied on a positional
rule: everything after the first heading-shaped line is not the requirement's.
That is not true. A `# comment` inside a scenario bullet, or a markdown example,
matches the same shape - and on MODIFIED the old text was then spliced back in
after the new, so the spec asserted both. The validator called the result valid,
and re-applying the same delta grew the file every time. Reproduced end to end.

It also turned a working archive into a hard abort: preserving an unindented
`### Notes` made the rebuilt spec fail validation as a scenario-less
requirement, so changes that archived cleanly on main stopped archiving, with an
error that never mentioned the note.

Measured before choosing: 3 of 742 requirement blocks in this repo contain a
heading-shaped line, and the repro shows those are false positives. Trading a
rare silent deletion for silent corruption on the most common operation is a bad
trade.

So the merge is left exactly as it was - byte-identical output, verified against
main - and the loss is reported instead. That fixes the part of the bug that
actually hurt: it was silent. A wrong warning costs a line of output; acting on
a wrong answer rewrites the spec.

Eight tests. Dropping the warning fails three; ignoring the fence mask fails
one - the fence case the previous version left unpinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): scope a scenario's bullets, and stop refusing ordinary prose

Defect nine, plus the over-refusal it exposed.

Every bullet counted as a scenario's own, anywhere in the block. So an
operational note bulleted below the last scenario - "IMPORTANT: escrow keys
live in the legacy vault" - was deleted with the file, on a spec that passes
`validate --strict`, and the report named only "Purpose". A scenario's bullets
run unbroken beneath its header; a blank line after them ends the run, and
bullets past that point are the author's own note.

Measuring the guard against this repo's 36 specs then showed the opposite
failure was already there: 7 of them could never be retired, almost entirely
because every fenced line inside a requirement was treated as foreign. A code
example inside a scenario is that requirement's own content - a
`### Requirement:` inside a fence is not a heading to any reader - so fenced
lines are now accounted for, as are numbered lists and a statement that opens
with inline code.

One ambiguity is left deliberately unresolved: a scenario whose bullets are
split by a blank line reads exactly like a note bulleted below it, and no
line-based rule separates them. Those specs are REFUSED, never deleted. The
abort quotes the lines, and the author moves them or removes the file by hand.
Refusing costs a message; the alternative costs the file.

Two regressions: the bulleted note must refuse, and a requirement using a
numbered list, a fenced example and an inline-code statement must still retire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): a section is not only an ATX heading

Defect nine, from a deep adversarial pass, and it is the same species as the
eight before it: the guard decided what a section IS by one syntax while a
reader recognises three.

Once `## Purpose` was seen, every later line in the pre-requirements slice was
accepted as its body until the next ATX `##`. But a setext underline turns the
line above it into a heading, and raw HTML says so outright - a reader sees a
sibling of `## Purpose`, not more of it. So a whole authored section could sit
between Purpose and Requirements, pass `validate --specs --strict`, and be
deleted with the file while the report said only "Purpose". On main the same
archive aborts and loses nothing.

Reproduced with a `Data Migration Notes` section underlined with dashes: the
capability retired, the notes gone, unnamed. Now refused, with the lines quoted.

Two path defects from the same review, one fix: the reported path was rebuilt
from the capability id, so on a case-insensitive filesystem it differed in case
from the file actually unlinked and git rejected the printed command; and a
capability directory symlinked to a sibling deleted one spec while naming
another. `retireSpec` now always returns the path it unlinked, and archive
reports that. Whether to print a command at all is decided against the REAL
repo root, so a symlink that stays inside the repo still gets a working command
and only a path that genuinely leaves it falls back to prose.

Also pins `!skipValidation` in isolation. The existing --no-validate test passed
for the wrong reason - its fixture was blocked by the content guard - so the
conjunct itself was unpinned.

Four regressions, all mutation-verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): close remaining capability retirement gaps

* fix(archive): close final transaction safety gaps

* fix(archive): close retirement race windows

* fix(archive): preserve retirement authorization

* fix(archive): verify complete fallback copies

* fix(archive): preserve transactional safety

Reject structurally ambiguous or symlinked inputs before mutation, serialize archive claims safely, and preserve permissions during verified fallback moves.

Keep retired specs as inode-preserving backups until the archive commits, restore them on rollback, and retain any backup changed concurrently instead of deleting user data.

* fix(archive): preserve replaced claims on Windows

Add a per-claim nonce and verify stable claim contents before unlinking because Windows file IDs may not distinguish a replacement lock entry.

* test(archive): respect Windows deferred deletion

Skip the POSIX unlink-and-recreate claim simulation on Windows, where deletion of an open file remains pending until the original handle closes.

* test(archive): align symlink fixtures with path boundaries

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 19:00:25 +00:00
Clay Good 9cd845fc45 fix(security): keep paths on a short leash (#1499)
* fix(security): keep paths on a short leash

* fix(security): tighten linked path handling

* test(security): prove schema escape rejection

* fix(security): close remaining trust boundary gaps

* fix(security): close review-found read windows

* fix(security): preserve safe linked workflows

* fix(schema): preserve fork failure details
2026-08-04 18:28:03 +00:00
Clay Good 4e4c9e1ffd docs(workflows): visualize the OpenSpec lifecycle (#1507)
* docs(workflows): add lifecycle diagrams

* docs(workflows): clarify optional archive paths

* docs(workflows): correct lifecycle diagrams

* docs(website): render Mermaid diagrams

* fix(website): preserve Mermaid label text
2026-08-04 18:09:08 +00:00
dependabot[bot] 80ad1fbaef chore(deps): bump the website-dependencies group (#1496)
Bumps the website-dependencies group in /website with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [fumadocs-core](https://github.com/fuma-nama/fumadocs) | `16.11.5` | `16.12.1` |
| [fumadocs-mdx](https://github.com/fuma-nama/fumadocs) | `15.2.0` | `15.2.1` |
| [fumadocs-ui](https://github.com/fuma-nama/fumadocs) | `16.11.5` | `16.12.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.25.0` | `1.27.0` |
| [next](https://github.com/vercel/next.js) | `16.2.11` | `16.2.12` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.1.1` | `26.1.2` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.17` | `19.2.18` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.3` | `19.2.4` |
| [postcss](https://github.com/postcss/postcss) | `8.5.22` | `8.5.25` |


Updates `fumadocs-core` from 16.11.5 to 16.12.1
- [Release notes](https://github.com/fuma-nama/fumadocs/releases)
- [Commits](https://github.com/fuma-nama/fumadocs/compare/fumadocs@16.11.5...fumadocs@16.12.1)

Updates `fumadocs-mdx` from 15.2.0 to 15.2.1
- [Release notes](https://github.com/fuma-nama/fumadocs/releases)
- [Commits](https://github.com/fuma-nama/fumadocs/compare/fumadocs-mdx@15.2.0...fumadocs-mdx@15.2.1)

Updates `fumadocs-ui` from 16.11.5 to 16.12.1
- [Release notes](https://github.com/fuma-nama/fumadocs/releases)
- [Commits](https://github.com/fuma-nama/fumadocs/compare/fumadocs@16.11.5...fumadocs@16.12.1)

Updates `lucide-react` from 1.25.0 to 1.27.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.27.0/packages/lucide-react)

Updates `next` from 16.2.11 to 16.2.12
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.11...v16.2.12)

Updates `@types/node` from 26.1.1 to 26.1.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/react` from 19.2.17 to 19.2.18
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/react-dom` from 19.2.3 to 19.2.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `postcss` from 8.5.22 to 8.5.25
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.22...8.5.25)

---
updated-dependencies:
- dependency-name: fumadocs-core
  dependency-version: 16.12.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: website-dependencies
- dependency-name: fumadocs-mdx
  dependency-version: 15.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: website-dependencies
- dependency-name: fumadocs-ui
  dependency-version: 16.12.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: website-dependencies
- dependency-name: lucide-react
  dependency-version: 1.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: website-dependencies
- dependency-name: next
  dependency-version: 16.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: website-dependencies
- dependency-name: "@types/node"
  dependency-version: 26.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: website-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: website-dependencies
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: website-dependencies
- dependency-name: postcss
  dependency-version: 8.5.25
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: website-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 17:39:57 +00:00
dependabot[bot]andClay Good 23c2787789 chore(deps-dev): bump eslint from 10.7.0 to 10.8.0 in the development-dependencies group (#1494)
* chore(deps-dev): bump eslint in the development-dependencies group

Bumps the development-dependencies group with 1 update: [eslint](https://github.com/eslint/eslint).


Updates `eslint` from 10.7.0 to 10.8.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.7.0...v10.8.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(nix): refresh pnpm dependency hash

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Clay Good <hi@claygood.com>
2026-08-04 17:39:36 +00:00
Jun 690a27e649 fix(adapters): stop deleting the CoStrict and Junie commands on every run (#1492)
LEGACY_SLASH_COMMAND_PATHS lists artifacts older OpenSpec versions left
behind, so init and update remove whatever matches. Two entries named
paths the current adapters still write to.

`costrict` was a whole-directory entry for `.cospec/openspec/commands`,
the folder its adapter writes `opsx-<id>.md` into, so every run deleted
the directory and everything in it — including files the user put there
— under a heading reading 'No user content to preserve'. It is now a
file pattern for `.cospec/openspec/commands/openspec-*.md`: the only
files that folder ever held before the opsx rename were
openspec-proposal.md, openspec-apply.md and openspec-archive.md, written
by the slash configurator added in #240 and dropped in #565.

`junie` listed `.junie/commands/opsx-*.md`, its adapter's own output,
next to `openspec-*.md`. Both halves arrived in #853 one file apart, so
the entry has collided with itself since day one. Cleanup runs before
migrateIfNeeded, so on a config with no `profile` key yet — the state
after a first init — the deleted command files make inferDelivery read
the project as skills-only and write that to the global config. The
files are not regenerated, and the delivery preference changes for every
other project too.

The entry is removed rather than narrowed. Junie support landed in #853,
months after #565 deleted the slash configurators that wrote
`openspec-*` files, and no junie configurator ever existed — so
`.junie/commands/openspec-*.md` is a shape OpenSpec never produced. The
same reasoning already keeps `.devin/` off the list two lines above.

The regression test is an invariant rather than a fixture — it writes
every registered adapter's output for every workflow into a temp project
and asserts detection reports nothing — and it names codex, the one
legacy id with no adapter, instead of silently skipping it.

Nothing else changes. The surviving `openspec-*` globs stay as broad as
they have always been, since narrowing them to the three ids the pre-opsx
configurators actually wrote is a separate, uniform change, and qwen's
`opsx-*.toml` pair stays because its adapter emits Markdown now.
2026-08-04 17:39:09 +00:00
Clay GoodandClaude Opus 5 45cca5db61 fix(specs): warn before archiving deletes a note next to a requirement (#1490)
* fix(specs): keep content absorbed into a removed requirement

A requirement block's `raw` runs to the next header the parser RECOGNISES, so a
heading it does not - one indented by the 0-3 spaces CommonMark allows, or a
plain `### Notes` - is absorbed into the requirement above it. Removing that
requirement deleted the absorbed content with it. Silently: nothing counted it,
so nothing warned, and the spec left behind still validated.

Reproducible on main with no marker and no capability retirement involved.

Anything from the first `#`/`##`/`###` heading after a removed block's own
header is now kept in place. `####` is excluded deliberately - a requirement's
`#### Scenario:` headings are its own and go with it.

This replaces an earlier attempt on this branch that widened every heading
pattern in both parsers to accept indentation. That was wrong twice over. It
reclassified content, so a spec that was valid became invalid - commented-out
and indented examples started parsing as real requirements, taking `list` from
1 requirement to 3. And it did not even fix the bug: moving the line out of the
block only meant the reconstruction dropped it at a different step, since
`rebuilt` is assembled from `before + header + kept blocks + after` and anything
skipped is simply gone.

So nothing is reclassified now. An indented heading is still not a requirement,
exactly as before; it just survives its neighbour's removal, which is all this
ever needed to do. The repo's own corpus produces byte-identical `list`,
`validate --specs --strict` and `validate --changes --strict` output.

Four regressions, each mutation-verified: removing the salvage fails the three
absorbed-content cases, and counting `####` as a boundary fails the scenario
case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(specs): keep notes absorbed into a modified or removed requirement

A slow audit of the previous commit found the fix covered one of three paths.

A requirement block absorbs anything below it that the parser does not read as
a new header - a note indented by the 0-3 spaces CommonMark allows, say - so
that content rides inside the block. The previous commit salvaged it when the
requirement was REMOVED and missed MODIFIED entirely: that path rebuilds the
block from the delta, which never carried the note, so it was dropped exactly as
before. Verified against the real CLI: main loses it on both paths.

RENAMED was the opposite trap. It rewrites the original block's header line in
place, so the note is already there - but it also deletes the original key from
the block map, which made the requirement look REMOVED to the salvage and
produced a duplicate. Tracking which operation applied is therefore not reliable
at this point in the merge, so the salvage now asks the assembled result
instead: re-insert a note only when nothing else in the rebuilt section already
carries it. That is correct for all three paths by construction.

Salvaged content also keeps its position now, next to the requirement it was
written beside, rather than being appended at the end of the section.

Six regressions, three of them mutation-verified against this logic: never
re-inserting fails four, always re-inserting duplicates on rename, and appending
at the end loses the position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(specs): decide salvage by identity, not by matching text

Another audit pass, another defect in my own fix.

Deciding whether a note survived by searching the rebuilt section for its text
is wrong when two requirements carry the same note: the first copy is found,
and the second is dropped. Reproduced - two removed requirements each followed
by an identical `### Notes`, one note destroyed.

Survival is a question about the block, not about text. An untouched block is
the same object the parser produced and still carries its note; a replaced one
is a different object and does not. The RENAMED path previously blurred that by
copying the whole raw, so it now carries only the requirement's own lines and
the salvage puts the note back like every other path. With every replacement
uniformly lacking the tail, `replacement !== block` decides it exactly, and no
text is compared at all.

Four properties, each mutation-verified: matching text instead of identity
loses the duplicate note, always re-inserting doubles an untouched block's note,
letting RENAMED keep the tail doubles it on rename, and counting `####` as a
boundary severs a requirement from its scenarios.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(specs): warn when a note absorbed into a requirement will be deleted

An adversarial review found the previous approach was worse than the bug.

Salvaging the "foreign tail" out of a requirement block relied on a positional
rule: everything after the first heading-shaped line is not the requirement's.
That is not true. A `# comment` inside a scenario bullet, or a markdown example,
matches the same shape - and on MODIFIED the old text was then spliced back in
after the new, so the spec asserted both. The validator called the result valid,
and re-applying the same delta grew the file every time. Reproduced end to end.

It also turned a working archive into a hard abort: preserving an unindented
`### Notes` made the rebuilt spec fail validation as a scenario-less
requirement, so changes that archived cleanly on main stopped archiving, with an
error that never mentioned the note.

Measured before choosing: 3 of 742 requirement blocks in this repo contain a
heading-shaped line, and the repro shows those are false positives. Trading a
rare silent deletion for silent corruption on the most common operation is a bad
trade.

So the merge is left exactly as it was - byte-identical output, verified against
main - and the loss is reported instead. That fixes the part of the bug that
actually hurt: it was silent. A wrong warning costs a line of output; acting on
a wrong answer rewrites the spec.

Eight tests. Dropping the warning fails three; ignoring the fence mask fails
one - the fence case the previous version left unpinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): warn before actual content loss

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 02:04:10 +00:00
Arda KılıçdağıandClay Good 1da6dfa8d7 Docs: add deno install instructions (#1079)
* docs: add deno install instructions

* chore(docs): address pr feedback

* chore(docs): address note feedback.

---------

Co-authored-by: Clay Good <hi@claygood.com>
2026-07-30 23:38:36 +00:00
Clay GoodandClaude Opus 5 2b3d368539 fix(archive): tell the caller which flag to pass when archive can't ask its questions (#1483)
* fix(archive): name the flag when a prompt has no terminal to answer it

An AI agent runs the CLI with stdin closed, so every confirmation
`openspec archive` asks rejects with @inquirer's "User force closed the
prompt with 0 null" - true, and useless: it names neither the question
nor the flag that answers it, so agents abort and guess (#1479).

Each confirmation now reports the same guidance JSON mode has always
given for that decision point, with a pasteable command. The change
picker got the opposite treatment: it swallowed the same failure,
printed "No change selected. Aborting." and exited 0, reporting success
for a run that archived nothing. It now exits 1 asking for a change
name, matching `openspec show` and `openspec validate`.

The detection is reactive - a prompt that already failed, at a stdin
that is not a terminal - so piped answers, --yes, --json and Ctrl-C at a
real terminal are untouched.

Closes #1479

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): carry the caller's flags into the suggested rerun, and honor every non-interactive signal

Adversarial review of the first commit found four defects in it:

- The suggested rerun dropped the flags the caller had passed. For
  `archive x --skip-specs` it suggested a bare `--yes` rerun, and
  following it merged deltas into the main specs - the exact thing
  --skip-specs was passed to prevent.
- The change name went into that command unquoted, so a change named
  `my change` produced an unrunnable paste and one named `a;touch x`
  produced a paste that runs a second command.
- The predicate keyed on stdin.isTTY alone, so a CI runner that
  allocates a pty still got the raw @inquirer failure - #1479 unfixed
  under the very signals `isInteractive()` already treats as
  authoritative.
- A genuine Ctrl-C reaches a process whose stdin is a pipe, and that
  was reported as "this terminal is not interactive", telling a user
  who deliberately quit to rerun with --yes.

The signal is now `!isInteractive()` with SIGINT excluded, so the
terminal proves capability and the signal proves intent. Messages say
what happened ("no answer could be read from stdin") rather than
asserting a property of the terminal, which was false under MinTTY.

Mutation testing found four more gaps in the tests: an unconditional
`throw blocked()`, a stripped `withStoreFlag`, and either half of the
predicate's `||` all left the suite green. Each now has a test, along
with the flag carry-forward, the quoting, the pty-CI case, and the two
prompts that had no end-to-end coverage. docs/cli.md documents the
behavior without a terminal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(archive): close two gaps CodeRabbit found in the new guards

The template guard required a space after `openspec archive`, so a
regression to a bare `openspec archive` line - which blocks agents
exactly as #1479 describes - would have passed it. Verified by
mutation: the widened pattern fails on that edit.

Expected filesystem paths in the new e2e assertions are built from
path segments, per the repo's testing guideline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): make the suggested rerun runnable for dashed names, stores, and Windows shells

A second adversarial pass, scoped to the previous two commits, found
three defects in the fix itself:

- A change named `--force` was emitted bare, and commander reads it as
  an option however it is quoted, so the suggested command failed with
  `unknown option`. Such changes do archive, so the case is reachable:
  the name now goes behind a `--`, with the store flag kept in front of
  it where it is still read as an option.
- The change-name-required path was the one blocked site left
  hard-coded, so `archive --skip-specs` with nothing to answer the
  picker suggested a rerun without `--skip-specs` - the same merge the
  previous commit set out to prevent.
- Quoting was POSIX-only: cmd.exe does not treat `'` as quoting at all,
  and PowerShell escapes an embedded quote by doubling it, so the
  emitted command was wrong on Windows. Names now use double quotes,
  which bash, zsh, PowerShell and cmd.exe all read the same way, and a
  name containing something with no portable spelling (a quote,
  backslash, `$`, backtick, newline) names the placeholder rather than
  emitting a command that could expand.

Two tests were pinning less than they claimed. The real-terminal
cancellation test had become a duplicate of the piped one, since the
SIGINT check short-circuits before the terminal is consulted; it now
covers the terminal leg with a non-SIGINT failure, which is the leg
nothing else guarded. The template guard iterated two identical strings
and could not see an indented invocation; it now sweeps every rendered
skill and command template, and both mutations were confirmed to fail
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(changeset): name the quoting form the fix actually emits

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): stop quoting change names cmd.exe would expand anyway

`%USERNAME%` is a legal change directory name, and cmd.exe expands it
inside double quotes, so the suggested rerun `openspec archive
"%USERNAME%" --yes` targets a different change than the one that was
blocked. `!` has the same problem under cmd.exe's delayed expansion and
bash's interactive history expansion.

Both characters now fall back to the `<change-name>` placeholder, the
same path a `$`/backtick name already took: a rerun the reader has to
fill in beats one that silently archives something else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): stop a change directory from forging its own Fix line

Four adversarial reviews of this branch turned up one real defect and two
guards that were not actually pinned.

The human-mode message for an unanswerable incomplete-task confirmation
interpolated the change name raw, and archive resolves a change by stat-ing
its directory, so the name is attacker-influenceable. A newline in it added a
second, forged `Fix:` line - and because `quoteChangeName` degrades the real
fix to `<change-name>` for exactly those names, the forged line was the only
pasteable command on screen. Control characters are now collapsed.

Also pinned two mutations that passed the whole suite green: dropping
`withStoreFlag` from only the dash-leading branch of `rerunCommand`, and
dropping the `validate === false` leg of the `--no-validate` test - the one
leg Commander actually produces.

The --yes parity guard only saw invocations that opened a line, so a `$ `
prompt, a list marker or `openspec --store x archive` slipped past it. It now
matches those and names the onboarding floor instead of trusting `total > 0`.

Docs and spec catch up: a troubleshooting entry under the message people
actually search for, and cli-archive scenarios for the unanswerable-prompt
paths, including that Ctrl-C stays a cancellation.

* test(archive): tokenise the --yes guard instead of pattern-matching it

Accepting a global flag between `openspec` and `archive` needed nested
quantifiers, and CodeQL was right to call that a ReDoS shape (js/redos, high)
even in a test over our own templates. Splitting the line into tokens decides
the same question in linear time - a 20k-flag line now costs ~2ms - and reads
more plainly than the pattern did.

Same classifications as before, plus it correctly ignores `openspec list
archive`, where `archive` is an argument rather than the subcommand.

* test(archive): skip the forged-Fix-line case on Windows

Windows rejects control characters in a filename, so the change directory the
test needs cannot be created there - which is also why the hole it covers is
POSIX-only. Matches the existing `it.skipIf(process.platform === 'win32')`
idiom in the suite.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:25:13 +00:00
Clay GoodandClaude Opus 5 427abf40ac fix(tasks): count indented sub-tasks in task progress (#1486)
Both checkbox parsers anchored the bullet at column 0, so an indented
sub-task was invisible to `openspec list`/`view` progress, to the apply
task list, and to archive's incomplete-task check. A change whose
sub-tasks were unfinished reported "✓ Complete" and archived with no
warning.

One shared `parseTaskLines()` now backs both surfaces and allows leading
whitespace. It matches every line the two patterns it replaces matched,
and more - including a tab or non-breaking space inside the brackets,
which the old counting pattern accepted - so task counts can rise but
never fall: no change starts reporting less work than before, and
archive's gate can only get stricter.

Checkboxes still count wherever they sit, including inside a code fence.
Skipping fenced ones was implemented and dropped: every rule for deciding
which fence is real has an input where a stray or unbalanced ``` swallows
genuine tasks, which is the silent failure this fix exists to remove.

Verified differentially against a build of main over hand-built fixtures
and the repo's own 120 tasks.md files: 0 files count fewer tasks, 0 lose
an incomplete-task warning.

Closes #1485

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:41:57 +00:00
Clay GoodandClaude Opus 5 84ebc57cb3 fix(validate): report scenarios a MODIFIED requirement would drop (#1482)
* fix(validate): report scenarios a MODIFIED requirement would drop

`openspec validate <change>` accepted a MODIFIED requirement that omits a
scenario the main spec still has, even with --strict. Archive refuses to
apply that block (a MODIFIED replaces the whole requirement, so the omitted
scenario would be lost), so the change could pass validation, be implemented
and reviewed, and fail only days later at archive time (#1477).

Validate now runs the same non-mutating check against the main specs and
reports each omitted scenario, naming the delta file. The comparison itself
moved to the parser module so archive and validate share one implementation
and cannot drift.

The check is silent when the main spec file or the requirement header is
absent — a MODIFIED written against a sister change still in flight is a
separate condition archive gates — so validate can only report what archive
already refuses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(validate): tighten the scenario-loss check after review

Keep the moved scenario parser module-private, derive change validate's
main specs root from the changes root it already resolved, replace the
rename re-keying with a lookup fallback, and say at archive's call site
why it does not opt in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(validate): follow rename chains when checking for dropped scenarios

A delta that renames A to B and then B to C leaves C holding A's block at
archive time. Walk the rename map instead of looking it up once, so the
chained case reports the same loss archive refuses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(validate): close the gaps five adversarial reviews found

Code:
- An unreadable main spec was swallowed, so a change archive aborts on
  validated clean. Only ENOENT/ENOTDIR mean "no main spec" now; anything
  else is reported.
- A MODIFIED naming a header the same delta renames away no longer names
  scenarios from the block it would not land on. That contradiction is
  already reported on its own, and the scenario list pointed at the wrong
  requirement.

Guidance: the sync-specs skill told agents a MODIFIED block may carry only
the changed scenario, and its format reference showed one. Both validate and
archive reject that shape, so the template, the generated skill, and the
golden hashes are updated to match the schema's own rule.

Tests: the CLI wiring had no coverage at all — removing the argument that
turns the check on broke nothing. Adds end-to-end coverage of every entry
point and exit code, plus the non-strict default, a fenced scenario in the
delta, an unreadable main spec, the rename-away case, and a rename cycle.
Loose assertions now pin the scenario-loss issue itself.

Docs: a troubleshooting entry for the new message, and the changeset says
that a stale change will newly fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(validate): never turn a transient read error into a verdict

The unreadable-main-spec report added in the last commit fired for any
errno that was not ENOENT/ENOTDIR, which includes resource errors like
EMFILE that say nothing about the file. `validate --all` reads six changes
at once, so a busy process could have failed a change that is fine.

Reported now only for the codes that mean the file itself is unusable and
will be just as unusable when archive reads it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(troubleshooting): label the example fence (MD040)

Every other fence in the file names its language.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:41:51 +00:00
solanabandClay Good 1aa0f2abfc feat(init): add shared agents skills target (#1303)
Co-authored-by: Clay Good <hi@claygood.com>
2026-07-29 22:41:47 +00:00
Suhaib AslamandSuhaibAslam 1014c59ed1 docs: catalog intent-driven community schema (#1487)
Co-authored-by: SuhaibAslam <SuhaibAslam@users.noreply.github.com>
2026-07-29 20:26:12 +00:00
198 changed files with 21824 additions and 1995 deletions
+23
View File
@@ -1,5 +1,14 @@
version: 2
# Dependabot does not manage two dependency surfaces in this repo:
# 1. pnpm `overrides` (pnpm-workspace.yaml + package.json, root and website) —
# transitive version pins that remediate advisories Dependabot can't otherwise
# reach. It never bumps or removes these; each carries an inline advisory
# comment noting the removal condition (see pnpm-workspace.yaml).
# 2. The Nix flake (flake.nix / flake.lock). Update nixpkgs manually with
# `nix flake update`; there is no Dependabot ecosystem for Nix. Note that the
# pnpmDeps FOD hash in flake.nix must be regenerated on any root lockfile change.
updates:
# Published CLI package
- package-ecosystem: npm
@@ -16,6 +25,13 @@ updates:
semver-minor-days: 7
semver-patch-days: 3
open-pull-requests-limit: 5
ignore:
- dependency-name: "@types/node"
update-types:
- version-update:semver-major
- dependency-name: "typescript"
update-types:
- version-update:semver-major
commit-message:
prefix: chore
include: scope
@@ -46,6 +62,13 @@ updates:
semver-minor-days: 7
semver-patch-days: 3
open-pull-requests-limit: 3
ignore:
- dependency-name: "@types/node"
update-types:
- version-update:semver-major
- dependency-name: "typescript"
update-types:
- version-update:semver-major
commit-message:
prefix: chore
include: scope
+4 -3
View File
@@ -39,6 +39,7 @@ jobs:
- 'flake.lock'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'scripts/update-flake.sh'
- '.github/workflows/ci.yml'
@@ -76,7 +77,7 @@ jobs:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -131,7 +132,7 @@ jobs:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -253,7 +254,7 @@ jobs:
- name: Setup pnpm
if: steps.changed-changesets.outputs.has_changesets == 'true'
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- name: Setup Node.js
if: steps.changed-changesets.outputs.has_changesets == 'true'
+9 -3
View File
@@ -5,9 +5,11 @@ on:
branches: [main]
workflow_dispatch: # manually cut a beta prerelease from main
# Floor for both jobs. The prepare job widens this to pull-requests: write for
# the Version Packages PR; the beta job only tags/releases + publishes via OIDC
# and needs no PR access, so it inherits this narrower default.
permissions:
contents: write
pull-requests: write
id-token: write # Required for npm OIDC trusted publishing
concurrency:
@@ -18,6 +20,10 @@ jobs:
prepare:
if: github.repository == 'Fission-AI/OpenSpec' && github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write # changesets opens/updates the Version Packages PR
id-token: write # Required for npm OIDC trusted publishing
steps:
# Generate GitHub App token first - used for checkout and changesets
# This allows git operations to trigger CI workflows on the version PR
@@ -34,7 +40,7 @@ jobs:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -74,7 +80,7 @@ jobs:
with:
fetch-depth: 0
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
+31 -1
View File
@@ -6,6 +6,7 @@ on:
paths:
- '**/package.json'
- '**/pnpm-lock.yaml'
- '**/pnpm-workspace.yaml'
- '.github/workflows/security.yml'
pull_request:
branches: [main]
@@ -50,7 +51,7 @@ jobs:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
# No dependency cache: `pnpm audit` reads the lockfile, nothing is installed,
# so a cache-save step would fail on the missing store path.
@@ -88,3 +89,32 @@ jobs:
if: ${{ !cancelled() }}
continue-on-error: ${{ github.event_name == 'pull_request' }}
run: pnpm audit --audit-level high --dir website
# The website keeps its own lockfile and is never installed or built elsewhere
# in CI, so a website/package.json change — e.g. a security override — that is
# not reflected in website/pnpm-lock.yaml goes unnoticed: the override you think
# patches an advisory may not be in the committed graph at all, and `pnpm audit`
# would happily audit the stale (possibly still-vulnerable) tree. A frozen-lockfile
# install fails fast on that drift. Root drift is already caught by the
# `--frozen-lockfile` installs in ci.yml; this closes the same gap for the website.
# `--ignore-scripts` skips sharp's native build (irrelevant to lockfile validation
# and the usual source of install flake).
website-lockfile:
name: Website Lockfile Drift
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20.19.0'
- name: Verify website lockfile matches package.json
run: pnpm install --frozen-lockfile --ignore-scripts --dir website
+106
View File
@@ -1,5 +1,111 @@
# @fission-ai/openspec
## 1.9.0
### Minor Changes
- [#1622](https://github.com/Fission-AI/OpenSpec/pull/1622) [`59c16a4`](https://github.com/Fission-AI/OpenSpec/commit/59c16a4461254ed984d1d5e29d00af1a5610035a) Thanks [@clay-good](https://github.com/clay-good)! - ### New Features
- **Command Code command adapter** — Command Code is now a first-class, adapter-backed tool. `openspec init` generates OpenSpec commands under `.commandcode/commands/opsx-<id>.md` (invoked as `/opsx-<id>`) alongside the skills under `.commandcode/skills/`, matching Command Code's documented custom-slash-command surface.
- [#1613](https://github.com/Fission-AI/OpenSpec/pull/1613) [`42d7f67`](https://github.com/Fission-AI/OpenSpec/commit/42d7f673bc5f13378451267c8a9d0c23f63a2d1a) Thanks [@Angelthebestone](https://github.com/Angelthebestone)! - ### New Features
- **Command Code support** — `openspec init` now supports Command Code as an adapterless skills-only tool. It installs the OpenSpec skills under `.commandcode/skills/` and invokes them as `/openspec-*` commands, matching Command Code's native skill surface.
- [#1604](https://github.com/Fission-AI/OpenSpec/pull/1604) [`83be9d1`](https://github.com/Fission-AI/OpenSpec/commit/83be9d113e8310789c281f7c8a00ed4fad191dd5) Thanks [@clay-good](https://github.com/clay-good)! - Add `openspec validate --archived`: an opt-in check that every change under `changes/archive/` has all of its `tasks.md` checkboxes ticked, exiting non-zero if any are unchecked. This surfaces changes that were archived with unfinished work — which the normal validate flow never catches, because it only looks at active changes — and is meant for a pre-commit or CI hook ([#205](https://github.com/Fission-AI/OpenSpec/issues/205)). It is a standalone scope: it does not alter any existing `validate` invocation and does not re-validate already-applied spec deltas.
### Patch Changes
- [#1530](https://github.com/Fission-AI/OpenSpec/pull/1530) [`bf5099e`](https://github.com/Fission-AI/OpenSpec/commit/bf5099e39fdb5d7bde2adc84f49ea93afd7463e9) Thanks [@clay-good](https://github.com/clay-good)! - Apply workflow now tells agents to surface unexpected scope instead of hiding it. When a task needs work beyond what the spec describes, the `/opsx:apply` skill and command guidance direct the agent to pause and report the added scope rather than silently narrowing, deferring, or simplifying away specified behavior, and to mark a task complete only when its specified behavior is fully implemented. Fixes [#1529](https://github.com/Fission-AI/OpenSpec/issues/1529).
- [#1603](https://github.com/Fission-AI/OpenSpec/pull/1603) [`9ae75c8`](https://github.com/Fission-AI/OpenSpec/commit/9ae75c86efe5d326ffa7ca5a3fd64b1f1e7728c2) Thanks [@clay-good](https://github.com/clay-good)! - `openspec archive` no longer writes terminal escape codes to a redirected or captured stdout. Its confirmation prompts and the no-argument change picker drew their live UI with ANSI cursor-move sequences even when stdout was not a terminal — noise in a redirected log, and in some non-interactive hosts an unbounded render loop that could grow the captured output until the disk filled. When stdout (or stdin) is not a terminal, archive now reads the confirmations as plain text, and a no-argument run asks you to pass a change name up front instead of drawing a menu. Piped answers (`printf 'y\n' | openspec archive …`) and `--yes` behave as before, and interactive terminals are unchanged. Fixes [#1526](https://github.com/Fission-AI/OpenSpec/issues/1526).
- [#1528](https://github.com/Fission-AI/OpenSpec/pull/1528) [`9425897`](https://github.com/Fission-AI/OpenSpec/commit/942589741de35f1b8896b410d7ea70295bb137c0) Thanks [@Marzx13](https://github.com/Marzx13)! - Canonicalize rebuilt specs to end with exactly one final LF. Previously a spec whose `## Requirements` section was last was rebuilt with a trailing blank line (`\n\n`), which failed Markdown whitespace checks after sync or archive. Internal spacing and content after the Requirements section are unchanged.
- [#1640](https://github.com/Fission-AI/OpenSpec/pull/1640) [`610b78f`](https://github.com/Fission-AI/OpenSpec/commit/610b78f6554e8aabfa294df53962428ff85c8b76) Thanks [@clay-good](https://github.com/clay-good)! - Preserve the blank lines around a spec's `## Requirements` heading when syncing a delta. `openspec archive` rebuilt `openspec/specs/<capability>/spec.md` by joining its slices with a bare newline, so the blank lines that surround the heading were dropped and the resulting file failed Markdown whitespace checks. The rebuild now keeps that spacing intact. Fixes [#1625](https://github.com/Fission-AI/OpenSpec/issues/1625). Thanks [@jwang513](https://github.com/jwang513)! ([#1637](https://github.com/Fission-AI/OpenSpec/pull/1637))
- [#1640](https://github.com/Fission-AI/OpenSpec/pull/1640) [`610b78f`](https://github.com/Fission-AI/OpenSpec/commit/610b78f6554e8aabfa294df53962428ff85c8b76) Thanks [@clay-good](https://github.com/clay-good)! - `openspec validate --all` and `openspec list --json` no longer silently pass when run outside an OpenSpec project. From a directory with no root they used to resolve the current directory as an implicit root, exit 0, and report empty results — a false pass for CI and agents. Bulk validation (`--all`, `--changes`, `--specs`) and `list` now require an existing root (the `openspec/project.md` fallback for legacy projects is kept), while direct validation and other intentional implicit-root workflows are unchanged. Thanks [@clay-good](https://github.com/clay-good)! ([#1612](https://github.com/Fission-AI/OpenSpec/pull/1612))
- [#1640](https://github.com/Fission-AI/OpenSpec/pull/1640) [`610b78f`](https://github.com/Fission-AI/OpenSpec/commit/610b78f6554e8aabfa294df53962428ff85c8b76) Thanks [@clay-good](https://github.com/clay-good)! - Label the `update` workflow in the `openspec config` workflow picker. The checklist had friendly labels for 11 of the 12 workflows but was missing `update`, so that row — one of the six core workflows every user sees — fell back to its raw id with a placeholder description. The update-change template's stale "expanded-profile" wording is also reworded to "optional". Fixes [#1627](https://github.com/Fission-AI/OpenSpec/issues/1627). Thanks [@clay-good](https://github.com/clay-good)! ([#1632](https://github.com/Fission-AI/OpenSpec/pull/1632))
- [#1640](https://github.com/Fission-AI/OpenSpec/pull/1640) [`610b78f`](https://github.com/Fission-AI/OpenSpec/commit/610b78f6554e8aabfa294df53962428ff85c8b76) Thanks [@clay-good](https://github.com/clay-good)! - `openspec schema fork` now preserves the source schema's YAML formatting. Renaming a forked `schema.yaml` round-tripped through a parse/re-serialize step that dropped comments, could rewrite block-scalar style (a literal `|` folded to `>`), and reordered keys, so the fork no longer matched its source. The rename now edits the document in place via the YAML Document API, leaving comments, scalar style, and key order untouched. Thanks [@clay-good](https://github.com/clay-good)! ([#1607](https://github.com/Fission-AI/OpenSpec/pull/1607))
- [#1640](https://github.com/Fission-AI/OpenSpec/pull/1640) [`610b78f`](https://github.com/Fission-AI/OpenSpec/commit/610b78f6554e8aabfa294df53962428ff85c8b76) Thanks [@clay-good](https://github.com/clay-good)! - `openspec schemas` now resolves through the canonical OpenSpec root-selection precedence instead of always reading from the current directory. It accepts `--store <id>`, rejects `--store-path` like the other store-aware commands, and returns the shared machine-readable diagnostics on JSON failures, while preserving the existing human output and bare JSON array on success. Thanks [@Patodo](https://github.com/Patodo)! ([#1616](https://github.com/Fission-AI/OpenSpec/pull/1616))
- [#1640](https://github.com/Fission-AI/OpenSpec/pull/1640) [`610b78f`](https://github.com/Fission-AI/OpenSpec/commit/610b78f6554e8aabfa294df53962428ff85c8b76) Thanks [@clay-good](https://github.com/clay-good)! - `openspec validate` now warns on ambiguous task numbering in `spec-driven` changes: a task ID duplicated at full depth (including across resolved task files), or a task whose leading number disagrees with its enclosing `## N.` group. Numeric-looking text outside numbered groups is ignored, and custom schemas are unchanged until they opt in. The checks run across direct, bulk, and deprecated change validation. Closes [#1520](https://github.com/Fission-AI/OpenSpec/issues/1520). Thanks [@alectimison-maker](https://github.com/alectimison-maker)! ([#1523](https://github.com/Fission-AI/OpenSpec/pull/1523))
- [#1522](https://github.com/Fission-AI/OpenSpec/pull/1522) [`07dea6e`](https://github.com/Fission-AI/OpenSpec/commit/07dea6ed2faf71c8b9f4944d64246f2ff39eeffc) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes
- **Don't let a legacy Codex upgrade hijack the vendor-neutral `agents` target** — `openspec update` no longer overwrites an existing `.agents` skills tree (and its ownership marker) when Codex is detected only from leftover global `~/.codex/prompts`. Because Codex and the vendor-neutral `agents` target share `.agents/skills`, a project that used the `agents` target could have its generic skills silently rewritten with Codex-specific syntax and its target flipped to Codex on the next `update --force`. The legacy-upgrade path now respects the established owner of a shared skills directory, matching the one-writer rule `openspec init` already applies. When an upgrade is skipped this way, that tool's repo-local legacy files (e.g. `.codex/prompts/openspec-*.md`) are also preserved rather than cleaned up, since no replacement was written to take their place. A genuine first-time Codex upgrade (no `.agents` tree yet) is unaffected.
- [#1521](https://github.com/Fission-AI/OpenSpec/pull/1521) [`c751b3d`](https://github.com/Fission-AI/OpenSpec/commit/c751b3da52a7f06d6662a8673feff4685566cdd4) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes
- **Stop silently dropping unlabeled scenarios on archive** — `openspec validate` and `openspec archive` now recognize every level-4 (`####` followed by whitespace) child of a requirement as a scenario, matching how the spec is counted elsewhere. Before, the scenario-loss guard only recognized headers written exactly as `#### Scenario:`, so a `MODIFIED` requirement that dropped a differently-labeled child (for example `#### Edge case`) passed validation and was then permanently deleted by archive with no warning. Both paths now agree, so the loss is caught at authoring time. Scenario names are normalized when comparing (an optional `Scenario:` prefix and a CommonMark closing `#` run are ignored), so simply relabeling a scenario is not mistaken for dropping one.
- [#1610](https://github.com/Fission-AI/OpenSpec/pull/1610) [`17581c1`](https://github.com/Fission-AI/OpenSpec/commit/17581c11edf6b27ef18be7be1e4dcc06c81a3fff) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes
- `openspec init` now suggests an IDE restart only when an IDE-resident tool such as Cursor, GitHub Copilot, Continue, or Cline was configured. CLI tools like Claude Code, Codex, and Gemini CLI no longer show the hint, since their commands work as soon as the files exist.
- [#1609](https://github.com/Fission-AI/OpenSpec/pull/1609) [`804427b`](https://github.com/Fission-AI/OpenSpec/commit/804427b6ff3f3b35b542365ba8b32e183fce3287) Thanks [@clay-good](https://github.com/clay-good)! - Suppress the first-run telemetry disclosure notice when `--json` is used. On a
first-ever run the notice was written to stdout and could break `--json`
consumers; it is now deferred to the first later non-JSON run, keeping `--json`
output valid while still guaranteeing the disclosure.
## 1.8.0
### Minor Changes
- [#1303](https://github.com/Fission-AI/OpenSpec/pull/1303) [`1aa0f2a`](https://github.com/Fission-AI/OpenSpec/commit/1aa0f2abfc19f2487f5b8566e6eb3bf15f41c20a) Thanks [@solanab](https://github.com/solanab)! - Add the vendor-neutral `agents` target: `openspec init --tools agents` installs the workflow skills to `.agents/skills/openspec-*/SKILL.md`, the shared location AGENTS.md-compatible assistants read. It is skills-only, so no slash commands are generated. Because `agents` is now a real target, `--tools all` includes it and creates `.agents/skills/` where it previously did not.
- [#1274](https://github.com/Fission-AI/OpenSpec/pull/1274) [`7a4a745`](https://github.com/Fission-AI/OpenSpec/commit/7a4a745d803b698c34947eda6d73b5a24aebb58c) Thanks [@NicoAvanzDev](https://github.com/NicoAvanzDev)! - Generate GitHub Copilot coding agent setup and custom agent files during `openspec init` and keep them synchronized during `openspec update`.
- [#1214](https://github.com/Fission-AI/OpenSpec/pull/1214) [`161f945`](https://github.com/Fission-AI/OpenSpec/commit/161f9454a372aab67c495d780928bba89c829f3e) Thanks [@showms](https://github.com/showms)! - Add MiniMax Code as a global skills-only tool target.
- [#1518](https://github.com/Fission-AI/OpenSpec/pull/1518) [`568e56c`](https://github.com/Fission-AI/OpenSpec/commit/568e56c67231dbe2447aca4f0e7995c05ada95a3) Thanks [@clay-good](https://github.com/clay-good)! - ### New Features
- **Atlassian Rovo Dev CLI** — `openspec init --tools rovodev` installs the OpenSpec workflow skills for Atlassian's Rovo Dev CLI. It is skills-only (no slash commands), written to `.rovodev`.
### Bug Fixes
- **Codex skills now live in the shared `.agents` directory** — `openspec init` and `openspec update` install Codex skills under `.agents/skills/` (the canonical location assistants read) and migrate an existing `.codex` skills directory in place. Files you customized are preserved, not overwritten.
- **`openspec status` separates planning from implementation** — status now reports `isPlanningComplete` (every non-skipped planning artifact exists; skipped artifacts count as satisfied without being written) distinctly from overall progress, and its messages no longer imply a change is finished before it has been implemented. `isComplete` is kept as a compatibility alias, so existing scripts keep working.
- [#1517](https://github.com/Fission-AI/OpenSpec/pull/1517) [`73207a6`](https://github.com/Fission-AI/OpenSpec/commit/73207a6f2cd235729ac3fe3cb1e44152b8f63f12) Thanks [@clay-good](https://github.com/clay-good)! - Make GitHub Copilot cloud coding-agent files opt-in. Selecting the `github-copilot` tool no longer silently writes a GitHub Actions workflow into `.github/`; `openspec init` now asks first (default No) and remembers the choice in `openspec/config.yaml` (`githubCopilot.cloudAgent`). Use `--copilot-cloud` / `--no-copilot-cloud` to decide non-interactively.
- `openspec update` never prompts — it only refreshes cloud files for projects that opted in (or that already have generated cloud files, so existing setups keep working).
- Opting out (`--no-copilot-cloud` or `cloudAgent: false`) removes OpenSpec-managed cloud files; a user-customized file is always preserved, never overwritten or deleted.
- `init` and `update` now report whether cloud files were written, skipped, or left untouched — and if you already have your own `copilot-setup-steps.yml`, they say it was preserved and that you need to add the OpenSpec install step by hand.
- [#1484](https://github.com/Fission-AI/OpenSpec/pull/1484) [`521ee33`](https://github.com/Fission-AI/OpenSpec/commit/521ee33e6ece269241b45e08017ee60f13fdef08) Thanks [@clay-good](https://github.com/clay-good)! - Retire a capability when a change removes its last requirement. A change that declares `retire_capabilities: true` in its `.openspec.yaml` (alongside the `schema:` that file requires) may now be archived even when its REMOVED entries take a capability's last requirement: `openspec archive` deletes that capability's main spec instead of aborting with "Spec must have at least one requirement". Without the marker nothing changes — the archive aborts exactly as before, except the message now names the marker as the way out. Retirement happens only when the emptied spec could not have been written at all, every one is named in the archive output, a pasteable `git checkout` is included when the spec lived in the caller's checkout, and `--no-validate` never retires. Archive now also rejects a main spec with duplicate canonical requirement names instead of letting delta reconciliation collapse one of the duplicate blocks. One thing to know before retiring: a capability's spec is the base another change's MODIFIED block is checked against, so an in-flight change that modifies the capability you just retired will keep validating clean and then refuse to archive ("target spec does not exist; only ADDED requirements are allowed for new specs") — close or rework that change alongside the retirement.
### Patch Changes
- [#1502](https://github.com/Fission-AI/OpenSpec/pull/1502) [`ece8660`](https://github.com/Fission-AI/OpenSpec/commit/ece8660d44bd19b86440376327752cda3d7b0717) Thanks [@clay-good](https://github.com/clay-good)! - `openspec validate` now treats the English `SHALL`/`MUST` convention as guidance in normal mode, so requirements written in other languages can validate. Strict mode continues to enforce the convention.
- [#1483](https://github.com/Fission-AI/OpenSpec/pull/1483) [`2b3d368`](https://github.com/Fission-AI/OpenSpec/commit/2b3d368539132be6311e55db58899abbf5306b81) Thanks [@clay-good](https://github.com/clay-good)! - Tell the caller which flag to pass when `openspec archive` cannot ask its confirmation questions. An AI agent (or any script) runs the CLI with stdin closed, so every prompt rejects with `@inquirer`'s `User force closed the prompt with 0 null` — the archive aborted with an error that named neither the question nor the flag, and agents burned a turn guessing ([#1479](https://github.com/Fission-AI/OpenSpec/issues/1479)). Each confirmation now reports what it needed and a pasteable rerun that carries the flags you already passed: `openspec archive <name> --skip-specs --yes` stays a `--skip-specs` run, so following the suggestion cannot merge specs you opted out of merging, and a change name that needs quoting gets double quotes, the one form bash, zsh, PowerShell and cmd.exe all read the same way (a name no shell reads literally even quoted — one containing `$`, a backtick, or the `%`/`!` that cmd.exe still expands inside quotes — is left as a `<change-name>` placeholder rather than a command that would target something else). `openspec archive` with no change name used to swallow the same failure, print `No change selected. Aborting.` and exit 0 — success for a run that archived nothing; it now exits 1 asking for a change name, matching how `openspec show` and `openspec validate` already behave without a terminal. The check is reactive — it inspects a prompt that already failed — so answers piped into the command, `--yes`, `--json`, and Ctrl-C all behave exactly as before, and a run that OpenSpec already considers non-interactive (`CI`, `OPEN_SPEC_INTERACTIVE=0`, `--no-interactive`) gets the guidance even when the runner allocated a pty. The onboarding walkthrough, the only generated guidance that tells an agent to run `openspec archive`, now shows `--yes`.
- [#1486](https://github.com/Fission-AI/OpenSpec/pull/1486) [`427abf4`](https://github.com/Fission-AI/OpenSpec/commit/427abf40ac45a9a44f78eb74c81f53f9f4197ccf) Thanks [@clay-good](https://github.com/clay-good)! - Task progress now counts indented sub-tasks. A `tasks.md` whose sub-tasks were unfinished reported `✓ Complete` in `openspec list` and `openspec view`, was missing those tasks from the `openspec instructions apply` list, and archived with no incomplete-task warning, because both checkbox parsers only matched checkboxes at column 0.
Progress counting and the apply task list now share one parser, so `list`, `view`, `archive` and `apply` agree about which lines of a tasks file are tasks. A checkbox with no text after it is left out of the apply list, which has nothing to act on, but still counts toward every progress number; a file of nothing but such checkboxes now asks to be rewritten rather than reporting itself done. The shared pattern matches every line the two it replaced matched, and more, so task counts can rise but never fall: no change starts reporting less work than before, and archive's incomplete-task warning can only become stricter. Checkboxes are still counted wherever they appear, including inside a code fence, an HTML comment or an indented block, so a `tasks.md` that shows a checklist as a format example can now count that example as work — remove it from the file, or pass `--yes` to archive.
- [#1500](https://github.com/Fission-AI/OpenSpec/pull/1500) [`26bd1d4`](https://github.com/Fission-AI/OpenSpec/commit/26bd1d4e5c6c6ba75bd7d6136424019b2bf89ced) Thanks [@clay-good](https://github.com/clay-good)! - Keep generated workflows on the selected store, handle optional workflow fallbacks safely, and validate synced specs before reporting success.
- [#1490](https://github.com/Fission-AI/OpenSpec/pull/1490) [`45cca5d`](https://github.com/Fission-AI/OpenSpec/commit/45cca5db6137ed209117cc70510eb3e057fb981b) Thanks [@clay-good](https://github.com/clay-good)! - Say before confirmation when archiving a change will delete a note written next to a requirement. A requirement absorbs anything below it that OpenSpec doesn't recognize as a new heading — a note indented by the one to three spaces Markdown allows, for example — so removing or modifying that requirement took the note with it, silently. `openspec archive` now names content the rebuilt spec would actually drop and where to move it to keep it. The merge itself is unchanged: nothing is relocated, because a `#` line inside a scenario looks identical to a note and moving one of those would rewrite the spec wrongly.
- [#1492](https://github.com/Fission-AI/OpenSpec/pull/1492) [`690a27e`](https://github.com/Fission-AI/OpenSpec/commit/690a27e649c4a3325daeb0f6667ebe0f82792179) Thanks [@mc856](https://github.com/mc856)! - `openspec init` and `openspec update` no longer delete the CoStrict and Junie command files they just generated. Legacy cleanup removes artifacts older OpenSpec versions left behind, and two of its patterns named paths the current adapters still write to. CoStrict's was a whole-directory removal of `.cospec/openspec/commands/`, the folder the adapter writes `opsx-<id>.md` into, so every run wiped the directory — including any file the user kept there — while the banner above it read `No user content to preserve`. Junie's `.junie/commands/opsx-*.md` listed its own current output. Cleanup runs before the config migration, so on a config that has no `profile` key yet the missing command files make delivery detection read the project as skills-only and persist that to the global config: the files are not regenerated, and the preference changes for every other project too.
CoStrict is now a file pattern, `.cospec/openspec/commands/openspec-*.md`, matching the three commands the pre-`opsx` CoStrict integration wrote there (`openspec-proposal.md`, `openspec-apply.md`, `openspec-archive.md`) and the same shape every other file-based tool already uses. Junie's entry is removed outright: Junie support arrived after the slash configurators that wrote `openspec-*` files were deleted, so no OpenSpec version ever created those files there. Genuinely legacy files are still detected and removed, and no other tool's patterns change — they never overlapped their adapter's current output.
- [#1501](https://github.com/Fission-AI/OpenSpec/pull/1501) [`0b20ae3`](https://github.com/Fission-AI/OpenSpec/commit/0b20ae3964283bdcb4e34ea7380770857f6a339c) Thanks [@clay-good](https://github.com/clay-good)! - Keep the propose workflow focused on planning, clarify material ambiguities before creating a change, and hand implementation off to the apply workflow.
- [#1503](https://github.com/Fission-AI/OpenSpec/pull/1503) [`8a3850d`](https://github.com/Fission-AI/OpenSpec/commit/8a3850da735e241c14ad94935463f879b33f21a9) Thanks [@clay-good](https://github.com/clay-good)! - When exploration turns into a new change, generated explore guidance now instructs agents to run `openspec new change` before writing requested artifacts. This preserves the required `.openspec.yaml` metadata instead of letting an agent create an incomplete change directory by hand. After the user accepts a capture, explore also creates the requested artifacts without requiring another workflow command.
- [#1513](https://github.com/Fission-AI/OpenSpec/pull/1513) [`622c509`](https://github.com/Fission-AI/OpenSpec/commit/622c509a1349c3ad9c52cd1a4ee007bd47549204) Thanks [@FasterPHP](https://github.com/FasterPHP)! - Honor `telemetry.enabled` in global config. `false` disables anonymous telemetry and `openspec update` version checks; unset keeps telemetry enabled, and env/CI opt-outs still take precedence.
- [#1499](https://github.com/Fission-AI/OpenSpec/pull/1499) [`9cd845f`](https://github.com/Fission-AI/OpenSpec/commit/9cd845fc459b71486d9f2424c2e1f38e2ca8766e) Thanks [@clay-good](https://github.com/clay-good)! - Keep generated files, specs, archive moves, and local state inside their intended security boundaries without breaking linked monorepo workflows.
- [#1482](https://github.com/Fission-AI/OpenSpec/pull/1482) [`84ebc57`](https://github.com/Fission-AI/OpenSpec/commit/84ebc57cb3f0e91b93484484092fdc2f9fcf39e6) Thanks [@clay-good](https://github.com/clay-good)! - `openspec validate <change>` now reports a MODIFIED requirement that omits a scenario the main spec still has — the same loss archive already refuses to apply — so the change fails at authoring time instead of at archive time. A change carrying a stale MODIFIED block will start failing validation; it was already unarchivable, and the message names the scenarios to copy back in.
## 1.7.0
### Minor Changes
+3 -1
View File
@@ -248,7 +248,9 @@ OpenSpec collects anonymous usage stats.
We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI.
**Opt-out:** `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1`
**Opt-out (any one is enough):**
- `openspec config set telemetry.enabled false` (global config; unset means on)
- `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1` (env overrides config)
</details>
+9 -8
View File
@@ -27,15 +27,16 @@ Diagnostics appear in two positions: **status arrays** (`status: StoreDiagnostic
## 3. Root selection and `RootOutput`
All root-resolving commands (`list`, `show`, `validate`, `status`, `instructions`, `instructions apply`, `instructions archive`, `new change`, `archive`, `doctor`, `context`) resolve one OpenSpec root with one precedence:
All root-resolving commands (`list`, `show`, `validate`, `status`, `instructions`, `instructions apply`, `instructions archive`, `new change`, `archive`, `doctor`, `context`, `schemas`) resolve one OpenSpec root with one precedence:
1. `--store <id>` → the registered store's root (`source: "store"`).
2. Otherwise, nearest ancestor with `openspec/`: planning shape → `source: "nearest"` (a `store:` pointer is ignored with a stderr warning); config-only dir with a valid `store:` pointer → that store, `source: "declared"`.
3. No nearest root + global `defaultStore` set (`openspec config set defaultStore <id>`) → that store, `source: "global_default"`; a stale id fails with the underlying store error and a `fix` naming `openspec config unset defaultStore`.
4. No nearest root, no default + registered stores exist → error `no_root_with_registered_stores`.
5. No root, no default, no stores: scaffolding commands treat the cwd as `source: "implicit"`; diagnostic commands (`doctor`, `context`) fail with `no_openspec_root` instead — they inspect, never scaffold.
5. No root, no default, no stores: commands may treat the cwd as `source: "implicit"`; `doctor`, `context`, `list`, and bulk `validate` instead fail with `no_openspec_root`. `list` preserves the implicit fallback for legacy projects with `openspec/project.md`.
Successful JSON payloads embed the root:
Successful JSON payloads normally embed the root; successful `schemas --json`
deliberately remains the compatibility bare array documented in §4.13:
```json
"root": { "path": "/abs/path", "source": "store" | "declared" | "global_default" | "nearest" | "implicit", "store_id": "id (only when store-selected)" }
@@ -55,7 +56,7 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id
`{ "items": [ { "id", "type": "change"|"spec", "valid", "issues": [ { "level", "path", "message", "line"?, "column"? } ], "durationMs" } ], "summary": { "totals": {items,passed,failed}, "byType": {...} }, "version": "1.0", "root" }`. Exit 1 when any item fails.
### 4.4 `status --json`
`{ "changeName", "schemaName", "planningHome"?: { "kind", "root", "changesDir", "defaultSchema" }, "changeRoot", "artifactPaths": { "<id>": {outputPath, resolvedOutputPath, existingOutputPaths} }, "nextSteps": ["..."], "actionContext": { "mode": "repo-local", "sourceOfTruth": "repo", "planningArtifacts", "linkedContext", "allowedEditRoots", "requiresAffectedAreaSelection", "constraints" }, "isComplete", "applyRequires", "artifacts": [ {id, outputPath, status: "done"|"skipped"|"ready"|"blocked", requires, missingDeps?} ], "root" }`. Each artifact's `requires` is its direct dependency ids (present for every status, so the transitive required set is computable even when the artifact is `done`); `missingDeps` appears only when `blocked`. The `artifacts` array is in dependency order, with the schema's `artifacts:` declaration order breaking ties between artifacts that become ready at the same time (never alphabetical), so the first `ready` entry is the artifact to write next; `missingDeps` uses that same order. `"skipped"` marks an artifact whose `generates` path is under `specs/` in a change whose `.openspec.yaml` declares `skip_specs: true`; it satisfies dependencies but must not be created. No active changes: `{ "changes": [], "message", "root" }`, exit 0.
`{ "changeName", "schemaName", "planningHome"?: { "kind", "root", "changesDir", "defaultSchema" }, "changeRoot", "artifactPaths": { "<id>": {outputPath, resolvedOutputPath, existingOutputPaths} }, "nextSteps": ["..."], "actionContext": { "mode": "repo-local", "sourceOfTruth": "repo", "planningArtifacts", "linkedContext", "allowedEditRoots", "requiresAffectedAreaSelection", "constraints" }, "isPlanningComplete", "isComplete", "applyRequires", "artifacts": [ {id, outputPath, status: "done"|"skipped"|"ready"|"blocked", requires, missingDeps?} ], "root" }`. `isPlanningComplete` means every non-skipped planning artifact exists; skipped artifacts count as satisfied without being created. It does not mean implementation tasks are complete. `isComplete` is retained as a compatibility alias with the same value. Each artifact's `requires` is its direct dependency ids (present for every status, so the transitive required set is computable even when the artifact is `done`); `missingDeps` appears only when `blocked`. The `artifacts` array is in dependency order, with the schema's `artifacts:` declaration order breaking ties between artifacts that become ready at the same time (never alphabetical), so the first `ready` entry is the artifact to write next; `missingDeps` uses that same order. `"skipped"` marks an artifact whose `generates` path is under `specs/` in a change whose `.openspec.yaml` declares `skip_specs: true`; it satisfies dependencies but must not be created. No active changes: `{ "changes": [], "message", "root" }`, exit 0.
### 4.5 `instructions <artifact> --json`
`{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "skipped"?, "warning"?, "template", "dependencies": [{id,done,path,description,skipped?}], "unlocks", "root" }`. `unlocks` lists the artifacts this one makes ready, in the schema's declaration order (the same order `status` recommends them). `"skipped": true` (with `"warning"`) appears when the change declares `skip_specs: true` and this artifact is skipped — do not create its files. A dependency entry with `skipped: true` is satisfied without files — do not try to read its paths.
@@ -72,7 +73,7 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id
Success: `{ "change": { "id", "path", "metadataPath", "schema" }, "root" }`. Failure: `{ "change": null, "status": [d] }`, exit 1.
### 4.9 `archive <name> --json`
Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "specsUpdated", "totals"?, "warnings"? }, "root" }`. Failure: `{ "archive": null, "root"?, "status": [d] }`, exit 1. `specsUpdated` is true only when at least one spec file was written; an already-synced change archives with all-zero totals and the skips listed in `warnings`. JSON mode is strictly non-interactive: every prompt point becomes an `archive_*` code.
Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "specsUpdated", "totals"?, "warnings"? }, "root" }`. Failure: `{ "archive": null, "root"?, "status": [d] }`, exit 1. `specsUpdated` is true only when at least one spec file was written or retired (a capability whose last requirement the change removed has its spec deleted, which requires `retire_capabilities: true` in the change's `.openspec.yaml`; every retirement is named in `warnings`, with a pasteable Git recovery command only when the spec lived in the caller's checkout); an already-synced change archives with all-zero totals and the skips listed in `warnings`. JSON mode is strictly non-interactive: every prompt point becomes an `archive_*` code.
### 4.10 `doctor --json`
`{ "root": { "path", "source", "store_id"?, "healthy", "status": [] }, "store": { "id", "metadata": {present,valid,remote?}, "origin_url"?, "drift"?: {ahead,behind}, "status": [] } | null, "references": [...], "status": [] }`. `drift` (present only for a git-backed store checkout that has an upstream tracking ref) is ahead/behind counts against the last-fetched upstream, not the live remote. Health findings of any severity exit 0. Failure payload: `{ "root": null, "store": null, "references": [], "status": [d] }`, exit 1.
@@ -84,7 +85,7 @@ Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "spe
setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, registered, already_registered}, "git": {is_repository, initialized, committed}, "created_files": [], "status": [] }`. unregister/remove: `{ "store", "registry": {path, removed}, "files": {deleted, deleted_path, left_on_disk}, "status": [] }`. list: `{ "stores": [{id, root}], "status": [] }`. doctor: `{ "stores": [ { id, root, metadata_path?, openspec_root: {...healthy, status}, metadata: {present, valid, id?, remote}, git: {is_repository, has_commits, has_uncommitted_changes, has_remote, origin_url}, status } ], "status": [] }` (`null` = unknown/not probed). Health findings exit 0; failures exit 1 with the matching null-shape. Prompt cancellation exits 130.
### 4.13 `schemas --json` / `templates --json`
`schemas`: bare array `[ {name, description, artifacts, source} ]`. `templates`: keyed object `{ "<artifactId>": {path, source} }`. Both cwd-based, no root/status keys.
`schemas`: success remains a bare array `[ {name, description, artifacts, source} ]`; it resolves the canonical root-selection precedence and accepts `--store <id>`. Root-selection failure: `{ "schemas": [], "root": null, "status": [d] }`, exit 1. `templates`: keyed object `{ "<artifactId>": {path, source} }`, still cwd-based with no root/status keys.
## 5. Exit-code contract
@@ -119,7 +120,7 @@ setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, regis
`relationship_registry_unreadable`, `root_pointer_ignored`, `root_pointer_invalid`, `pointer_declarations_inert`.
### Archive (JSON mode)
`archive_change_name_required`, `archive_change_not_found`, `archive_validation_failed`, `archive_confirmation_required`, `archive_tasks_incomplete`, `archive_spec_update_failed`, `archive_spec_validation_failed`, `archive_target_exists`, `archive_error`.
`archive_change_name_required`, `archive_change_not_found`, `archive_change_symlink`, `archive_validation_failed`, `archive_confirmation_required`, `archive_tasks_incomplete`, `archive_spec_update_failed`, `archive_spec_validation_failed`, `archive_target_exists`, `archive_error`.
### Context writes
`context_file_exists`, `context_output_dir_missing`.
@@ -137,5 +138,5 @@ Recorded by the capstone audit; published-key renames are product decisions defe
4. Four parallel envelope type declarations exist in src; archive diagnostics never carry `target`.
5. `list --json` reuses the `status` key as a string enum per change.
6. Only `validate` output carries a `version` field.
7. `schemas`/`templates` ignore root selection (cwd-based, no `--store`).
7. `templates` ignores root selection (cwd-based, no `--store`).
8. Deprecated noun forms (`change`/`spec` subcommands) emit unenveloped payloads without `root`/`status`.
+45 -13
View File
@@ -50,7 +50,7 @@ These commands support `--json` output for programmatic use by AI agents and scr
| `openspec status` | See artifact progress | `--json` for structured status |
| `openspec instructions` | Get next steps | `--json` for agent instructions |
| `openspec templates` | Find template paths | `--json` for path resolution |
| `openspec schemas` | List available schemas | `--json` for schema discovery |
| `openspec schemas` | List available schemas | `--json` for schema discovery; `--store <id>` to select a registered root |
| `openspec store setup <id>` | Create and register a local store | `--json` with explicit inputs for structured setup output |
| `openspec store register <path>` | Register an existing store | `--json` for structured registration output |
| `openspec store unregister <id>` | Forget a local store registration | `--json` for structured cleanup output |
@@ -102,12 +102,14 @@ openspec init [path] [options]
| `--force` | Auto-cleanup legacy files without prompting |
| `--profile <profile>` | Override global profile for this init run (`core` or `custom`) |
| `--no-animation` | Show a static welcome screen instead of the animated one |
| `--copilot-cloud` | Set up GitHub Copilot [cloud coding-agent files](supported-tools.md#github-copilot-cloud-coding-agent) without prompting |
| `--no-copilot-cloud` | Skip GitHub Copilot cloud coding-agent files without prompting |
`--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`).
The welcome animation is also skipped when the `OPENSPEC_NO_ANIMATION` environment variable is set (any value, including empty), when `NO_COLOR` is set to a non-empty value, or when the OS reduced-motion preference is enabled (macOS Reduce Motion, GNOME animations disabled).
**Supported tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zcode`
**Supported tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zcode`, `agents`
> This list mirrors `AI_TOOLS` in `src/core/config.ts`. See [Supported Tools](supported-tools.md) for each tool's skill and command paths.
@@ -123,6 +125,9 @@ openspec init ./my-project
# Non-interactive: configure for Claude and Cursor
openspec init --tools claude,cursor
# Non-interactive: configure global MiniMax Code skills
openspec init --tools minimax-code
# Configure for all supported tools
openspec init --tools all
@@ -144,6 +149,7 @@ openspec/
.claude/skills/ # Claude Code skills (if claude selected)
.cursor/skills/ # Cursor skills (if cursor selected)
.cursor/commands/ # Cursor OPSX commands (if delivery includes commands)
.agents/skills/ # Shared skills for AGENTS.md-compatible tools (if agents selected)
... (other tool configs)
```
@@ -526,7 +532,7 @@ openspec show add-dark-mode --json
### `openspec validate`
Validate changes and specs for structural issues.
Validate changes and specs for structural issues, and check a change's MODIFIED requirements against the main specs they would replace.
```
openspec validate [item-name] [options]
@@ -547,12 +553,15 @@ A change with zero spec deltas fails validation unless its `.openspec.yaml` decl
| `--all` | Validate all changes and specs |
| `--changes` | Validate all changes |
| `--specs` | Validate all specs |
| `--archived` | Validate that archived changes have all tasks completed (for pre-commit linting) |
| `--type <type>` | Specify type when name is ambiguous: `change` or `spec` |
| `--strict` | Enable strict validation mode |
| `--json` | Output as JSON |
| `--concurrency <n>` | Max parallel validations (default: 6, or `OPENSPEC_CONCURRENCY` env) |
| `--no-interactive` | Disable prompts |
`--archived` is its own scope: it does not validate spec deltas (already applied at archive time), it verifies that every change under `changes/archive/` has all of its `tasks.md` checkboxes ticked, exiting non-zero if any are unchecked. This catches changes that were archived with unfinished work — handy in a pre-commit hook.
**Examples:**
```bash
@@ -570,6 +579,9 @@ openspec validate --all --json
# Strict validation with increased parallelism
openspec validate --all --strict --concurrency 12
# Fail if any archived change still has unchecked tasks
openspec validate --archived
```
**Output (text):**
@@ -621,26 +633,26 @@ openspec archive [change-name] [options]
| Argument | Required | Description |
|----------|----------|-------------|
| `change-name` | No | Change to archive (prompts if omitted) |
| `change-name` | No | Change to archive (prompts if omitted; required when nothing can answer the prompt) |
**Options:**
| Option | Description |
|--------|-------------|
| `-y, --yes` | Skip confirmation prompts |
| `-y, --yes` | Skip confirmation prompts. Required when nothing can answer them — an AI agent, a CI job, or any run with stdin closed |
| `--skip-specs` | Skip spec updates for one archive run. A change that permanently has no spec deltas should declare `skip_specs: true` in its `.openspec.yaml` instead — it archives with no flag |
| `--no-validate` | Skip validation (requires confirmation) |
| `--no-validate` | Skip validation (requires confirmation). Also disables capability retirement — with no validator verdict, nothing is retired |
**Examples:**
```bash
# Interactive archive
# Interactive archive (asks which change, then confirms)
openspec archive
# Archive specific change
openspec archive add-dark-mode
# Archive without prompts (CI/scripts)
# Archive without prompts (agents, CI, scripts)
openspec archive add-dark-mode --yes
# Archive a tooling change that doesn't affect specs
@@ -651,8 +663,16 @@ openspec archive update-ci-config --skip-specs
1. Validates the change (unless `--no-validate`)
2. Prompts for confirmation (unless `--yes`)
3. Merges delta specs into `openspec/specs/`
4. Moves change folder to `openspec/changes/archive/YYYY-MM-DD-<name>/`
3. Claims the archive destination before changing any main spec
4. Validates and merges the active delta specs into `openspec/specs/` — a capability whose last requirement the change removes is retired, and its spec file deleted, but only when the change's `.openspec.yaml` declares `retire_capabilities: true` next to its `schema:`
5. Moves the change folder to `openspec/changes/archive/YYYY-MM-DD-<name>/`
6. If a spec mutation or final move fails before a complete archive is secured, restores the specs and leaves or returns the change at its active path
7. If a verified fallback copy completes but staged-source cleanup fails, retains the complete archive and committed spec state for recovery
**Without a terminal:** an AI agent, a CI job, or any run with stdin closed cannot
answer step 2, so archive stops before touching anything, exits 1, and names the
command to rerun — `openspec archive <name> --yes`, carrying whatever other flags
you passed. Pass `--yes` (and the change name) up front to skip the round trip.
---
@@ -741,6 +761,7 @@ A change that declares `skip_specs: true` shows its specs stage as `[~] specs (s
{
"changeName": "add-dark-mode",
"schemaName": "spec-driven",
"isPlanningComplete": false,
"isComplete": false,
"applyRequires": ["tasks"],
"artifacts": [
@@ -752,6 +773,11 @@ A change that declares `skip_specs: true` shows its specs stage as `[~] specs (s
}
```
`isPlanningComplete` reports whether every non-skipped planning artifact exists;
skipped artifacts count as satisfied without being created. It does not report
whether implementation tasks are complete. `isComplete` is retained as a
compatibility alias with the same value.
Artifacts are listed in dependency order - a dependency never appears after
something that requires it - and artifacts that become ready at the same time
(spec-driven's `specs` and `design` both need only `proposal`) keep the order the
@@ -884,6 +910,7 @@ openspec schemas [options]
| Option | Description |
|--------|-------------|
| `--json` | Output as JSON |
| `--store <id>` | Use a registered store as the OpenSpec root |
**Example:**
@@ -1105,7 +1132,7 @@ openspec config list
# Get a specific value
openspec config get telemetry.enabled
# Set a value
# Set a value (disable anonymous usage telemetry)
openspec config set telemetry.enabled false
# Set a string value explicitly
@@ -1131,6 +1158,11 @@ openspec config profile
openspec config profile core
```
**Telemetry opt-out:** `telemetry.enabled` defaults to on when unset (opt-out model).
Set it to `false` to disable anonymous usage stats and the `openspec update` version check.
Environment variables take precedence over config: `OPENSPEC_TELEMETRY=0`, `DO_NOT_TRACK=1`,
and a truthy `CI` value (e.g. `true`/`1`/`yes`) always disable telemetry regardless of the config value.
`openspec config profile` starts with a current-state summary, then lets you choose:
- Change delivery + workflows
- Change delivery only
@@ -1240,8 +1272,8 @@ openspec completion uninstall
| Variable | Description |
|----------|-------------|
| `OPENSPEC_TELEMETRY` | Set to `0` to disable telemetry and the `openspec update` version check |
| `DO_NOT_TRACK` | Set to `1` to disable telemetry and the `openspec update` version check (standard DNT signal) |
| `OPENSPEC_TELEMETRY` | Set to `0` to disable telemetry and the `openspec update` version check (overrides `telemetry.enabled` in global config) |
| `DO_NOT_TRACK` | Set to `1` to disable telemetry and the `openspec update` version check (standard DNT signal; overrides config) |
| `OPENSPEC_CONCURRENCY` | Default concurrency for bulk validation (default: 6) |
| `EDITOR` or `VISUAL` | Editor for `openspec config edit` |
| `NO_COLOR` | Disable color output when set |
+1 -1
View File
@@ -673,7 +673,7 @@ Different AI tools use slightly different command syntax. Use the format that ma
|--------------------------|----------------|---------------|
| `.../commands/opsx/<id>.*` | `/opsx:propose`, `/opsx:apply` | Claude Code, Gemini CLI, Crush |
| `.../opsx-<id>.*` | `/opsx-propose`, `/opsx-apply` | Cursor, Devin Desktop, Copilot (IDE), Trae, Oh My Pi |
| none — skills only | `/openspec-propose`, `/openspec-apply-change` | CodeArts, ForgeCode, Hermes, Mistral Vibe |
| none — skills only | `/openspec-propose`, `/openspec-apply-change` | CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, shared `.agents` |
| none — Kimi Code | `/skill:openspec-propose` | Kimi Code |
| none — Codex CLI | `$openspec-propose` | Codex |
+2 -2
View File
@@ -190,7 +190,7 @@ openspec/changes/add-dark-mode/
├── proposal.md # Why and what
├── design.md # How (technical approach)
├── tasks.md # Implementation checklist
├── .openspec.yaml # Change metadata (optional): schema, created, skip_specs
├── .openspec.yaml # Change metadata (optional): schema, created, skip_specs, retire_capabilities
└── specs/ # Delta specs
└── ui/
└── spec.md # What's changing in ui/spec.md
@@ -392,7 +392,7 @@ The system MUST expire sessions after 15 minutes of inactivity.
|---------|---------|------------------------|
| `## ADDED Requirements` | New behavior | Appended to main spec |
| `## MODIFIED Requirements` | Changed behavior | Replaces existing requirement |
| `## REMOVED Requirements` | Deprecated behavior | Deleted from main spec |
| `## REMOVED Requirements` | Deprecated behavior | Deleted from main spec; removing the last requirement retires the capability and deletes its spec file, when the change declares `retire_capabilities: true` |
| `## Purpose` | What a brand-new capability is for | Seeds the Purpose of the main spec being created; ignored when the spec already exists |
### Why Deltas Instead of Full Specs
+7
View File
@@ -18,6 +18,7 @@ The `openspec/config.yaml` file is the easiest way to customize OpenSpec for you
- **Inject project context** - AI sees your tech stack, conventions, etc.
- **Add per-artifact rules** - Custom rules for specific artifacts
- **Add per-operation guidance** - Advisory preferences for apply and archive work
- **Remember integration choices** - e.g. the [GitHub Copilot cloud coding agent](supported-tools.md#github-copilot-cloud-coding-agent) opt-in
### Quick Setup
@@ -52,6 +53,11 @@ operations:
archive:
guidance:
- Keep the completion summary concise
# Set by `openspec init` when you choose (or decline) the GitHub Copilot
# cloud coding agent; controls whether `init`/`update` generate its files.
githubCopilot:
cloudAgent: false
```
### How It Works
@@ -412,6 +418,7 @@ Community schemas are not vendored into OpenSpec core — they live in their own
| Schema | Maintainer | Repository | Description |
|--------|-----------|-----------|-------------|
| `intent-driven` | @harikrishnan83 | [intent-driven-dev/openspec-schemas](https://github.com/intent-driven-dev/openspec-schemas/tree/main/openspec/schemas/intent-driven) | Captures change intent, observable behaviour, technical design, and durable architectural decisions before implementation. Adds a change-local ADR review manifest and writes qualifying long-lived decisions as immutable, supersedable ADRs. |
| `superpowers-bridge` | @JiangWay | [JiangWay/openspec-schemas](https://github.com/JiangWay/openspec-schemas/tree/main/superpowers-bridge) | Integrates OpenSpec's artifact governance with [obra/superpowers](https://github.com/obra/superpowers) execution skills (brainstorming, writing-plans, TDD via subagents, code review, finishing). Adds an evidence-first `retrospective` artifact filling a gap Superpowers does not natively cover. |
| `nanopm` | @nmrtn | [nmrtn/nanopm](https://github.com/nmrtn/nanopm/tree/main/openspec-schema) | PM-first workflow. Runs [nanopm](https://github.com/nmrtn/nanopm)'s planning pipeline (audit → strategy → roadmap → PRD) upstream of implementation. Bridges product planning to OpenSpec's spec-driven engineering workflow. Artifacts read from `.nanopm/` if present — proposal sources the audit, design sources the strategy, and tasks source the PRD breakdown. |
| `e2e-runbooks` | @Lukk17 | [Lukk17/openspec-schemas](https://github.com/Lukk17/openspec-schemas/tree/master/openspec/schemas/e2e-runbooks) | Capability-level end-to-end test runbooks. Each capability gets an immutable spec, an immutable tasks-template, and one timestamped run record per execution. Assertions are observable behaviour only (HTTP status, response body, persisted state — never log substrings); each run records start/end UTC, duration, and best-estimate LLM token consumption. |
+1 -1
View File
@@ -108,7 +108,7 @@ A spec that describes only what's changing, using `ADDED`, `MODIFIED`, and `REMO
### Where do archived changes go?
To `openspec/changes/archive/YYYY-MM-DD-<name>/`, with all artifacts preserved. Nothing is deleted; the change just moves out of your active list.
To `openspec/changes/archive/YYYY-MM-DD-<name>/`, with all change artifacts preserved. The change moves out of your active list. A change that explicitly declares `retire_capabilities: true` can also delete a main capability spec when it removes that capability's final requirement.
## Configuration and customization
+3 -3
View File
@@ -78,7 +78,7 @@ The intent is identical everywhere. The spelling follows the file your tool load
| `.../commands/opsx/<id>.*` | `/opsx:propose` | Claude Code, Gemini CLI, Crush |
| `.../opsx-<id>.*` | `/opsx-propose` | Cursor, GitHub Copilot (IDE), Devin Desktop, Trae, Oh My Pi |
| `.amazonq/prompts/opsx-<id>.md` | `@opsx-propose` | Amazon Q Developer |
| none — skills only | `/openspec-propose` | CodeArts, ForgeCode, Hermes, Mistral Vibe |
| none — skills only | `/openspec-propose` | CodeArts, ForgeCode, Hermes, Mistral Vibe, shared `.agents` |
| none — Kimi Code | `/skill:openspec-propose` | Kimi Code |
| none — Codex CLI | `$openspec-propose` | Codex |
@@ -104,7 +104,7 @@ works too, for the tools that surface slash commands at all.
When you run `openspec init` (or `openspec update`), OpenSpec writes small files into your project so your AI tool can find the workflow. Depending on your tool and settings, these are **skills**, **commands**, or both.
- **Skills** live in places like `.claude/skills/openspec-*/SKILL.md`. They're the emerging cross-tool standard: a folder of instructions your assistant auto-detects.
- **Commands** live in places like `.cursor/commands/opsx-<id>.md` or `.claude/commands/opsx/<id>.md` — the layout is the tool's, and it decides how you type the command. They're the older per-tool slash command files. Codex does not get generated command files; use `.codex/skills/openspec-*`.
- **Commands** live in places like `.cursor/commands/opsx-<id>.md` or `.claude/commands/opsx/<id>.md` — the layout is the tool's, and it decides how you type the command. They're the older per-tool slash command files. Codex does not get generated command files; use `.agents/skills/openspec-*`.
You don't have to care which one your tool uses. You just type the slash command and it works. But knowing these files exist helps when something goes wrong: if your commands vanish, it usually means these files are missing or stale, and `openspec update` regenerates them.
@@ -114,7 +114,7 @@ See [Supported Tools](supported-tools.md) for the exact paths per tool, and [Mig
Quick checks, fastest first:
1. **Type a slash in your AI chat.** Start typing `/opsx` and watch for autocomplete suggestions. If they appear, you're set. On a skills-only tool (Codex, Kimi Code, CodeArts, ForgeCode, Hermes, Mistral Vibe) `/opsx` never completes even on a healthy install — try the skill name from the table above instead.
1. **Type a slash in your AI chat.** Start typing `/opsx` and watch for autocomplete suggestions. If they appear, you're set. On a skills-only tool (Codex, Kimi Code, CodeArts, ForgeCode, Hermes, Mistral Vibe, or the shared `.agents` target) `/opsx` never completes even on a healthy install — try the skill name from the table above instead.
2. **Look for the files.** For Claude Code, check that `.claude/skills/` contains `openspec-*` folders. Other tools use their own directories ([Supported Tools](supported-tools.md) lists them).
3. **Re-run setup.** From your project root, run `openspec update`. This regenerates the skill and command files for whatever tools you configured.
4. **Restart your assistant.** Many tools scan for skills and commands at startup, so a fresh window can be the missing step.
+17
View File
@@ -98,6 +98,23 @@ yarn global add @fission-ai/openspec@latest
Yarn 2 and later (Berry) removed the `global` command. On those versions, install OpenSpec with npm, pnpm, or bun instead — a global CLI doesn't need to share your project's package manager.
### deno
Deno sometimes has issues parsing the @latest tag, but we can specify a version while installing initially.
If that happens, you could try to change the @latest tag with the version, something like `@^1.3.1`
```bash
deno install --global \
--allow-read --allow-write --allow-env --allow-sys=cpus,homedir --allow-net=edge.openspec.dev \
npm:@fission-ai/openspec@latest
# or
deno install --global \
--allow-read --allow-write --allow-env --allow-sys=cpus,homedir --allow-net=edge.openspec.dev \
npm:@fission-ai/openspec@^1.3.1
```
Note: If your subcommands launch external tools, like config edit, feedback, or workspace open, you may need a scoped --allow-run=<program>.
### bun
Bun can install OpenSpec globally, but OpenSpec currently runs on Node.js.
+3 -3
View File
@@ -47,7 +47,7 @@ Only OpenSpec-managed files that are being replaced:
- Cline: `.clinerules/workflows/openspec-*.md`
- Roo: `.roo/commands/openspec-*.md`
- GitHub Copilot: `.github/prompts/openspec-*.prompt.md` (IDE extensions only; not supported in Copilot CLI)
- Codex: OpenSpec now uses `.codex/skills/openspec-*`; legacy cleanup only targets OpenSpec's allowlisted prompt filenames in `$CODEX_HOME/prompts` or `~/.codex/prompts`, and only removes them after replacement skills exist.
- Codex: OpenSpec now uses the canonical `.agents/skills/openspec-*` path. OpenSpec-managed `SKILL.md` files under the former `.codex/skills` path are reconciled only after replacements exist; custom files and divergent copies stay in place. If an unmarked `.agents` tree already contains OpenSpec skills, OpenSpec preserves its existing Codex (`$openspec-*`) or generic (`/openspec-*`) rendering instead of guessing from the legacy directory. Select `codex` explicitly with `openspec init` to switch ownership. Legacy prompt cleanup still targets only OpenSpec's allowlisted filenames in `$CODEX_HOME/prompts` or `~/.codex/prompts`.
- And others (Augment, Continue, Amazon Q, etc.)
The migration detects whichever tools you have configured and cleans up their legacy files.
@@ -157,7 +157,7 @@ openspec init --force --tools claude
The `--force` flag skips prompts and auto-accepts cleanup.
This includes cleanup of OpenSpec-managed Codex prompt files in the global Codex prompt directory. Cleanup only targets OpenSpec's allowlisted legacy Codex prompt filenames, removes them only after replacement `.codex/skills/openspec-*` skills exist, and preserves all other files.
This includes cleanup of OpenSpec-managed Codex prompt files in the global Codex prompt directory. Cleanup only targets OpenSpec's allowlisted legacy Codex prompt filenames, removes them only after replacement `.agents/skills/openspec-*` skills exist, and preserves all other files.
---
@@ -411,7 +411,7 @@ OPSX uses the emerging **skills** standard:
Skills are recognized across multiple AI coding tools and provide richer metadata.
Codex is skills-only in OPSX. OpenSpec no longer generates Codex custom prompt files; use the generated `.codex/skills/openspec-*` directories instead.
Codex is skills-only in OPSX. OpenSpec no longer generates Codex custom prompt files; use the generated `.agents/skills/openspec-*` directories instead.
---
+8 -2
View File
@@ -165,7 +165,7 @@ rules:
| `/opsx:apply` | Implement tasks, updating artifacts as needed |
| `/opsx:update` | Revise a change's planning artifacts and keep them coherent |
| `/opsx:verify` | Validate implementation against artifacts (expanded workflow) |
| `/opsx:sync` | Sync delta specs to main (default workflow, optional) |
| `/opsx:sync` | Merge delta specs into main specs (optional) |
| `/opsx:archive` | Archive when done |
| `/opsx:bulk-archive` | Archive multiple completed changes (expanded workflow) |
| `/opsx:onboard` | Guided walkthrough of an end-to-end change (expanded workflow) |
@@ -215,6 +215,12 @@ Works through tasks, checking them off as you go. If you're juggling multiple ch
```
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).
### Sync delta specs
```text
/opsx:sync
```
Merges the current change's delta specs into your main `openspec/specs/` without archiving — the change stays active. It applies the whole delta: a requirement under `## REMOVED` is deleted from the main spec and a renamed one is retitled in place, while content the delta doesn't mention is left untouched. Syncing is optional — archive prompts you to sync first if you haven't. Reach for it when you want main specs updated before archiving, when a parallel change needs to build on specs this one just added, or when you want to review the merged main spec before archiving.
### Finish up
```
/opsx:archive # Move to archive when done (prompts to sync specs if needed)
@@ -478,7 +484,7 @@ Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, no
│ • Create proposal.md │
│ • Create tasks.md │
│ • Create design.md │
│ • Create specs/<capability>/spec.md │
│ • Create delta spec files │
│ │
│ No awareness of what exists or │
│ dependencies between artifacts │
+97 -3
View File
@@ -165,6 +165,98 @@ machine-wide default from a repo's own pointer. Clear it with
`openspec config unset defaultStore`. If the id is not registered, commands
error and tell you to register it or clear the stale default.
## Example: one feature, two component repos
Suppose `add-checkout-promo` changes both `checkout-api` and
`checkout-web`. The team wants one shared product contract, while each code
repo still needs its own implementation tasks, branch, and review.
Use two layers:
1. Keep the shared behavior in `team-plans`.
2. Keep implementation plans in each component repo and reference the store
as read-only upstream context.
First, plan the shared contract in the store:
```bash
openspec new change add-checkout-promo --store team-plans
openspec status --change add-checkout-promo --store team-plans
```
The proposal and specs should describe the behavior at the boundary between
the components — for example, the promotion fields returned by the service
and how the frontend handles an ineligible checkout. Review this change in
the store repo like any other branch and pull request.
### What context does planning see?
Selecting a store changes the OpenSpec root; it does not discover or read
every code repo that uses that store. Store instructions see the artifacts
and configured context in the store. They see component code only when those
folders are also available to the agent or editor and the agent reads them.
A workset is a convenient way to open the planning store and both code repos
together:
```bash
openspec workset create checkout-promo \
--member ~/openspec/team-plans \
--member ~/src/checkout-api \
--member ~/src/checkout-web \
--tool code
openspec workset open checkout-promo
```
This makes the folders visible in one IDE workspace. It does not copy source
context into the store, select affected repos, or grant an agent permission
to edit them. Put durable cross-component facts in the shared specs; do not
rely on a planner remembering source it happened to inspect.
### How does implementation start in each repo?
When no explicit `--store` or nearer `openspec/` root applies, a
`store: team-plans` pointer routes commands to that store. It does not split
one store task list by the directory from which `apply` was invoked. OpenSpec
currently does not route tasks to repos.
When each component needs an independently scoped apply/review cycle, give it
a local OpenSpec root and reference the central store instead of pointing at
it:
```yaml
# checkout-api/openspec/config.yaml (and likewise in checkout-web)
schema: spec-driven
references:
- team-plans
```
After the shared contract is approved and available in the store's main
specs, create a small local change for the component's part:
```bash
cd ~/src/checkout-api
openspec new change implement-checkout-promo-api
cd ~/src/checkout-web
openspec new change implement-checkout-promo-ui
```
The reference index in each repo's instructions supplies the store spec's
summary and exact `openspec show ... --store team-plans` fetch command. Each
local proposal cites that shared contract, and its tasks describe only work
in that component. Then run `/opsx:apply` in each repo separately; root
resolution keeps the artifacts and implementation edits scoped to that repo.
The service and frontend changes can now be tested, reviewed, merged, and
archived independently.
If implementation must begin while the shared store change is still active,
fetch it explicitly with
`openspec show add-checkout-promo --store team-plans`; reference indexes list
canonical store specs, not active store changes. Keep the store branch and
component branches linked in their pull-request descriptions so reviewers
can see which version of the contract each implementation follows.
## Story: requirements that cross team lines
A platform team owns the requirements. Product teams build against them,
@@ -335,9 +427,11 @@ tells you which case you're in.
`openspec/config.yaml` declares `store: <id>` is treated as externalized
planning, not as a store checkout to register. Remove the `store:` line first
if you intentionally want to convert that repo into a local store root.
- **Some commands stay where they are.** `view`, `templates`, `schemas`,
and the deprecated noun forms (`openspec change show`, ...) act on the
current directory only — no `--store`.
- **Some commands stay where they are.** `templates` and the
deprecated noun forms (`openspec change show`, ...) act on the current
directory only — no `--store`. `schemas` follows the canonical root-selection
precedence and accepts `--store <id>` while keeping its successful JSON array
shape unchanged.
- **Per-machine state is per-machine.** The store registry and worksets
are local settings. Nothing about your machine's layout is
ever committed to shared planning.
+88 -5
View File
@@ -9,7 +9,7 @@ For each selected tool, OpenSpec can install:
1. **Skills** (if delivery includes skills): `.../skills/openspec-*/SKILL.md`
2. **Commands** (if delivery includes commands): tool-specific `opsx-*` command files
Codex is skills-only: OpenSpec installs `.codex/skills/openspec-*/SKILL.md` for Codex even when delivery is set to `commands`, and it does not generate Codex custom prompt files.
Codex is skills-only: OpenSpec installs `.agents/skills/openspec-*/SKILL.md` for Codex even when delivery is set to `commands`, and it does not generate Codex custom prompt files. Existing OpenSpec-managed skills under the legacy `.codex/skills` path are reconciled after their replacements are written; custom and divergent files are preserved.
By default, OpenSpec uses the `core` profile, which includes:
- `propose`
@@ -33,7 +33,7 @@ way it loads the file OpenSpec wrote. Find your tool's command path in the
| `.../opsx-<id>.*` — the filename is the command | `/opsx-<id>` | Every other tool with generated command files, except Amazon Q and Devin |
| `.devin/workflows/opsx-<id>.md` — read by only one of Devin's two agents | `/opsx-<id>` on Devin Desktop, `/openspec-<skill>` on Devin Local | Devin Desktop\*\*\*\* |
| `.amazonq/prompts/opsx-<id>.md` — a prompt, not a command | `@opsx-<id>` | Amazon Q Developer |
| none — skills only | `/openspec-<skill>` | CodeArts, ForgeCode, Hermes, Mistral Vibe |
| none — skills only | `/openspec-<skill>` | CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, shared `.agents` |
| none — Kimi Code | `/skill:openspec-<skill>` | Kimi Code |
| none — Codex CLI | `$openspec-<skill>` | Codex ([`/openspec-<skill>` is not recognized](https://github.com/openai/codex/issues/11817)) |
@@ -70,9 +70,10 @@ to read the hint.
| IBM Bob Shell (`bob`) | `.bob/skills/openspec-*/SKILL.md` | `.bob/commands/opsx-<id>.md` |
| Claude Code (`claude`) | `.claude/skills/openspec-*/SKILL.md` | `.claude/commands/opsx/<id>.md` |
| Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-<id>.md` |
| Command Code (`command-code`) | `.commandcode/skills/openspec-*/SKILL.md` | `.commandcode/commands/opsx-<id>.md` |
| CodeArts (`codeartsagent`) | `.codeartsdoer/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
| CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/<id>.md` |
| Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | Not generated (skills-only; use `.codex/skills/openspec-*`) |
| Codex (`codex`) | `.agents/skills/openspec-*/SKILL.md` | Not generated (skills-only; use `$openspec-*`) |
| Devin Desktop, formerly Windsurf (`devin`) | `.devin/skills/openspec-*/SKILL.md` | `.devin/workflows/opsx-<id>.md`\*\*\*\* |
| ForgeCode (`forgecode`) | `.forge/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
| Continue (`continue`) | `.continue/skills/openspec-*/SKILL.md` | `.continue/prompts/opsx-<id>.prompt` |
@@ -89,22 +90,104 @@ to read the hint.
| 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` |
| MiniMax Code (`minimax-code`) | `~/.minimax/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use MiniMax Code skills) |
| Mistral Vibe (`vibe`) | `.vibe/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
| Oh My Pi (`oh-my-pi`) | `.omp/skills/openspec-*/SKILL.md` | `.omp/commands/opsx-<id>.md` |
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-<id>.md` |
| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-<id>.md` |
| Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/<id>.md` |
| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-<id>.md` |
| [Rovo Dev CLI](https://support.atlassian.com/rovo/docs/use-rovo-dev-cli/) (`rovodev`) | `.rovodev/skills/openspec-*/SKILL.md` | Not generated. Rovo has no slash-command surface — it matches skills automatically or by prompt (e.g. "use the openspec-propose skill"); `/skills` only manages them. Generated content references skills by name, never as `/openspec-*` commands. |
| [Zoo Code](https://github.com/Zoo-Code-Org/Zoo-Code) (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-<id>.md` |
| Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | `.trae/commands/opsx-<id>.md` |
| ZCode (`zcode`) | `.zcode/skills/openspec-*/SKILL.md` | `.zcode/commands/opsx/<id>.md` |
| Shared `.agents` skills (`agents`) | `.agents/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
\*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly.
\*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. Selecting `github-copilot` can also set up the GitHub-hosted **cloud coding agent** — see [GitHub Copilot cloud coding agent](#github-copilot-cloud-coding-agent) below.
\*\*\* Hermes loads skills from `~/.hermes/skills/` by default. To use project-local OpenSpec skills, add the project `.hermes/skills/` directory to `skills.external_dirs` in `~/.hermes/config.yaml`; Hermes then exposes skills with user-facing slash invocations such as `/openspec-propose`.
\*\*\*\* Windsurf was [rebranded to Devin Desktop](https://docs.devin.ai/desktop/devin-desktop-faq) on June 2, 2026, and its config directory moved: `.devin/` is the preferred read + write location, `.windsurf/` a legacy read-only fallback. OpenSpec follows the rename — the tool id is `devin`, and `--tools windsurf` still resolves to it so existing setup scripts keep working. A project still holding OpenSpec files in `.windsurf/` is offered the move on the next `openspec update`; declining leaves them in place, and files you wrote yourself are never touched. Workflows are invoked by filename, so `.devin/workflows/opsx-apply.md` is `/opsx-apply`. The [Devin Local agent does not support workflows](https://docs.devin.ai/desktop/devin-local) — only skills, and it does not read `.windsurf/` at all — so whenever OpenSpec writes Devin skills it keeps their bodies, and the getting-started hint, on `/openspec-*` skill invocations, which work on both agents. Under commands-only delivery no skills are written and both fall back to `/opsx-*`.
MiniMax Code is a global skills-only integration. OpenSpec writes only its
`openspec-*` directories under `~/.minimax/skills/`; it does not create
repo-local `.minimax` or `.mavis` directories. Commands-only delivery leaves
existing global MiniMax Code skills untouched so one project's delivery setting
cannot remove skills used by another project.
### GitHub Copilot cloud coding agent
GitHub's [Copilot coding agent](https://docs.github.com/en/copilot/using-github-copilot/coding-agent) runs on GitHub in a GitHub Actions environment — separate from Copilot in your editor. OpenSpec can set it up to use the OpenSpec CLI by generating two files:
- `.github/workflows/copilot-setup-steps.yml` — installs `@fission-ai/openspec` in the agent's environment
- `.github/agents/openspec.agent.md` — tells the agent how to drive OpenSpec
Because this writes a GitHub Actions workflow into your repository, it is **opt-in**:
| How | Behavior |
|-----|----------|
| `openspec init` (interactive) | Asks whether to set up cloud files. Default is **No**. |
| `openspec init --copilot-cloud` | Sets them up without prompting (for scripts/CI). |
| `openspec init --no-copilot-cloud` | Skips them without prompting, and removes any previously generated ones. |
| `openspec update` | Never prompts. Refreshes the files only if you opted in (or the project already has them). If you opted out, it removes OpenSpec-managed cloud files. |
Your choice is saved in `openspec/config.yaml` as `githubCopilot.cloudAgent: true|false`, so non-interactive updates honor it. OpenSpec only ever writes or removes files whose content it generated — if you customize `copilot-setup-steps.yml` or `openspec.agent.md`, or already have your own, it is left untouched (and `init`/`update` tell you so).
### When to pick the shared `.agents` target
`agents` is the vendor-neutral option: it writes skills to `.agents/skills/`, the
shared root many agent tools read, instead of a tool-specific directory.
| Situation | Pick |
|-----------|------|
| Your tool has its own row above | Its own ID — you get that tool's integration, including slash commands where it supports them |
| Several agents on one repo, all reading `.agents/skills` | `agents` — one skill tree instead of one per tool |
| Your tool isn't listed yet but reads `.agents/skills` | `agents` |
Selecting it alongside a tool-specific ID is fine; each normally writes to its
own root. Codex is the exception because it uses the same canonical `.agents`
root. If both `codex` and `agents` are selected, OpenSpec keeps one
Codex-led tree. Its handoffs name both `$openspec-*` for Codex and
`/openspec-*` for other agents, so `--tools all` and existing multi-agent
setups keep working without two writers overwriting the same files.
OpenSpec also offers it automatically once a project has a `.agents/skills/`
directory — a bare `.agents/` is not enough, since tools use that root for rules
and subagent definitions too. Note `.agents` is not `.agent`: the singular
directory belongs to Antigravity.
Two things to know:
- **Skills only.** No command adapter exists, so no `opsx-*` command files are
written; with a commands-inclusive delivery mode `openspec init` lists `agents`
among the tools it reports under `Commands skipped for: … (no adapter)`.
Invoke the workflows by skill name —
most assistants that read `.agents/skills` spell that `/openspec-propose`, the form
OpenSpec's setup hint prints. The target is vendor-neutral, so check your
assistant's own docs if it uses another form.
- **No `AGENTS.md` is created or edited.** The target is the `.agents/` directory.
If your root `AGENTS.md` still carries OpenSpec marker blocks from an older
version, `openspec update` strips them — see the [Migration Guide](migration-guide.md).
Because `.agents/skills/` is shared, it is worth knowing what OpenSpec claims there:
it writes, refreshes, and removes only the `openspec-*` skill directories for your
selected workflows, plus an `.openspec-target` marker that records whether Codex
or the vendor-neutral target rendered that shared tree. Anything else in that
directory is left alone. Treat the `openspec-*` names and marker as OpenSpec's —
edits inside them are replaced on the next `openspec update`, the same as for
every other tool.
For pre-marker projects, OpenSpec infers ownership from managed skill references:
`$openspec-*` means Codex and `/openspec-*` means the vendor-neutral target. A
generic canonical tree alongside legacy `.codex/skills` is treated as an older
dual-target install and consolidated into the compatible shared tree.
`openspec update` honors this ownership too. If a project owns `.agents` as the
vendor-neutral target and a leftover Codex install is detected only from stray
prompt files, the update leaves the established `agents` tree in place instead of
rewriting it with Codex syntax, and preserves those legacy prompt files rather
than deleting them. To hand the shared tree to Codex, run `openspec init --tools
codex` explicitly.
## Non-Interactive Setup
For CI/CD or scripted setup, use `--tools` (and optionally `--profile`):
@@ -123,7 +206,7 @@ openspec init --tools none
openspec init --profile core
```
**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zcode`
**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zcode`, `agents`
## Workflow-Dependent Installation
+25 -2
View File
@@ -59,7 +59,7 @@ If `/opsx:propose` (or your tool's equivalent) doesn't appear or doesn't do anyt
5. **Check you initialized this project.** Skills are written per project. If you cloned a repo or switched folders, run `openspec init` (or `openspec update`) there.
6. **Confirm your tool supports command files.** Codex, CodeArts, ForgeCode, Hermes, Kimi Code and Mistral Vibe don't get generated `opsx-*` command files; they use skill-based invocations instead, so `/opsx` will never autocomplete for them. Type `$openspec-propose` in Codex, `/skill:openspec-propose` in Kimi Code, and `/openspec-propose` in the rest. Amazon Q does get command files, but loads them into its prompt library rather than its slash menu — type `@opsx-propose` there, not `/opsx`. Every tool's form is listed in [How To Invoke](supported-tools.md#how-to-invoke).
6. **Confirm your tool supports command files.** Codex, CodeArts, ForgeCode, Hermes, Kimi Code, Mistral Vibe and the shared `.agents` target don't get generated `opsx-*` command files; they use skill-based invocations instead, so `/opsx` will never autocomplete for them. Type `$openspec-propose` in Codex, `/skill:openspec-propose` in Kimi Code, and `/openspec-propose` in the rest. The shared `.agents` target is vendor-neutral, so `/openspec-propose` is the common form rather than a guaranteed one — if your assistant does not answer to it, check its own docs for how it invokes a skill. Amazon Q does get command files, but loads them into its prompt library rather than its slash menu — type `@opsx-propose` there, not `/opsx`. Every tool's form is listed in [How To Invoke](supported-tools.md#how-to-invoke).
## Working with changes
@@ -92,10 +92,19 @@ Validation checks your specs and changes for structural problems. Read the messa
openspec validate <name> # validate one item
openspec validate --all # validate everything
openspec validate --all --strict # stricter checks, good for CI
openspec validate --archived # fail if archived changes have unchecked tasks
```
Common causes are a missing required section (like a spec with no scenarios) or a malformed delta header. Fix the file and re-run. The [CLI reference](cli.md#openspec-validate) documents the output format.
One message deserves its own note:
```text
MODIFIED "<requirement>" omits scenario(s) the current spec still has: "<scenario>"
```
A `MODIFIED` requirement replaces the whole requirement block, so it has to carry every scenario that survives the change, not only the ones you edited. Copy the named scenarios from `openspec/specs/<capability-path>/spec.md` back into the delta, preserving any domain directories in the path. This often appears on an older change after someone else's change added a scenario to the same requirement — archive refuses that change either way, and validation now says so before you implement it.
### The AI created incomplete or wrong artifacts
The AI didn't have enough context. A few levers help:
@@ -109,6 +118,20 @@ The AI didn't have enough context. A few levers help:
Archive won't *block* on incomplete tasks, but it warns you, because archiving usually means the work is done. If tasks remain on purpose (you're filing a partial change), proceed. Otherwise finish the tasks first. Archive will also offer to sync your delta specs into the main specs if you haven't synced yet; say yes unless you have a reason not to.
### "User force closed the prompt with 0 null"
Something ran `openspec archive` where nothing can answer a question — an AI agent calling it from a tool, a CI job, or any shell with stdin closed. Archive asks up to three confirmations, and an unanswerable one used to fail with that raw message.
Pass `--yes` to answer them up front:
```bash
openspec archive <change-name> --yes
```
Keep any flags you were already passing — `--skip-specs` and `--no-validate` change what archive does, so a bare `--yes` rerun is not the same command. Current versions name the flag for you and print a `Fix:` line you can paste. If you meant to pick from a list, pass the change name explicitly: the picker needs an answer too.
If you instead ran archive with its output redirected to a file or captured by a tool and *did* pipe an answer (`printf 'y\n' | openspec archive …`), older versions wrote terminal escape codes into that capture while drawing the prompt — in some environments enough to bloat the file badly. Current versions read the confirmation prompts as plain text whenever stdout is not a terminal, and a no-argument `openspec archive` (which would otherwise draw an interactive change picker) asks you to pass a change name up front instead of rendering a menu into the capture. Either way, redirected and agent runs stay clean; passing `--yes` (with a change name) skips the prompts entirely.
## Configuration
### My `config.yaml` isn't being applied
@@ -153,7 +176,7 @@ You're in CI or a non-interactive shell, and OpenSpec found old files to clean u
openspec init --force
```
For Codex, OpenSpec may detect old managed prompt files in `$CODEX_HOME/prompts` or `~/.codex/prompts`. That cleanup is limited to OpenSpec's allowlisted legacy Codex prompt filenames, and non-interactive `openspec init` removes only the files whose replacement `.codex/skills/openspec-*` skills exist. Non-interactive `openspec update` leaves all legacy cleanup untouched unless you pass `--force`.
For Codex, OpenSpec may detect old managed prompt files in `$CODEX_HOME/prompts` or `~/.codex/prompts`. That cleanup is limited to OpenSpec's allowlisted legacy Codex prompt filenames, and non-interactive `openspec init` removes only the files whose replacement `.agents/skills/openspec-*` skills exist. Non-interactive `openspec update` leaves all legacy cleanup untouched unless you pass `--force`.
### Commands didn't appear after migrating
+69
View File
@@ -28,6 +28,75 @@ OPSX (fluid actions):
> **Customization:** OPSX workflows are driven by schemas that define artifact sequences. See [Customization](customization.md) for details on creating custom schemas.
## Workflow at a Glance
The default workflow stays fluid: exploration and verification are optional, and
you can update planning artifacts whenever implementation reveals something new.
```mermaid
flowchart TD
Idea["Idea or problem"] --> Explore["/opsx:explore<br/>(optional)"]
Idea --> Propose["/opsx:propose"]
Explore --> Propose
Propose --> Review{"Planning artifacts<br/>ready?"}
Review -->|"Refine"| Update["/opsx:update"]
Update --> Review
Review -->|"Implement"| Apply["/opsx:apply"]
Apply -->|"Plan changed"| Update
Apply --> Archive["/opsx:archive"]
Apply --> Verify["/opsx:verify<br/>(optional, custom selection)"]
Apply --> Sync["/opsx:sync<br/>(optional before archive)"]
Verify --> Verified{"Ready to archive?"}
Verified -->|"Fix implementation"| Apply
Verified -->|"Revise plan"| Update
Verified -->|"Ready"| Sync
Verified -->|"Ready"| Archive
Sync --> Archive
```
The AI assistant drives the workflow, while the CLI provides deterministic
scaffolding, status, and artifact instructions:
```mermaid
sequenceDiagram
actor Human
participant Assistant as AI assistant
participant CLI as OpenSpec CLI
participant Files as Planning and implementation files
Human->>Assistant: /opsx:propose "change"
Assistant->>CLI: openspec new change
CLI->>Files: Scaffold change metadata
Assistant->>CLI: Request status and artifact instructions
CLI-->>Assistant: Build order, paths, and templates
Assistant->>Files: Write schema-defined planning artifacts
Assistant-->>Human: Present artifacts for review
Human->>Assistant: /opsx:apply
Assistant->>CLI: Request apply instructions
CLI-->>Assistant: Context files and task state
Assistant->>Files: Implement tasks and update checkboxes
Assistant-->>Human: Report implementation status
Human->>Assistant: /opsx:archive
Assistant->>CLI: Request archive inputs and artifact status
CLI-->>Assistant: Planning paths and artifact completion
Assistant->>Files: Read task state and compare delta specs
opt Delta specs exist
Assistant-->>Human: Offer to sync before archiving
alt Sync accepted
Human->>Assistant: Confirm sync
Assistant->>Files: Merge delta specs into main specs
else Sync skipped
Human->>Assistant: Archive without syncing
end
end
Assistant->>Files: Move the change into the archive
Assistant-->>Human: Report archive location and sync result
Note over Human,CLI: CLI alternative: openspec archive change-name --yes skips confirmation prompts; it still validates, then applies any delta specs and archives
```
## Two Modes
### Default Quick Path (`core` profile)
+2 -2
View File
@@ -56,9 +56,9 @@ A change describes its edits to the specs with three section types. Using the ri
- **`## MODIFIED Requirements`** — behavior that already existed and is changing. Include the full new version; a short note on what changed helps a reviewer.
- **`## REMOVED Requirements`** — behavior going away, with a line on why.
On archive, ADDED gets appended to the main spec, MODIFIED replaces the old version, and REMOVED is deleted. If you mark a real change as ADDED, you end up with two competing requirements; if you describe new behavior as MODIFIED, there's nothing to replace. When in doubt, open the current spec and see whether the requirement is already there.
On archive, ADDED gets appended to the main spec, MODIFIED replaces the old version, and REMOVED is dropped from it. Remove the last requirement a capability has and you retire it: rather than leave a spec with nothing in it, archive deletes `openspec/specs/<capability>/spec.md`. Because that is the one archive step that removes a file, it has to be asked for — add `retire_capabilities: true` to the change's `.openspec.yaml`, alongside the `schema:` that file already needs. Without it the archive aborts and tells you so. For a spec in the caller's checkout, the archive output also names the `git checkout` that restores a committed file; selected stores receive checkout-scoped recovery guidance instead. If you mark a real change as ADDED, you end up with two competing requirements; if you describe new behavior as MODIFIED, there's nothing to replace. When in doubt, open the current spec and see whether the requirement is already there.
One more section is worth knowing about. When your delta creates a capability that doesn't exist yet, open it with `## Purpose` — a sentence or two on what the capability is for. Archive uses it as the Purpose of the main spec it creates; skip it and you get a `TBD` placeholder to fill in by hand. An existing spec already has a Purpose, so a delta's is ignored there — edit `openspec/specs/<capability>/spec.md` directly to change one.
One more section is worth knowing about. When your delta creates a capability that doesn't exist yet, open it with `## Purpose` — a sentence or two on what the capability is for. Archive uses it as the Purpose of the main spec it creates; skip it and you get a `TBD` placeholder to fill in by hand. An existing spec already has a Purpose, so a delta's is ignored there — edit `openspec/specs/<capability-path>/spec.md` directly to change one. Here, `<capability-path>` is the directory relative to `specs/`, such as `user-auth` in a flat project or `identity/user-auth` in a project organized by domain.
## Right-size the change
+2 -1
View File
@@ -39,6 +39,7 @@
./test
./package.json
./pnpm-lock.yaml
./pnpm-workspace.yaml
./tsconfig.json
./build.js
./vitest.config.ts
@@ -51,7 +52,7 @@
inherit (finalAttrs) pname version src;
pnpm = pkgs.pnpm_9;
fetcherVersion = 3;
hash = "sha256-AHPKWjhrk4aTJvp9uqTJk15vASEZyRUoSw0W9oV2650=";
hash = "sha256-LerQoKH3MX5mWZ2Sk9p9Q3kUNwckfA1RnP7Z3FueAXU=";
};
nativeBuildInputs = with pkgs; [
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-29
@@ -0,0 +1,33 @@
## Why
`.agents/skills` has become the shared, vendor-neutral location modern agent tools read. OpenSpec already carried an `agents` entry in `AI_TOOLS`, but with `available: false` and no `skillsDir` it was unreachable — every real gate keys off `skillsDir`. Teams running several agents on one repo, or a tool with no first-class integration yet, had to generate for some other tool and move the files by hand (#1480), or pick a vendor target they do not use (#1104, #653).
## What Changes
- Enable `agents` in `AI_TOOLS` with `skillsDir: '.agents'`, making it selectable interactively and via `--tools agents`.
- Scope detection to `detectionPaths: ['.agents/skills']` so a bare `.agents/` written by another framework does not select — or silently install into — the target.
- Rename the entry to `Shared .agents skills`. The old label said "AGENTS.md", but OpenSpec writes no `AGENTS.md` — it strips its markers out of one.
- Document the target, including when to prefer it over a tool-specific integration.
## Capabilities
### New Capabilities
_None._
### Modified Capabilities
- `ai-tool-paths`: define the `.agents` skills root and its scoped detection path
- `cli-init`: record that the shared target installs skills and skips command generation
## Impact
- `src/core/config.ts` - enable the `agents` entry, scope detection, correct the label
- `.changeset/add-agents-tool.md` - minor release note, including the `--tools all` behavior change
- `docs/supported-tools.md`, `docs/cli.md`, `docs/commands.md`, `docs/how-commands-work.md`, `docs/troubleshooting.md` - list `agents` among skills-only tools and explain when to choose it
- `test/core/*`, `test/commands/*`, `test/cli-e2e/*` - cover init, update, detection, and the deprecated alias
## Non-Goals
- No command adapter for `agents`. There is no cross-vendor slash-command format, so commands stay skills-only (the Kimi/Hermes pattern).
- No `.pi`, `.codex`, or `.agent` migration into `.agents`. Moving vendor tools to the shared root is separate work (#830, #1157).
@@ -0,0 +1,23 @@
# ai-tool-paths Delta Specification
## ADDED Requirements
### Requirement: Shared .agents skills target
OpenSpec SHALL provide a vendor-neutral `agents` tool target rooted at the shared `.agents` directory, for assistants that read skills from the shared location rather than a vendor-specific one.
#### Scenario: Shared agents target paths defined
- **WHEN** looking up the `agents` tool
- **THEN** `skillsDir` SHALL be `.agents`
#### Scenario: Detection keys off the shared skills subtree
- **WHEN** a project contains a `.agents/skills` path
- **THEN** OpenSpec SHALL detect `agents` as an available target
#### Scenario: A bare shared root does not select the target
- **GIVEN** a project contains `.agents` but no `.agents/skills` path
- **WHEN** OpenSpec detects available tools
- **THEN** `agents` SHALL NOT be reported as available
@@ -0,0 +1,20 @@
# cli-init Delta Specification
## ADDED Requirements
### Requirement: Shared .agents target initialization
`openspec init` SHALL accept the shared `agents` target wherever tool IDs are selected, and SHALL treat it as a skills-only tool.
#### Scenario: Non-interactive selection of the shared target
- **WHEN** the user runs `openspec init --tools agents`
- **THEN** OpenSpec SHALL generate skills for the `agents` target
- **AND** initialization SHALL NOT fail because `agents` has no registered command adapter
#### Scenario: Shared agents target skips command-file generation
- **GIVEN** the configured delivery includes command generation
- **WHEN** the user selects the shared `agents` target during initialization
- **THEN** command-file generation SHALL be skipped because no `agents` adapter is registered
- **AND** `agents` SHALL be listed among the tools reported as having commands skipped
@@ -0,0 +1,21 @@
## 1. Tests
- [x] 1.1 Cover `agents` init, update, detection, and the deprecated `experimental --tool` alias
- [x] 1.2 Assert a bare `.agents/` directory does not select the target
## 2. Registry
- [x] 2.1 Enable `agents` in `src/core/config.ts` with `skillsDir: '.agents'`
- [x] 2.2 Scope detection with `detectionPaths: ['.agents/skills']`
- [x] 2.3 Rename the entry to `Shared .agents skills` so it names the directory instead of a file OpenSpec never writes
## 3. Docs
- [x] 3.1 Add `agents` to the tool ID lists in `docs/cli.md` and `docs/supported-tools.md`
- [x] 3.2 Add the Tool Directory row and the skills-only invocation rows across `docs/supported-tools.md`, `docs/commands.md`, `docs/how-commands-work.md`, and `docs/troubleshooting.md`
- [x] 3.3 Document when to choose the shared target over a tool-specific integration
## 4. Verification
- [x] 4.1 Run `pnpm run build` and the full Vitest suite
- [x] 4.2 Validate with `openspec validate --strict`, and confirm `openspec archive` applies cleanly against a scratch copy of `openspec/`
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-09
@@ -0,0 +1,79 @@
## Context
See `proposal.md` for motivation and `specs/schema-resolution/spec.md` for the behavioral contract.
The pre-fix CLI has two already-compatible pieces that are not connected:
- `schemasCommand()` passes `process.cwd()` directly to `listSchemasWithInfo()`.
- `listSchemasWithInfo(projectRoot)` already lists the correct project-local, user, and package schemas when given an authoritative project root.
- Normal root-scoped commands already call `resolveRootForCommand()`, which implements explicit store, nearest root, local `store:` pointer, global `defaultStore`, rootless fallback, canonicalization, and shared diagnostics.
The mismatch was reproduced against the built CLI with distinct `local-only` and `store-only` schemas. From the local project, `schemas --json` returned `local-only` and omitted `store-only`, while `context --json --store team-context` resolved the operation root to the store. The relevant pre-fix test baseline passes (110 tests), so the reproduction is not caused by an existing failing suite.
## Goals / Non-Goals
**Goals:**
- Make schema discovery and schema consumption resolve the same root.
- Carry explicit store selection through a supported CLI flag.
- Reuse the canonical root-selection implementation and its diagnostics.
- Preserve successful schema-list output compatibility and cross-platform path handling.
**Non-Goals:**
- Change schema resolution precedence within a resolved root.
- Change schema descriptions, semantic selection policy, or workflow-specific behavior beyond correcting stale `schemas --store` guidance.
- Add a raw filesystem-root flag or expose a resolved path in successful JSON output.
- Modify `context`, `templates`, change creation, or the root resolver itself.
- Refactor the existing `propose` compatibility sequence; only its stale flag-support claim changes.
## Decisions
### 1. Resolve the root at the CLI command boundary
`schemasCommand()` will accept the standard store selector fields and call `resolveRootForCommand()` before invoking `listSchemasWithInfo(root.path)`. This is the same boundary used by `status` and other root-scoped workflow commands.
Resolving inside `listSchemasWithInfo()` was rejected because that function is also a programmatic API with intentional backward-compatible behavior when `projectRoot` is omitted. Root selection is a CLI/session concern; schema enumeration should remain a pure operation over the root it receives.
### 2. Add the standard store option and rejection path
The Commander registration for `schemas` will add `--store <id>` using `COMMON_FLAGS.store` and the shared hidden `--store-path` option. `SchemasOptions` will carry `store` and `storePath`, and command-completion metadata will add the same common store flag. Because the repository enforces that every command exposing `--store` is named by the shared store-selection guidance, that shared command list, committed generated skill snapshots, and generated-content parity hashes will be updated to include `schemas`. Formal CLI/JSON agent-contract references will be synchronized, and the existing `propose` compatibility flow will only lose its now-false assertion that `schemas` cannot accept the flag; its root-resolution sequence remains unchanged.
A raw `--root` or `--cwd` flag was rejected because it would bypass registry validation, store identity checks, canonicalization, and existing diagnostics. Asking an Agent to run `cd <root.path> && openspec schemas` was rejected because generated tool permissions and working-directory support differ across Agents.
### 3. Preserve canonical root precedence without a schemas-specific fallback
The command will use `resolveRootForCommand()` unchanged:
1. Explicit `--store`.
2. Nearest OpenSpec root, including resolution of a config-only `store:` pointer.
3. Global `defaultStore` when no nearer root exists.
4. An implicit current-directory root only when no root or registered-store selection is available.
Invalid pointers, stale defaults, unknown stores, and the presence of unselected registered stores remain fail-closed. Adding a schemas-only catch-and-fallback path was rejected because it would recreate the mismatch this change removes.
### 4. Preserve success output; use the existing JSON failure contract
Successful human output remains the current listing, and successful JSON remains the top-level schema array. No root metadata is added, avoiding a breaking output-shape change.
When root resolution fails under `--json`, the existing command adapter will emit one machine-readable failure document with an empty schema list, null root, and the shared status diagnostic. Human mode keeps the standard root banner and error/fix presentation used by other commands.
### 5. Test the user-visible command, not an implementation mock
A focused CLI suite will construct real temporary roots and registered stores with distinct valid project-local schemas. It will exercise explicit store selection, local pointers, global defaults, nearest-root precedence, rootless compatibility, fail-closed errors, paths with spaces, and the hidden removed option. Completion metadata gets a focused registry assertion.
The tests will use Node path utilities and canonical fixture helpers, following `test/AGENTS.md`; no path identity assertion will compare non-canonical spellings.
## Risks / Trade-offs
- **Users with registered stores but no selected root can no longer use `schemas` as an unscoped built-in-only listing.** → Return the same actionable selection diagnostic as other root-scoped commands; selecting a store or entering a root makes the result authoritative.
- **Adding root resolution introduces new JSON failure paths.** → Assert one-document failure output and non-zero exit behavior explicitly.
- **Store roots containing spaces or platform-specific separators could expose path assumptions.** → Resolve paths internally and add a real CLI fixture with a spaced store path; never compose a shell command.
- **The feature PR still needs to integrate its schema-selection flow with explicit store choice.** → This fix synchronizes shared guidance and the existing `propose` compatibility wording, but leaves feature-specific selection/confirmation behavior to that branch after this independent CLI fix merges.
## Migration Plan
1. Ship the root-aware `schemas` command and `--store` option.
2. Update dependent feature-specific schema-selection guidance in its own branch; the shared store-capable command list and existing `propose` compatibility wording already support `schemas --store` after this fix.
3. Existing successful unscoped output remains compatible; scripts targeting a registered store should add `--store <id>`.
4. Rollback removes the option and returns `schemasCommand()` to `process.cwd()` without changing schema files or registered-store state.
@@ -0,0 +1,29 @@
## Why
`openspec schemas` discovers project-local schemas from the shell's current directory, while commands that consume a schema resolve an authoritative OpenSpec root first. When an explicit store, a local `store:` pointer, or `defaultStore` selects a different root, discovery can recommend a schema that is unavailable where the change will actually be created. Agents currently have to work around this mismatch by resolving a path and trying to change their shell working directory, which is not reliable across supported tools.
## What Changes
- Make `openspec schemas` resolve its project root through the same root-selection contract used by normal OpenSpec commands before listing schemas.
- Add `--store <id>` to `openspec schemas`, including the standard hidden `--store-path` rejection path, so explicit store selection is carried directly by the CLI.
- Honor nearest roots, local `store:` pointers, and global `defaultStore` using existing precedence and diagnostics; do not add a parallel schema-specific root resolver.
- Preserve the successful human and JSON schema-list output shapes and the existing rootless fallback when no root or registered store exists.
- Add CLI regression coverage for explicit stores, declared pointers, global defaults, nearest-root precedence, error handling, and completion metadata.
- Update the shared store-capable command guidance, committed generated skill snapshots, and generated-content parity hashes to name `schemas`, plus the formal CLI and JSON agent-contract references; preserve the existing `propose` compatibility flow while removing its now-false claim that `schemas` cannot accept `--store`.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `schema-resolution`: `openspec schemas` resolves and lists schemas from the authoritative OpenSpec root, including explicitly selected and configured stores.
## Impact
- Affected CLI surface: `openspec schemas [--json] [--store <id>]`.
- Affected code: workflow schemas command, CLI option registration, command completion metadata, the shared store-capable command list, and directly affected command-contract documentation.
- Affected tests: a focused schemas command suite plus CLI/completion regression coverage.
- No schema format, selection policy, workflow-specific flow, or change-creation behavior is modified; the only workflow-specific wording change corrects the stale claim that `schemas` cannot accept `--store`.
@@ -0,0 +1,80 @@
## ADDED Requirements
### Requirement: Schemas command SHALL honor authoritative root selection
`openspec schemas` SHALL resolve the authoritative OpenSpec root with the same precedence and diagnostics as other root-scoped commands, then list schemas using that root. The command SHALL accept `--store <id>` for explicit registered-store selection. Successful human output and successful `--json` output SHALL retain their existing formats.
#### Scenario: Nearest project root supplies schemas
- **GIVEN** the current directory is inside an OpenSpec root containing a project-local schema
- **WHEN** the user runs `openspec schemas --json`
- **THEN** the result SHALL include that root's project-local schema
#### Scenario: Explicit store overrides the current project
- **GIVEN** the current project and a registered store contain different project-local schemas
- **WHEN** the user runs `openspec schemas --json --store <id>`
- **THEN** the result SHALL include schemas from the selected store root
- **AND** it SHALL NOT include schemas that exist only in the current project
#### Scenario: Local store pointer supplies schemas
- **GIVEN** the nearest `openspec/config.yaml` is a config-only root declaring `store: <id>`
- **WHEN** the user runs `openspec schemas --json` without an explicit store flag
- **THEN** the result SHALL include schemas from the declared store root
#### Scenario: Global default store supplies schemas
- **GIVEN** no nearer OpenSpec root or pointer exists
- **AND** global configuration declares `defaultStore: <id>`
- **WHEN** the user runs `openspec schemas --json`
- **THEN** the result SHALL include schemas from the default store root
#### Scenario: Explicit store preserves root-selection precedence
- **GIVEN** a nearest project root, a global default store, and an explicitly selected registered store all exist
- **WHEN** the user runs `openspec schemas --json --store <id>`
- **THEN** the explicitly selected store SHALL supply the project-local schemas
#### Scenario: Nearest root precedes the global default
- **GIVEN** a nearest project root and a global default store contain different schemas
- **WHEN** the user runs `openspec schemas --json` without `--store`
- **THEN** the nearest project root SHALL supply the project-local schemas
#### Scenario: Rootless listing remains available without registered stores
- **GIVEN** no OpenSpec root, pointer, global default, or registered store exists
- **WHEN** the user runs `openspec schemas --json`
- **THEN** the command SHALL list user and package schemas using the current directory as its implicit root, as before
#### Scenario: Registered stores require an authoritative selection
- **GIVEN** no OpenSpec root, pointer, or global default exists
- **AND** one or more stores are registered
- **WHEN** the user runs `openspec schemas --json` without `--store`
- **THEN** the command SHALL fail with the standard root-selection diagnostic that asks the user to select a registered store
- **AND** it SHALL NOT silently list schemas from the current directory
#### Scenario: Invalid or unavailable store fails closed
- **WHEN** explicit, declared, or global-default store resolution fails
- **THEN** `openspec schemas` SHALL report the existing root-selection diagnostic and exit non-zero
- **AND** it SHALL NOT fall back to schemas from the current directory
#### Scenario: Removed store-path option is rejected deliberately
- **WHEN** the user runs `openspec schemas --store-path <path>`
- **THEN** the command SHALL reject the removed option with the standard instruction to register the store and use `--store <id>`
#### Scenario: Success output remains compatible
- **WHEN** root resolution succeeds
- **THEN** human output SHALL retain the existing schema listing and source labels
- **AND** `--json` output SHALL remain the existing top-level array of schema information
#### Scenario: Store path works across supported platforms
- **GIVEN** the selected store root uses a valid platform-native path, including a path containing spaces
- **WHEN** the user runs `openspec schemas --json --store <id>`
- **THEN** the command SHALL list schemas from that store without requiring the user or an Agent to compose a shell `cd` command
@@ -0,0 +1,22 @@
## 1. Lock the root-selection regression with CLI tests
- [x] 1.1 Add `test/commands/schemas.test.ts` with real temporary local and registered-store roots, valid distinct project schemas, isolated XDG data/config homes, canonical cleanup, and a store path containing spaces.
- [x] 1.2 Add failing cases proving `schemas --json --store <id>` returns the store-only schema rather than the cwd-only schema, and `schemas --store-path <path>` reaches the deliberate removed-option diagnostic.
- [x] 1.3 Add failing cases proving config-only `store:` and global `defaultStore` roots supply schemas without a flag, while a nearest real root wins over `defaultStore`.
- [x] 1.4 Add failing cases for rootless compatibility, unselected registered-store failure, invalid/unavailable store failure, one-document JSON diagnostics, and unchanged successful array output.
- [x] 1.5 Extend `test/core/completions/command-registry.test.ts` to require the common `store` flag on the `schemas` definition and require the shared store-selection guidance to name it.
- [x] 1.6 Run `pnpm exec vitest run test/commands/schemas.test.ts test/core/completions/command-registry.test.ts` and verify the new tests fail only because `schemas` lacks authoritative root selection and `--store` support.
## 2. Implement canonical schemas root selection
- [x] 2.1 Extend `SchemasOptions` in `src/commands/workflow/schemas.ts` with `store` and `storePath`, resolve through `resolveRootForCommand()`, return on a JSON resolution failure, and pass `root.path` to `listSchemasWithInfo()`.
- [x] 2.2 Update the `schemas` registration in `src/cli/index.ts` with `--store <id>`, the shared hidden `--store-path` option, and JSON-aware failure handling without changing successful output shapes.
- [x] 2.3 Add `COMMON_FLAGS.store` to the `schemas` entry in `src/core/completions/command-registry.ts`, add `schemas` to the shared store-capable command guidance, synchronize committed generated skill snapshots and the formal CLI/JSON agent-contract references, remove the stale `propose` claim without changing its compatibility flow, and refresh generated-content parity hashes.
- [x] 2.4 Run `pnpm run build`, then rerun `pnpm exec vitest run test/commands/schemas.test.ts test/core/completions/command-registry.test.ts` and verify all root, error, output-compatibility, and completion cases pass.
## 3. Regression and cross-platform verification
- [x] 3.1 Run `pnpm exec vitest run test/cli-e2e/basic.test.ts test/commands/context.test.ts test/commands/global-default-store.test.ts test/core/root-selection.test.ts test/core/artifact-graph/resolver.test.ts` to verify adjacent root and schema behavior.
- [x] 3.2 Run `pnpm run lint`, `pnpm run build`, and `pnpm test`; confirm no successful `schemas` output regression and no changes outside the scoped CLI, tests, generated guidance/documentation, and proposal files.
- [x] 3.3 Run `pnpm exec openspec validate fix-schemas-root-selection --strict` and `git diff --check`.
- [ ] 3.4 Verify the focused schemas suite on Windows CI, specifically the spaced native store path and absence of hard-coded path separators.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-07
@@ -0,0 +1,44 @@
# Suppress the first-run telemetry notice in --json mode
## Why
`openspec <cmd> --json` is meant to emit exactly one machine-readable JSON
document on stdout so agents and automation can parse it. Spinner suppression
and structured JSON errors already ship on main, but one stdout writer remains:
the first-run telemetry disclosure notice.
On a user's first-ever command, `maybeShowTelemetryNotice()` runs from the
global `preAction` hook and `console.log`s the disclosure to **stdout** — before
the command's JSON payload. A `--json` consumer parsing that first run gets
invalid JSON. It is first-run-only (the notice sets `noticeSeen`), but that is
exactly the run an automation is most likely to hit on a fresh machine or CI
image.
## What Changes
- `maybeShowTelemetryNotice()` accepts a `silent` option. When silent, it prints
nothing **and** leaves `noticeSeen` unset, so the disclosure is deferred rather
than skipped.
- The `preAction` hook passes `silent: true` when the executing command asked
for JSON, decided by `isJsonRun(command)`. `--json` reaches commands three
ways, so a single parsed option (`opts().json`) is not enough: on the leaf
(`status --json`), on a parent group read via `optsWithGlobals`
(`workset --json list`), and as a residual arg on permissive groups that never
declare the option (`openspec store --json`). `isJsonRun` checks
`optsWithGlobals().json` and `command.args`, covering all three.
Net effect: any `--json` invocation never emits the notice on stdout; the user
still sees the disclosure on their first later non-JSON run. Suppressing is
always safe — worst case the disclosure defers one run. Telemetry remains opt-out
and otherwise unchanged.
Out of scope: a few commands write scriptable output to stdout without a `--json`
flag (`completion generate`, `config get`, `config path`, the hidden `__complete`).
Their first-run notice pollution is a separate, pre-existing issue not addressed
here.
## Impact
- Affected specs: `telemetry` (MODIFIED: First-run telemetry notice)
- Affected code: `src/telemetry/index.ts`, `src/cli/index.ts`
- No change to non-JSON behavior; no new events or data collected.
@@ -0,0 +1,28 @@
## MODIFIED Requirements
### Requirement: First-run telemetry notice
The system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent. In `--json` mode the system SHALL NOT display the notice on that run and SHALL leave `noticeSeen` unset, deferring the disclosure to the first later non-JSON run.
#### Scenario: First command execution
- **WHEN** a user runs their first openspec command without `--json`
- **AND** telemetry is enabled
- **THEN** the system displays: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
#### Scenario: Subsequent command execution
- **WHEN** a user has already seen the notice (noticeSeen: true in config)
- **THEN** the system does not display the notice
#### Scenario: Notice before telemetry
- **WHEN** displaying the first-run notice
- **THEN** the notice appears before any telemetry event is sent
#### Scenario: First command execution in JSON mode
- **WHEN** a user's first openspec command passes `--json`
- **AND** telemetry is enabled
- **THEN** the system displays no notice on stdout
- **AND** `noticeSeen` remains unset
#### Scenario: Disclosure deferred, not skipped
- **WHEN** a user's first run was in `--json` mode and displayed no notice
- **AND** the user later runs a command without `--json`
- **THEN** the system displays the disclosure notice on that later run
@@ -0,0 +1,9 @@
# Tasks
## 1. Suppress notice in JSON mode
- [x] 1.1 Add a `silent` option to `maybeShowTelemetryNotice()` that skips the notice and leaves `noticeSeen` unset
- [x] 1.2 Read `actionCommand.opts().json` in the `preAction` hook and pass `silent` accordingly
## 2. Tests
- [x] 2.1 Assert a first-run `--json` (silent) call prints nothing and does not mark the notice seen
- [x] 2.2 Assert the disclosure still appears on the first later non-silent run
+102 -6
View File
@@ -27,6 +27,14 @@ The command SHALL support both interactive and direct change selection methods.
- **THEN** use that change directly
- **AND** validate it exists
#### Scenario: No change name and no answer available
- **WHEN** no change-name is provided and the selection prompt cannot be answered
- **THEN** report that a change name is required
- **AND** state that no answer could be read from stdin
- **AND** suggest a rerun naming the change and passing `--yes`
- **AND** exit with a non-zero status code rather than reporting success for a run that archived nothing
### Requirement: Task Completion Check
The command SHALL verify task completion status before archiving to prevent premature archival.
@@ -53,9 +61,12 @@ The archive operation SHALL follow a structured process to safely move changes t
- **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. Check if target directory already exists
4. Update main specs from the change's future state specs (see Spec Update Process below)
5. Move the entire change directory to the archive location
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
@@ -70,7 +81,7 @@ The archive operation SHALL follow a structured process to safely move changes t
### Requirement: Spec Update Process
Before moving the change to archive, the command SHALL apply delta changes to main specs to reflect the deployed reality.
After claiming the archive destination, the command SHALL apply delta changes to main specs to reflect the deployed reality, then move the change to its archive destination. It SHALL restore the spec transaction when a mutation or final move fails before a complete archive is secured. Once a verified fallback archive is complete, a staged-source cleanup failure SHALL retain that archive and committed spec state for recovery.
#### Scenario: Applying delta changes
@@ -90,6 +101,12 @@ Before moving the change to archive, the command SHALL apply delta changes to ma
- **THEN** abort with error message showing the conflict
- **AND** suggest manual resolution
#### Scenario: Duplicate requirement already exists in the main spec
- **WHEN** a main spec contains two canonical requirement headers with the same name
- **THEN** reject the structurally ambiguous main spec before applying any delta
- **AND** preserve the main spec and active change unchanged
#### Scenario: New main spec inherits the delta's Purpose
- **WHEN** a delta creates a main spec that does not exist yet
@@ -122,6 +139,70 @@ Before moving the change to archive, the command SHALL apply delta changes to ma
- **THEN** leave the existing Purpose untouched
- **AND** warn that the delta Purpose was ignored, naming the spec file to edit directly, but only when that spec has a Purpose of its own and it differs from the delta's
### Requirement: Capability Retirement
A delta whose REMOVED entries cover every requirement a capability has SHALL retire that capability instead of writing a main spec with no requirements, which can never pass validation.
#### Scenario: Deciding that a rebuilt spec cannot be written
- **WHEN** applying a delta leaves the rebuilt spec with no requirement blocks, and every other nonblank line in the whole file is accounted for as the title, Purpose, Requirements header, or a canonical requirement's statement, scenarios, or fenced examples
- **THEN** put that rebuilt spec to the spec validator
- **AND** treat it as retirable only when its sole validation error is that the spec has no requirements
- **AND** otherwise write or reject it exactly as any other rebuilt spec, so a spec the validator still accepts, one broken in some further way, and one still holding a `###` heading are all left alone
#### Scenario: Validation was skipped
- **WHEN** the archive runs with validation disabled
- **THEN** retire nothing, because no verdict was produced to justify a deletion
- **AND** write the rebuilt spec exactly as an archive without this behavior would
#### Scenario: Retirement is not declared
- **WHEN** a rebuilt spec is retirable but the change does not declare `retire_capabilities: true` in its metadata, or declares it in metadata that cannot be honored
- **THEN** write the spec as any other, so the archive aborts on it exactly as it did before this behavior existed
- **AND** name the marker as the fix in that abort, and say when a marker that is present cannot be honored
- **AND** say nothing about the marker when retiring would not have made the spec writable anyway
#### Scenario: Delta removes the capability's last requirement
- **WHEN** a retirable rebuilt spec belongs to a capability whose main spec exists
- **AND** at least one requirement was actually removed by this run
- **AND** the change declares `retire_capabilities: true`
- **THEN** delete the capability's `spec.md` instead of writing it
- **AND** refuse to delete when the target resolves outside the real specs root
- **AND** delete any in-root directory the deletion leaves empty, and never the specs root itself
- **AND** count every operation the delta applied in the archive totals
- **AND** record the retirement in the archive warnings, naming what the deleted file held and giving a pasteable Git recovery command only when the spec lived in the caller's checkout
#### Scenario: Retirement is deferred until every spec is written
- **WHEN** an archive both retires one capability and updates another
- **THEN** settle the archive destination before touching any spec, so a name collision cannot strand a retirement
- **AND** perform the deletion only after every spec write has succeeded
- **AND** report a destination claimed while the merge ran as the same collision, rather than as a raw filesystem error
#### Scenario: Capability directory holds other files
- **WHEN** retiring a capability whose directory still holds other files after `spec.md` is deleted
- **THEN** leave that directory in place
#### Scenario: Removal was already synced
- **WHEN** a retirable rebuilt spec removed nothing this run and its main spec exists
- **THEN** leave the file untouched
- **AND** abort the archive with the validation error, as for any other unwritable spec, unless validation was skipped
#### Scenario: Content the merge cannot account for
- **WHEN** the spec holds any non-blank line the merge cannot name - anywhere in the file, including above the requirements section and inside a requirement block, where content the parser did not read as a new header rides along
- **THEN** refuse the retirement, because deleting the file would take that content with it
- **AND** say which lines stood in the way when the change declared the marker, rather than aborting on the bare validation error
#### Scenario: Main spec is already gone
- **WHEN** a REMOVED-only delta targets a capability that has no main spec, and the change declares `retire_capabilities: true`
- **THEN** complete the archive without creating or retiring one
### Requirement: Confirmation Behavior
The spec update confirmation SHALL provide clear visibility into changes before they are applied.
@@ -170,6 +251,21 @@ The command SHALL handle various error conditions gracefully.
- Change not found
- Archive target already exists
- File system permissions issues
- A confirmation prompt that cannot be answered because no answer can be read from stdin
#### Scenario: Confirmation cannot be answered
- **WHEN** a confirmation prompt fails because no answer can be read from stdin
- **THEN** report which decision needed an answer
- **AND** suggest a rerun that adds `--yes` and reproduces the flags the caller already passed
- **AND** make no filesystem change
- **AND** exit with a non-zero status code
#### Scenario: Cancellation is not treated as a missing answer
- **WHEN** the user cancels a prompt with Ctrl-C
- **THEN** treat it as a cancellation rather than an unanswerable prompt
- **AND** preserve the existing cancellation behavior
### Requirement: Skip Specs Option
@@ -246,6 +342,6 @@ The archive command SHALL validate changes before applying them to ensure data i
**Task checking**: Prevents accidental archiving of incomplete work
**Date prefixing**: Maintains chronological order and prevents naming conflicts; a name that already carries a date prefix keeps it, so archived names never stack dates
**No overwrite**: Preserves historical archives and prevents data loss
**Spec updates before archiving**: Specs in the main directory represent current reality; when a change is deployed and archived, its future state specs become the new reality and must replace the main specs
**Claim-first transaction**: The destination is claimed before main specs are mutated, spec changes are rollback-protected, and the active change is moved only after the spec transaction succeeds
**Confirmation for spec updates**: Provides visibility into what will change, prevents accidental overwrites, and ensures users understand the impact before specs are modified
**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use
**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use
+5 -2
View File
@@ -25,13 +25,16 @@ The system SHALL display artifact completion status for a change, including scaf
#### Scenario: Status JSON output
- **WHEN** user runs `openspec status --change <id> --json`
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
- **THEN** the system outputs JSON with changeName, schemaName, isPlanningComplete, isComplete, and artifacts array
- **AND** `isPlanningComplete` is true only when every non-skipped planning artifact exists
- **AND** a skipped artifact counts as satisfied without being created
- **AND** `isComplete` remains a compatibility alias with the same value
#### Scenario: Status JSON includes apply requirements
- **WHEN** user runs `openspec status --change <id> --json`
- **THEN** the system outputs JSON with:
- `changeName`, `schemaName`, `isComplete`, `artifacts` array
- `changeName`, `schemaName`, `isPlanningComplete`, `isComplete`, `artifacts` array
- `applyRequires`: array of artifact IDs needed for apply phase
#### Scenario: Status JSON exposes each artifact's dependency edges
+59 -3
View File
@@ -11,7 +11,7 @@ Validation output SHALL include specific guidance to fix each error, including e
- **WHEN** validating a change with zero parsed deltas
- **THEN** show error "No deltas found" with guidance:
- Explain that change specs must include `## ADDED Requirements`, `## MODIFIED Requirements`, `## REMOVED Requirements`, or `## RENAMED Requirements`
- Remind authors that files must live under `openspec/changes/{id}/specs/<capability>/spec.md`
- Remind authors that files must live under `openspec/changes/{id}/specs/<capability-path>/spec.md`
- Include an explicit note: "Spec delta files cannot start with titles before the operation headers"
- Suggest running `openspec change show {id} --json --deltas-only` for debugging
@@ -43,6 +43,34 @@ The validator SHALL recognize bulleted lines that look like scenarios (e.g., lin
- **AND** ...
```
### Requirement: Normative keyword guidance SHALL not require English
The validation report SHALL include a warning for a non-empty requirement body without the literal English keywords `SHALL` or `MUST`. Normal validation SHALL remain valid when that warning is the only issue, while strict validation SHALL remain invalid because strict mode treats warnings as failures.
A requirement with no body content before its scenarios SHALL remain an error.
#### Scenario: Non-English main spec
- **WHEN** a main spec has a non-empty requirement body written without the English keywords `SHALL` or `MUST`
- **THEN** the validation report includes an RFC 2119 guidance warning
- **AND** normal validation succeeds
#### Scenario: Non-English change delta
- **WHEN** an ADDED or MODIFIED requirement has a non-empty body written without the English keywords `SHALL` or `MUST`
- **THEN** the validation report includes an RFC 2119 guidance warning
- **AND** normal validation succeeds
#### Scenario: Strict validation preserves keyword enforcement
- **WHEN** the same main spec or change is validated in strict mode
- **THEN** the warning causes validation to fail
#### Scenario: Requirement body is missing
- **WHEN** a requirement has no body content before its scenarios
- **THEN** validation reports an error
### Requirement: All issues SHALL include file paths and structured locations
Error, warning, and info messages SHALL include:
- Source file path (`openspec/changes/{id}/proposal.md`, `.../specs/{cap}/spec.md`)
@@ -62,6 +90,35 @@ The CLI SHALL append a Next steps footer when the item is invalid and not using
- **WHEN** a change validation fails
- **THEN** print "Next steps" with 2-3 targeted bullets and suggest `openspec change show <id> --json --deltas-only`
### Requirement: Change validation SHALL report scenarios a MODIFIED block would drop
The `validate` command SHALL compare every `MODIFIED` requirement in a change against the main specs and report, as an error naming the delta file, each scenario the main spec still has that the `MODIFIED` block omits. A `MODIFIED` requirement replaces the whole requirement block, so archive refuses to apply one that drops a scenario; this is the same check, run without writing anything.
The comparison SHALL match archive's operation order, comparing a `MODIFIED` that names the new header of a rename against the renamed requirement's scenarios.
The check SHALL be silent when the main spec file or the requirement header is absent, because a `MODIFIED` written against a base that has not landed yet is a separate condition that archive gates. A main spec that exists but cannot be read SHALL be reported instead, since archive fails on it too.
Validation run inside `openspec archive` SHALL NOT report these issues, because archive enforces the same check when it applies the deltas.
#### Scenario: MODIFIED omits an existing scenario
- **GIVEN** the main spec's requirement has scenarios "A" and "B"
- **WHEN** a change MODIFIES that requirement with only scenario "A" and `openspec validate <change>` runs
- **THEN** report an error naming the delta file and scenario "B"
- **AND** exit with code 1
#### Scenario: MODIFIED names the new header of a rename
- **GIVEN** the main spec has requirement "A" with scenarios "S1" and "S2"
- **WHEN** a change renames "A" to "B" and MODIFIES "B" with only scenario "S1"
- **THEN** report an error naming scenario "S2"
#### Scenario: MODIFIED header is not in the main spec
- **GIVEN** a change MODIFIES a requirement header the main spec does not contain
- **WHEN** `openspec validate <change>` runs
- **THEN** do not report a dropped-scenario error for that requirement
### Requirement: Top-level validate command
The CLI SHALL provide a top-level `validate` command for validating changes and specs with flexible selection options.
@@ -106,7 +163,7 @@ The validate command SHALL support flags for bulk validation (--all) and filtere
- **AND** exclude the `openspec/changes/archive/` directory
- **WHEN** validating with `--specs`
- **THEN** include all specs that have a `spec.md` under `openspec/specs/<id>/spec.md`
- **THEN** include all specs that have a `spec.md` under `openspec/specs/<capability-path>/spec.md`
#### Scenario: Validate all changes
@@ -216,4 +273,3 @@ The markdown parser SHALL correctly identify sections regardless of line ending
- **AND** the document contains `## Why` and `## What Changes`
- **WHEN** running `openspec validate <change-id>`
- **THEN** validation SHALL recognize the sections and NOT raise parsing errors
+3 -3
View File
@@ -47,7 +47,7 @@ openspec/
├── project.md # Project-specific context
├── AGENTS.md # AI assistant instructions
├── specs/ # Current deployed capabilities
│ └── [capability]/ # Single, focused capability
│ └── <capability-path>/ # One or more directories for a focused capability
│ ├── spec.md # WHAT and WHY
│ └── design.md # HOW (optional, for established patterns)
└── changes/ # Proposed changes
@@ -56,7 +56,7 @@ openspec/
│ ├── tasks.md # Implementation checklist
│ ├── design.md # Technical decisions (optional)
│ └── specs/ # Complete future state
│ └── [capability]/
│ └── <capability-path>/
│ └── spec.md # Clean markdown (no diff syntax)
└── archive/ # Completed changes
└── YYYY-MM-DD-[name]/
@@ -224,7 +224,7 @@ The system SHALL support multiple methods for reviewing proposed changes.
- **WHEN** reviewing proposed changes
- **THEN** reviewers can compare using:
- GitHub PR diff view when changes are committed
- Command line: `diff -u specs/[capability]/spec.md changes/[name]/specs/[capability]/spec.md`
- Command line: `diff -u "specs/<capability-path>/spec.md" "changes/<name>/specs/<capability-path>/spec.md"`
- Any visual diff tool comparing current vs future state
### Requirement: Structured Format Adoption
@@ -78,6 +78,7 @@ The skill SHALL prompt to sync delta specs before archiving if specs exist.
- **AND** if user cancels, stop without archiving
- **AND** if user confirms, execute `/opsx:sync` logic inline and wait for it to complete
- **AND** verify every capability that has a delta spec, not only those the sync reports it touched: ADDED requirements present, MODIFIED requirements carrying the changes named in the delta, REMOVED requirements absent, RENAMED requirements present under the new name and absent under the old one
- **AND** treat a capability whose last requirement the sync removed as verified when its main spec was deleted rather than left empty, and a spec the sync deliberately kept and reported as verified too
- **AND** stop without archiving if the sync fails or any capability does not verify
- **AND** archive only after verification passes, or when the user explicitly chose to archive without syncing or to archive already-synced specs
+17 -1
View File
@@ -48,6 +48,22 @@ The agent SHALL reconcile main specs with delta specs using the delta operation
- **AND** the requirement exists in main spec
- **THEN** remove the requirement from main spec
#### Scenario: REMOVED requirements retire the capability
- **WHEN** removing the requirements named in the delta leaves no requirement blocks
- **AND** every other nonblank line in the whole file is accounted for as the title, Purpose, Requirements header, or a canonical requirement's statement, scenarios, or fenced examples
- **AND** the rest of the spec is well-formed and it was not already empty before this sync
- **AND** the change declares `retire_capabilities: true` in its metadata
- **AND** the `spec.md` resolves inside the real specs root
- **THEN** delete that capability's `spec.md`, and its directory once nothing else remains in it
- **AND** report the retirement and name the deleted `## Purpose`
- **AND** leave the file in place and say the marker is missing when it is not declared
#### Scenario: Something is left in the spec
- **WHEN** any of those conditions fails - unaccounted content remains anywhere in the file, the spec is malformed, or nothing was removed this run
- **THEN** do not modify the main spec and stop the sync for that capability
- **AND** report the blocking condition and how the user can resolve it
- **AND** never write or leave an empty `## Requirements` section
#### Scenario: RENAMED requirements
- **WHEN** delta contains `## RENAMED Requirements` with FROM:/TO: format
- **AND** the FROM requirement exists in main spec
@@ -55,7 +71,7 @@ The agent SHALL reconcile main specs with delta specs using the delta operation
#### Scenario: New capability spec
- **WHEN** delta spec exists for a capability not in main specs
- **THEN** create new main spec file at `openspec/specs/<capability>/spec.md`
- **THEN** create new main spec file at `openspec/specs/<capability-path>/spec.md`, preserving the delta's path relative to `specs/`
- **AND** copy the delta's `## Purpose` body into it when the delta has one, matching what `openspec archive` does
- **AND** write a brief TBD placeholder Purpose only when the delta has none
+9 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "1.7.0",
"version": "1.9.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
@@ -85,8 +85,15 @@
"zod": "^4.4.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
],
"overrides": {
"brace-expansion@<=5.0.7": ">=5.0.8"
"brace-expansion@<=5.0.8": ">=5.0.9 <6",
"postcss@<8.5.23": ">=8.5.23 <9",
"js-yaml@>=3.0.0 <3.15.1": ">=3.15.1 <4",
"js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5",
"nanoid@<3.3.17": ">=3.3.17 <4"
}
}
}
+144 -142
View File
@@ -5,7 +5,11 @@ settings:
excludeLinksFromLockfile: false
overrides:
brace-expansion@<=5.0.7: '>=5.0.8'
brace-expansion@<=5.0.8: '>=5.0.9 <6'
postcss@<8.5.23: '>=8.5.23 <9'
js-yaml@>=3.0.0 <3.15.1: '>=3.15.1 <4'
js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5'
nanoid@<3.3.17: '>=3.3.17 <4'
importers:
@@ -53,7 +57,7 @@ importers:
version: 3.2.6(vitest@3.2.6)
eslint:
specifier: ^10.5.0
version: 10.7.0
version: 10.8.1
smol-toml:
specifier: ^1.7.1
version: 1.7.1
@@ -62,7 +66,7 @@ importers:
version: 6.0.3
typescript-eslint:
specifier: ^8.65.0
version: 8.65.0(eslint@10.7.0)(typescript@6.0.3)
version: 8.66.0(eslint@10.8.1)(typescript@6.0.3)
vitest:
specifier: ^3.2.6
version: 3.2.6(@types/node@20.19.43)(@vitest/ui@3.2.6)(yaml@2.9.0)
@@ -296,12 +300,6 @@ packages:
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/regexpp@4.12.2':
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
@@ -310,8 +308,8 @@ packages:
resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/config-helpers@0.6.0':
resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==}
'@eslint/config-helpers@0.7.0':
resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/core@1.2.1':
@@ -326,12 +324,16 @@ packages:
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
'@humanfs/node@0.16.7':
resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
'@humanfs/node@0.16.8':
resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
engines: {node: '>=18.18.0'}
'@humanfs/types@0.15.0':
resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
engines: {node: '>=18.18.0'}
'@humanwhocodes/module-importer@1.0.1':
@@ -634,9 +636,6 @@ packages:
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -649,63 +648,63 @@ packages:
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
'@typescript-eslint/eslint-plugin@8.65.0':
resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==}
'@typescript-eslint/eslint-plugin@8.66.0':
resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.65.0
'@typescript-eslint/parser': ^8.66.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/parser@8.65.0':
resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==}
'@typescript-eslint/parser@8.66.0':
resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/project-service@8.65.0':
resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==}
'@typescript-eslint/project-service@8.66.0':
resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/scope-manager@8.65.0':
resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==}
'@typescript-eslint/scope-manager@8.66.0':
resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.65.0':
resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==}
'@typescript-eslint/tsconfig-utils@8.66.0':
resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/type-utils@8.65.0':
resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==}
'@typescript-eslint/type-utils@8.66.0':
resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/types@8.65.0':
resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==}
'@typescript-eslint/types@8.66.0':
resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.65.0':
resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==}
'@typescript-eslint/typescript-estree@8.66.0':
resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/utils@8.65.0':
resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==}
'@typescript-eslint/utils@8.66.0':
resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/visitor-keys@8.65.0':
resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==}
'@typescript-eslint/visitor-keys@8.66.0':
resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitest/expect@3.2.6':
@@ -747,8 +746,8 @@ packages:
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn@8.17.0:
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
acorn@8.18.0:
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
engines: {node: '>=0.4.0'}
hasBin: true
@@ -793,8 +792,8 @@ packages:
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
engines: {node: '>=4'}
brace-expansion@5.0.8:
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
braces@3.0.3:
@@ -918,8 +917,8 @@ packages:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@10.7.0:
resolution: {integrity: sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==}
eslint@10.8.1:
resolution: {integrity: sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
hasBin: true
peerDependencies:
@@ -1014,6 +1013,9 @@ packages:
flatted@3.4.3:
resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==}
flatted@3.4.4:
resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
fs-extra@7.0.1:
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
engines: {node: '>=6 <7 || >=8'}
@@ -1104,12 +1106,12 @@ packages:
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@3.15.0:
resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
js-yaml@3.15.1:
resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==}
hasBin: true
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
json-buffer@3.0.1:
@@ -1164,8 +1166,8 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
minimatch@10.2.6:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
mri@1.2.0:
@@ -1183,8 +1185,8 @@ packages:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -1284,8 +1286,8 @@ packages:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
postcss@8.5.22:
resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==}
postcss@8.5.25:
resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1:
@@ -1465,8 +1467,8 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
typescript-eslint@8.65.0:
resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==}
typescript-eslint@8.66.0:
resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -1731,7 +1733,7 @@ snapshots:
'@changesets/parse@0.4.3':
dependencies:
'@changesets/types': 6.1.0
js-yaml: 4.3.0
js-yaml: 4.3.1
'@changesets/pre@2.0.2':
dependencies:
@@ -1844,14 +1846,9 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
'@eslint-community/eslint-utils@4.10.1(eslint@10.7.0)':
'@eslint-community/eslint-utils@4.10.1(eslint@10.8.1)':
dependencies:
eslint: 10.7.0
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@10.7.0)':
dependencies:
eslint: 10.7.0
eslint: 10.8.1
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
@@ -1860,11 +1857,11 @@ snapshots:
dependencies:
'@eslint/object-schema': 3.0.5
debug: 4.4.3
minimatch: 10.2.5
minimatch: 10.2.6
transitivePeerDependencies:
- supports-color
'@eslint/config-helpers@0.6.0':
'@eslint/config-helpers@0.7.0':
dependencies:
'@eslint/core': 1.2.1
@@ -1879,13 +1876,18 @@ snapshots:
'@eslint/core': 1.2.1
levn: 0.4.1
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/core': 0.19.1
'@humanfs/types': 0.15.0
'@humanfs/node@0.16.8':
dependencies:
'@humanfs/core': 0.19.2
'@humanfs/types': 0.15.0
'@humanwhocodes/retry': 0.4.3
'@humanfs/types@0.15.0': {}
'@humanwhocodes/module-importer@1.0.1': {}
'@humanwhocodes/retry@0.4.3': {}
@@ -2130,8 +2132,6 @@ snapshots:
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.8': {}
'@types/estree@1.0.9': {}
'@types/json-schema@7.0.15': {}
@@ -2142,15 +2142,15 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0)(typescript@6.0.3))(eslint@10.7.0)(typescript@6.0.3)':
'@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1)(typescript@6.0.3))(eslint@10.8.1)(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.65.0(eslint@10.7.0)(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.65.0
'@typescript-eslint/type-utils': 8.65.0(eslint@10.7.0)(typescript@6.0.3)
'@typescript-eslint/utils': 8.65.0(eslint@10.7.0)(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.65.0
eslint: 10.7.0
'@typescript-eslint/parser': 8.66.0(eslint@10.8.1)(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.66.0
'@typescript-eslint/type-utils': 8.66.0(eslint@10.8.1)(typescript@6.0.3)
'@typescript-eslint/utils': 8.66.0(eslint@10.8.1)(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.66.0
eslint: 10.8.1
ignore: 7.0.6
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@6.0.3)
@@ -2158,58 +2158,58 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.65.0(eslint@10.7.0)(typescript@6.0.3)':
'@typescript-eslint/parser@8.66.0(eslint@10.8.1)(typescript@6.0.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.65.0
'@typescript-eslint/types': 8.65.0
'@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.65.0
'@typescript-eslint/scope-manager': 8.66.0
'@typescript-eslint/types': 8.66.0
'@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.66.0
debug: 4.4.3
eslint: 10.7.0
eslint: 10.8.1
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.65.0(typescript@6.0.3)':
'@typescript-eslint/project-service@8.66.0(typescript@6.0.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3)
'@typescript-eslint/types': 8.65.0
'@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3)
'@typescript-eslint/types': 8.66.0
debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.65.0':
'@typescript-eslint/scope-manager@8.66.0':
dependencies:
'@typescript-eslint/types': 8.65.0
'@typescript-eslint/visitor-keys': 8.65.0
'@typescript-eslint/types': 8.66.0
'@typescript-eslint/visitor-keys': 8.66.0
'@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)':
'@typescript-eslint/tsconfig-utils@8.66.0(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
'@typescript-eslint/type-utils@8.65.0(eslint@10.7.0)(typescript@6.0.3)':
'@typescript-eslint/type-utils@8.66.0(eslint@10.8.1)(typescript@6.0.3)':
dependencies:
'@typescript-eslint/types': 8.65.0
'@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3)
'@typescript-eslint/utils': 8.65.0(eslint@10.7.0)(typescript@6.0.3)
'@typescript-eslint/types': 8.66.0
'@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3)
'@typescript-eslint/utils': 8.66.0(eslint@10.8.1)(typescript@6.0.3)
debug: 4.4.3
eslint: 10.7.0
eslint: 10.8.1
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.65.0': {}
'@typescript-eslint/types@8.66.0': {}
'@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3)':
'@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3)':
dependencies:
'@typescript-eslint/project-service': 8.65.0(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3)
'@typescript-eslint/types': 8.65.0
'@typescript-eslint/visitor-keys': 8.65.0
'@typescript-eslint/project-service': 8.66.0(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3)
'@typescript-eslint/types': 8.66.0
'@typescript-eslint/visitor-keys': 8.66.0
debug: 4.4.3
minimatch: 10.2.5
minimatch: 10.2.6
semver: 7.8.5
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@6.0.3)
@@ -2217,20 +2217,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.65.0(eslint@10.7.0)(typescript@6.0.3)':
'@typescript-eslint/utils@8.66.0(eslint@10.8.1)(typescript@6.0.3)':
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@10.7.0)
'@typescript-eslint/scope-manager': 8.65.0
'@typescript-eslint/types': 8.65.0
'@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3)
eslint: 10.7.0
'@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1)
'@typescript-eslint/scope-manager': 8.66.0
'@typescript-eslint/types': 8.66.0
'@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3)
eslint: 10.8.1
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.65.0':
'@typescript-eslint/visitor-keys@8.66.0':
dependencies:
'@typescript-eslint/types': 8.65.0
'@typescript-eslint/types': 8.66.0
eslint-visitor-keys: 5.0.1
'@vitest/expect@3.2.6':
@@ -2286,11 +2286,11 @@ snapshots:
loupe: 3.2.0
tinyrainbow: 2.0.0
acorn-jsx@5.3.2(acorn@8.17.0):
acorn-jsx@5.3.2(acorn@8.18.0):
dependencies:
acorn: 8.17.0
acorn: 8.18.0
acorn@8.17.0: {}
acorn@8.18.0: {}
ajv@6.15.0:
dependencies:
@@ -2325,7 +2325,7 @@ snapshots:
dependencies:
is-windows: 1.0.2
brace-expansion@5.0.8:
brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -2444,15 +2444,15 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
eslint@10.7.0:
eslint@10.8.1:
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0)
'@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1)
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.5
'@eslint/config-helpers': 0.6.0
'@eslint/config-helpers': 0.7.0
'@eslint/core': 1.2.1
'@eslint/plugin-kit': 0.7.2
'@humanfs/node': 0.16.7
'@humanfs/node': 0.16.8
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.9
@@ -2473,7 +2473,7 @@ snapshots:
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
minimatch: 10.2.5
minimatch: 10.2.6
natural-compare: 1.4.0
optionator: 0.9.4
transitivePeerDependencies:
@@ -2481,8 +2481,8 @@ snapshots:
espree@11.2.0:
dependencies:
acorn: 8.17.0
acorn-jsx: 5.3.2(acorn@8.17.0)
acorn: 8.18.0
acorn-jsx: 5.3.2(acorn@8.18.0)
eslint-visitor-keys: 5.0.1
esprima@4.0.1: {}
@@ -2499,7 +2499,7 @@ snapshots:
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
esutils@2.0.3: {}
@@ -2555,11 +2555,13 @@ snapshots:
flat-cache@4.0.1:
dependencies:
flatted: 3.4.3
flatted: 3.4.4
keyv: 4.5.4
flatted@3.4.3: {}
flatted@3.4.4: {}
fs-extra@7.0.1:
dependencies:
graceful-fs: 4.2.11
@@ -2632,12 +2634,12 @@ snapshots:
js-tokens@9.0.1: {}
js-yaml@3.15.0:
js-yaml@3.15.1:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
js-yaml@4.3.0:
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
@@ -2690,9 +2692,9 @@ snapshots:
mimic-function@5.0.1: {}
minimatch@10.2.5:
minimatch@10.2.6:
dependencies:
brace-expansion: 5.0.8
brace-expansion: 5.0.9
mri@1.2.0: {}
@@ -2702,7 +2704,7 @@ snapshots:
mute-stream@2.0.0: {}
nanoid@3.3.16: {}
nanoid@3.3.18: {}
natural-compare@1.4.0: {}
@@ -2784,9 +2786,9 @@ snapshots:
pify@4.0.1: {}
postcss@8.5.22:
postcss@8.5.25:
dependencies:
nanoid: 3.3.16
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -2803,7 +2805,7 @@ snapshots:
read-yaml-file@1.1.0:
dependencies:
graceful-fs: 4.2.11
js-yaml: 3.15.0
js-yaml: 3.15.1
pify: 4.0.1
strip-bom: 3.0.0
@@ -2955,13 +2957,13 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
typescript-eslint@8.65.0(eslint@10.7.0)(typescript@6.0.3):
typescript-eslint@8.66.0(eslint@10.8.1)(typescript@6.0.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0)(typescript@6.0.3))(eslint@10.7.0)(typescript@6.0.3)
'@typescript-eslint/parser': 8.65.0(eslint@10.7.0)(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3)
'@typescript-eslint/utils': 8.65.0(eslint@10.7.0)(typescript@6.0.3)
eslint: 10.7.0
'@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1)(typescript@6.0.3))(eslint@10.8.1)(typescript@6.0.3)
'@typescript-eslint/parser': 8.66.0(eslint@10.8.1)(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3)
'@typescript-eslint/utils': 8.66.0(eslint@10.8.1)(typescript@6.0.3)
eslint: 10.8.1
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -2979,7 +2981,7 @@ snapshots:
vite-node@3.2.4(@types/node@20.19.43)(yaml@2.9.0):
dependencies:
cac: 6.7.14
debug: 4.4.1
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.6(@types/node@20.19.43)(yaml@2.9.0)
@@ -3002,7 +3004,7 @@ snapshots:
esbuild: 0.28.1
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.22
postcss: 8.5.25
rollup: 4.62.2
tinyglobby: 0.2.15
optionalDependencies:
+19
View File
@@ -0,0 +1,19 @@
packages:
- '.'
allowBuilds:
esbuild@0.28.1: true
overrides:
brace-expansion@<=5.0.8: '>=5.0.9 <6'
postcss@<8.5.23: '>=8.5.23 <9'
# GHSA-5p4m-2wfm-xmqj — js-yaml quadratic-CPU !!omap DoS. Dev-only (pulled by
# @changesets: read-yaml-file for 3.x, @changesets/parse for 4.x); never in the
# published CLI. Remove once changesets' transitive js-yaml is >=3.15.1 / >=4.3.1
# (check: pnpm why js-yaml).
js-yaml@>=3.0.0 <3.15.1: '>=3.15.1 <4'
js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5'
# GHSA-2v37-7h3g-55p8 / CVE-2026-67213 — nanoid infinite loop on size=0. Dev-only
# (transitive via postcss). Remove once transitive nanoid is >=3.3.17
# (check: pnpm why nanoid).
nanoid@<3.3.17: '>=3.3.17 <4'
+8 -6
View File
@@ -13,8 +13,8 @@ artifacts:
- **Why**: 1-2 sentences on the problem or opportunity. What problem does this solve? Why now?
- **What Changes**: Bullet list of changes. Be specific about new capabilities, modifications, or removals. Mark breaking changes with **BREAKING**.
- **Capabilities**: Identify which specs will be created or modified:
- **New Capabilities**: List capabilities being introduced. Each becomes a new `specs/<name>/spec.md`. Use kebab-case names (e.g., `user-auth`, `data-export`).
- **Modified Capabilities**: List existing capabilities whose REQUIREMENTS are changing. Only include if spec-level behavior changes (not just implementation details). Each needs a delta spec file. Check `openspec/specs/` for existing spec names. Leave empty if no requirement changes.
- **New Capabilities**: List capabilities being introduced. Each becomes a new `specs/<capability-path>/spec.md`. Use kebab-case for path segments you introduce (e.g., `user-auth` or `identity/user-auth`) and follow the project's existing spec organization.
- **Modified Capabilities**: List existing capabilities whose REQUIREMENTS are changing. Only include if spec-level behavior changes (not just implementation details). Each needs a delta spec file. Use the exact existing path under `openspec/specs/`. Leave empty if no requirement changes.
- **Impact**: Affected code, APIs, dependencies, or systems.
IMPORTANT: The Capabilities section is critical. It creates the contract between
@@ -60,8 +60,10 @@ artifacts:
visible behavior, it likely does not belong in the spec.
Create one spec file per capability listed in the proposal's Capabilities section.
- New capabilities: use the exact kebab-case name from the proposal (specs/<capability>/spec.md).
- Modified capabilities: use the existing spec folder name from openspec/specs/<capability>/ when creating the delta spec at specs/<capability>/spec.md.
`<capability-path>` is the spec directory relative to `specs/` (for example,
`user-auth` or `identity/user-auth`). Preserve the full path:
- New capabilities: use the exact path from the proposal at `specs/<capability-path>/spec.md`. Any path segment newly introduced in the proposal must be kebab-case. Follow the project's existing organization; do not add a new domain level when the project uses a flat layout.
- Modified capabilities: use the exact existing path from `openspec/specs/<capability-path>/` when creating the delta at `specs/<capability-path>/spec.md`. Do not move or rename the capability.
There must be at least one spec file unless the change's `.openspec.yaml`
sets `skip_specs: true` (no spec-level behavior change) - `openspec validate`
@@ -89,10 +91,10 @@ artifacts:
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>/spec.md` directly.
edit `openspec/specs/<capability-path>/spec.md` directly.
MODIFIED requirements workflow:
1. Locate the existing requirement in openspec/specs/<capability>/spec.md
1. Locate the existing requirement in openspec/specs/<capability-path>/spec.md
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)
+6 -4
View File
@@ -9,18 +9,20 @@
## Capabilities
### New Capabilities
<!-- Capabilities being introduced. Replace <name> with kebab-case identifier (e.g., user-auth, data-export, api-rate-limiting). Each creates specs/<name>/spec.md -->
- `<name>`: <brief description of what this capability covers>
<!-- Capabilities being introduced. Use kebab-case for path segments you introduce
(e.g., user-auth or identity/user-auth) that follow the project's existing
spec organization. Each creates specs/<capability-path>/spec.md. -->
- `<capability-path>`: <brief description of what this capability covers>
### Modified Capabilities
<!-- Existing capabilities whose REQUIREMENTS are changing (not just implementation).
Only list here if spec-level behavior changes. Each needs a delta spec file.
Use existing spec names from openspec/specs/. Leave empty if no requirement
Use the exact existing path under openspec/specs/. Leave empty if no requirement
changes. A change with no capabilities at all (pure refactor, tooling, docs)
must set `skip_specs: true` in its .openspec.yaml - openspec validate rejects
a zero-delta change without that marker. Do not invent a requirement just to
satisfy validation. -->
- `<existing-name>`: <what requirement is changing>
- `<existing-capability-path>`: <what requirement is changing>
## Impact
+7 -4
View File
@@ -11,9 +11,9 @@ metadata:
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Input**: Optionally specify a change name (e.g., `/openspec-apply-change add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@@ -50,7 +50,7 @@ Implement tasks from an OpenSpec change.
- Optional `operationGuidance`: current advisory guidance for apply
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change (if it is not installed, run `openspec status --change "<name>" --json` to see the next artifact and `openspec instructions <artifact-id> --change "<name>" --json` for how to create it)
- If `state: "blocked"` (missing artifacts): show message, suggest using `/openspec-continue-change` (if it is not installed, run `openspec status --change "<name>" --json` to see the next artifact and `openspec instructions <artifact-id> --change "<name>" --json` for how to create it)
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
@@ -99,6 +99,7 @@ Implement tasks from an OpenSpec change.
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- A task needs work beyond what the spec and tasks describe, or you are tempted to drop, narrow, defer, or accept exceptions to specified behavior to make it fit → surface the added scope and ask; do not absorb it silently
- Error or blocker encountered → report and wait for guidance
- User interrupts
@@ -138,7 +139,7 @@ Working on task 4/7: <task description>
- [x] Task 2
...
All tasks complete! Ready to archive this change.
All tasks complete! You can archive this change with `/openspec-archive-change`.
```
**Output On Pause (Issue Encountered)**
@@ -169,6 +170,8 @@ What would you like to do?
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- When a task needs work beyond what the spec describes, surface the added scope and pause - never silently narrow, defer, or simplify away specified behavior
- Only mark a task `- [x]` when its specified behavior is fully implemented, not when it is partially done or deferred
- Use contextFiles from CLI output, don't assume specific file names
- Do not use context or operation guidance as proof that a task is complete
- Apply relevant project context; report conflicts with controlling workflow inputs
+5 -3
View File
@@ -11,7 +11,9 @@ metadata:
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
@@ -91,7 +93,7 @@ Archive a completed change in the experimental workflow.
delta specs from other artifacts.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `<planningHome.root>/openspec/specs/<capability>/spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path)
- Compare each delta spec with its corresponding main spec at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path)
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
@@ -119,7 +121,7 @@ Archive a completed change in the experimental workflow.
Then re-run the comparison from the top of this step against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
- ADDED requirements present
- MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
- REMOVED requirements gone
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
- RENAMED requirements present under the new name and absent under the old one
If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and `changeRoot` is intact, so the user can fix the mismatch or re-run the sync and start the archive again.
+16 -14
View File
@@ -13,7 +13,9 @@ Archive multiple completed changes in a single operation.
This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec.
**Input**: None required (prompts for selection)
@@ -81,14 +83,14 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
batches where some schemas have no `specs` artifact.
4. **Detect spec conflicts**
Build a map of `capability -> [changes that touch it]`:
Build a map keyed by `<capability-path>`, the exact path relative to `specs/`:
```text
auth -> [change-a, change-b] <- CONFLICT (2+ changes)
api -> [change-c] <- OK (only 1 change)
identity/user-auth -> [change-a, change-b] <- CONFLICT (2+ changes)
billing/user-auth -> [change-c] <- OK (different full path)
```
A conflict exists when 2+ selected changes have delta specs for the same capability.
A conflict exists when 2+ selected changes have delta specs for the exact same `<capability-path>`.
5. **Resolve conflicts agentically**
@@ -106,7 +108,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
- If neither implemented -> skip spec sync, warn user
d. **Record resolution** for each conflict:
- An inclusion or exclusion decision for every delta spec, keyed by change and capability
- An inclusion or exclusion decision for every delta spec, keyed by change and `<capability-path>`
- Which included delta specs to apply and in what order
- Which delta specs to exclude from sync because their implementation is missing
- Rationale (what was found in codebase)
@@ -120,14 +122,14 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
|---------------------|-----------|-------|---------|-----------|--------|
| schema-management | Done | 5/5 | 2 delta | None | Ready |
| project-config | Done | 3/3 | 1 delta | None | Ready |
| add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* |
| add-oauth | Done | 4/4 | 1 delta | identity/user-auth (!) | Ready* |
| add-verify-skill | 1 left | 2/5 | None | None | Warn |
```
For conflicts, show the resolution:
```text
* Conflict resolution:
- auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)
- identity/user-auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)
```
For incomplete changes, show warnings:
@@ -186,11 +188,11 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
- If a change has no included delta specs, do not run the sync workflow for it.
b. **Verify included delta specs before moving changeRoot**:
- Re-run the comparison only for delta specs in `includedDeltas` against main spec at `<planningHome.root>/openspec/specs/<capability>/spec.md` (use the store-aware `planningHome.root` from step 3 status JSON, not a hardcoded repo path).
- Re-run the comparison only for delta specs in `includedDeltas` against main spec at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (use the store-aware `planningHome.root` from step 3 status JSON, not a hardcoded repo path).
- Verify that main specs are updated:
- ADDED requirements present
- MODIFIED requirements carrying scenario and description changes named in the delta, with their other scenarios intact
- REMOVED requirements gone
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
- RENAMED requirements present under the new name and absent under the old one
- Do not verify delta specs in `excludedDeltas`; they are intentionally left unsynced.
- If sync failed or any capability does not match verification, report what differs and fail/skip moving that change's `changeRoot` — do not archive that change. `changeRoot` remains intact.
@@ -208,7 +210,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
- Success: archived successfully
- Failed: error during archive or spec verification (record error)
- Skipped: user chose not to archive (if applicable)
- Sync skipped: for every delta in `excludedDeltas`, report `sync skipped` with the change, capability, and recorded reason. This is distinct from skipping the archive.
- Sync skipped: for every delta in `excludedDeltas`, report `sync skipped` with the change, `<capability-path>`, and recorded reason. This is distinct from skipping the archive.
9. **Display summary**
@@ -227,8 +229,8 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
Spec sync summary:
- 4 delta specs synced to main specs
- 1 delta spec sync skipped (add-jwt/auth: implementation not found)
- 1 conflict resolved (auth: synced add-oauth, skipped add-jwt)
- 1 delta spec sync skipped (add-jwt, identity/user-auth: implementation not found)
- 1 conflict resolved (identity/user-auth: synced add-oauth, skipped add-jwt)
```
If any failures:
@@ -323,7 +325,7 @@ No active changes found. Create a new change to get started.
- If sync is requested, run the `openspec-sync-specs` workflow inline (agent-driven) for each change with included delta specs
- Carry the per-delta `includedDeltas` and `excludedDeltas` decisions into execution; sync and verify only included deltas
- Report every excluded delta as `sync skipped` without treating the archive itself as skipped
- 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>/spec.md` before moving `changeRoot`
- 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`
- Fetch archive inputs once per selected root before spec inspection or moves
- Fetch all required specs-rule snapshots before the batch's first main-spec write or move
- A failed archive-inputs lookup never blocks the batch; it proceeds with no context or guidance
+4 -4
View File
@@ -11,7 +11,7 @@ metadata:
Continue working on a change by creating the next artifact.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
@@ -41,17 +41,17 @@ Continue working on a change by creating the next artifact.
Parse the JSON to understand current state. The response includes:
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
- `artifacts`: Array of artifacts with their status ("done", "skipped", "ready", "blocked")
- `isComplete`: Boolean indicating if all artifacts are complete
- `isPlanningComplete`: Boolean indicating if all planning artifacts are complete. Older CLI versions expose the same value as `isComplete`.
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
3. **Act based on status**:
---
**If all artifacts are complete (`isComplete: true`)**:
**If all planning artifacts are complete (`isPlanningComplete: true`, or legacy `isComplete: true`)**:
- Congratulate the user
- Show final status including the schema used
- Suggest: "All artifacts created! You can now implement this change or archive it."
- Suggest: "Planning is complete! You can now implement this change. Once implementation and any tracked work are complete, archive it."
- STOP
---
+22 -10
View File
@@ -11,11 +11,11 @@ metadata:
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. For a new change, scaffold it first as described below.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
---
@@ -106,6 +106,15 @@ Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
If the user asks you to capture the exploration as a new change, transition seamlessly into the requested capture:
1. Run `openspec new change "<name>"` (with `--store <id>` when applicable) before creating any artifacts. Never create a new change directory under `openspec/changes/` by hand; the CLI scaffold creates required metadata such as `.openspec.yaml`. Keep the selected `--store <id>` on every applicable follow-up `status` and `instructions` command.
2. Run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is `ready`, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own `instruction` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run `openspec instructions "<prerequisite-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) for that prerequisite whether it is `ready` or `blocked`. If its own `instruction` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves.
3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to `resolvedOutputPath`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists.
4. After creating each artifact, re-run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) and continue until every requested artifact is `done`, `skipped`, or was deliberately skipped because its own `instruction` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still `blocked` only because you deliberately skipped a conditional prerequisite, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture.
Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status.
### When a change exists
If the user mentions a change or you detect one is relevant:
@@ -121,14 +130,16 @@ If the user mentions a change or you detect one is relevant:
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
| Insight Type | Where to Capture |
|----------------------------|-------------------------------------|
| New requirement discovered | `specs/<capability-path>/spec.md` |
| Requirement changed | `specs/<capability-path>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
@@ -290,6 +301,7 @@ But this summary is optional. Sometimes the thinking IS the value.
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Don't manually scaffold changes** - Never create a new change directory under `openspec/changes/` by hand. Always use `openspec new change "<name>"` (with `--store <id>` when applicable) so required metadata such as `.openspec.yaml` is created before writing artifacts.
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+1 -1
View File
@@ -11,7 +11,7 @@ metadata:
Fast-forward through artifact creation - generate everything needed to start implementation in one go.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
+1 -1
View File
@@ -11,7 +11,7 @@ metadata:
Start a new change using the experimental artifact-driven approach.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
+10 -4
View File
@@ -11,7 +11,7 @@ metadata:
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
---
@@ -210,6 +210,11 @@ I'll draft one based on our task.
**DO:** Draft the proposal content (don't save yet):
`<capability-path>` is the spec directory relative to `specs/` (for example,
`user-auth` or `identity/user-auth`). Use the exact existing path for modified
capabilities. For new capabilities, follow the project's established spec
organization.
```
Here's a draft proposal:
@@ -226,10 +231,11 @@ Here's a draft proposal:
## Capabilities
### New Capabilities
- `<capability-name>`: [brief description]
- `<capability-path>`: [brief description]
### Modified Capabilities
<!-- If modifying existing behavior -->
- `<existing-capability-path>`: [brief description]
## Impact
@@ -430,9 +436,9 @@ When a change is complete, we archive it. The archive path is derived from `plan
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
```
**DO:**
**DO:** Archive the change (`--yes` answers the confirmation prompts, which you cannot answer from a tool call):
```bash
openspec archive "<name>"
openspec archive "<name>" --yes
```
**SHOW:**
+37 -11
View File
@@ -11,38 +11,63 @@ metadata:
Propose a new change - create the change and generate all artifacts in one step.
**Planning boundary**: This workflow creates planning artifacts only. The user request that selected or triggered this workflow authorizes planning only, even if it asks to build or fix something. Do not edit project code. After the planning artifacts are complete, stop. Do not start implementation in the same response, even if the initial request asks for it. Wait for a new user request after the artifacts are presented; then start the apply workflow.
I'll create a change with the artifacts your schema defines. With the default spec-driven schema that is:
- proposal.md (what & why)
- `specs/<capability>/spec.md` (what the system must do - a delta, not the main spec)
- `specs/<capability-path>/spec.md` (what the system must do - a delta, not the main spec)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /openspec-apply-change
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
When the user is ready to implement, they must start the apply workflow explicitly.
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
1. **Understand the request and clarify material ambiguity**
Ask the user (open-ended, no preset options):
If no clear input is provided, ask the user (open-ended, no preset options):
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
If the request contains ambiguity that would materially affect scope, externally observable behavior, compatibility, or acceptance criteria, ask the user before creating the change. For minor details, make a reasonable assumption and record it in the planning artifacts.
2. **Determine the workflow schema**
Use the configured default schema unless the user explicitly requests a different workflow.
**Use a different schema only if the user:**
- Explicitly requests a specific schema by name → use `--schema <schema-name>`
- Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running `openspec context --json` from the current working directory. If the user explicitly selected a registered store, use `openspec context --json --store "<store-id>"`. Then run `openspec schemas --json` with its working directory set to the returned `root.path` and let them choose. This preserves roots selected by a local `store:` pointer or the global `defaultStore`; when a registered store was explicitly selected, append `--store "<store-id>"` to `openspec schemas --json` as well. If context reports only `no_openspec_root`, run `openspec schemas --json` from the current working directory instead. Do not use this fallback for invalid or unavailable stores.
Otherwise, omit `--schema` to preserve the configured default.
3. **Create the change directory**
Choose one schema form below. If a registered store is selected, append `--store "<store-id>"` to that command and each later OpenSpec command shown below that accepts `--store`.
Using the configured default:
```bash
openspec new change "<name>"
```
Using an explicitly requested schema:
```bash
openspec new change "<name>" --schema "<schema-name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
4. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
@@ -51,7 +76,7 @@ When ready to implement, run /openspec-apply-change
- `artifacts`: list of all artifacts, each with its `status` and its `requires` edges (the artifact IDs it directly depends on)
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create every artifact in the required set**
5. **Create every artifact in the required set**
Use a todo list to track progress through the artifacts.
@@ -90,7 +115,7 @@ When ready to implement, run /openspec-apply-change
- Ask the user to clarify
- Then continue with creation
5. **Show final status**
6. **Show final status**
```bash
openspec status --change "<name>"
```
@@ -101,7 +126,7 @@ After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why
- What's ready: "All artifacts needed for implementation are ready."
- Prompt: "Run `/openspec-apply-change` or ask me to implement to start working on the tasks."
- Prompt: "The artifacts are ready for review. When you are ready, run `/openspec-apply-change` or ask me to apply this change."
**Artifact Creation Guidelines**
@@ -115,8 +140,9 @@ After completing all artifacts, summarize:
- These guide what you write, but should never appear in the output
**Guardrails**
- The request that invoked this workflow authorizes planning only. Any implementation or apply instruction in that request does not carry forward. Do NOT implement the change, start the apply workflow, or edit project code during this workflow. After presenting the artifacts, stop and wait for a new user request to start the apply workflow
- Create every artifact the apply phase transitively depends on, not just the ids listed in `apply.requires`
- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them)
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- Ask about ambiguities that would materially change scope, externally observable behavior, compatibility, or acceptance criteria; for minor details, make reasonable assumptions and record them
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
+49 -10
View File
@@ -13,7 +13,9 @@ Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
@@ -48,8 +50,10 @@ This is an **agent-driven** operation - you will read delta specs and directly e
instructions or writing a main spec.
Sync every path in `existingOutputPaths` unless the caller narrowed the set.
A caller narrows it by naming an explicit list of delta spec paths to sync —
archive does this inline, and a user can too ("only sync the billing delta").
A caller narrows it by naming an explicit list of complete entries from
`existingOutputPaths` — copy those absolute values verbatim. Archive does
this inline, and a user can too (for example, by selecting the entry ending
in `/specs/billing/invoices/spec.md`).
Then sync only the named paths and leave the remaining delta specs untouched:
bulk archive excludes a delta whose implementation it could not find, and
syncing it anyway would write a main spec the caller deliberately withheld.
@@ -89,7 +93,7 @@ This is an **agent-driven** operation - you will read delta specs and directly e
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `<planningHome.root>/openspec/specs/<capability>/spec.md` (may not exist yet)
b. **Read the main spec** at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
@@ -100,13 +104,35 @@ This is an **agent-driven** operation - you will read delta specs and directly e
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Adding new scenarios the main spec does not have yet
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
- Retiring the capability. Delete the whole `spec.md` - and the directory once
nothing else is left in it - only when ALL of these hold:
1. removing the requirements *this run* left no requirement blocks;
2. the rest of the spec is well-formed (it still has a `## Purpose`);
3. the main spec was not already empty before this sync - if you removed
nothing, change nothing;
4. every other nonblank line in the whole file is accounted for as the
title, Purpose, Requirements header, or a canonical requirement's
statement, scenarios, or fenced examples;
5. the change's `.openspec.yaml` declares `retire_capabilities: true`;
6. the `spec.md` resolves inside the real specs root (do not follow a
capability-directory symlink to delete an external file).
If removing the selected requirements would leave no requirement blocks and
any retirement condition is not satisfied, do not modify the main spec. Stop
the sync for that capability, report the blocking condition, and tell the user
how to resolve it. Never write or leave an empty `## Requirements` section.
When only the marker is missing, say that too - it is the one thing the user
can add to make the retirement go through.
- Deleting the file also deletes its `## Purpose`; any other section blocks
retirement. Name Purpose when you report the retirement. Include a pasteable
`git checkout` only when the spec lived in the caller's checkout;
otherwise give checkout-scoped recovery guidance.
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
@@ -116,19 +142,26 @@ This is an **agent-driven** operation - you will read delta specs and directly e
(this is what `openspec archive` does; it warns and moves on)
d. **Create new main spec** if capability doesn't exist yet:
- Create `<planningHome.root>/openspec/specs/<capability>/spec.md`
- Create `<planningHome.root>/openspec/specs/<capability-path>/spec.md`
- Add Purpose section: copy the delta's `## Purpose` body verbatim when it has one
(this is what `openspec archive` does); only write a brief TBD placeholder when it does not
- Add Requirements section with the ADDED requirements
- Follow the **Main Spec Format Reference** below
5. **Show summary**
5. **Validate updated main specs**
Run `openspec validate --specs` with the same selected-root flags used earlier.
If validation fails, report the problems and do not claim the sync succeeded.
6. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
- Any new main spec left with a TBD Purpose placeholder, so it gets written
now rather than lingering
- Any capability retired, naming the deleted `spec.md`, its Purpose, and
either a pasteable `git checkout` or checkout-scoped recovery guidance
**Delta Spec Format Reference**
@@ -149,6 +182,12 @@ The system SHALL do something new.
## MODIFIED Requirements
### Requirement: Existing Feature
The system SHALL keep doing the existing thing, now also handling A.
#### Scenario: Scenario the main spec already has
- **WHEN** user does X
- **THEN** system does Y
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
@@ -185,9 +224,9 @@ The system SHALL do something new.
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
Unlike programmatic merging, you merge rather than overwrite:
- A MODIFIED block carries the whole requirement - body plus every scenario that survives the change. `openspec validate` and `openspec archive` both reject one that drops a scenario the main spec still has.
- Keep anything the delta does not mention, in the main spec's existing order
- Use your judgment to merge changes sensibly
**Output On Success**
+6 -5
View File
@@ -11,10 +11,12 @@ metadata:
Revise a change's existing planning artifacts and keep them coherent. Never edit code.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
`/openspec-continue-change` is an optional workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, `openspec status --change "<name>" --json` shows the next artifact and `openspec instructions "<artifact-id>" --change "<name>" --json` explains how to create it.
**Steps**
1. **Select the change**
@@ -41,7 +43,7 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit
Parse the JSON to understand current state. The response includes:
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
- `artifacts`: Array of artifacts with their status ("done", "skipped", "ready", "blocked")
- `isComplete`: Boolean indicating if all artifacts are complete
- `isPlanningComplete`: Boolean indicating if all planning artifacts are complete. Older CLI versions expose the same value as `isComplete`.
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
@@ -64,7 +66,7 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit
- If the user rejects a revision, do not write it - leave that artifact unchanged.
- When a substantial rewrite is needed, get that artifact's rules and template first:
```bash
openspec instructions <artifact-id> --change "<name>" --json
openspec instructions "<artifact-id>" --change "<name>" --json
```
6. **Point to the next step (guidance only - NEVER act on it)**
@@ -85,5 +87,4 @@ After each invocation, show:
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/openspec-continue-change`'s job.
- Confirm every edit with the user before writing.
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic).
- `/openspec-continue-change` and `/openspec-new-change` may not be installed (core profile). When suggesting one that is unavailable, point to the CLI instead: `openspec status --change "<name>" --json` shows the next artifact and `openspec instructions <artifact-id> --change "<name>" --json` explains how to create it.
- If the request changes the change's *intent* rather than refining it, first verify whether the optional `/openspec-new-change` workflow is available. If it is, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend `openspec new change "<new-change-name>"` instead.
+1 -1
View File
@@ -11,7 +11,7 @@ metadata:
Verify that an implementation matches the change artifacts (specs, tasks, design).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
+44 -7
View File
@@ -4,7 +4,7 @@ import { createRequire } from 'module';
import ora from 'ora';
import path from 'path';
import { fileURLToPath } from 'url';
import { promises as fs } from 'fs';
import { existsSync, promises as fs } from 'fs';
import { AI_TOOLS, TOOL_ID_ALIASES } from '../core/config.js';
import { UpdateCommand } from '../core/update.js';
import {
@@ -115,6 +115,27 @@ export function getCommandPath(command: Command): string {
return names.join(':') || 'openspec';
}
/**
* True when the executing command asked for JSON output — used to suppress the
* first-run telemetry notice so stdout stays a single valid JSON document.
*
* `--json` reaches commands three ways, so a single parsed option is not enough:
* - declared on the leaf (`openspec status --json`) → `opts().json`
* - declared on a parent group and read via globals (`openspec workset --json list`)
* → `optsWithGlobals().json`
* - a residual arg on a permissive group that never declares the option
* (`openspec store --json`, which detects it from `command.args`) → `args`
*
* Suppressing is always safe: the disclosure is only deferred to the next
* non-JSON run, never lost, whereas printing it on a JSON run corrupts stdout.
*/
export function isJsonRun(command: Command): boolean {
return (
command.optsWithGlobals().json === true ||
command.args.includes('--json')
);
}
program
.name('openspec')
.description('AI-native system for spec-driven development')
@@ -133,8 +154,9 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
process.env.NO_COLOR = '1';
}
// Show first-run telemetry notice (if not seen)
await maybeShowTelemetryNotice();
// Show first-run telemetry notice (if not seen). Suppress it whenever the run
// asked for JSON so stdout stays a single valid JSON document (see isJsonRun).
await maybeShowTelemetryNotice({ silent: isJsonRun(actionCommand) });
// Track command execution (use actionCommand to get the actual subcommand)
const commandPath = getCommandPath(actionCommand);
@@ -146,7 +168,9 @@ program.hook('postAction', async () => {
await shutdown();
});
const availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value);
const availableToolIds = AI_TOOLS
.filter((tool) => tool.skillsDir || tool.globalSkillsDir)
.map((tool) => tool.value);
const toolAliasNote = Object.entries(TOOL_ID_ALIASES)
.map(([retired, current]) => `${retired} (now ${current})`)
.join(', ');
@@ -159,7 +183,9 @@ program
.option('--force', 'Auto-cleanup legacy files without prompting')
.option('--profile <profile>', 'Override global config profile (core or custom)')
.option('--no-animation', 'Show a static welcome screen instead of the animated one')
.action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string; animation?: boolean }) => {
.option('--copilot-cloud', 'Set up GitHub Copilot cloud coding-agent files without prompting')
.option('--no-copilot-cloud', 'Skip GitHub Copilot cloud coding-agent files without prompting')
.action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string; animation?: boolean; copilotCloud?: boolean }) => {
try {
// Validate that the path is a valid directory
const resolvedPath = path.resolve(targetPath);
@@ -186,6 +212,7 @@ program
force: options?.force,
profile: options?.profile,
animation: options?.animation,
copilotCloud: options?.copilotCloud,
});
await initCommand.execute(targetPath);
} catch (error) {
@@ -294,6 +321,9 @@ program
const root = await resolveRootForCommand(options ?? {}, {
json: options?.json,
failurePayload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
// Preserve the cwd fallback for pre-config.yaml projects. The resolver
// still lets a registered/default store take precedence over it.
allowImplicitRoot: existsSync(path.join(process.cwd(), 'openspec', 'project.md')),
});
if (!root) {
return;
@@ -434,6 +464,7 @@ program
.option('--all', 'Validate all changes and specs')
.option('--changes', 'Validate all changes')
.option('--specs', 'Validate all specs')
.option('--archived', 'Validate that archived changes have all tasks completed (for pre-commit linting)')
.option('--type <type>', 'Specify item type when ambiguous: change|spec')
.option('--strict', 'Enable strict validation mode')
.option('--json', 'Output validation results as JSON')
@@ -441,7 +472,7 @@ program
.option('--no-interactive', 'Disable interactive prompts')
.option('--store <id>', STORE_OPTION_DESCRIPTION)
.addOption(hiddenStorePathOption())
.action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string; store?: string; storePath?: string }) => {
.action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; archived?: boolean; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string; store?: string; storePath?: string }) => {
try {
const validateCommand = new ValidateCommand();
await validateCommand.execute(itemName, options);
@@ -623,11 +654,17 @@ program
.command('schemas')
.description('List available workflow schemas with descriptions')
.option('--json', 'Output as JSON (for agent use)')
.option('--store <id>', STORE_OPTION_DESCRIPTION)
.addOption(hiddenStorePathOption())
.action(async (options: SchemasOptions) => {
try {
await schemasCommand(options);
} catch (error) {
failWithError(error);
failWithError(error, {
enabled: options.json,
payload: { schemas: [], root: null },
fallbackCode: 'schemas_error',
});
process.exit(1);
}
});
+16 -2
View File
@@ -9,6 +9,7 @@ import type { RootOutput } from '../core/root-selection.js';
import { isInteractive } from '../utils/interactive.js';
import { getActiveChangeIds } from '../utils/item-discovery.js';
import { getTaskProgressForChange } from '../utils/task-progress.js';
import { FileSystemUtils } from '../utils/file-system.js';
/**
* True only when `target` is definitively absent. An EACCES or I/O failure
@@ -106,8 +107,10 @@ export class ChangeCommand {
}
throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
}
FileSystemUtils.assertPathWithin(path.dirname(proposalPath), proposalPath);
if (options?.json) {
FileSystemUtils.assertPathWithin(changeDir, proposalPath);
const jsonOutput = await this.converter.convertChangeToJson(proposalPath);
if (options.requirementsOnly) {
@@ -115,6 +118,7 @@ export class ChangeCommand {
}
const parsed: Change = JSON.parse(jsonOutput);
FileSystemUtils.assertPathWithin(changeDir, proposalPath);
const contentForTitle = await fs.readFile(proposalPath, 'utf-8');
const title = this.extractTitle(contentForTitle, changeName);
const id = parsed.name;
@@ -129,6 +133,7 @@ export class ChangeCommand {
};
console.log(JSON.stringify(output, null, 2));
} else {
FileSystemUtils.assertPathWithin(changeDir, proposalPath);
const content = await fs.readFile(proposalPath, 'utf-8');
console.log(content);
}
@@ -168,6 +173,7 @@ export class ChangeCommand {
}
try {
FileSystemUtils.assertPathWithin(changeDir, proposalPath);
const content = await fs.readFile(proposalPath, 'utf-8');
const parser = new ChangeParser(content, changeDir);
const change = await parser.parseChangeWithDeltas(changeName);
@@ -209,6 +215,7 @@ export class ChangeCommand {
continue;
}
try {
FileSystemUtils.assertPathWithin(changeDir, proposalPath);
const content = await fs.readFile(proposalPath, 'utf-8');
const title = this.extractTitle(content, changeName);
const parser = new ChangeParser(content, changeDir);
@@ -248,7 +255,9 @@ export class ChangeCommand {
}
const changeDir = path.join(changesPath, changeName);
if (!isChangeDirectoryName(changesPath, changeDir)) {
throw new Error(`Change "${changeName}" not found at ${changeDir}`);
}
try {
await fs.access(changeDir);
} catch {
@@ -256,7 +265,12 @@ export class ChangeCommand {
}
const validator = new Validator(options?.strict || false);
const report = await validator.validateChangeDeltaSpecs(changeDir);
const report = await validator.validateChangeDeltaSpecs(changeDir, {
// Derived from changesPath so the main specs come from the same root the
// change itself was resolved against.
mainSpecsDir: path.join(path.dirname(changesPath), 'specs'),
projectRoot: path.dirname(path.dirname(changesPath)),
});
if (options?.json) {
console.log(JSON.stringify(report, null, 2));
+5 -1
View File
@@ -44,7 +44,7 @@ interface WorkflowPromptMeta {
description: string;
}
const WORKFLOW_PROMPT_META: Record<string, WorkflowPromptMeta> = {
export const WORKFLOW_PROMPT_META: Record<string, WorkflowPromptMeta> = {
propose: {
name: 'Propose change',
description: 'Create proposal, design, and tasks from a request',
@@ -65,6 +65,10 @@ const WORKFLOW_PROMPT_META: Record<string, WorkflowPromptMeta> = {
name: 'Apply tasks',
description: 'Implement tasks from the current change',
},
update: {
name: 'Update change',
description: 'Revise the planning artifacts of an existing change',
},
ff: {
name: 'Fast-forward',
description: 'Run a faster implementation workflow',
+304 -48
View File
@@ -1,8 +1,9 @@
import { Command } from 'commander';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import ora from 'ora';
import { stringify as stringifyYaml } from 'yaml';
import { stringify as stringifyYaml, parseDocument } from 'yaml';
import {
getSchemaDir,
getProjectSchemasDir,
@@ -13,6 +14,7 @@ import {
} from '../core/artifact-graph/resolver.js';
import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
import type { SchemaYaml, Artifact } from '../core/artifact-graph/types.js';
import { FileSystemUtils } from '../utils/file-system.js';
/**
* Schema source location type
@@ -196,22 +198,31 @@ function validateSchema(
return { valid: false, issues };
}
// Check template files exist
// Templates can be in schemaDir directly or in a templates/ subdirectory
// Check template files exist in the same directory used at runtime.
if (verbose) {
console.log(' Checking template files...');
}
for (const artifact of schema.artifacts) {
// Try templates subdirectory first (standard location), then root
const templatePathInTemplates = path.join(schemaDir, 'templates', artifact.template);
const templatePathInRoot = path.join(schemaDir, artifact.template);
const templatesDir = path.join(schemaDir, 'templates');
const existingTemplatePath = path.join(templatesDir, artifact.template);
if (!fs.existsSync(templatePathInTemplates) && !fs.existsSync(templatePathInRoot)) {
if (!fs.existsSync(existingTemplatePath)) {
issues.push({
level: 'error',
path: `artifacts.${artifact.id}.template`,
message: `Template file '${artifact.template}' not found for artifact '${artifact.id}'`,
});
continue;
}
try {
FileSystemUtils.assertPathWithin(templatesDir, existingTemplatePath);
} catch {
issues.push({
level: 'error',
path: `artifacts.${artifact.id}.template`,
message: `Template file '${artifact.template}' points outside the schema templates directory`,
});
}
}
@@ -234,22 +245,132 @@ function isValidSchemaName(name: string): boolean {
/**
* Copy a directory recursively.
*/
function copyDirRecursive(src: string, dest: string): void {
function resolveSchemaCopyPath(allowedRoot: string, sourcePath: string): string {
try {
const canonicalRoot = fs.realpathSync(allowedRoot);
const canonicalPath = fs.realpathSync(sourcePath);
FileSystemUtils.assertPathWithin(canonicalRoot, canonicalPath);
return canonicalPath;
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Cannot fork schema with linked or unsupported entry: ${sourcePath}: ${detail}`,
{ cause: error }
);
}
}
function copyDirRecursive(
src: string,
dest: string,
allowedRoot = src,
ancestors = new Set<string>()
): void {
const canonicalSrc = resolveSchemaCopyPath(allowedRoot, src);
if (ancestors.has(canonicalSrc)) {
throw new Error(`Cannot fork schema with a linked directory cycle: ${src}`);
}
ancestors.add(canonicalSrc);
fs.mkdirSync(dest, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
try {
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
const canonicalEntry = resolveSchemaCopyPath(allowedRoot, srcPath);
const stats = fs.statSync(canonicalEntry);
if (entry.isDirectory()) {
copyDirRecursive(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
if (stats.isDirectory()) {
copyDirRecursive(canonicalEntry, destPath, allowedRoot, ancestors);
} else if (stats.isFile()) {
// Dereference confined links so the fork is an independent schema.
fs.copyFileSync(canonicalEntry, destPath);
} else {
throw new Error(`Cannot fork schema with linked or unsupported entry: ${srcPath}`);
}
}
} finally {
ancestors.delete(canonicalSrc);
}
}
/**
* Verifies a schema tree before replacing or creating the fork destination.
*/
function assertSchemaTreeCanBeCopied(
src: string,
allowedRoot = src,
ancestors = new Set<string>()
): void {
const canonicalSrc = resolveSchemaCopyPath(allowedRoot, src);
if (ancestors.has(canonicalSrc)) {
throw new Error(`Cannot fork schema with a linked directory cycle: ${src}`);
}
ancestors.add(canonicalSrc);
try {
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const entryPath = path.join(src, entry.name);
const canonicalEntry = resolveSchemaCopyPath(allowedRoot, entryPath);
const stats = fs.statSync(canonicalEntry);
if (stats.isDirectory()) {
assertSchemaTreeCanBeCopied(canonicalEntry, allowedRoot, ancestors);
} else if (!stats.isFile()) {
throw new Error(`Cannot fork schema with linked or unsupported entry: ${entryPath}`);
}
}
} finally {
ancestors.delete(canonicalSrc);
}
}
/**
* Produces a stable content fingerprint of a directory: a SHA-256 over every
* file's relative path AND its bytes (plus directory paths), walked in sorted
* order. Two directories with byte-identical trees produce the same digest, and
* ANY change to a file's contents, size, or the set of paths changes it. Used to
* detect a concurrent modification of a fork destination between the moment the
* overwrite is authorized and the moment it is actually moved/deleted, so those
* changes are never silently destroyed.
*/
function fingerprintDir(dir: string): string {
const hash = createHash('sha256');
const walk = (current: string, rel: string): void => {
const entries = fs
.readdirSync(current, { withFileTypes: true })
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const entry of entries) {
const abs = path.join(current, entry.name);
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
// Use the entry type from readdir (no separate lstat), then read the file
// directly — avoiding a stat-then-read check/use gap. Size is derived from
// the bytes actually read, so the digest still covers content and length.
if (entry.isDirectory()) {
hash.update(`D:${relPath}\n`);
walk(abs, relPath);
} else if (entry.isFile()) {
const contents = fs.readFileSync(abs);
hash.update(`F:${relPath}:${contents.length}:`);
hash.update(contents);
hash.update('\n');
} else {
// Symlinks / other entry types: record the type + path (and the link
// target when readable) so a swap of one for another is still detected.
let target = '';
try {
target = fs.readlinkSync(abs);
} catch {
// Non-symlink or unreadable target; the type marker below suffices.
}
hash.update(`O:${relPath}:${target}\n`);
}
}
};
walk(dir, '');
return hash.digest('hex');
}
/**
* Default artifacts with descriptions for schema init.
*/
@@ -481,10 +602,10 @@ export function registerSchemaCommand(program: Command): void {
console.log(` ${issue.level}: ${issue.message}`);
}
}
}
if (anyInvalid) {
process.exitCode = 1;
}
if (anyInvalid) {
process.exitCode = 1;
}
return;
}
@@ -529,9 +650,11 @@ export function registerSchemaCommand(program: Command): void {
for (const issue of result.issues) {
console.log(` ${issue.level}: ${issue.message}`);
}
process.exitCode = 1;
}
}
if (!result.valid) {
process.exitCode = 1;
}
} catch (error) {
if (options?.json) {
console.log(JSON.stringify({
@@ -595,41 +718,174 @@ export function registerSchemaCommand(program: Command): void {
const sourceResolution = getSchemaResolution(source, projectRoot);
const sourceLocation = sourceResolution?.source || 'package';
// Validate the complete source before a forced fork removes anything.
const trustedSourceDir = fs.realpathSync(sourceDir);
assertSchemaTreeCanBeCopied(trustedSourceDir);
// Validate the source's schema content up front too, so a structurally
// invalid source is rejected before the --force path can remove an
// existing destination. This keeps `fork --force` atomic — an unusable
// source never destroys a valid destination — matching `schema init`,
// which likewise validates before it overwrites.
parseSchema(
fs.readFileSync(path.join(trustedSourceDir, 'schema.yaml'), 'utf-8')
);
// Check destination
const destinationDir = path.join(getProjectSchemasDir(projectRoot), destinationName);
const schemasDir = getProjectSchemasDir(projectRoot);
const destinationDir = path.join(schemasDir, destinationName);
if (fs.existsSync(destinationDir)) {
if (!options?.force) {
if (options?.json) {
console.log(JSON.stringify({
forked: false,
error: `Schema '${destinationName}' already exists`,
suggestion: 'Use --force to overwrite',
}, null, 2));
} else {
console.error(`Error: Schema '${destinationName}' already exists at ${destinationDir}`);
console.error('Use --force to overwrite');
}
process.exitCode = 1;
return;
}
// Remove existing
if (spinner) spinner.start(`Removing existing schema '${destinationName}'...`);
fs.rmSync(destinationDir, { recursive: true });
// Reject a self-fork. Forking a schema onto itself with --force would
// otherwise remove the source at the replacement step below and then
// fail the copy, destroying the only copy of the schema. Resolve both
// sides to their real paths (realpathSync follows symlinks; path.resolve
// is a fallback only for a destination that does not exist yet) so a
// symlink or a `.`/`..` spelling of the same directory is still caught.
const resolvedDestination = fs.existsSync(destinationDir)
? fs.realpathSync(destinationDir)
: path.resolve(destinationDir);
if (resolvedDestination === trustedSourceDir) {
throw new Error(
`Cannot fork schema '${source}' onto itself; choose a different destination name`
);
}
// Copy schema
const destinationExists = fs.existsSync(destinationDir);
if (destinationExists && !options?.force) {
if (options?.json) {
console.log(JSON.stringify({
forked: false,
error: `Schema '${destinationName}' already exists`,
suggestion: 'Use --force to overwrite',
}, null, 2));
} else {
console.error(`Error: Schema '${destinationName}' already exists at ${destinationDir}`);
console.error('Use --force to overwrite');
}
process.exitCode = 1;
return;
}
// Fingerprint the destination the user authorized us to overwrite, BEFORE
// we spend time staging. Staging can take a while, and a concurrent
// process may edit the destination in that window; the fingerprint lets
// us detect such a change and abort rather than clobber it.
const authorizedDestinationFingerprint = destinationExists
? fingerprintDir(destinationDir)
: null;
// Stage the complete fork in a temporary sibling directory first, then
// swap it into place. This keeps `fork --force` atomic: an existing
// destination is only removed once the new fork has been fully copied,
// name-updated, and (via the up-front parseSchema above) validated. Any
// failure while staging leaves both the source and the existing
// destination exactly as they were.
if (spinner) spinner.start(`Forking '${source}' to '${destinationName}'...`);
copyDirRecursive(sourceDir, destinationDir);
fs.mkdirSync(schemasDir, { recursive: true });
const stagingDir = fs.mkdtempSync(path.join(schemasDir, '.fork-staging-'));
try {
copyDirRecursive(trustedSourceDir, stagingDir);
// Update name in schema.yaml
const destSchemaPath = path.join(destinationDir, 'schema.yaml');
const schemaContent = fs.readFileSync(destSchemaPath, 'utf-8');
const schema = parseSchema(schemaContent);
schema.name = destinationName;
// Update name in the staged schema.yaml via yaml's Document API
// instead of re-serializing the parsed object, so block scalars,
// comments, and key order in the source schema.yaml survive the fork.
const stagedSchemaPath = path.join(stagingDir, 'schema.yaml');
const schemaContent = fs.readFileSync(stagedSchemaPath, 'utf-8');
const doc = parseDocument(schemaContent);
doc.set('name', destinationName);
fs.writeFileSync(stagedSchemaPath, doc.toString());
fs.writeFileSync(destSchemaPath, stringifyYaml(schema));
// Authoritative gate: validate the COMPLETED staged schema — the exact
// bytes we are about to install — not just the source at the pre-check.
// The source files copyDirRecursive reads can change mid-copy, so a
// source that was valid up front can still produce an invalid staged
// fork. Validating here, before ANY destructive step, guarantees we
// never install an invalid fork or delete a valid destination for one.
try {
parseSchema(fs.readFileSync(stagedSchemaPath, 'utf-8'));
} catch (validationError) {
throw new Error(
`The staged fork of '${source}' is not a valid schema (the source may have changed during copy); ` +
`aborted, '${destinationName}' was not modified.`,
{ cause: validationError }
);
}
// Swap the staged fork into place. When a destination already exists,
// move it aside to a sibling backup FIRST, then install the staged
// fork; only once the install succeeds is the backup discarded. If the
// install rename itself fails (e.g. a Windows lock), the backup is
// moved back so the user's original destination is never lost.
if (destinationExists) {
if (spinner) spinner.text = `Replacing existing schema '${destinationName}'...`;
// Revalidate immediately before the destructive move: if the
// destination changed on disk while we were staging (or was removed),
// its fingerprint no longer matches what the user authorized. Abort
// WITHOUT touching it, so the concurrent changes are preserved. The
// outer catch cleans up staging.
const currentFingerprint = fs.existsSync(destinationDir)
? fingerprintDir(destinationDir)
: null;
if (currentFingerprint !== authorizedDestinationFingerprint) {
throw new Error(
`Schema '${destinationName}' at ${destinationDir} changed on disk while the fork was being prepared. ` +
`Aborted to preserve those concurrent changes; nothing was overwritten. Re-run the fork to overwrite the current contents.`
);
}
const backupDir = `${destinationDir}.fork-backup-${process.pid}-${Date.now()}`;
fs.renameSync(destinationDir, backupDir);
try {
fs.renameSync(stagingDir, destinationDir);
} catch (installError) {
// Install failed after the original was moved aside. Try to move
// it back. If that restore ALSO fails, the original is stranded in
// the backup dir — surface an error naming both the backup and the
// destination so the user can recover manually, and attach the
// original install error as the cause. Never swallow this.
try {
fs.renameSync(backupDir, destinationDir);
} catch (restoreError) {
throw new Error(
`Failed to install the forked schema and could not restore the previous '${destinationName}'. ` +
`Your previous schema is preserved at ${backupDir}; move it back to ${destinationDir} to restore. ` +
`Restore error: ${(restoreError as Error).message}`,
{ cause: installError }
);
}
throw installError;
}
// Revalidate before discarding the backup: only delete it if it is
// still byte-for-byte the original destination we moved aside. If it
// changed during the install window (a concurrent write to the
// moved-aside directory), do NOT delete it — leave it in place and
// surface where it is so nothing is lost.
if (fingerprintDir(backupDir) === authorizedDestinationFingerprint) {
fs.rmSync(backupDir, { recursive: true, force: true });
} else {
console.error(
`Warning: the previous '${destinationName}' changed during the fork and was NOT deleted; ` +
`its pre-fork copy is preserved at ${backupDir}.`
);
}
} else {
fs.renameSync(stagingDir, destinationDir);
}
} catch (error) {
// Remove only the staging directory we created this run; the source
// and any existing destination are left exactly as we found them.
// Guard the cleanup in its own try/catch so a failed removal (e.g. a
// locked file on Windows) can never mask the original error, then
// rethrow so the real failure still drives the JSON/exit-code report.
try {
fs.rmSync(stagingDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup; the original error below is what matters.
}
throw error;
}
if (spinner) spinner.succeed(`Forked '${source}' to '${destinationName}'`);
+33 -6
View File
@@ -1,6 +1,6 @@
import { program } from 'commander';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import path, { join } from 'path';
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
import { Validator } from '../core/validation/validator.js';
import type { Spec } from '../core/schemas/index.js';
@@ -8,9 +8,30 @@ import type { RootOutput } from '../core/root-selection.js';
import { isInteractive } from '../utils/interactive.js';
import { getSpecIds } from '../utils/item-discovery.js';
import { discoverSpecFiles } from '../utils/spec-discovery.js';
import { FileSystemUtils } from '../utils/file-system.js';
const SPECS_DIR = 'openspec/specs';
function assertSpecPath(specsDir: string, specPath: string): void {
const relativePath = path.relative(path.resolve(specsDir), path.resolve(specPath));
if (
relativePath === '..' ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
throw new Error(`Path is outside the allowed directory: ${specPath}`);
}
try {
// Preserve confined spec.md links, including links to a sibling capability.
FileSystemUtils.assertPathWithin(specsDir, specPath);
} catch {
// A capability directory may intentionally be a monorepo symlink. Treat it
// as the trust root while still rejecting a link outside that capability.
FileSystemUtils.assertPathWithin(path.dirname(specPath), specPath);
}
}
interface ShowOptions {
json?: boolean;
// JSON-only filters (raw-first text has no filters)
@@ -21,7 +42,8 @@ interface ShowOptions {
rootOutput?: RootOutput;
}
function parseSpecFromFile(specPath: string, specId: string): Spec {
function parseSpecFromFile(specsDir: string, specPath: string, specId: string): Spec {
assertSpecPath(specsDir, specPath);
const content = readFileSync(specPath, 'utf-8');
const parser = new MarkdownParser(content);
return parser.parseSpec(specId);
@@ -62,7 +84,8 @@ function filterSpec(spec: Spec, options: ShowOptions): Spec {
* Print the raw markdown content for a spec file without any formatting.
* Raw-first behavior ensures text mode is a passthrough for deterministic output.
*/
function printSpecTextRaw(specPath: string): void {
function printSpecTextRaw(specsDir: string, specPath: string): void {
assertSpecPath(specsDir, specPath);
const content = readFileSync(specPath, 'utf-8');
console.log(content);
}
@@ -94,6 +117,7 @@ export class SpecCommand {
}
const specPath = join(this.specsDir, specId, 'spec.md');
assertSpecPath(this.specsDir, specPath);
if (!existsSync(specPath)) {
// Root-aware callers get the absolute path; the cwd-based noun form
// keeps its historical forward-slash relative message on all platforms.
@@ -105,7 +129,7 @@ export class SpecCommand {
if (options.requirements && options.requirement) {
throw new Error('Options --requirements and --requirement cannot be used together');
}
const parsed = parseSpecFromFile(specPath, specId);
const parsed = parseSpecFromFile(this.specsDir, specPath, specId);
const filtered = filterSpec(parsed, options);
const output = {
id: specId,
@@ -119,7 +143,7 @@ export class SpecCommand {
console.log(JSON.stringify(output, null, 2));
return;
}
printSpecTextRaw(specPath);
printSpecTextRaw(this.specsDir, specPath);
}
}
@@ -167,7 +191,8 @@ export function registerSpecCommand(rootProgram: typeof program) {
const specs = discovered
.map(({ id, specFile }) => {
try {
const spec = parseSpecFromFile(specFile, id);
assertSpecPath(SPECS_DIR, specFile);
const spec = parseSpecFromFile(SPECS_DIR, specFile, id);
return {
id,
@@ -228,12 +253,14 @@ export function registerSpecCommand(rootProgram: typeof program) {
}
const specPath = join(SPECS_DIR, specId, 'spec.md');
assertSpecPath(SPECS_DIR, specPath);
if (!existsSync(specPath)) {
throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
}
const validator = new Validator(options.strict);
assertSpecPath(SPECS_DIR, specPath);
const report = await validator.validateSpec(specPath);
if (options.json) {
+154 -4
View File
@@ -13,6 +13,9 @@ import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
import { getSpecIds } from '../utils/item-discovery.js';
import { getAvailableChanges } from './workflow/shared.js';
import { nearestMatches } from '../utils/match.js';
import { promises as fs } from 'fs';
import { getTaskProgressDetailForChange, type SchemaGlobCache } from '../utils/task-progress.js';
import { FileSystemUtils } from '../utils/file-system.js';
type ItemType = 'change' | 'spec';
@@ -20,6 +23,7 @@ interface ExecuteOptions {
all?: boolean;
changes?: boolean;
specs?: boolean;
archived?: boolean;
type?: string;
strict?: boolean;
json?: boolean;
@@ -40,15 +44,31 @@ interface BulkItemResult {
export class ValidateCommand {
async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise<void> {
const root = await resolveRootForCommand(options, { json: options.json });
const bulk = options.all || options.changes || options.specs;
const root = await resolveRootForCommand(options, {
json: options.json,
...(bulk ? { allowImplicitRoot: false } : {}),
});
if (!root) {
return;
}
const interactive = isInteractive(options);
// Archived-task linting is its own scope: it checks task completion of
// already-archived changes, not delta specs (whose operations are already
// applied). Handled before the other bulk flags so `--archived` is explicit
// and never alters an existing invocation's behavior (#205).
if (options.archived) {
await this.runArchivedTaskValidation(root, {
json: !!options.json,
noInteractive: resolveNoInteractive(options),
});
return;
}
// Handle bulk flags first
if (options.all || options.changes || options.specs) {
if (bulk) {
await this.runBulkValidation(root, {
changes: !!options.all || !!options.changes,
specs: !!options.all || !!options.specs,
@@ -197,7 +217,10 @@ export class ValidateCommand {
if (type === 'change') {
const changeDir = path.join(root.changesDir, id);
const start = Date.now();
const report = await validator.validateChangeDeltaSpecs(changeDir);
const report = await validator.validateChangeDeltaSpecs(changeDir, {
mainSpecsDir: root.specsDir,
projectRoot: root.path,
});
const durationMs = Date.now() - start;
this.printReport('change', id, report, durationMs, opts.json, root);
// Non-zero exit if invalid (keeps enriched output test semantics)
@@ -279,7 +302,10 @@ export class ValidateCommand {
queue.push(async () => {
const start = Date.now();
const changeDir = path.join(root.changesDir, id);
const report = await validator.validateChangeDeltaSpecs(changeDir);
const report = await validator.validateChangeDeltaSpecs(changeDir, {
mainSpecsDir: root.specsDir,
projectRoot: root.path,
});
const durationMs = Date.now() - start;
return { id, type: 'change' as const, valid: report.valid, issues: report.issues, durationMs };
});
@@ -381,6 +407,130 @@ export class ValidateCommand {
process.exitCode = failed > 0 ? 1 : 0;
}
/**
* Lists archived change ids from the resolved root's archive directory,
* mirroring `getArchivedChangeIds` but store-aware (uses `root.archiveDir`
* rather than a cwd-relative path). Directories only, hidden entries skipped.
*
* Only a missing archive directory (ENOENT) is an empty list; a permission
* error, an I/O error, or an `archive` path that is a file (ENOTDIR) is a real
* failure and must not read as "no archived changes" — that would let a
* pre-commit lint pass without inspecting anything (#205).
*/
private async listArchivedChangeIds(root: ResolvedOpenSpecRoot): Promise<string[]> {
try {
const entries = await fs.readdir(root.archiveDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
.map((entry) => entry.name)
.sort();
} catch (error: any) {
if (error?.code === 'ENOENT') return [];
throw error;
}
}
/**
* Validates that every archived change has all of its tasks completed.
*
* An archived change is expected to be finished; an archived change with
* unchecked tasks is a real integrity problem the normal validate flow never
* surfaces, because active-change discovery excludes the archive directory
* (#205). Reuses the same task-progress counting `status`, `list`, and
* `archive` rely on, so what counts as a task never forks. Changes with no
* tasks pass (nothing to complete).
*/
private async runArchivedTaskValidation(
root: ResolvedOpenSpecRoot,
opts: { json: boolean; noInteractive?: boolean }
): Promise<void> {
// List first (may throw on a real archive-read failure), then start the
// spinner so a thrown error never leaves a spinner spinning.
const ids = await this.listArchivedChangeIds(root);
const spinner = !opts.json && !opts.noInteractive ? ora('Validating archived changes...').start() : undefined;
// The archive is append-only and can hold thousands of changes; a single
// run resolves them all under one constant projectRoot (root.path), so
// memoize the schema→glob lookup to avoid re-parsing the same schema.yaml
// once per change. The loop is intentionally sequential: the per-change work
// is dominated by synchronous schema/config resolution, which a promise pool
// cannot overlap on Node's single thread — a pool would add complexity for
// no real gain here.
const schemaGlobCache: SchemaGlobCache = new Map();
const results: BulkItemResult[] = [];
let passed = 0;
let failed = 0;
for (const id of ids) {
const start = Date.now();
const issues: BulkItemResult['issues'] = [];
try {
// The explicit root.path override is load-bearing: an archived change
// lives one directory deeper (changes/archive/<id>), so the default
// "../../.." projectRoot derivation would be wrong without it.
const progress = await getTaskProgressDetailForChange(root.archiveDir, id, root.path, schemaGlobCache);
// A tasks file that exists but cannot be read must fail loudly, not be
// silently counted as "no tasks" and pass. Report one issue per file,
// pathed like every other validate issue (POSIX, root-relative).
for (const file of progress.unreadable) {
issues.push({
level: 'ERROR',
path: FileSystemUtils.toPosixPath(path.relative(root.path, file)),
message: 'could not read task file',
});
}
const incomplete = Math.max(progress.total - progress.completed, 0);
if (incomplete > 0) {
issues.push({
level: 'ERROR',
path: 'tasks.md',
message: `${incomplete} incomplete task${incomplete === 1 ? '' : 's'} (${progress.completed}/${progress.total} completed)`,
});
}
} catch (error: any) {
issues.push({ level: 'ERROR', path: 'tasks.md', message: error?.message || 'Unknown error' });
}
const valid = issues.length === 0;
if (valid) passed++; else failed++;
results.push({ id, type: 'change', valid, issues, durationMs: Date.now() - start });
}
spinner?.stop();
const summary = {
totals: { items: results.length, passed, failed },
byType: { change: summarizeType(results, 'change') },
} as const;
if (opts.json) {
const out = { items: results, summary, version: '1.0', root: toRootOutput(root) };
console.log(JSON.stringify(out, null, 2));
process.exitCode = failed > 0 ? 1 : 0;
return;
}
if (results.length === 0) {
console.log('No archived changes found.');
process.exitCode = 0;
return;
}
// Use the same `<type>/<id>` prefix bulk validation prints, so the plain
// output maps to the JSON `type` ('change') and stays greppable the same way.
for (const res of results) {
if (res.valid) {
console.log(`✓ change/${res.id}`);
} else {
console.error(`✗ change/${res.id}`);
for (const issue of res.issues) {
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
console.error(` ${prefix} ${issue.message}`);
}
}
}
console.log(`Totals: ${summary.totals.passed} passed, ${summary.totals.failed} failed (${summary.totals.items} items)`);
process.exitCode = failed > 0 ? 1 : 0;
}
}
function summarizeType(results: BulkItemResult[], type: ItemType) {
+31 -26
View File
@@ -12,6 +12,7 @@ import {
loadChangeContext,
generateInstructions,
resolveSchema,
resolveArtifactOutputPath,
resolveArtifactOutputs,
type ArtifactInstructions,
} from '../../core/artifact-graph/index.js';
@@ -46,6 +47,7 @@ import {
type ApplyInstructions,
type ArchiveInstructions,
} from './shared.js';
import { parseTaskLines, type ParsedTask } from '../../utils/task-progress.js';
// -----------------------------------------------------------------------------
// Types
@@ -323,26 +325,26 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc
// -----------------------------------------------------------------------------
/**
* Parses tasks.md content and extracts task items with their completion status.
* Turns parsed task lines into the listed task items.
*
* A checkbox with no text after it is left out of the list: this is work for an
* agent to act on and tick off, and a bare `- [ ]` gives it nothing to match.
* It still counts toward progress, which is taken from every parsed line, so
* this list can be shorter than the totals beside it but never disagrees with
* `openspec list` or archive about how much work is left. An empty list is also
* what puts apply in its "nothing to work on" state, so a file of nothing but
* text-less checkboxes asks to be rewritten instead of being called done.
*/
function parseTasksFile(content: string): TaskItem[] {
function toTaskItems(parsed: ParsedTask[]): TaskItem[] {
const tasks: TaskItem[] = [];
const lines = content.split('\n');
let taskIndex = 0;
for (const line of lines) {
// Match checkbox patterns: - [ ] or - [x] or - [X]
const checkboxMatch = line.match(/^[-*]\s*\[([ xX])\]\s*(.+)\s*$/);
if (checkboxMatch) {
taskIndex++;
const done = checkboxMatch[1].toLowerCase() === 'x';
const description = checkboxMatch[2].trim();
tasks.push({
id: `${taskIndex}`,
description,
done,
});
}
for (const task of parsed) {
if (task.description.length === 0) continue;
tasks.push({
id: `${tasks.length + 1}`,
description: task.description,
done: task.done,
});
}
return tasks;
@@ -411,20 +413,22 @@ export async function generateApplyInstructions(
}
// Parse tasks if tracking file exists
let tasks: TaskItem[] = [];
let parsedTasks: ParsedTask[] = [];
let tracksFileExists = false;
if (tracksFile) {
const tracksPath = path.join(changeDir, tracksFile);
const tracksPath = resolveArtifactOutputPath(changeDir, tracksFile);
tracksFileExists = fs.existsSync(tracksPath);
if (tracksFileExists) {
const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
tasks = parseTasksFile(tasksContent);
parsedTasks = parseTaskLines(tasksContent);
}
}
const tasks = toTaskItems(parsedTasks);
// Calculate progress
const total = tasks.length;
const complete = tasks.filter((t) => t.done).length;
// Calculate progress over every checkbox in the file, listed or not, so these
// numbers match `openspec list` and archive's incomplete-task check.
const total = parsedTasks.length;
const complete = parsedTasks.filter((task) => task.done).length;
const remaining = total - complete;
// Determine state and instruction
@@ -439,11 +443,12 @@ export async function generateApplyInstructions(
const tracksFilename = path.basename(tracksFile);
state = 'blocked';
instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`;
} else if (tracksFile && tracksFileExists && total === 0) {
// Tracking file exists but contains no tasks
} else if (tracksFile && tracksFileExists && tasks.length === 0) {
// Tracking file exists but lists nothing an agent can work on: either no
// checkboxes at all, or only checkboxes with no text after them.
const tracksFilename = path.basename(tracksFile);
state = 'blocked';
instruction = `The ${tracksFilename} file exists but contains no tasks.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`;
instruction = `The ${tracksFilename} file exists but contains no tasks to work on.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`;
} else if (tracksFile && remaining === 0 && total > 0) {
state = 'all_done';
instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.';
+12 -2
View File
@@ -6,6 +6,7 @@
import chalk from 'chalk';
import { listSchemasWithInfo } from '../../core/artifact-graph/index.js';
import { resolveRootForCommand } from '../../core/root-selection.js';
// -----------------------------------------------------------------------------
// Types
@@ -13,6 +14,8 @@ import { listSchemasWithInfo } from '../../core/artifact-graph/index.js';
export interface SchemasOptions {
json?: boolean;
store?: string;
storePath?: string;
}
// -----------------------------------------------------------------------------
@@ -20,8 +23,15 @@ export interface SchemasOptions {
// -----------------------------------------------------------------------------
export async function schemasCommand(options: SchemasOptions): Promise<void> {
const projectRoot = process.cwd();
const schemas = listSchemasWithInfo(projectRoot);
const root = await resolveRootForCommand(options, {
json: options.json,
failurePayload: { schemas: [], root: null },
});
if (!root) {
return;
}
const schemas = listSchemasWithInfo(root.path);
if (options.json) {
console.log(JSON.stringify(schemas, null, 2));
+2 -2
View File
@@ -151,8 +151,8 @@ export function printStatusText(status: ChangeStatus): void {
console.log(line);
}
if (status.isComplete) {
if (status.isPlanningComplete) {
console.log();
console.log(chalk.green('All artifacts complete!'));
console.log(chalk.green('All planning artifacts complete!'));
}
}
+16 -7
View File
@@ -67,13 +67,22 @@ export async function templatesCommand(options: TemplatesOptions): Promise<void>
source = 'package';
}
const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({
artifactId: artifact.id,
templatePath: FileSystemUtils.canonicalizeExistingPath(
path.join(schemaDir, 'templates', artifact.template)
),
source,
}));
const templatesDir = path.join(schemaDir, 'templates');
const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => {
const templatePath = path.join(templatesDir, artifact.template);
try {
FileSystemUtils.assertPathWithin(templatesDir, templatePath);
return {
artifactId: artifact.id,
templatePath: FileSystemUtils.canonicalizeExistingPath(templatePath),
source,
};
} catch {
throw new Error(
`Template '${artifact.template}' for artifact '${artifact.id}' points outside the schema templates directory`
);
}
});
spinner?.stop();
+1527 -97
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -16,7 +16,12 @@ export { ArtifactGraph } from './graph.js';
// State detection
export { detectCompleted } from './state.js';
export { artifactOutputExists, isGlobPattern, resolveArtifactOutputs } from './outputs.js';
export {
artifactOutputExists,
isGlobPattern,
resolveArtifactOutputPath,
resolveArtifactOutputs,
} from './outputs.js';
// Schema resolution
export {
+22 -6
View File
@@ -3,7 +3,7 @@ import * as path from 'node:path';
import { getSchemaDir, resolveSchema, listSchemasWithInfo } from './resolver.js';
import { ArtifactGraph } from './graph.js';
import { detectCompleted } from './state.js';
import { resolveArtifactOutputs } from './outputs.js';
import { resolveArtifactOutputPath, resolveArtifactOutputs } from './outputs.js';
import { readChangeMetadata, resolveSchemaForChange } from '../../utils/change-metadata.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import {
@@ -175,7 +175,9 @@ export interface ChangeStatus {
nextSteps: string[];
/** Machine-readable action constraints for agents */
actionContext: ActionContext;
/** Whether all artifacts are complete */
/** Whether all planning artifacts are complete */
isPlanningComplete: boolean;
/** Compatibility alias for isPlanningComplete */
isComplete: boolean;
/** Artifact IDs required before apply phase (from schema's apply.requires) */
applyRequires: string[];
@@ -211,7 +213,17 @@ export function loadTemplate(
);
}
const templatePathOnDisk = path.join(schemaDir, 'templates', templatePath);
const templatesDir = path.join(schemaDir, 'templates');
const templatePathOnDisk = path.join(templatesDir, templatePath);
try {
FileSystemUtils.assertPathWithin(templatesDir, templatePathOnDisk);
} catch (error) {
throw new TemplateLoadError(
error instanceof Error ? error.message : String(error),
templatePathOnDisk
);
}
if (!fs.existsSync(templatePathOnDisk)) {
throw new TemplateLoadError(
@@ -367,7 +379,10 @@ export function generateInstructions(
// Extract context and rules as separate fields (not prepended to template)
const configContext = projectConfig?.context?.trim() || undefined;
const rulesForArtifact = projectConfig?.rules?.[artifactId];
const rulesForArtifact =
projectConfig?.rules && Object.hasOwn(projectConfig.rules, artifactId)
? projectConfig.rules[artifactId]
: undefined;
const configRules = rulesForArtifact && rulesForArtifact.length > 0 ? rulesForArtifact : undefined;
return {
@@ -377,7 +392,7 @@ export function generateInstructions(
changeDir: context.changeDir,
planningHome: summarizePlanningHome(context.planningHome),
outputPath: artifact.generates,
resolvedOutputPath: path.join(context.changeDir, artifact.generates),
resolvedOutputPath: resolveArtifactOutputPath(context.changeDir, artifact.generates),
existingOutputPaths: resolveArtifactOutputs(context.changeDir, artifact.generates),
description: artifact.description,
instruction: artifact.instruction,
@@ -455,7 +470,7 @@ export function formatChangeStatus(
const artifactStatuses: ArtifactStatus[] = artifacts.map(artifact => {
artifactPaths[artifact.id] = {
outputPath: artifact.generates,
resolvedOutputPath: path.join(context.changeDir, artifact.generates),
resolvedOutputPath: resolveArtifactOutputPath(context.changeDir, artifact.generates),
existingOutputPaths: resolveArtifactOutputs(context.changeDir, artifact.generates),
};
@@ -508,6 +523,7 @@ export function formatChangeStatus(
planningHome: summarizePlanningHome(context.planningHome),
changeRoot: context.changeDir,
artifactPaths,
isPlanningComplete: isComplete,
isComplete,
applyRequires,
nextSteps: buildNextSteps({
+94 -5
View File
@@ -10,16 +10,89 @@ export function isGlobPattern(pattern: string): boolean {
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
}
export function resolveArtifactOutputPath(changeDir: string, generates: string): string {
const outputPath = path.join(changeDir, generates);
FileSystemUtils.assertPathWithin(changeDir, outputPath);
return outputPath;
}
function assertGlobDirectoryTraversal(
changeDir: string,
currentDir: string,
directorySegments: string[],
segmentIndex = 0,
visited = new Set<string>(),
canonicalChangeDir = FileSystemUtils.canonicalizeExistingPath(changeDir),
ancestors = new Set<string>()
): void {
if (segmentIndex >= directorySegments.length) return;
const canonicalDir = FileSystemUtils.canonicalizeExistingPath(currentDir);
FileSystemUtils.assertPathWithin(canonicalChangeDir, canonicalDir);
const visitKey = `${canonicalDir}\0${segmentIndex}`;
if (ancestors.has(visitKey)) {
throw new Error(`Cannot resolve artifact outputs through a linked directory cycle: ${currentDir}`);
}
if (visited.has(visitKey)) return;
visited.add(visitKey);
ancestors.add(visitKey);
try {
const segment = directorySegments[segmentIndex];
if (segment === '**') {
// `**` may consume no directory at all.
assertGlobDirectoryTraversal(
changeDir,
canonicalDir,
directorySegments,
segmentIndex + 1,
visited,
canonicalChangeDir,
ancestors
);
}
const matches = fg.sync(segment === '**' ? '*' : segment, {
cwd: canonicalDir,
onlyFiles: false,
followSymbolicLinks: false,
deep: 1,
});
for (const match of matches) {
const candidate = path.join(canonicalDir, match);
try {
if (!fs.statSync(candidate).isDirectory()) continue;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
throw error;
}
const canonicalCandidate = FileSystemUtils.canonicalizeExistingPath(candidate);
FileSystemUtils.assertPathWithin(canonicalChangeDir, canonicalCandidate);
assertGlobDirectoryTraversal(
changeDir,
canonicalCandidate,
directorySegments,
segment === '**' ? segmentIndex : segmentIndex + 1,
visited,
canonicalChangeDir,
ancestors
);
}
} finally {
ancestors.delete(visitKey);
}
}
/**
* Resolves an artifact's output path(s) to concrete files that currently exist.
* Returns absolute file paths. Glob matches are sorted for deterministic output.
*/
export function resolveArtifactOutputs(changeDir: string, generates: string): string[] {
const outputPath = resolveArtifactOutputPath(changeDir, generates);
if (!isGlobPattern(generates)) {
const fullPath = path.join(changeDir, generates);
try {
return fs.statSync(fullPath).isFile()
? [FileSystemUtils.canonicalizeExistingPath(fullPath)]
return fs.statSync(outputPath).isFile()
? [FileSystemUtils.canonicalizeExistingPath(outputPath)]
: [];
} catch {
return [];
@@ -27,9 +100,25 @@ export function resolveArtifactOutputs(changeDir: string, generates: string): st
}
const normalizedPattern = FileSystemUtils.toPosixPath(generates);
assertGlobDirectoryTraversal(
changeDir,
changeDir,
normalizedPattern.split('/').slice(0, -1)
);
const matches = fg
.sync(normalizedPattern, { cwd: changeDir, onlyFiles: true, absolute: true })
.map((match) => FileSystemUtils.canonicalizeExistingPath(path.normalize(match)));
.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,
})
.map((match) => {
const normalizedMatch = path.normalize(match);
FileSystemUtils.assertPathWithin(changeDir, normalizedMatch);
return FileSystemUtils.canonicalizeExistingPath(normalizedMatch);
});
return Array.from(new Set(matches)).sort();
}
+54 -9
View File
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getGlobalDataDir } from '../global-config.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { parseSchema, SchemaValidationError } from './schema.js';
import type { SchemaYaml } from './types.js';
@@ -57,7 +58,22 @@ export function getProjectSchemasDir(projectRoot: string): string {
* @param parentDir - The directory containing the entry
* @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
*/
/**
* Directories `schema fork` creates transiently while swapping a fork into
* place: a staging copy (`.fork-staging-<rand>`, created via mkdtemp) and a
* backup of the previous destination (`<name>.fork-backup-<pid>-<ts>`). Either
* can briefly coexist with real schemas in the schemas dir, so discovery must
* never surface them. Real schema names are kebab-case (no dots), so excluding
* these dot-bearing temp names can never hide a legitimate schema.
*/
function isOwnedForkTempDir(name: string): boolean {
return name.startsWith('.fork-staging-') || name.includes('.fork-backup-');
}
export function isSchemaDir(parentDir: string, entry: fs.Dirent): boolean {
if (isOwnedForkTempDir(entry.name)) {
return false;
}
if (entry.isDirectory()) {
return true;
}
@@ -73,6 +89,26 @@ export function isSchemaDir(parentDir: string, entry: fs.Dirent): boolean {
return false;
}
/**
* Returns a schema directory only when its schema file stays within that
* directory's canonical trust boundary. The directory itself may be a symlink;
* external user schema links are an intentionally supported workflow.
*/
function getSchemaCandidateDir(schemasDir: string, name: string): string | null {
const schemaDir = path.join(schemasDir, name);
const schemaPath = path.join(schemaDir, 'schema.yaml');
if (!fs.existsSync(schemaPath)) {
return null;
}
try {
FileSystemUtils.assertPathWithin(schemaDir, schemaPath);
return schemaDir;
} catch {
return null;
}
}
/**
* Resolves a schema name to its directory path.
*
@@ -92,26 +128,35 @@ export function getSchemaDir(
name: string,
projectRoot?: string
): string | null {
if (
name.length === 0 ||
name === '.' ||
name === '..' ||
/[\\/]/u.test(name) ||
/^[A-Za-z]:/u.test(name) ||
path.posix.isAbsolute(name) ||
path.win32.isAbsolute(name)
) {
return null;
}
// 1. Check project-local directory (if projectRoot provided)
if (projectRoot) {
const projectDir = path.join(getProjectSchemasDir(projectRoot), name);
const projectSchemaPath = path.join(projectDir, 'schema.yaml');
if (fs.existsSync(projectSchemaPath)) {
const projectDir = getSchemaCandidateDir(getProjectSchemasDir(projectRoot), name);
if (projectDir) {
return projectDir;
}
}
// 2. Check user override directory
const userDir = path.join(getUserSchemasDir(), name);
const userSchemaPath = path.join(userDir, 'schema.yaml');
if (fs.existsSync(userSchemaPath)) {
const userDir = getSchemaCandidateDir(getUserSchemasDir(), name);
if (userDir) {
return userDir;
}
// 3. Check package built-in directory
const packageDir = path.join(getPackageSchemasDir(), name);
const packageSchemaPath = path.join(packageDir, 'schema.yaml');
if (fs.existsSync(packageSchemaPath)) {
const packageDir = getSchemaCandidateDir(getPackageSchemasDir(), name);
if (packageDir) {
return packageDir;
}
+24 -3
View File
@@ -1,11 +1,32 @@
import * as path from 'node:path';
import { z } from 'zod';
function relativePathSchema(fieldName: string) {
return z
.string()
.min(1, { error: `${fieldName} is required` })
.superRefine((value, ctx) => {
const segments = value.split(/[\\/]+/u);
const isDrivePath = /^[A-Za-z]:/u.test(value);
const isAbsolute =
path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || isDrivePath;
const escapes = segments.includes('..');
if (isAbsolute || escapes || value.includes('\0')) {
ctx.addIssue({
code: 'custom',
message: `${fieldName} must be a relative path inside its allowed directory`,
});
}
});
}
// Artifact definition schema
export const ArtifactSchema = z.object({
id: z.string().min(1, { error: 'Artifact ID is required' }),
generates: z.string().min(1, { error: 'generates field is required' }),
generates: relativePathSchema('generates field'),
description: z.string(),
template: z.string().min(1, { error: 'template field is required' }),
template: relativePathSchema('template field'),
instruction: z.string().optional(),
requires: z.array(z.string()).default([]),
});
@@ -15,7 +36,7 @@ export const ApplyPhaseSchema = z.object({
// Artifact IDs that must exist before apply is available
requires: z.array(z.string()).min(1, { error: 'At least one required artifact' }),
// Path to file with checkboxes for progress (relative to change dir), or null if no tracking
tracks: z.string().nullable().optional(),
tracks: relativePathSchema('apply.tracks').nullable().optional(),
// Custom guidance for the apply phase
instruction: z.string().optional(),
});
+24 -3
View File
@@ -8,17 +8,29 @@
import path from 'path';
import * as fs from 'fs';
import { AI_TOOLS, type AIToolOption } from './config.js';
import { reconcileSharedSkillTargets } from './shared-skill-target.js';
import { SKILL_NAMES } from './shared/tool-detection.js';
import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js';
/**
* Scans the project path for AI tool configuration directories and returns
* the tools that are present.
*
* For tools with `detectionPaths`, checks those specific paths (files or
* directories). Otherwise checks for the tool's `skillsDir` directory at
* the project root. Only tools with a `skillsDir` property are considered.
* directories). Otherwise checks the project's `skillsDir`, or managed skill
* files in the user's home directory for a global skill target.
*/
export function getAvailableTools(projectPath: string): AIToolOption[] {
return AI_TOOLS.filter((tool) => {
const available = AI_TOOLS.filter((tool) => {
if (!toolSupportsSkills(tool)) return false;
if (tool.globalSkillsDir) {
const skillsDir = resolveToolSkillsDir(projectPath, tool);
return SKILL_NAMES.some((skillName) =>
fs.existsSync(path.join(skillsDir, skillName, 'SKILL.md'))
);
}
if (!tool.skillsDir) return false;
if (tool.detectionPaths && tool.detectionPaths.length > 0) {
@@ -40,4 +52,13 @@ export function getAvailableTools(projectPath: string): AIToolOption[] {
return false;
}
});
const activeProjectTools = new Set(
reconcileSharedSkillTargets(
projectPath,
available.filter((tool) => tool.skillsDir)
).map((tool) => tool.value)
);
return available.filter(
(tool) => tool.globalSkillsDir || activeProjectTools.has(tool.value)
);
}
+7
View File
@@ -39,6 +39,13 @@ export const ChangeMetadataSchema = z.object({
// complete - that path prefix, not the artifact id, is the contract custom
// schemas inherit.
skip_specs: z.boolean().optional(),
// Declares that this change may retire a capability: when its REMOVED entries
// take the last requirement a capability has, archive deletes that
// capability's main spec instead of aborting on a spec it could not write
// (#1302). Required because the deletion is not recoverable from the working
// tree - only from git - so it is the author's call, not an inference from the
// shape of a delta.
retire_capabilities: z.boolean().optional(),
});
export type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>;
+4 -2
View File
@@ -65,14 +65,16 @@ export function buildActionContext(input: ActionContextInput): ActionContext {
export function buildNextSteps(input: ChangeNextStepsInput): string[] {
const readyArtifact = input.artifactStatuses.find((artifact) => artifact.status === 'ready');
const steps: string[] = [];
const storeFlag = input.storeId ? ` --store ${input.storeId}` : '';
if (readyArtifact) {
const storeFlag = input.storeId ? ` --store ${input.storeId}` : '';
steps.push(
`Run openspec instructions ${readyArtifact.id} --change "${input.changeName}"${storeFlag} --json before writing that artifact.`
);
} else if (input.allArtifactsComplete) {
steps.push('All planning artifacts are complete; review tasks before implementation.');
steps.push(
`All planning artifacts are complete. Run openspec instructions apply --change "${input.changeName}"${storeFlag} --json to inspect implementation progress.`
);
}
return steps;
@@ -0,0 +1,44 @@
/**
* Command Code Command Adapter
*
* Command Code reads custom slash commands from `.commandcode/commands/`. The
* command name is the markdown filename without its `.md` extension, so
* `opsx-<id>.md` registers `/opsx-<id>` — the same flat naming Cursor and
* OpenCode use. See https://commandcode.ai/docs/reference/slash-commands.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
const COMMAND_CODE_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m;
function injectCommandCodeArgs(body: string): string {
if (/^\*\*Provided arguments\*\*:\s*(?:\$(?:ARGUMENTS|@)|\$\{(?:ARGUMENTS|@)\})\s*$/m.test(body)) {
return body;
}
return body.replace(
COMMAND_CODE_INPUT_HEADING,
(heading) => `${heading}\n**Provided arguments**: $ARGUMENTS`
);
}
/**
* Command Code adapter for command generation.
* File path: .commandcode/commands/opsx-<id>.md
* Format: plain Markdown with $ARGUMENTS injected after the input contract
*
* Command Code executes the full trimmed file body and substitutes invocation
* arguments only where the body includes one of its argument placeholders.
*/
export const commandCodeAdapter: ToolCommandAdapter = {
toolId: 'command-code',
getFilePath(commandId: string): string {
return path.join('.commandcode', 'commands', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `${injectCommandCodeArgs(content.body)}\n`;
},
};
@@ -10,6 +10,7 @@ export { auggieAdapter } from './auggie.js';
export { bobAdapter } from './bob.js';
export { claudeAdapter } from './claude.js';
export { clineAdapter } from './cline.js';
export { commandCodeAdapter } from './command-code.js';
export { codebuddyAdapter } from './codebuddy.js';
export { continueAdapter } from './continue.js';
export { costrictAdapter } from './costrict.js';
+2
View File
@@ -12,6 +12,7 @@ import { auggieAdapter } from './adapters/auggie.js';
import { bobAdapter } from './adapters/bob.js';
import { claudeAdapter } from './adapters/claude.js';
import { clineAdapter } from './adapters/cline.js';
import { commandCodeAdapter } from './adapters/command-code.js';
import { devinAdapter } from './adapters/devin.js';
import { codebuddyAdapter } from './adapters/codebuddy.js';
import { continueAdapter } from './adapters/continue.js';
@@ -49,6 +50,7 @@ export class CommandAdapterRegistry {
CommandAdapterRegistry.register(bobAdapter);
CommandAdapterRegistry.register(claudeAdapter);
CommandAdapterRegistry.register(clineAdapter);
CommandAdapterRegistry.register(commandCodeAdapter);
CommandAdapterRegistry.register(devinAdapter);
CommandAdapterRegistry.register(codebuddyAdapter);
CommandAdapterRegistry.register(continueAdapter);
+13
View File
@@ -27,6 +27,14 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
name: 'no-animation',
description: 'Show a static welcome screen instead of the animated one',
},
{
name: 'copilot-cloud',
description: 'Generate GitHub Copilot cloud coding-agent files (opt-in; default: prompt)',
},
{
name: 'no-copilot-cloud',
description: 'Skip generating GitHub Copilot cloud coding-agent files',
},
],
},
{
@@ -90,6 +98,10 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
name: 'specs',
description: 'Validate all specs',
},
{
name: 'archived',
description: 'Validate that archived changes have all tasks completed (for pre-commit linting)',
},
COMMON_FLAGS.type,
COMMON_FLAGS.strict,
COMMON_FLAGS.jsonValidation,
@@ -219,6 +231,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
description: 'List available workflow schemas with descriptions',
flags: [
COMMON_FLAGS.json,
COMMON_FLAGS.store,
],
},
{
+30 -1
View File
@@ -27,6 +27,14 @@ export const GlobalConfigSchema = z
.describe(
'Store id used as fallback root when no explicit --store, local root, or project-level store: pointer resolves'
),
// passthrough keeps runtime-managed fields (anonymousId, noticeSeen) valid
// under CLI validate when users only set telemetry.enabled.
telemetry: z
.object({
enabled: z.boolean().optional(),
})
.passthrough()
.optional(),
})
.passthrough();
@@ -41,7 +49,15 @@ export const DEFAULT_CONFIG: GlobalConfigType = {
delivery: 'both',
};
const KNOWN_TOP_LEVEL_KEYS = new Set([...Object.keys(DEFAULT_CONFIG), 'workflows', 'defaultStore']);
const KNOWN_TOP_LEVEL_KEYS = new Set([
...Object.keys(DEFAULT_CONFIG),
'workflows',
'defaultStore',
'telemetry',
]);
/** Nested keys users may set under `telemetry` via the CLI. */
const TELEMETRY_SETTABLE_KEYS = new Set(['enabled']);
/**
* Key segments that would reach the prototype chain instead of the config object.
@@ -89,6 +105,19 @@ export function validateConfigKeyPath(path: string): { valid: boolean; reason?:
return { valid: true };
}
if (rootKey === 'telemetry') {
if (rawKeys.length === 1) {
return { valid: false, reason: 'Set nested keys under telemetry (e.g. telemetry.enabled)' };
}
if (rawKeys.length !== 2 || !TELEMETRY_SETTABLE_KEYS.has(rawKeys[1])) {
return {
valid: false,
reason: `Unknown telemetry key "${rawKeys.slice(1).join('.')}" (allowed: enabled)`,
};
}
return { valid: true };
}
if (rawKeys.length > 1) {
return { valid: false, reason: `"${rootKey}" does not support nested keys` };
}
+44 -17
View File
@@ -1,5 +1,20 @@
export const OPENSPEC_DIR_NAME = 'openspec';
export const OPENSPEC_SKILL_NAMES = [
'openspec-explore',
'openspec-new-change',
'openspec-continue-change',
'openspec-apply-change',
'openspec-update-change',
'openspec-ff-change',
'openspec-sync-specs',
'openspec-archive-change',
'openspec-bulk-archive-change',
'openspec-verify-change',
'openspec-onboard',
'openspec-propose',
] as const;
export const OPENSPEC_MARKERS = {
start: '<!-- OPENSPEC:START -->',
end: '<!-- OPENSPEC:END -->'
@@ -15,46 +30,58 @@ export interface AIToolOption {
available: boolean;
successLabel?: string;
skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec
legacySkillsDirs?: string[]; // Former roots read for detection and migrated after replacement
globalSkillsDir?: string; // e.g., '.minimax' - /skills suffix, resolved from the user's home directory
detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection
setupNote?: string; // Manual setup required before the tool picks up generated files; shown after init/update
requiresIdeRestart?: boolean; // True when slash commands are loaded by an IDE/editor process (a CLI picks them up immediately, so no restart hint — see #1067)
}
export const AI_TOOLS: AIToolOption[] = [
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq' },
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent' },
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq', requiresIdeRestart: true },
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent', requiresIdeRestart: true },
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },
{ name: 'Bob Shell', value: 'bob', available: true, successLabel: 'Bob Shell', skillsDir: '.bob' },
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' },
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline', requiresIdeRestart: true },
{ name: 'Command Code', value: 'command-code', available: true, successLabel: 'Command Code', skillsDir: '.commandcode' },
{ name: 'CodeArts', value: 'codeartsagent', available: true, successLabel: 'CodeArts', skillsDir: '.codeartsdoer' },
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex' },
{ name: 'Devin Desktop (formerly Windsurf)', value: 'devin', available: true, successLabel: 'Devin Desktop', skillsDir: '.devin', detectionPaths: ['.devin', '.windsurf'] },
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.agents', legacySkillsDirs: ['.codex'], detectionPaths: ['.agents/skills', '.codex/skills'] },
{ name: 'Devin Desktop (formerly Windsurf)', value: 'devin', available: true, successLabel: 'Devin Desktop', skillsDir: '.devin', detectionPaths: ['.devin', '.windsurf'], requiresIdeRestart: true },
{ name: 'ForgeCode', value: 'forgecode', available: true, successLabel: 'ForgeCode', skillsDir: '.forge' },
{ name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' },
{ name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue' },
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec' },
{ name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue', requiresIdeRestart: true },
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec', requiresIdeRestart: true },
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush', skillsDir: '.crush' },
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor' },
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor', requiresIdeRestart: true },
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'] },
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'], requiresIdeRestart: true },
{ name: 'Hermes Agent', value: 'hermes', available: true, successLabel: 'Hermes Agent', skillsDir: '.hermes', detectionPaths: ['.hermes', 'HERMES.md', '.hermes.md'], setupNote: "Hermes only loads skills from ~/.hermes/skills by default. Add this project's .hermes/skills directory to skills.external_dirs in ~/.hermes/config.yaml so Hermes picks up the generated OpenSpec skills." },
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
{ name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie' },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },
{ name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie', requiresIdeRestart: true },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode', requiresIdeRestart: true },
{ name: 'Kimi Code', value: 'kimi', available: true, successLabel: 'Kimi Code', skillsDir: '.kimi-code', detectionPaths: ['.kimi-code', '.kimi'] },
{ name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' },
{ name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma' },
{ name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro', requiresIdeRestart: true },
{ name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma', requiresIdeRestart: true },
{ name: 'MiniMax Code', value: 'minimax-code', available: true, successLabel: 'MiniMax Code', globalSkillsDir: '.minimax' },
{ name: 'Mistral Vibe', value: 'vibe', available: true, successLabel: 'Mistral Vibe', skillsDir: '.vibe' },
{ name: 'Oh My Pi', value: 'oh-my-pi', available: true, successLabel: 'Oh My Pi', skillsDir: '.omp' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },
{ name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' },
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder', requiresIdeRestart: true },
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },
{ name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo' },
{ name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' },
{ name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] },
{ name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo', requiresIdeRestart: true },
{ name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae', requiresIdeRestart: true },
{ name: 'ZCode', value: 'zcode', available: true, successLabel: 'ZCode', skillsDir: '.zcode' },
{ name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
// Vendor-neutral target for assistants that read the shared `.agents` root.
// Detection keys off `.agents/skills` rather than the bare root: frameworks use
// `.agents/` for more than skills, so the root alone says nothing about skills.
// A project that does keep skills there is a project this target fits, the same
// way `.claude/` selects Claude Code — the signal is the user's setup, not
// OpenSpec's own files.
{ name: 'Shared .agents skills', value: 'agents', available: true, successLabel: 'shared .agents skills', skillsDir: '.agents', detectionPaths: ['.agents/skills'] }
];
/**
+58 -24
View File
@@ -1,5 +1,6 @@
import * as nodeFs from 'node:fs';
import * as path from 'node:path';
import { randomUUID } from 'node:crypto';
import { FileSystemUtils } from '../utils/file-system.js';
import { StoreError } from './store/errors.js';
@@ -59,9 +60,18 @@ export function makeLockErrorFactory(
};
}
const STALE_LOCK_THRESHOLD_MS = 30_000;
const LOCK_DEADLINE_MS = 5000;
const LOCK_POLL_MS = 25;
const PRIVATE_FILE_MODE = 0o600;
const lockOwnership = new WeakMap<nodeFs.promises.FileHandle, string>();
function isUnsupportedSyncError(error: unknown): boolean {
return (
isNodeErrorCode(error, 'EINVAL') ||
isNodeErrorCode(error, 'ENOTSUP') ||
isNodeErrorCode(error, 'ENOSYS')
);
}
export function isNodeErrorCode(error: unknown, code: string): boolean {
return (
@@ -108,7 +118,10 @@ export async function writeFileAtomically(
);
try {
await fs.writeFile(tempPath, content, 'utf-8');
await fs.writeFile(tempPath, content, {
encoding: 'utf-8',
mode: PRIVATE_FILE_MODE,
});
await fs.rename(tempPath, filePath);
} catch (error) {
await fs.rm(tempPath, { force: true }).catch(() => undefined);
@@ -129,34 +142,40 @@ export async function acquireFileLock(
while (true) {
try {
return await fs.open(lockPath, 'wx');
const lock = await fs.open(lockPath, 'wx', PRIVATE_FILE_MODE);
const ownershipToken = `${process.pid}:${randomUUID()}`;
try {
await lock.writeFile(ownershipToken, 'utf-8');
try {
await lock.sync();
} catch (error) {
// Some FUSE and network filesystems support exclusive lock files but
// explicitly do not implement fsync. The token is still visible to
// cooperating processes, so do not make those projects unusable.
if (!isUnsupportedSyncError(error)) {
throw error;
}
}
} catch (error) {
await lock.close().catch(() => undefined);
await fs.rm(lockPath, { force: true }).catch(() => undefined);
throw error;
}
lockOwnership.set(lock, ownershipToken);
return lock;
} catch (error) {
if (!isNodeErrorCode(error, 'EEXIST')) {
// A permission or filesystem problem, not contention - say so.
throw errorFor('create-failed', { lockPath, cause: error });
}
// A crashed process leaves the lock behind forever; state-file
// writes are sub-second, so an old lock is an orphan - steal it.
let staleStolen = false;
try {
const lockStat = await fs.stat(lockPath);
if (Date.now() - lockStat.mtimeMs > STALE_LOCK_THRESHOLD_MS) {
await fs.rm(lockPath, { force: true });
staleStolen = true;
}
} catch {
// The holder released between open and stat - retry, but stay
// bounded: a persistently failing stat (EPERM, delete-pending)
// must hit the deadline instead of spinning forever.
}
if (!staleStolen) {
if (Date.now() >= deadline) {
throw errorFor('timeout', { lockPath });
}
await sleep(LOCK_POLL_MS);
// Never steal by age: unlinking a supposedly stale path can race with
// its replacement and erase a live owner's lock. The timeout diagnostic
// gives the user an explicit recovery path for genuinely orphaned locks.
if (Date.now() >= deadline) {
throw errorFor('timeout', { lockPath });
}
await sleep(LOCK_POLL_MS);
}
}
}
@@ -165,6 +184,21 @@ export async function releaseFileLock(
lock: nodeFs.promises.FileHandle,
lockPath: string
): Promise<void> {
const ownershipToken = lockOwnership.get(lock);
lockOwnership.delete(lock);
await lock.close().catch(() => undefined);
await fs.rm(lockPath, { force: true }).catch(() => undefined);
if (ownershipToken === undefined) {
return;
}
try {
const currentToken = await fs.readFile(lockPath, 'utf-8');
if (currentToken === ownershipToken) {
await fs.rm(lockPath, { force: true });
}
} catch {
// The lock was already removed or replaced with an unreadable path.
// In either case, this owner must not remove anything else.
}
}
+632
View File
@@ -0,0 +1,632 @@
/**
* GitHub Copilot Cloud Agent Support
*
* Generates copilot-setup-steps.yml and .github/agents/openspec.agent.md
* when the github-copilot tool is selected during init/update.
* These files enable the GitHub Copilot coding agent (cloud) to use the
* OpenSpec CLI in its ephemeral dev environment.
*/
import path from 'path';
import { promises as fs } from 'fs';
import { Document, YAMLMap, parseDocument, isMap } from 'yaml';
import { FileSystemUtils } from '../../utils/file-system.js';
import { readProjectConfig, resolveConfigFilePath } from '../project-config.js';
const COPILOT_TOOL_ID = 'github-copilot';
const OPENSPEC_MANAGED_MARKER = 'Generated by OpenSpec for GitHub Copilot coding agent support.';
/**
* Check if a tool list includes github-copilot.
*/
export function includesGitHubCopilot(toolIds: string[]): boolean {
return toolIds.includes(COPILOT_TOOL_ID);
}
/**
* Generate the copilot-setup-steps.yml workflow file content.
* This workflow pre-installs the OpenSpec CLI in the Copilot coding agent's
* ephemeral GitHub Actions environment.
*/
export function generateCopilotSetupSteps(): string {
return `# ${OPENSPEC_MANAGED_MARKER}
${generateCopilotSetupStepsBody()}`;
}
function generateCopilotSetupStepsBody(): string {
return `name: "Copilot Setup Steps"
# Runs automatically when changed (for validation) and can be triggered manually.
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
# The job MUST be called \`copilot-setup-steps\` for Copilot coding agent to pick it up.
copilot-setup-steps:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install OpenSpec CLI
run: npm install -g @fission-ai/openspec
- name: Verify OpenSpec CLI
run: openspec --version
`;
}
/**
* Generate the .github/agents/openspec.agent.md custom agent file content.
* This tells the GitHub Copilot coding agent how to use the OpenSpec CLI.
*/
export function generateCopilotAgentFile(): string {
return generateCopilotAgentFileBody(true);
}
function generateCopilotAgentFileBody(includeManagedMarker = false): string {
const managedMarker = includeManagedMarker
? `<!-- ${OPENSPEC_MANAGED_MARKER} -->\n\n`
: '';
return `---
name: OpenSpec
description: "Manages OpenSpec changes, specs, and workflows using the OpenSpec CLI. Use this agent for proposing changes, exploring ideas, validating artifacts, checking status, and archiving completed work."
tools:
- "execute"
- "read"
- "search"
- "edit"
---
${managedMarker}# OpenSpec Agent
You are a specialized agent for managing OpenSpec workflows. Before using the \`openspec\` CLI, run \`openspec --version\`. If it is unavailable, install it with \`npm install -g @fission-ai/openspec\`.
## What is OpenSpec?
OpenSpec is a structured change management system for codebases. It organizes work into **changes** with planning artifacts (proposals, specs, designs, tasks) that guide implementation.
## Available Commands
### Agent-Compatible CLI Commands (prefer \`--json\` for structured output)
| Command | Purpose |
|---------|---------|
| \`openspec list [--json]\` | List all changes and specs |
| \`openspec show <item> [--json]\` | View a specific change or spec |
| \`openspec validate [--all] [--json]\` | Validate changes and specs for issues |
| \`openspec status [--change <name>] [--json]\` | Show artifact progress for a change |
| \`openspec instructions [artifact] [--change <name>] [--json]\` | Get next-step instructions for a change |
| \`openspec templates [--json]\` | List available templates |
| \`openspec schemas [--json]\` | List available workflow schemas |
| \`openspec archive <change> --json [--yes]\` | Archive a completed change; use \`--yes\` only after confirming all tasks are complete |
### Interactive CLI Commands (use when prompted by the user)
| Command | Purpose |
|---------|---------|
| \`openspec init\` | Initialize OpenSpec in the project |
| \`openspec update\` | Update OpenSpec configuration and artifacts |
| \`openspec view\` | Interactive dashboard |
| \`openspec config\` | View or modify settings |
## Workflow
When asked to work with OpenSpec, follow this pattern:
1. **Find the change**: Run \`openspec list --json\` to see active changes.
2. **Check progress**: Run \`openspec status --change <name> --json\` for the selected change.
3. **Follow instructions**: Run \`openspec instructions [artifact] --change <name> --json\` for the next artifact.
4. **Validate before completing**: Run \`openspec validate <name> --json\`.
## Creating New Changes
When the user wants to propose a new change:
1. Run \`openspec new change <name>\`.
2. Run \`openspec status --change <name> --json\` to see the artifact sequence.
3. Use \`openspec instructions [artifact] --change <name> --json\` before creating each artifact.
4. Run \`openspec validate <name> --json\` when the artifacts are complete.
## Key Directories
- \`openspec/\` — Root OpenSpec directory
- \`openspec/changes/\` — Active changes with their artifacts
- \`openspec/config.yaml\` — Project configuration
## Best Practices
- Always use \`--json\` flag when you need to parse output programmatically
- Run \`openspec validate\` after creating or modifying artifacts
- Check \`openspec status\` before starting work to understand the current state
- When archiving, ensure all tasks are completed and validated first
`;
}
function generatePreviousCopilotAgentFileBody(includeManagedMarker = false): string {
let content = generateCopilotAgentFileBody();
content = replaceRequired(
content,
'You are a specialized agent for managing OpenSpec workflows. Before using the `openspec` CLI, run `openspec --version`. If it is unavailable, install it with `npm install -g @fission-ai/openspec`.',
'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI through shell commands, pre-installed in the development environment via `copilot-setup-steps.yml`.',
'previous CLI access sentence'
);
content = replaceRequired(
content,
'| `openspec archive <change> --json [--yes]` | Archive a completed change; use `--yes` only after confirming all tasks are complete |',
'| `openspec archive <change>` | Archive a completed change |',
'previous archive command row'
);
if (!includeManagedMarker) {
return content;
}
return replaceRequired(
content,
'\n# OpenSpec Agent',
`\n<!-- ${OPENSPEC_MANAGED_MARKER} -->\n\n# OpenSpec Agent`,
'previous agent heading'
);
}
function generateLegacyCopilotAgentFileBody(): string {
let content = generatePreviousCopilotAgentFileBody();
content = replaceRequired(
content,
`## Workflow
When asked to work with OpenSpec, follow this pattern:
1. **Find the change**: Run \`openspec list --json\` to see active changes.
2. **Check progress**: Run \`openspec status --change <name> --json\` for the selected change.
3. **Follow instructions**: Run \`openspec instructions [artifact] --change <name> --json\` for the next artifact.
4. **Validate before completing**: Run \`openspec validate <name> --json\`.
## Creating New Changes
When the user wants to propose a new change:
1. Run \`openspec new change <name>\`.
2. Run \`openspec status --change <name> --json\` to see the artifact sequence.
3. Use \`openspec instructions [artifact] --change <name> --json\` before creating each artifact.
4. Run \`openspec validate <name> --json\` when the artifacts are complete.`,
`## Workflow
When asked to work with OpenSpec, follow this pattern:
1. **Check current state**: Run \`openspec status --json\` to understand what changes exist and their progress.
2. **Follow instructions**: Run \`openspec instructions --json\` to get context-aware next steps.
3. **Validate before completing**: Run \`openspec validate --all --json\` to ensure artifacts are correct.
## Creating New Changes
When the user wants to propose a new change:
1. Create the change directory under \`openspec/changes/<change-name>/\`
2. Generate the required planning artifacts based on the project's configured workflow schema
3. Run \`openspec validate --json\` to verify the artifacts are well-formed`,
'legacy workflow guidance'
);
content = replaceRequired(
content,
`tools:
- "execute"
- "read"
- "search"
- "edit"`,
`tools:
- "terminal"`,
'legacy tool alias'
);
content = replaceRequired(
content,
'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI through shell commands, pre-installed in the development environment via `copilot-setup-steps.yml`.',
'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI which is pre-installed in the development environment via `copilot-setup-steps.yml`.',
'legacy CLI access sentence'
);
content = replaceRequired(
content,
'| `openspec status [--change <name>] [--json]` | Show artifact progress for a change |',
'| `openspec status [--json]` | Show artifact progress for active changes |',
'legacy status command row'
);
content = replaceRequired(
content,
'| `openspec instructions [artifact] [--change <name>] [--json]` | Get next-step instructions for a change |',
'| `openspec instructions [--json]` | Get next-step instructions for a change |',
'legacy instructions command row'
);
return replaceRequired(
content,
'- `openspec/config.yaml` — Project configuration',
`- \`openspec/config.yaml\` — Project configuration
- \`openspec/explorations/\` — Exploration documents`,
'legacy exploration directory'
);
}
function replaceRequired(
content: string,
searchValue: string,
replaceValue: string,
label: string
): string {
if (!content.includes(searchValue)) {
throw new Error(`Cannot build Copilot cloud file content: missing ${label}`);
}
return content.replace(searchValue, replaceValue);
}
/**
* File paths (relative to project root) for the generated files.
*/
export const COPILOT_CLOUD_FILES = {
setupSteps: path.join('.github', 'workflows', 'copilot-setup-steps.yml'),
agent: path.join('.github', 'agents', 'openspec.agent.md'),
} as const;
const COPILOT_AGENT_ALTERNATE_FILE = path.join('.github', 'agents', 'openspec.md');
type CopilotCloudFile = (typeof COPILOT_CLOUD_FILES)[keyof typeof COPILOT_CLOUD_FILES];
const COPILOT_CLOUD_FILE_CONTENTS: Record<CopilotCloudFile, string> = {
[COPILOT_CLOUD_FILES.setupSteps]: generateCopilotSetupSteps(),
[COPILOT_CLOUD_FILES.agent]: generateCopilotAgentFile(),
};
function getLegacyCopilotCloudFileContents(relPath: CopilotCloudFile): string[] {
if (relPath === COPILOT_CLOUD_FILES.setupSteps) {
return [generateCopilotSetupStepsBody()];
}
return [
generateCopilotAgentFileBody(),
generatePreviousCopilotAgentFileBody(),
generatePreviousCopilotAgentFileBody(true),
generateLegacyCopilotAgentFileBody(),
];
}
function normalizeLineEndings(content: string): string {
return content.replace(/\r\n/g, '\n');
}
function isCurrentCopilotCloudFile(
relPath: CopilotCloudFile,
content: string
): boolean {
return normalizeLineEndings(content) === COPILOT_CLOUD_FILE_CONTENTS[relPath];
}
function isLegacyCopilotCloudFile(
relPath: CopilotCloudFile,
content: string
): boolean {
return getLegacyCopilotCloudFileContents(relPath).includes(normalizeLineEndings(content));
}
function isManagedCopilotCloudFile(
relPath: CopilotCloudFile,
content: string
): boolean {
return isCurrentCopilotCloudFile(relPath, content) || isLegacyCopilotCloudFile(relPath, content);
}
async function reconcileCopilotCloudFile(
fullPath: string,
relPath: CopilotCloudFile
): Promise<boolean> {
const currentContent = COPILOT_CLOUD_FILE_CONTENTS[relPath];
if (!(await FileSystemUtils.fileExists(fullPath))) {
await FileSystemUtils.writeFile(fullPath, currentContent);
return true;
}
const existingContent = await FileSystemUtils.readFile(fullPath);
if (isCurrentCopilotCloudFile(relPath, existingContent)) {
return false;
}
if (!isLegacyCopilotCloudFile(relPath, existingContent)) {
return false;
}
await FileSystemUtils.writeFile(fullPath, currentContent);
return true;
}
async function assertCreatableFilePath(filePath: string): Promise<void> {
let candidate = path.dirname(filePath);
while (true) {
try {
const stats = await fs.stat(candidate);
if (!stats.isDirectory()) {
throw new Error(`Parent path is not a directory: ${candidate}`);
}
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
const parent = path.dirname(candidate);
if (parent === candidate) {
throw new Error(`Cannot resolve a directory ancestor for: ${filePath}`);
}
candidate = parent;
}
}
async function assertMissingOrRegularFile(filePath: string): Promise<void> {
try {
const stats = await fs.stat(filePath);
if (!stats.isFile()) {
throw new Error(`Managed Copilot path is not a regular file: ${filePath}`);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
}
async function classifyCopilotAgentReconciliation(
agentPath: string,
alternateAgentPath: string
): Promise<'reconcile' | 'skip' | 'remove-managed'> {
if (!(await FileSystemUtils.fileExists(alternateAgentPath))) {
return 'reconcile';
}
if (!(await FileSystemUtils.fileExists(agentPath))) {
return 'skip';
}
const existingContent = await FileSystemUtils.readFile(agentPath);
if (isManagedCopilotCloudFile(COPILOT_CLOUD_FILES.agent, existingContent)) {
return 'remove-managed';
}
throw new Error(
`Conflicting Copilot agent profiles: preserve either ${COPILOT_AGENT_ALTERNATE_FILE} or ${COPILOT_CLOUD_FILES.agent}`
);
}
/**
* Reconcile Copilot cloud agent files in the project directory.
* Creates missing files and refreshes recognized legacy generated files while
* preserving current generated content and user customizations.
*
* @returns Object indicating which files were written.
*/
export async function writeCopilotCloudFiles(
projectPath: string
): Promise<{ setupStepsWritten: boolean; agentWritten: boolean }> {
const setupStepsPath = FileSystemUtils.resolveProjectArtifactPath(
projectPath,
COPILOT_CLOUD_FILES.setupSteps
);
const agentPath = FileSystemUtils.resolveProjectArtifactPath(
projectPath,
COPILOT_CLOUD_FILES.agent
);
const alternateAgentPath = FileSystemUtils.resolveProjectArtifactPath(
projectPath,
COPILOT_AGENT_ALTERNATE_FILE
);
await assertCreatableFilePath(setupStepsPath);
await assertCreatableFilePath(agentPath);
await assertMissingOrRegularFile(setupStepsPath);
await assertMissingOrRegularFile(agentPath);
await assertMissingOrRegularFile(alternateAgentPath);
const agentReconciliation = await classifyCopilotAgentReconciliation(
agentPath,
alternateAgentPath
);
const setupStepsWritten = await reconcileCopilotCloudFile(
setupStepsPath,
COPILOT_CLOUD_FILES.setupSteps
);
let agentWritten = false;
if (agentReconciliation === 'reconcile') {
agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent);
} else if (agentReconciliation === 'remove-managed') {
await fs.unlink(agentPath);
}
return { setupStepsWritten, agentWritten };
}
/**
* Remove copilot cloud agent files from the project directory.
* Used when github-copilot is deselected.
*
* @returns Number of files removed.
*/
export async function removeCopilotCloudFiles(projectPath: string): Promise<number> {
let removed = 0;
const managedPaths = Object.values(COPILOT_CLOUD_FILES).map((relPath) => ({
relPath,
fullPath: FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath),
}));
for (const { fullPath } of managedPaths) {
await assertMissingOrRegularFile(fullPath);
}
for (const { relPath, fullPath } of managedPaths) {
if (await FileSystemUtils.fileExists(fullPath)) {
const content = await FileSystemUtils.readFile(fullPath);
if (!isManagedCopilotCloudFile(relPath, content)) {
continue;
}
await fs.unlink(fullPath);
removed++;
}
}
return removed;
}
// ─────────────────────────────────────────────────────────────────────────────
// Opt-in
//
// Generating a GitHub Actions workflow into a user's `.github/` is invasive and
// ties us to Copilot's externally-owned coding-agent format, so cloud files are
// opt-in rather than an automatic side effect of selecting the Copilot tool.
// The decision is persisted in openspec/config.yaml so non-interactive
// `openspec update` (CI, agents) honors it without ever prompting.
// ─────────────────────────────────────────────────────────────────────────────
const COPILOT_CONFIG_KEY = 'githubCopilot';
const COPILOT_CLOUD_AGENT_KEY = 'cloudAgent';
/**
* Read the persisted opt-in for Copilot cloud-file generation.
*
* Tri-state: `true` (opted in), `false` (explicitly opted out), or `undefined`
* (never decided). A malformed value is treated as undecided rather than an
* error, matching how {@link readProjectConfig} degrades on bad fields.
*/
export function readCopilotCloudOptIn(projectPath: string): boolean | undefined {
const value = readProjectConfig(projectPath)?.githubCopilot?.cloudAgent;
return typeof value === 'boolean' ? value : undefined;
}
/**
* True when a managed Copilot cloud file (the current generation or a
* recognized legacy one) already exists. Projects created before the opt-in
* prompt existed are treated as implicitly opted in, so `openspec update`
* keeps their files current instead of silently abandoning them.
*/
export async function hasExistingManagedCloudFiles(projectPath: string): Promise<boolean> {
for (const relPath of Object.values(COPILOT_CLOUD_FILES)) {
const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath);
if (!(await FileSystemUtils.fileExists(fullPath))) {
continue;
}
const content = await FileSystemUtils.readFile(fullPath);
if (isManagedCopilotCloudFile(relPath, content)) {
return true;
}
}
return false;
}
/**
* Effective decision on whether to generate/refresh Copilot cloud files.
* An explicit opt-in or opt-out always wins; when undecided, fall back to
* whether managed files already exist (the migration path above).
*/
export async function isCopilotCloudEnabled(projectPath: string): Promise<boolean> {
const optIn = readCopilotCloudOptIn(projectPath);
if (typeof optIn === 'boolean') {
return optIn;
}
return hasExistingManagedCloudFiles(projectPath);
}
/**
* Persist the Copilot cloud opt-in into openspec/config.yaml.
*
* Uses the YAML document model rather than a re-serialize so the user's
* existing comments, ordering, and formatting survive untouched — the config
* file is hand-authored and heavily commented, so a lossy round-trip would be
* its own source of toil. No-op when no config file exists yet (init creates it
* before this is called); the caller treats persistence failures as non-fatal.
*/
export async function persistCopilotCloudOptIn(
projectPath: string,
value: boolean
): Promise<void> {
const configPath = resolveConfigFilePath(projectPath);
if (!configPath) {
return;
}
const existing = await FileSystemUtils.readFile(configPath);
const parsed = parseDocument(existing);
// A file YAML can't parse cleanly — a multi-document stream, a tab-indented
// syntax error — can't be edited without corrupting it, and toString() would
// throw. Leave it untouched rather than clobber or crash; such a file is
// already invalid, so readProjectConfig ignores it anyway.
if (parsed.errors.length > 0) {
return;
}
// `setIn(['githubCopilot', ...])` needs a top-level map. A config whose root
// is anything else — a scalar (`null`, a bare string) or even a sequence —
// has no map to set a key on and makes setIn throw. Such a file is already
// invalid (readProjectConfig rejects it), so start fresh rather than crash.
// An empty or comment-only file parses to null contents, which setIn fills in
// while keeping the comments — so only a non-map root is discarded.
const doc: Document =
parsed.contents === null || isMap(parsed.contents) ? parsed : new Document();
// The root is a map now, but the `githubCopilot` node itself may be a stray
// scalar/sequence/null (e.g. `githubCopilot: false`) — descending into that
// with setIn also throws. Replace any non-map node with an empty map first.
const section = doc.getIn([COPILOT_CONFIG_KEY], true);
if (section !== undefined && !isMap(section)) {
doc.setIn([COPILOT_CONFIG_KEY], new YAMLMap());
}
doc.setIn([COPILOT_CONFIG_KEY, COPILOT_CLOUD_AGENT_KEY], value);
await FileSystemUtils.writeFile(configPath, doc.toString());
}
/**
* Return the managed cloud-file paths (relative to the project root) that
* currently hold user-owned, non-managed content — i.e. files OpenSpec will
* deliberately leave untouched. Used to tell an opted-in user that we preserved
* their existing file rather than silently doing nothing, which is the honest
* answer to "will this affect my existing Copilot cloud setup?".
*/
export async function findUnmanagedCloudFiles(projectPath: string): Promise<string[]> {
const collisions: string[] = [];
for (const relPath of Object.values(COPILOT_CLOUD_FILES)) {
const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath);
if (!(await FileSystemUtils.fileExists(fullPath))) {
continue;
}
const content = await FileSystemUtils.readFile(fullPath);
if (!isManagedCopilotCloudFile(relPath, content)) {
collisions.push(relPath);
}
}
return collisions;
}
/**
* Return the managed cloud-file paths (relative to the project root) that
* currently exist and hold OpenSpec-generated content. Callers report this
* rather than the intended paths, so output never claims a file that a write
* skipped (user already owns it) or that reconciliation removed.
*/
export async function listManagedCloudFiles(projectPath: string): Promise<string[]> {
const present: string[] = [];
for (const relPath of Object.values(COPILOT_CLOUD_FILES)) {
const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath);
if (!(await FileSystemUtils.fileExists(fullPath))) {
continue;
}
const content = await FileSystemUtils.readFile(fullPath);
if (isManagedCopilotCloudFile(relPath, content)) {
present.push(relPath);
}
}
return present;
}
+12
View File
@@ -11,6 +11,16 @@ export const GLOBAL_DATA_DIR_NAME = 'openspec';
export type Profile = 'core' | 'custom';
export type Delivery = 'both' | 'skills' | 'commands';
/** Telemetry section of global config (identity + opt-out). */
export interface TelemetryConfig {
/** When false, telemetry is disabled. Unset means enabled (opt-out model). */
enabled?: boolean;
/** Anonymous random UUID; no relation to the user. */
anonymousId?: string;
/** Whether the first-run telemetry notice has been shown. */
noticeSeen?: boolean;
}
// TypeScript interfaces
export interface GlobalConfig {
featureFlags?: Record<string, boolean>;
@@ -24,6 +34,8 @@ export interface GlobalConfig {
defaultStore?: string;
/** Workset opener rows (slice 7.1); hand-edited, validated on use. */
openers?: unknown;
/** Anonymous usage analytics settings and identity. */
telemetry?: TelemetryConfig;
}
const DEFAULT_CONFIG: GlobalConfig = {
+352 -48
View File
@@ -13,7 +13,7 @@ import { createRequire } from 'module';
import { FileSystemUtils } from '../utils/file-system.js';
import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
import { findRepoPlanningRootSync } from './planning-home.js';
import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js';
import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
import {
AI_TOOLS,
OPENSPEC_DIR_NAME,
@@ -46,11 +46,15 @@ import {
getSkillTemplates,
getCommandContents,
generateSkillContent,
hasGlobalSkillTarget,
resolveToolSkillsDir,
toolSupportsSkills,
type ToolSkillStatus,
} from './shared/index.js';
import { getGlobalConfig, type Delivery, type Profile } from './global-config.js';
import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js';
import { getAvailableTools } from './available-tools.js';
import { writeSharedSkillTarget } from './shared-skill-target.js';
import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, hasMovableContent, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js';
import {
resolveCommandSurfaceCapability,
@@ -60,6 +64,15 @@ import {
shouldReconcileCommandFilesForTool,
shouldRemoveSkillsForTool,
} from './command-surface.js';
import {
writeCopilotCloudFiles,
readCopilotCloudOptIn,
hasExistingManagedCloudFiles,
persistCopilotCloudOptIn,
removeCopilotCloudFiles,
findUnmanagedCloudFiles,
listManagedCloudFiles,
} from './github-copilot/cloud-agent.js';
const require = createRequire(import.meta.url);
const { version: OPENSPEC_VERSION } = require('../../package.json');
@@ -101,6 +114,23 @@ type InitCommandOptions = {
profile?: string;
/** Commander's --no-animation flag: false disables the welcome animation. */
animation?: boolean;
/**
* Explicit opt-in/out for GitHub Copilot cloud coding-agent files.
* `--copilot-cloud` sets true, `--no-copilot-cloud` sets false; undefined
* leaves the decision to config, migration, or an interactive prompt.
*/
copilotCloud?: boolean;
};
type ValidatedInitTool = {
value: string;
name: string;
skillsDir?: string;
skillsPath: string;
skillsRoot: string;
isGlobalSkillTarget: boolean;
wasConfigured: boolean;
requiresIdeRestart?: boolean;
};
/**
@@ -121,6 +151,7 @@ export class InitCommand {
private readonly interactiveOption?: boolean;
private readonly profileOverride?: string;
private readonly animation: boolean;
private readonly copilotCloudOption?: boolean;
constructor(options: InitCommandOptions = {}) {
this.toolsArg = options.tools;
@@ -128,6 +159,7 @@ export class InitCommand {
this.interactiveOption = options.interactive;
this.profileOverride = options.profile;
this.animation = options.animation ?? true;
this.copilotCloudOption = options.copilotCloud;
}
async execute(targetPath: string): Promise<void> {
@@ -200,7 +232,7 @@ export class InitCommand {
const selectedToolIds = await this.getSelectedTools(toolStates, extendMode, detectedTools, projectPath);
// Validate selected tools
const validatedTools = this.validateTools(selectedToolIds, toolStates);
const validatedTools = this.validateTools(selectedToolIds, toolStates, projectPath);
// Selecting a renamed tool is consent to leave its former directory:
// init is about to write the current one, and leaving OpenSpec content
@@ -216,11 +248,22 @@ export class InitCommand {
if (kept) console.log(chalk.dim(kept));
}
// Decide whether to generate GitHub Copilot cloud files. This is opt-in
// (see cloud-agent.ts): selecting the Copilot tool no longer silently
// writes a GitHub Actions workflow into the user's .github/. The decision
// is made before generation so the write can be gated, and persisted after
// config.yaml exists so future non-interactive updates honor it.
const copilotDecision = await this.resolveCopilotCloudDecision(projectPath, validatedTools);
// Create directory structure and config
await this.createDirectoryStructure(openspecPath, extendMode);
// Generate skills and commands for each tool
const results = await this.generateSkillsAndCommands(projectPath, validatedTools);
const results = await this.generateSkillsAndCommands(
projectPath,
validatedTools,
copilotDecision.write
);
// Legacy cleanup was deferred to avoid interfering with skill/command generation;
// now that outputs are written, finalize the cleanup (e.g. remove stale files).
@@ -231,8 +274,54 @@ export class InitCommand {
// Create config.yaml if needed
const configStatus = await this.createConfig(openspecPath, extendMode);
// Persist an explicit Copilot cloud decision so `openspec update` (which
// never prompts) honors it. Best-effort: a config-write failure must not
// fail an otherwise-successful init.
if (copilotDecision.persist !== undefined) {
try {
await persistCopilotCloudOptIn(projectPath, copilotDecision.persist);
} catch {
// Non-fatal: the files (if any) were still written correctly.
}
}
// An explicit opt-out means "no cloud files here": clean up any that a
// previous run (or an older OpenSpec) generated. Only OpenSpec-managed
// files are removed — a user-customized file is preserved.
let copilotRemoved = 0;
if (copilotDecision.optedOut) {
try {
copilotRemoved = await removeCopilotCloudFiles(projectPath);
} catch {
// Non-fatal: removal targets files from a prior run; a failure here
// just leaves them for the next `openspec update` to clean up.
}
}
// Report the cloud outcome from what is actually on disk after the write,
// not from the decision alone: writing over a user-owned file is a no-op,
// and the alternate-agent path can remove a managed file — so list only
// managed files that exist, and separately flag any left-untouched ones.
const copilotSucceeded = [...results.createdTools, ...results.refreshedTools].some(
(tool) => tool.value === 'github-copilot'
);
const wroteCloud = copilotDecision.write && copilotSucceeded;
const copilotPresent = wroteCloud ? await listManagedCloudFiles(projectPath) : [];
const copilotCollisions = wroteCloud ? await findUnmanagedCloudFiles(projectPath) : [];
// Display success message
this.displaySuccessMessage(projectPath, validatedTools, results, configStatus);
this.displaySuccessMessage(projectPath, validatedTools, results, configStatus, {
write: copilotDecision.write,
skippedUndecided: copilotDecision.skippedUndecided,
present: copilotPresent,
collisions: copilotCollisions,
removed: copilotRemoved,
});
if (results.failedTools.length > 0) {
throw new Error(
`OpenSpec setup failed for: ${results.failedTools.map((tool) => tool.name).join(', ')}`
);
}
}
// ═══════════════════════════════════════════════════════════
@@ -258,6 +347,73 @@ export class InitCommand {
return isInteractive({ interactive: this.interactiveOption });
}
/**
* Decide whether to generate GitHub Copilot cloud files, and whether to
* persist that decision. Precedence:
* 1. `--copilot-cloud` / `--no-copilot-cloud` flag (explicit this run)
* 2. persisted opt-in in config.yaml
* 3. managed files already present (migration for pre-opt-in projects)
* 4. interactive confirm (default No)
* 5. non-interactive with no signal: skip, and don't persist a default
*
* @returns `write` — generate the files this run; `persist` — value to write
* back to config (undefined = leave config untouched); `optedOut` — the user
* explicitly declined, so any already-generated managed files should be
* removed; `skippedUndecided` — selected but no signal and couldn't ask, so
* the caller can hint that the opt-in exists.
*/
private async resolveCopilotCloudDecision(
projectPath: string,
tools: ValidatedInitTool[]
): Promise<{ write: boolean; persist?: boolean; optedOut: boolean; skippedUndecided: boolean }> {
const copilotSelected = tools.some((tool) => tool.value === 'github-copilot');
if (!copilotSelected) {
// A flag that can't apply is a likely mistake — say so rather than no-op.
if (this.copilotCloudOption !== undefined) {
console.log(
chalk.yellow(
'--copilot-cloud/--no-copilot-cloud was ignored because the github-copilot tool was not selected.'
)
);
}
return { write: false, optedOut: false, skippedUndecided: false };
}
if (this.copilotCloudOption !== undefined) {
return {
write: this.copilotCloudOption,
persist: this.copilotCloudOption,
optedOut: !this.copilotCloudOption,
skippedUndecided: false,
};
}
const persistedOptIn = readCopilotCloudOptIn(projectPath);
if (typeof persistedOptIn === 'boolean') {
return { write: persistedOptIn, optedOut: !persistedOptIn, skippedUndecided: false };
}
if (await hasExistingManagedCloudFiles(projectPath)) {
return { write: true, optedOut: false, skippedUndecided: false };
}
if (this.canPromptInteractively()) {
const { confirm } = await import('@inquirer/prompts');
const answer = await confirm({
message:
'Set up GitHub Copilot cloud coding-agent files? This is for the GitHub-hosted ' +
'Copilot coding agent (github.com), not Copilot in your editor. It writes two files: ' +
'.github/workflows/copilot-setup-steps.yml and .github/agents/openspec.agent.md.',
default: false,
});
return { write: answer, persist: answer, optedOut: !answer, skippedUndecided: false };
}
// Non-interactive with no explicit signal: don't write, and leave the
// decision unpersisted so a later interactive run can still prompt.
return { write: false, optedOut: false, skippedUndecided: true };
}
private resolveProfileOverride(): Profile | undefined {
if (this.profileOverride === undefined) {
return undefined;
@@ -590,11 +746,23 @@ export class InitCommand {
private validateTools(
toolIds: string[],
toolStates: Map<string, ToolSkillStatus>
): Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> {
const validatedTools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> = [];
toolStates: Map<string, ToolSkillStatus>,
projectPath: string
): ValidatedInitTool[] {
const validatedTools: ValidatedInitTool[] = [];
for (const toolId of toolIds) {
const reconciledToolIds = toolIds.includes('codex') && toolIds.includes('agents')
? toolIds.filter((toolId) => toolId !== 'agents')
: toolIds;
if (reconciledToolIds.length !== toolIds.length) {
console.log(
chalk.dim(
'Codex and agents share .agents/skills; writing one tree with Codex and generic skill references.'
)
);
}
for (const toolId of reconciledToolIds) {
const tool = AI_TOOLS.find((t) => t.value === toolId);
if (!tool) {
const validToolIds = getToolsWithSkillsDir();
@@ -603,7 +771,7 @@ export class InitCommand {
);
}
if (!tool.skillsDir) {
if (!toolSupportsSkills(tool)) {
const validToolsWithSkills = getToolsWithSkillsDir();
throw new Error(
`Tool '${toolId}' does not support skill generation.\nTools with skill generation support:\n ${validToolsWithSkills.join('\n ')}`
@@ -611,11 +779,17 @@ export class InitCommand {
}
const preState = toolStates.get(tool.value);
const skillsPath = resolveToolSkillsDir(projectPath, tool);
const isGlobalSkillTarget = hasGlobalSkillTarget(tool);
validatedTools.push({
value: tool.value,
name: tool.name,
skillsDir: tool.skillsDir,
skillsPath,
skillsRoot: isGlobalSkillTarget ? skillsPath : projectPath,
isGlobalSkillTarget,
wasConfigured: preState?.configured ?? false,
requiresIdeRestart: tool.requiresIdeRestart,
});
}
@@ -637,6 +811,7 @@ export class InitCommand {
];
for (const dir of directories) {
FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
await FileSystemUtils.createDirectory(dir);
}
return;
@@ -652,6 +827,7 @@ export class InitCommand {
];
for (const dir of directories) {
FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
await FileSystemUtils.createDirectory(dir);
}
@@ -675,7 +851,8 @@ export class InitCommand {
*/
private async generateSkillsAndCommands(
projectPath: string,
tools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }>
tools: ValidatedInitTool[],
writeCopilotCloud: boolean
): Promise<{
createdTools: typeof tools;
refreshedTools: typeof tools;
@@ -714,12 +891,9 @@ export class InitCommand {
// Generate skill files if the selected delivery and tool capability allow skills
if (shouldGenerateSkills) {
// Use tool-specific skillsDir
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
// Create skill directories and SKILL.md files
for (const { template, dirName } of skillTemplates) {
const skillDir = path.join(skillsDir, dirName);
const skillDir = path.join(tool.skillsPath, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');
// Generate SKILL.md content with YAML frontmatter including generatedBy
@@ -732,12 +906,16 @@ export class InitCommand {
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
// Write the skill file
FileSystemUtils.assertPathWithin(tool.skillsRoot, skillFile);
await FileSystemUtils.writeFile(skillFile, skillContent);
}
writeSharedSkillTarget(projectPath, tool.value);
}
if (shouldRemoveSkillsForTool(tool.value, delivery)) {
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
removedSkillCount += await this.removeSkillDirs(skillsDir);
if (shouldRemoveSkillsForTool(tool.value, delivery) && !tool.isGlobalSkillTarget) {
removedSkillCount += await this.removeSkillDirs(tool.skillsRoot, tool.skillsPath);
// Retain an explicit selection even when this delivery mode produces
// no skills, so a divergent legacy sibling cannot reclaim ownership.
writeSharedSkillTarget(projectPath, tool.value);
}
// Generate commands if delivery includes commands
@@ -747,7 +925,7 @@ export class InitCommand {
const generatedCommands = generateCommands(commandContents, adapter);
for (const cmd of generatedCommands) {
const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
const commandFile = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmd.path);
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
}
}
@@ -761,6 +939,9 @@ export class InitCommand {
if (shouldReconcileCommandFilesForTool(tool.value, delivery)) {
removedCommandCount += await this.removeCommandFiles(projectPath, tool.value);
}
if (tool.value === 'github-copilot' && writeCopilotCloud) {
await writeCopilotCloudFiles(projectPath);
}
spinner.succeed(`Setup complete for ${tool.name}`);
@@ -775,6 +956,20 @@ export class InitCommand {
}
}
for (const tool of [...createdTools, ...refreshedTools]) {
for (const migration of migrateLegacyToolDirs(
projectPath,
[tool.value],
'after-generation'
)) {
if (hasMovableContent(migration)) {
console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
}
const kept = keptInPlaceNotice(migration);
if (kept) console.log(chalk.dim(kept));
}
}
return {
createdTools,
refreshedTools,
@@ -803,6 +998,7 @@ export class InitCommand {
try {
const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA });
FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), configPath);
await FileSystemUtils.writeFile(configPath, yamlContent);
return 'created';
} catch {
@@ -816,7 +1012,7 @@ export class InitCommand {
private displaySuccessMessage(
projectPath: string,
tools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }>,
tools: ValidatedInitTool[],
results: {
createdTools: typeof tools;
refreshedTools: typeof tools;
@@ -826,10 +1022,21 @@ export class InitCommand {
removedCommandCount: number;
removedSkillCount: number;
},
configStatus: 'created' | 'exists' | 'skipped'
configStatus: 'created' | 'exists' | 'skipped',
copilot: {
write: boolean;
skippedUndecided: boolean;
present: string[];
collisions: string[];
removed: number;
}
): void {
console.log();
console.log(chalk.bold('OpenSpec Setup Complete'));
console.log(
chalk.bold(
results.failedTools.length > 0 ? 'OpenSpec Setup Incomplete' : 'OpenSpec Setup Complete'
)
);
console.log();
// Show created vs refreshed tools
@@ -847,19 +1054,66 @@ export class InitCommand {
const profile: Profile = (this.profileOverride as Profile) ?? globalConfig.profile ?? 'core';
const delivery: Delivery = globalConfig.delivery ?? 'both';
const workflows = getProfileWorkflows(profile, globalConfig.workflows);
const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', ');
const skillCount = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, delivery))
? getSkillTemplates(workflows).length
: 0;
const commandCount = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, delivery))
? getCommandContents(workflows).length
: 0;
if (skillCount > 0 && commandCount > 0) {
console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`);
} else if (skillCount > 0) {
console.log(`${skillCount} skills in ${toolDirs}/`);
} else if (commandCount > 0) {
console.log(`${commandCount} commands in ${toolDirs}/`);
const usesGlobalSkillTarget = successfulTools.some((tool) => tool.isGlobalSkillTarget);
if (!usesGlobalSkillTarget) {
const toolDirs = [
...new Set(
successfulTools
.map((tool) => tool.skillsDir)
.filter((skillsDir): skillsDir is string => Boolean(skillsDir))
),
].join(', ');
const skillCount = successfulTools.some((tool) =>
shouldGenerateSkillsForTool(tool.value, delivery)
)
? getSkillTemplates(workflows).length
: 0;
const commandCount = successfulTools.some((tool) =>
shouldGenerateCommandsForTool(tool.value, delivery)
)
? getCommandContents(workflows).length
: 0;
if (skillCount > 0 && commandCount > 0) {
console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`);
} else if (skillCount > 0) {
console.log(`${skillCount} skills in ${toolDirs}/`);
} else if (commandCount > 0) {
console.log(`${commandCount} commands in ${toolDirs}/`);
}
} else {
const skillTools = successfulTools.filter((tool) =>
shouldGenerateSkillsForTool(tool.value, delivery)
);
const skillCount = skillTools.length * getSkillTemplates(workflows).length;
if (skillCount > 0) {
const skillDirs = [...new Set(skillTools.map((tool) => tool.skillsPath))];
console.log(`${skillCount} skills in ${skillDirs.join(', ')}`);
}
const commandContents = getCommandContents(workflows);
const commandTools = successfulTools.filter((tool) =>
shouldGenerateCommandsForTool(tool.value, delivery)
);
const commandCount = commandTools.length * commandContents.length;
if (commandCount > 0) {
const commandDirs = [
...new Set(
commandTools.flatMap((tool) => {
const adapter = CommandAdapterRegistry.get(tool.value);
if (!adapter) return [];
return commandContents.map((command) => {
const commandPath = adapter.getFilePath(command.id);
const absolutePath = path.isAbsolute(commandPath)
? commandPath
: path.join(projectPath, commandPath);
return path.dirname(absolutePath);
});
})
),
];
console.log(`${commandCount} commands in ${commandDirs.join(', ')}`);
}
}
}
@@ -882,6 +1136,33 @@ export class InitCommand {
console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`));
}
// GitHub Copilot cloud files are opt-in — report what is actually on disk:
// list the managed files that now exist (never files we didn't write), flag
// any user-owned file we left untouched, note an opt-out cleanup, or (when
// skipped for want of a signal) say how to turn them on.
const copilotSucceeded = successfulTools.some((tool) => tool.value === 'github-copilot');
if (copilotSucceeded && copilot.write) {
if (copilot.present.length > 0) {
console.log(`GitHub Copilot cloud files: ${copilot.present.join(', ')}`);
}
if (copilot.collisions.length > 0) {
console.log(
chalk.dim(
`Left your existing ${copilot.collisions.join(' and ')} untouched — add the OpenSpec ` +
`install step by hand so the Copilot cloud agent can run openspec.`
)
);
}
} else if (copilotSucceeded && copilot.removed > 0) {
console.log(
chalk.dim(`Removed: ${copilot.removed} Copilot cloud agent file(s) (opted out of cloud files)`)
);
} else if (copilotSucceeded && copilot.skippedUndecided) {
console.log(
chalk.dim("Skipped GitHub Copilot cloud files (opt-in). Enable with 'openspec init --copilot-cloud'.")
);
}
// Show manual setup notes for tools that need extra configuration
for (const tool of successfulTools) {
const setupNote = AI_TOOLS.find((t) => t.value === tool.value)?.setupNote;
@@ -932,7 +1213,13 @@ export class InitCommand {
);
hint = `Start your first change: ${transformer ? transformer(command) : command} "your idea"`;
} else if (shouldGenerateSkillsForTool(tool.value, activeDelivery)) {
hint = `Start your first change: ${getSkillReferenceTransformer(tool.value)(command)} "your idea"`;
const skillReference = getSkillReferenceTransformer(tool.value)(command);
// 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"`;
} else {
continue;
}
@@ -989,16 +1276,33 @@ export class InitCommand {
console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);
// Restart instruction if any tools were configured and got a surface
// (when nothing was generated there is nothing a restart would pick up);
// only mention commands when commands were actually generated. Not "slash
// commands": Amazon Q's generated files are prompt-library entries invoked
// with @, so a restart line promising slash commands would be wrong for it.
if ((results.createdTools.length > 0 || results.refreshedTools.length > 0) && (commandsGenerated || skillsGenerated)) {
// Restart instruction only when at least one IDE/editor-resident tool
// actually received a generated surface. Two conditions, coupled to the SAME
// tool: (1) its commands/skills are loaded by a long-running editor process
// (CLI tools pick the files up immediately, so a restart line would be wrong
// for them — see #1067), and (2) a surface was actually generated for it
// under the active delivery (an IDE tool that generated nothing has nothing a
// restart would pick up, even if a co-configured CLI tool did generate).
// Wording follows what the IDE tool itself generated, not the global
// aggregate: it must not say "commands" when the IDE tool only got skills
// while a co-configured CLI tool got commands. Not "slash commands" either:
// Amazon Q's generated files are prompt-library entries invoked with @, so a
// restart line promising slash commands would be wrong for it.
const restartCommandsGenerated = successfulTools.some(
(tool) =>
tool.requiresIdeRestart &&
shouldGenerateCommandsForTool(tool.value, activeDelivery)
);
const restartSkillsGenerated = successfulTools.some(
(tool) =>
tool.requiresIdeRestart &&
shouldGenerateSkillsForTool(tool.value, activeDelivery)
);
if (restartCommandsGenerated || restartSkillsGenerated) {
console.log();
console.log(
chalk.white(
commandsGenerated
restartCommandsGenerated
? 'Restart your IDE for the new commands to take effect.'
: 'Restart your IDE for the new skills to take effect.'
)
@@ -1017,7 +1321,7 @@ export class InitCommand {
}).start();
}
private async removeSkillDirs(skillsDir: string): Promise<number> {
private async removeSkillDirs(skillsRoot: string, skillsDir: string): Promise<number> {
let removed = 0;
for (const workflow of ALL_WORKFLOWS) {
@@ -1025,11 +1329,11 @@ export class InitCommand {
if (!dirName) continue;
const skillDir = path.join(skillsDir, dirName);
if (!fs.existsSync(skillDir)) continue;
FileSystemUtils.assertPathWithin(skillsRoot, skillDir);
try {
if (fs.existsSync(skillDir)) {
await fs.promises.rm(skillDir, { recursive: true, force: true });
removed++;
}
await fs.promises.rm(skillDir, { recursive: true, force: true });
removed++;
} catch {
// Ignore errors
}
@@ -1045,7 +1349,7 @@ export class InitCommand {
for (const workflow of ALL_WORKFLOWS) {
const cmdPath = adapter.getFilePath(workflow);
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
try {
if (fs.existsSync(fullPath)) {
+56 -27
View File
@@ -39,7 +39,6 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
'lingma': { type: 'directory', path: '.lingma/commands/openspec' },
'crush': { type: 'directory', path: '.crush/commands/openspec' },
'gemini': { type: 'directory', path: '.gemini/commands/openspec' },
'costrict': { type: 'directory', path: '.cospec/openspec/commands' },
// File-based: individual openspec-*.md files in a commands/workflows/prompts folder
'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' },
@@ -59,9 +58,12 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
'junie': { type: 'files', pattern: ['.junie/commands/opsx-*.md', '.junie/commands/openspec-*.md'] },
'qwen': { type: 'files', pattern: ['.qwen/commands/opsx-*.toml', '.qwen/commands/openspec-*.toml'] },
'codex': { type: 'files', pattern: '.codex/prompts/openspec-*.md' },
// Keep this file-scoped: the CoStrict adapter writes `opsx-*.md` into the
// same folder, so a directory entry removes the live command files — and
// anything else the user keeps there — on every run.
'costrict': { type: 'files', pattern: '.cospec/openspec/commands/openspec-*.md' },
};
/**
@@ -799,35 +801,14 @@ export function formatDeferredGlobalPromptSummary(detection: LegacyDetectionResu
export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): string[] {
const tools = new Set<string>();
// Match directories to tool IDs
for (const dir of detection.slashCommandDirs) {
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
if (pattern.type === 'directory' && pattern.path === dir) {
tools.add(toolId);
break;
}
}
const toolId = legacyToolIdForDir(dir);
if (toolId) tools.add(toolId);
}
// Match files to tool IDs using glob patterns
for (const file of detection.slashCommandFiles) {
// Normalize file path to use forward slashes for consistent matching (Windows compatibility)
const normalizedFile = normalizePathForMatch(file);
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
if (pattern.type === 'files' && pattern.pattern) {
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
let matched = false;
for (const p of patterns) {
const regex = globToRegex(p);
if (regex.test(normalizedFile)) {
tools.add(toolId);
matched = true;
break;
}
}
if (matched) break;
}
}
const toolId = legacyToolIdForFile(file);
if (toolId) tools.add(toolId);
}
for (const prompt of getLegacyGlobalPromptMatches(detection)) {
@@ -837,6 +818,26 @@ export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): s
return Array.from(tools);
}
/** The tool that owns a repo-local legacy slash-command directory, if any. */
function legacyToolIdForDir(dir: string): string | undefined {
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
if (pattern.type === 'directory' && pattern.path === dir) return toolId;
}
return undefined;
}
/** The tool that owns a repo-local legacy slash-command file, if any. */
function legacyToolIdForFile(file: string): string | undefined {
// Normalize to forward slashes so the glob patterns match on Windows too.
const normalizedFile = normalizePathForMatch(file);
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
if (pattern.type !== 'files' || !pattern.pattern) continue;
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
if (patterns.some((p) => globToRegex(p).test(normalizedFile))) return toolId;
}
return undefined;
}
/**
* Normalizes global Codex prompt matches so callers can rely on workflow-aware
* metadata even when older detection results only carry file paths.
@@ -900,6 +901,34 @@ export function omitGlobalLegacyPromptFiles(detection: LegacyDetectionResult): L
return nextDetection;
}
/**
* Returns a detection snapshot with the repo-local slash-command artifacts of
* the given tools removed. The legacy-upgrade path uses this to skip cleaning a
* tool's legacy files when its replacement was deliberately NOT written — e.g. a
* Codex upgrade suppressed because the shared `.agents` root is already owned by
* another tool. Deleting the legacy prompt without writing its replacement would
* violate the cleanup contract ("remove X because replacement Y now exists") and
* strip the tool's only OpenSpec integration.
*/
export function omitToolLegacyArtifacts(
detection: LegacyDetectionResult,
toolIds: readonly string[]
): LegacyDetectionResult {
if (toolIds.length === 0) return detection;
const skip = new Set(toolIds);
const nextDetection: LegacyDetectionResult = {
...detection,
slashCommandDirs: detection.slashCommandDirs.filter(
(dir) => !skip.has(legacyToolIdForDir(dir) ?? '')
),
slashCommandFiles: detection.slashCommandFiles.filter(
(file) => !skip.has(legacyToolIdForFile(file) ?? '')
),
};
nextDetection.hasLegacyArtifacts = hasLegacyArtifacts(nextDetection);
return nextDetection;
}
/**
* Builds a detection snapshot containing only the selected global Codex prompt
* matches for replacement-gated cleanup.
+109 -27
View File
@@ -17,8 +17,12 @@ import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js';
import { COMMAND_IDS } from './shared/tool-detection.js';
import { ALL_WORKFLOWS } from './profiles.js';
import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js';
import { FileSystemUtils } from '../utils/file-system.js';
import { isSharedSkillTargetActive } from './shared-skill-target.js';
import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js';
import path from 'path';
import * as fs from 'fs';
import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js';
export interface LegacyToolRoot {
/** Former tool root, e.g. '.kimi' */
@@ -29,6 +33,8 @@ export interface LegacyToolRoot {
* location may still be the live one for somebody.
*/
needsConsent: boolean;
/** Migrations that need a freshly generated destination run afterward. */
timing?: 'before-generation' | 'after-generation';
}
/**
@@ -44,6 +50,9 @@ export const LEGACY_TOOL_ROOTS: Record<string, LegacyToolRoot[]> = {
// default — but a pre-rebrand Windsurf build reads ONLY .windsurf/, and
// nothing on disk tells that user apart, so the move is offered, not taken.
devin: [{ root: '.windsurf', needsConsent: true }],
// Codex now reads the canonical shared .agents root. Generate the current
// replacement first so a divergent legacy file is preserved, not overwritten.
codex: [{ root: '.codex', needsConsent: false, timing: 'after-generation' }],
};
export interface LegacyToolMigration {
@@ -58,8 +67,8 @@ export interface LegacyToolMigration {
commandFiles: number;
/**
* OpenSpec-managed files left under the legacy root because the copy there
* differs from the one that survives — the user edited it, so it is reported
* rather than dropped.
* differs materially from the one that survives, so it is reported rather
* than dropped.
*/
keptInPlace: number;
/** Whether this move needs the user's consent first */
@@ -68,9 +77,9 @@ export interface LegacyToolMigration {
/**
* Classifies one OpenSpec-managed file. `move` is the fast path (nothing at
* the destination yet); `drop` means the destination already holds the same
* bytes, so the legacy copy is redundant; `keep` means the two differ, which
* only happens when the user edited one, and an edit is not ours to discard.
* the destination yet); `drop` means the destination already holds equivalent
* generated content, so the legacy copy is redundant; `keep` means the two
* differ materially and the legacy copy is not ours to discard.
*/
type FileDisposition = 'move' | 'drop' | 'keep' | 'skip';
@@ -78,7 +87,13 @@ function classifyManagedFile(source: string, destination: string): FileDispositi
if (isSamePath(source, destination)) return 'skip';
if (!fs.existsSync(destination)) return 'move';
try {
return fs.readFileSync(source, 'utf-8') === fs.readFileSync(destination, 'utf-8')
const sourceContent = fs.readFileSync(source, 'utf-8');
const destinationContent = fs.readFileSync(destination, 'utf-8');
const equivalentGeneratedSkills =
path.basename(source) === 'SKILL.md' &&
path.basename(destination) === 'SKILL.md' &&
isLegacyCodexSkillEquivalentToCurrent(sourceContent, destinationContent);
return sourceContent === destinationContent || equivalentGeneratedSkills
? 'drop'
: 'keep';
} catch {
@@ -111,8 +126,11 @@ function legacyCommandPath(
* Reports the OpenSpec content sitting under each tool's legacy root, without
* moving anything. Callers use this to ask before a move that needs consent.
*/
export function findLegacyToolMigrations(projectPath: string): LegacyToolMigration[] {
return collectLegacyToolMigrations(projectPath, false);
export function findLegacyToolMigrations(
projectPath: string,
timing: 'before-generation' | 'after-generation' = 'before-generation'
): LegacyToolMigration[] {
return collectLegacyToolMigrations(projectPath, false, undefined, timing);
}
/**
@@ -128,15 +146,17 @@ export function findLegacyToolMigrations(projectPath: string): LegacyToolMigrati
*/
export function migrateLegacyToolDirs(
projectPath: string,
toolIds?: string[]
toolIds?: string[],
timing: 'before-generation' | 'after-generation' = 'before-generation'
): LegacyToolMigration[] {
return collectLegacyToolMigrations(projectPath, true, toolIds);
return collectLegacyToolMigrations(projectPath, true, toolIds, timing);
}
function collectLegacyToolMigrations(
projectPath: string,
apply: boolean,
toolIds?: string[]
toolIds?: string[],
timing: 'before-generation' | 'after-generation' = 'before-generation'
): LegacyToolMigration[] {
const migrations: LegacyToolMigration[] = [];
@@ -145,18 +165,39 @@ function collectLegacyToolMigrations(
if (toolIds && !toolIds.includes(tool.value)) continue;
for (const legacy of LEGACY_TOOL_ROOTS[tool.value] ?? []) {
const legacyTiming = legacy.timing ?? 'before-generation';
if (legacyTiming !== timing) continue;
if (legacy.root === tool.skillsDir) continue;
// Without an explicit tool list, only moves that need no consent run.
if (apply && !toolIds && legacy.needsConsent) continue;
if (!fs.existsSync(path.join(projectPath, legacy.root))) continue;
const legacyRootPath = path.join(projectPath, legacy.root);
if (!fs.existsSync(legacyRootPath)) continue;
try {
FileSystemUtils.assertProjectArtifactPath(projectPath, legacyRootPath);
FileSystemUtils.assertProjectArtifactPath(
projectPath,
path.join(projectPath, tool.skillsDir)
);
} catch {
console.warn(
`Skipping legacy ${legacy.root}/ migration because the directory resolves outside this project.`
);
continue;
}
const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply);
const skills = migrateSkillDirs(
projectPath,
tool.skillsDir,
legacy.root,
apply,
legacyTiming === 'after-generation'
);
const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply);
if (apply) {
removeDirIfEmpty(path.join(projectPath, legacy.root, 'skills'));
removeDirIfEmpty(path.join(projectPath, legacy.root, 'workflows'));
removeDirIfEmpty(path.join(projectPath, legacy.root));
removeDirIfEmpty(path.join(legacyRootPath, 'skills'));
removeDirIfEmpty(path.join(legacyRootPath, 'workflows'));
removeDirIfEmpty(legacyRootPath);
}
// Kept-only results are retained deliberately. When every legacy file
@@ -184,7 +225,8 @@ function migrateSkillDirs(
projectPath: string,
currentRoot: string,
legacyRoot: string,
apply: boolean
apply: boolean,
requireDestination = false
): { moved: number; kept: number } {
const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills');
if (!fs.existsSync(legacySkillsDir)) return { moved: 0, kept: 0 };
@@ -200,6 +242,13 @@ function migrateSkillDirs(
const destination = path.join(currentSkillsDir, dirName);
const destinationSkill = path.join(destination, 'SKILL.md');
if (requireDestination && !fs.existsSync(destinationSkill)) continue;
if (!areProjectArtifacts(projectPath, sourceSkill, destinationSkill)) {
console.warn(
`Skipping legacy ${legacyRoot}/skills/${dirName} migration because it resolves outside this project.`
);
continue;
}
const disposition = classifyManagedFile(sourceSkill, destinationSkill);
if (disposition === 'skip') continue;
if (disposition === 'keep') {
@@ -254,6 +303,12 @@ function migrateCommandFiles(
if (!fs.existsSync(source)) continue;
const destination = path.join(projectPath, currentPath);
if (!areProjectArtifacts(projectPath, source, destination)) {
console.warn(
`Skipping legacy ${legacyPath} migration because it resolves outside this project.`
);
continue;
}
const disposition = classifyManagedFile(source, destination);
if (disposition === 'skip') continue;
if (disposition === 'keep') {
@@ -352,6 +407,17 @@ function isSamePath(a: string, b: string): boolean {
}
}
function areProjectArtifacts(projectPath: string, ...artifactPaths: string[]): boolean {
try {
for (const artifactPath of artifactPaths) {
FileSystemUtils.assertProjectArtifactPath(projectPath, artifactPath);
}
return true;
} catch {
return false;
}
}
function removeDirIfEmpty(dirPath: string): void {
try {
if (fs.readdirSync(dirPath).length === 0) {
@@ -370,22 +436,38 @@ interface InstalledWorkflowArtifacts {
function scanInstalledWorkflowArtifacts(
projectPath: string,
tools: AIToolOption[]
tools: AIToolOption[],
includeLegacySkills = false
): InstalledWorkflowArtifacts {
const installed = new Set<string>();
let hasSkills = false;
let hasCommands = false;
for (const tool of tools) {
if (!tool.skillsDir) continue;
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
if (!toolSupportsSkills(tool)) continue;
for (const workflowId of ALL_WORKFLOWS) {
const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId];
const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md');
if (fs.existsSync(skillFile)) {
installed.add(workflowId);
hasSkills = true;
const skillsDirs: string[] = [];
if (tool.globalSkillsDir) {
skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
} else if (isSharedSkillTargetActive(projectPath, tool.value)) {
skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
if (includeLegacySkills) {
skillsDirs.push(
...(tool.legacySkillsDirs ?? []).map((root) =>
path.join(projectPath, root, 'skills')
)
);
}
}
for (const skillsDir of skillsDirs) {
for (const workflowId of ALL_WORKFLOWS) {
const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId];
const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md');
if (fs.existsSync(skillFile)) {
installed.add(workflowId);
hasSkills = true;
}
}
}
@@ -459,7 +541,7 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi
}
// Scan for installed workflows
const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools);
const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools, true);
const installedWorkflows = artifacts.workflows;
if (installedWorkflows.length === 0) {
+106 -1
View File
@@ -1,4 +1,4 @@
import { buildCodeFenceMask } from './requirement-text.js';
import { buildCodeFenceMask, SCENARIO_HEADER } from './requirement-text.js';
export interface RequirementBlock {
headerLine: string; // e.g., '### Requirement: Something'
@@ -323,3 +323,108 @@ function parseRenamedPairs(sectionBody: SectionBody): Array<{ from: string; to:
}
return pairs;
}
interface ScenarioBlock {
name: string;
raw: string;
}
/**
* 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.
*/
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);
}
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);
}
}
return missing;
}
/**
* Any non-fenced level-4 header on the given (masked) line. Reuses the spec
* path's SCENARIO_HEADER so the two counters cannot drift apart.
*/
function scenarioHeaderAt(lines: string[], mask: boolean[], index: number): boolean {
return !mask[index] && SCENARIO_HEADER.test(lines[index]);
}
/**
* The scenario name for a `#### ` header, matching the label the author reads:
* the header text with the leading `####`, an optional CommonMark closing `#`
* run (`#### Foo ####` renders as `Foo`), and an optional `Scenario:` prefix
* stripped. Both the current and incoming blocks run through here, so the
* comparison in findMissingCurrentScenarios stays internally consistent
* regardless of label — and two headers that render to the same title (one
* ATX-closed, one not) are not mistaken for a dropped scenario.
*/
function scenarioNameAt(line: string): string {
return line
.replace(SCENARIO_HEADER, '')
// Optional ATX closing sequence. CommonMark only treats a trailing `#` run
// as a close when it is preceded by a space or tab — not any Unicode space —
// so this uses `[ \t]`, not `\s`. A looser `\s` could strip a `#` run after
// an exotic space (e.g. NBSP) that CommonMark keeps, folding two distinct
// scenario names into one and masking a real loss. `[ \t]` keeps the fold
// faithful to how the header actually renders.
.replace(/[ \t]+#+[ \t]*$/, '')
.replace(/^Scenario:\s*/i, '')
.trim();
}
function parseScenarioBlocks(requirementRaw: string): ScenarioBlock[] {
const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
// A scenario is ANY non-fenced `#### ` header, matching the spec path's
// SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly — not only
// `#### Scenario:`. The two MUST agree: a level-4 child whose header is not
// literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the spec
// path counts, so a MODIFIED block that drops it would otherwise slip past
// this loss check and be deleted by archive with no error (the parity the
// SCENARIO_HEADER comment warns not to break). A `####` inside a fenced
// example is masked out, matching countScenarios.
const mask = buildCodeFenceMask(lines);
const scenarios: ScenarioBlock[] = [];
let index = 0;
while (index < lines.length) {
if (!scenarioHeaderAt(lines, mask, index)) {
index++;
continue;
}
const start = index;
const name = scenarioNameAt(lines[index]);
index++;
while (index < lines.length && !scenarioHeaderAt(lines, mask, index)) {
index++;
}
scenarios.push({
name,
raw: lines.slice(start, index).join('\n').trimEnd(),
});
}
return scenarios;
}
+4 -3
View File
@@ -23,10 +23,11 @@ const HEADER_LINE = /^#{1,6}\s/;
/**
* A level-4 header. Deliberately matches ANY `####` header, not only
* `#### Scenario:` — the spec path treats every level-4 child of a requirement
* as a scenario, so the delta counter must too (parity). Don't tighten this to
* `Scenario:` without changing both paths together.
* as a scenario, so the delta counter must too (parity). The delta/loss path
* reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
* keep both paths on it rather than reintroducing a separate `Scenario:` regex.
*/
const SCENARIO_HEADER = /^####\s+/;
export const SCENARIO_HEADER = /^####\s+/;
/**
* The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
+19 -2
View File
@@ -6,7 +6,7 @@ const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/
const REQUIREMENT_HEADER = /^###\s+Requirement:\s*(.+)\s*$/i;
export interface MainSpecStructureIssue {
kind: 'delta-header' | 'requirement-outside-requirements';
kind: 'delta-header' | 'requirement-outside-requirements' | 'duplicate-requirement';
line: number;
header: string;
message: string;
@@ -17,6 +17,7 @@ export function findMainSpecStructureIssues(content: string): MainSpecStructureI
const stripped = stripFencedCodeBlocksPreservingLines(normalized);
const lines = stripped.split('\n');
const issues: MainSpecStructureIssue[] = [];
const requirementLines = new Map<string, number>();
const requirementsHeaderIndex = lines.findIndex(line => REQUIREMENTS_SECTION_HEADER.test(line));
let requirementsEndIndex = lines.length;
@@ -44,7 +45,7 @@ export function findMainSpecStructureIssues(content: string): MainSpecStructureI
header: trimmed,
message:
`Main spec contains delta header "${trimmed}". ` +
'Delta headers are only valid inside openspec/changes/<name>/specs/<capability>/spec.md ' +
'Delta headers are only valid inside openspec/changes/<name>/specs/<capability-path>/spec.md ' +
'and truncate the parsed ## Requirements section.',
});
continue;
@@ -69,6 +70,22 @@ export function findMainSpecStructureIssues(content: string): MainSpecStructureI
`Requirement header "${trimmed}" appears outside the main ## Requirements section. ` +
'Main specs only parse requirements inside that section, so this requirement is currently invisible to validate, list, and archive.',
});
continue;
}
const requirementName = requirementMatch[1].trim();
const previousLine = requirementLines.get(requirementName);
if (previousLine !== undefined) {
issues.push({
kind: 'duplicate-requirement',
line: i + 1,
header: trimmed,
message:
`Requirement header "${trimmed}" duplicates the requirement declared on line ${previousLine}. ` +
'Requirement names must be unique so spec updates cannot discard one block while updating another.',
});
} else {
requirementLines.set(requirementName, i + 1);
}
}
+51 -5
View File
@@ -11,6 +11,14 @@ import {
shouldReconcileCommandFilesForTool,
shouldRemoveSkillsForTool,
} from './command-surface.js';
import { readSharedSkillTarget } from './shared-skill-target.js';
import { FileSystemUtils } from '../utils/file-system.js';
import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js';
import {
hasGlobalSkillTarget,
resolveToolSkillsDir,
toolSupportsSkills,
} from './shared/skill-paths.js';
type WorkflowId = (typeof ALL_WORKFLOWS)[number];
@@ -61,15 +69,53 @@ export function hasToolProfileOrDeliveryDrift(
delivery: Delivery
): boolean {
const tool = AI_TOOLS.find((t) => t.value === toolId);
if (!tool?.skillsDir) return false;
if (!tool || !toolSupportsSkills(tool)) return false;
const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows);
const desiredWorkflowSet = new Set<WorkflowId>(knownDesiredWorkflows);
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
const skillsDir = resolveToolSkillsDir(projectPath, tool);
const adapter = CommandAdapterRegistry.get(toolId);
const shouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery);
const shouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery);
const sharedTarget = tool.skillsDir
? readSharedSkillTarget(projectPath, tool.skillsDir)
: undefined;
for (const root of tool.legacySkillsDirs ?? []) {
for (const workflow of knownDesiredWorkflows) {
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
const legacySkill = path.join(projectPath, root, 'skills', dirName, 'SKILL.md');
if (!fs.existsSync(legacySkill)) continue;
const currentSkill = path.join(skillsDir, dirName, 'SKILL.md');
if (!fs.existsSync(currentSkill) || sharedTarget !== toolId) {
return true;
}
try {
if (
FileSystemUtils.canonicalizeExistingPath(legacySkill) ===
FileSystemUtils.canonicalizeExistingPath(currentSkill)
) {
continue;
}
// Equivalent generated copies are actionable: migration can safely
// remove the redundant legacy file even when version, line endings,
// or supported invocation syntax changed. Materially divergent copies
// stay in place without forcing an update on every run.
if (
isLegacyCodexSkillEquivalentToCurrent(
fs.readFileSync(legacySkill, 'utf-8'),
fs.readFileSync(currentSkill, 'utf-8')
)
) {
return true;
}
} catch {
return true;
}
}
}
if (shouldGenerateSkills) {
for (const workflow of knownDesiredWorkflows) {
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
@@ -88,7 +134,7 @@ export function hasToolProfileOrDeliveryDrift(
return true;
}
}
} else if (shouldRemoveSkillsForTool(toolId, delivery)) {
} else if (shouldRemoveSkillsForTool(toolId, delivery) && !hasGlobalSkillTarget(tool)) {
for (const workflow of ALL_WORKFLOWS) {
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
const skillDir = path.join(skillsDir, dirName);
@@ -150,10 +196,10 @@ function getInstalledWorkflowsForTool(
options: { includeSkills: boolean; includeCommands: boolean }
): WorkflowId[] {
const tool = AI_TOOLS.find((t) => t.value === toolId);
if (!tool?.skillsDir) return [];
if (!tool || !toolSupportsSkills(tool)) return [];
const installed = new Set<WorkflowId>();
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
const skillsDir = resolveToolSkillsDir(projectPath, tool);
if (options.includeSkills) {
for (const workflow of ALL_WORKFLOWS) {
+33 -1
View File
@@ -73,6 +73,16 @@ export const ProjectConfigSchema = z.object({
.string()
.optional()
.describe('Store id used as the OpenSpec root when no local planning shape exists'),
// Optional: GitHub Copilot integration preferences. `cloudAgent` is the
// opt-in for generating the Copilot cloud coding-agent files (a GitHub
// Actions workflow + agent file); absent means "not yet decided".
githubCopilot: z
.object({
cloudAgent: z.boolean().optional(),
})
.optional()
.describe('GitHub Copilot integration preferences'),
});
/** Normalized in-memory shape of a referenced store declaration. */
@@ -306,7 +316,11 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null {
// First check if it's an object structure (guard against null since typeof null === 'object')
if (typeof raw.rules === 'object' && raw.rules !== null && !Array.isArray(raw.rules)) {
const parsedRules: Record<string, string[]> = {};
// Artifact ids are intentionally not restricted to the built-in naming
// convention, so keys such as "constructor" remain valid for custom
// schemas. A null-prototype map preserves those keys as data without
// letting "__proto__" mutate the lookup object's prototype.
const parsedRules: Record<string, string[]> = Object.create(null);
let hasValidRules = false;
for (const [artifactId, rules] of Object.entries(raw.rules)) {
@@ -362,6 +376,24 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null {
}
}
// Parse githubCopilot preferences (only cloudAgent is recognized today).
if (raw.githubCopilot !== undefined) {
if (
typeof raw.githubCopilot === 'object' &&
raw.githubCopilot !== null &&
!Array.isArray(raw.githubCopilot)
) {
const cloudAgent = (raw.githubCopilot as Record<string, unknown>).cloudAgent;
if (typeof cloudAgent === 'boolean') {
config.githubCopilot = { cloudAgent };
} else if (cloudAgent !== undefined) {
console.warn(`Invalid 'githubCopilot.cloudAgent' field in config (must be a boolean)`);
}
} else {
console.warn(`Invalid 'githubCopilot' field in config (must be an object)`);
}
}
// Return partial config even if some fields failed
return Object.keys(config).length > 0 ? (config as ProjectConfig) : null;
} catch (error) {
+1 -1
View File
@@ -529,7 +529,7 @@ export async function resolveRootForCommand(
output: {
json?: boolean;
failurePayload?: Record<string, unknown>;
/** Diagnostic commands inspect what exists; they never scaffold. */
/** Commands that require an existing root set this to false. */
allowImplicitRoot?: boolean;
} = {}
): Promise<ResolvedOpenSpecRoot | null> {
+203
View File
@@ -0,0 +1,203 @@
import path from 'path';
import * as fs from 'fs';
import { AI_TOOLS, OPENSPEC_SKILL_NAMES, type AIToolOption } from './config.js';
import { FileSystemUtils } from '../utils/file-system.js';
const TARGET_MARKER = '.openspec-target';
/** Returns the ownership-marker path for one shared skills root. */
function markerPath(projectPath: string, skillsDir: string): string {
return path.join(projectPath, skillsDir, 'skills', TARGET_MARKER);
}
/** Reads a valid-looking marker value without letting linked roots escape. */
export function readSharedSkillTarget(
projectPath: string,
skillsDir: string
): string | undefined {
try {
const target = markerPath(projectPath, skillsDir);
FileSystemUtils.assertProjectArtifactPath(projectPath, target);
return fs.readFileSync(target, 'utf-8').trim() || undefined;
} catch {
return undefined;
}
}
/** Whether a tool still has an allowlisted managed skill under an old root. */
function hasLegacySkills(projectPath: string, tool: AIToolOption): boolean {
return (tool.legacySkillsDirs ?? []).some((root) => {
const skillsDir = path.join(projectPath, root, 'skills');
return OPENSPEC_SKILL_NAMES.some((skillName) => {
try {
const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile);
return fs.existsSync(skillFile);
} catch {
return false;
}
});
});
}
/**
* Infers pre-marker ownership from generated invocation syntax. This preserves
* both existing generic `.agents` trees and Codex trees users moved manually.
*/
function inferSharedSkillTarget(projectPath: string, skillsDir: string): string | undefined {
let foundGenericReference = false;
for (const skillName of OPENSPEC_SKILL_NAMES) {
const skillFile = path.join(projectPath, skillsDir, 'skills', skillName, 'SKILL.md');
try {
FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile);
const content = fs.readFileSync(skillFile, 'utf-8');
if (content.includes('$openspec-')) return 'codex';
if (content.includes('/openspec-')) foundGenericReference = true;
} catch {
// Missing, unreadable, or out-of-project files provide no ownership signal.
}
}
return foundGenericReference ? 'agents' : undefined;
}
/** Whether the canonical shared root already contains an OpenSpec skill. */
function hasCurrentSkills(projectPath: string, skillsDir: string): boolean {
return OPENSPEC_SKILL_NAMES.some((skillName) => {
const skillFile = path.join(projectPath, skillsDir, 'skills', skillName, 'SKILL.md');
try {
FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile);
return fs.existsSync(skillFile);
} catch {
return false;
}
});
}
/**
* A shared skill root can only hold one rendered variant of each skill.
* Keep the writer recorded so later updates do not infer every tool that
* happens to use the same directory.
*/
export function reconcileSharedSkillTargets(
projectPath: string,
tools: AIToolOption[]
): AIToolOption[] {
const byRoot = new Map<string, AIToolOption[]>();
for (const tool of tools) {
if (!tool.skillsDir) continue;
const group = byRoot.get(tool.skillsDir) ?? [];
group.push(tool);
byRoot.set(tool.skillsDir, group);
}
const reconciled: AIToolOption[] = [];
for (const group of byRoot.values()) {
if (group.length === 1) {
reconciled.push(group[0]);
continue;
}
const root = group[0].skillsDir!;
const marked = readSharedSkillTarget(projectPath, root);
const markedTool = group.find((tool) => tool.value === marked);
if (markedTool) {
reconciled.push(markedTool);
continue;
}
const inferred = inferSharedSkillTarget(projectPath, root);
const legacyCodex = group.find(
(tool) => tool.value === 'codex' && hasLegacySkills(projectPath, tool)
);
if (inferred === 'agents' && legacyCodex) {
// Before ownership markers existed, selecting both targets produced a
// generic canonical tree plus a Codex-only legacy tree. Codex now emits
// a dual-syntax canonical tree, so it can safely consolidate that state.
reconciled.push(legacyCodex);
continue;
}
const inferredTool = group.find((tool) => tool.value === inferred);
if (inferredTool) {
reconciled.push(inferredTool);
continue;
}
// An unmarked canonical tree predates Codex's move into `.agents`; keep
// that established agents target instead of overwriting it from `.codex`.
if (hasCurrentSkills(projectPath, root)) {
reconciled.push(group.find((tool) => tool.value === 'agents') ?? group[0]);
continue;
}
const legacyTool = group.find((tool) => hasLegacySkills(projectPath, tool));
if (legacyTool) {
reconciled.push(legacyTool);
continue;
}
// `.agents` existed as the vendor-neutral target before Codex adopted it.
// Unmarked trees therefore retain that established meaning.
reconciled.push(group.find((tool) => tool.value === 'agents') ?? group[0]);
}
return reconciled;
}
/**
* Returns whether a tool is the active writer for its physical skills root.
* Non-shared roots are always active.
*/
export function isSharedSkillTargetActive(projectPath: string, toolId: string): boolean {
const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
if (!tool?.skillsDir) return false;
const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir);
if (sharingRoot.length < 2) return true;
return reconcileSharedSkillTargets(projectPath, sharingRoot)
.some((candidate) => candidate.value === toolId);
}
/**
* The tool that already owns `toolId`'s shared skills root, when a DIFFERENT
* one does. Returns the owner's tool id only when the root already carries an
* ownership signal (a marker or generated skills) AND reconciliation resolves
* it to another tool. An empty or unclaimed root returns undefined, so a
* genuine first-time legacy upgrade — e.g. a Codex-only user with no `.agents`
* yet — is never reported as owned.
*/
export function sharedSkillRootOwner(projectPath: string, toolId: string): string | undefined {
const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
if (!tool?.skillsDir) return undefined;
const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir);
if (sharingRoot.length < 2) return undefined;
const hasOwnerSignal =
readSharedSkillTarget(projectPath, tool.skillsDir) !== undefined ||
hasCurrentSkills(projectPath, tool.skillsDir);
if (!hasOwnerSignal) return undefined;
const owner = reconcileSharedSkillTargets(projectPath, sharingRoot)[0]?.value;
return owner && owner !== toolId ? owner : undefined;
}
/**
* Whether generating `toolId` into its shared skills root would clobber a tree
* a DIFFERENT tool already owns — the guard the legacy-upgrade path uses before
* writing skills. See {@link sharedSkillRootOwner} for the ownership rules.
*/
export function sharedSkillRootOwnedByOther(projectPath: string, toolId: string): boolean {
return sharedSkillRootOwner(projectPath, toolId) !== undefined;
}
export function writeSharedSkillTarget(projectPath: string, toolId: string): void {
const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
if (!tool?.skillsDir) return;
const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir);
if (sharingRoot.length < 2) return;
const target = markerPath(projectPath, tool.skillsDir);
FileSystemUtils.assertProjectArtifactPath(projectPath, target);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, `${toolId}\n`, 'utf-8');
}
+8
View File
@@ -28,3 +28,11 @@ export {
getCommandContents,
generateSkillContent,
} from './skill-generation.js';
export {
type SkillCapableTool,
toolSupportsSkills,
getSkillCapableTools,
hasGlobalSkillTarget,
resolveToolSkillsDir,
} from './skill-paths.js';
@@ -0,0 +1,64 @@
import { OPENSPEC_SKILL_NAMES } from '../config.js';
const GENERATED_VERSION =
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
const OPENSPEC_SKILL_NAME_SET = new Set<string>(OPENSPEC_SKILL_NAMES);
/**
* Normalizes checkout line endings and a valid generated version inside the
* YAML frontmatter. Free-form `generatedBy` text in the instructions remains
* material.
*/
function normalizeGeneratedSkill(content: string): string {
const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
const frontmatter = normalized.match(/^---\n[\s\S]*?\n---(?:\n|$)/)?.[0];
if (!frontmatter) return normalized;
const versionLine =
/^(\s*generatedBy:\s*)(?:"([^"\n]+)"|'([^'\n]+)'|([^\s"'#]+))\s*$/m;
const normalizedFrontmatter = frontmatter.replace(
versionLine,
(
line: string,
prefix: string,
doubleQuoted: string | undefined,
singleQuoted: string | undefined,
bare: string | undefined
) => {
const version = doubleQuoted ?? singleQuoted ?? bare;
return version && GENERATED_VERSION.test(version)
? `${prefix}"<generated-version>"`
: line;
}
);
return normalizedFrontmatter + normalized.slice(frontmatter.length);
}
/**
* Converts only known generated dual references in current Codex content back
* to the direct syntax used by legacy `.codex` output.
*/
function toLegacyCodexReferences(content: string): string {
return content.replace(
/\$(openspec-[a-z0-9-]+) \(Codex\) or \/\1 \(other agents\)/g,
(match, skillName: string) =>
OPENSPEC_SKILL_NAME_SET.has(skillName) ? `$${skillName}` : match
);
}
/**
* Returns whether a legacy Codex skill differs from the current canonical
* replacement only by generated version, checkout line endings/BOM, or the
* known Codex/generic dual-reference expansion.
*/
export function isLegacyCodexSkillEquivalentToCurrent(
legacyContent: string,
currentContent: string
): boolean {
const normalizedLegacy = normalizeGeneratedSkill(legacyContent);
const normalizedCurrent = normalizeGeneratedSkill(currentContent);
return (
normalizedLegacy === normalizedCurrent ||
normalizedLegacy === toLegacyCodexReferences(normalizedCurrent)
);
}
+38
View File
@@ -0,0 +1,38 @@
import os from 'node:os';
import path from 'node:path';
import { AI_TOOLS, type AIToolOption } from '../config.js';
export type SkillCapableTool = AIToolOption & (
| { skillsDir: string }
| { globalSkillsDir: string }
);
export function toolSupportsSkills(tool: AIToolOption): tool is SkillCapableTool {
return Boolean(tool.skillsDir || tool.globalSkillsDir);
}
export function getSkillCapableTools(): SkillCapableTool[] {
return AI_TOOLS.filter(toolSupportsSkills);
}
export function hasGlobalSkillTarget(tool: AIToolOption): boolean {
return Boolean(tool.globalSkillsDir);
}
export function resolveToolSkillsDir(
projectRoot: string,
tool: SkillCapableTool,
options: { homeDir?: string } = {}
): string {
if (tool.globalSkillsDir) {
const homeDir = options.homeDir ?? process.env.USERPROFILE ?? process.env.HOME ?? os.homedir();
return path.join(homeDir, tool.globalSkillsDir, 'skills');
}
if (tool.skillsDir) {
return path.join(projectRoot, tool.skillsDir, 'skills');
}
throw new Error(`Tool '${tool.value}' does not support skill generation.`);
}

Some files were not shown because too many files have changed in this diff Show More