mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-11 04:49:52 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2826b8889e | ||
|
|
610b78f655 | ||
|
|
0221ac3d46 | ||
|
|
6d031f12f7 | ||
|
|
8127c7b7cc | ||
|
|
3281f1f068 | ||
|
|
b96b3e85cd | ||
|
|
207f3cc515 | ||
|
|
4b114aade9 | ||
|
|
8364428661 | ||
|
|
804427b6ff | ||
|
|
17581c11ed | ||
|
|
1a10dd5820 | ||
|
|
137404b423 | ||
|
|
144901ca74 | ||
|
|
89169627e0 | ||
|
|
942589741d | ||
|
|
c751b3da52 | ||
|
|
07dea6ed2f | ||
|
|
bf5099e39f | ||
|
|
9ae75c86ef | ||
|
|
83be9d113e | ||
|
|
59c16a4461 | ||
|
|
42d7f673bc | ||
|
|
e50bd0983d | ||
|
|
d57889664c | ||
|
|
568e56c672 | ||
|
|
73207a6f2c | ||
|
|
13e213e00f | ||
|
|
96a6548664 | ||
|
|
d9bcc18582 | ||
|
|
622c509a13 | ||
|
|
06b310bf57 | ||
|
|
59bfb27a76 | ||
|
|
161f9454a3 | ||
|
|
0b233efb86 | ||
|
|
7a4a745d80 | ||
|
|
3e50944fb0 | ||
|
|
02b124e6b6 | ||
|
|
3d0701f871 | ||
|
|
8a3850da73 | ||
|
|
afea111cd4 | ||
|
|
f43fe0e7d5 | ||
|
|
0b20ae3964 | ||
|
|
26bd1d4e5c | ||
|
|
ece8660d44 | ||
|
|
521ee33e6e | ||
|
|
9cd845fc45 | ||
|
|
4e4c9e1ffd | ||
|
|
80ad1fbaef | ||
|
|
23c2787789 | ||
|
|
690a27e649 | ||
|
|
45cca5db61 | ||
|
|
1da6dfa8d7 | ||
|
|
2b3d368539 | ||
|
|
427abf40ac | ||
|
|
84ebc57cb3 | ||
|
|
1aa0f2abfc | ||
|
|
1014c59ed1 |
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 │
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+144
-142
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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!'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user