mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-18 05:59:56 +08:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c794978f4f | ||
|
|
e33245d22b | ||
|
|
c9a8adef50 | ||
|
|
5251f2ef5e | ||
|
|
276d015090 | ||
|
|
87a69a2bf8 | ||
|
|
f80d860fc5 | ||
|
|
e0a2f00f95 | ||
|
|
4c56264b79 | ||
|
|
3fea2a96b2 | ||
|
|
4e490dfac3 | ||
|
|
7acf282050 | ||
|
|
9ce48081af | ||
|
|
7701149db3 | ||
|
|
9cb5ba8cec | ||
|
|
40db61856a | ||
|
|
1bce87f858 | ||
|
|
d9c703ab38 | ||
|
|
352fd3774a | ||
|
|
f8f8d4c5a5 | ||
|
|
8153fc5efc | ||
|
|
37597d88ce | ||
|
|
76d49a1d81 | ||
|
|
eb315c9d75 | ||
|
|
90c95c2a23 | ||
|
|
43820cae29 | ||
|
|
9111a7654d | ||
|
|
87c3595ace | ||
|
|
73583067dd | ||
|
|
69cf0a9050 | ||
|
|
112fea53a8 | ||
|
|
0654cfb68d | ||
|
|
43d23ccb96 | ||
|
|
852a073883 | ||
|
|
2500d6da97 | ||
|
|
bfa670eda9 | ||
|
|
760584ba9a | ||
|
|
ba0f508763 | ||
|
|
7056a58056 | ||
|
|
3a34ea309d |
@@ -4,7 +4,7 @@ This directory contains the VS Code dev container configuration for OpenSpec dev
|
||||
|
||||
## What's Included
|
||||
|
||||
- **Node.js 20 LTS** (>=20.19.0) - TypeScript/JavaScript runtime
|
||||
- **Node.js 22 LTS** - TypeScript/JavaScript runtime (OpenSpec itself supports Node 20.19.0 and newer)
|
||||
- **pnpm** - Fast, disk space efficient package manager
|
||||
- **Git + GitHub CLI** - Version control tools
|
||||
- **VS Code Extensions**:
|
||||
|
||||
@@ -55,19 +55,34 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Every OS runs the oldest supported Node (package.json engines).
|
||||
# Linux also runs the current LTS lines users are likely to have.
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
shell: bash
|
||||
label: linux-bash
|
||||
node: '20.19.0'
|
||||
vitest_workers: 4
|
||||
- os: macos-latest
|
||||
shell: bash
|
||||
label: macos-bash
|
||||
node: '20.19.0'
|
||||
vitest_workers: 4
|
||||
- os: windows-latest
|
||||
shell: pwsh
|
||||
label: windows-pwsh
|
||||
node: '20.19.0'
|
||||
vitest_workers: 2
|
||||
- os: ubuntu-latest
|
||||
shell: bash
|
||||
label: linux-bash-node22
|
||||
node: '22'
|
||||
vitest_workers: 4
|
||||
- os: ubuntu-latest
|
||||
shell: bash
|
||||
label: linux-bash-node24
|
||||
node: '24'
|
||||
vitest_workers: 4
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -86,7 +101,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '20.19.0'
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Print environment diagnostics
|
||||
@@ -105,7 +120,7 @@ jobs:
|
||||
run: pnpm test
|
||||
|
||||
- name: Upload test coverage
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node == '20.19.0'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: coverage-report-${{ github.event_name }}
|
||||
|
||||
@@ -1,5 +1,82 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 1.14.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#2101](https://github.com/Fission-AI/OpenSpec/pull/2101) [`87a69a2`](https://github.com/Fission-AI/OpenSpec/commit/87a69a2bf8eb3315a8c042d001520eb7766ab580) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes
|
||||
|
||||
- **A capability's `spec.md` may be a symlink to a file elsewhere in the project** — `list --specs`, `show`, `validate` and `archive` no longer refuse `openspec/specs/<capability>/spec.md` when it links to the file the project keeps somewhere else, such as beside the product documentation it belongs to. A link that resolves outside the project is still refused.
|
||||
|
||||
- [#2103](https://github.com/Fission-AI/OpenSpec/pull/2103) [`e33245d`](https://github.com/Fission-AI/OpenSpec/commit/e33245d22ba6f994596a1747baf17130fe04515d) Thanks [@surlymochan](https://github.com/surlymochan)! - ### Bug Fixes
|
||||
|
||||
- Installing OpenSpec no longer pulls in the unpatched `braces` package, so a consumer audit does not report GHSA-vfj7-8cjw-p6xm through this package. A `generates` brace pattern that nests deeply, or expands to more than 1024 paths, is now rejected with a clear message instead of crashing `openspec status` with a stack overflow or an out-of-memory error.
|
||||
|
||||
- [#2104](https://github.com/Fission-AI/OpenSpec/pull/2104) [`276d015`](https://github.com/Fission-AI/OpenSpec/commit/276d015090965791d8be3d02daf6bfd71de884c3) Thanks [@CRYPTONIKAV](https://github.com/CRYPTONIKAV)! - Keep glob artifact paths inside the change directory in the propose, ff-change and continue-change workflows
|
||||
|
||||
- [#2108](https://github.com/Fission-AI/OpenSpec/pull/2108) [`5251f2e`](https://github.com/Fission-AI/OpenSpec/commit/5251f2ef5e9e83eac68dd6950091928c4324b127) Thanks [@clay-good](https://github.com/clay-good)! - Stop `openspec validate --all` hanging on a zero-width TTY. Some hosts hand the
|
||||
process a terminal that reports `columns === 0` — a pty inherited from a
|
||||
pre-commit runner such as lefthook, or `script -qec`. ora computes how many
|
||||
lines to erase as `ceil(width / columns)`, so zero columns makes that count
|
||||
`Infinity` and the clear loop never ends: the command burned a core and wrote
|
||||
hundreds of megabytes of cursor escapes without finishing. The spinner is now
|
||||
skipped when nothing can be drawn, and validate falls back to its plain output.
|
||||
`OPEN_SPEC_INTERACTIVE=0` and `CI` now disable the spinner too, the same way
|
||||
`--no-interactive` already did.
|
||||
|
||||
- [#2110](https://github.com/Fission-AI/OpenSpec/pull/2110) [`c9a8ade`](https://github.com/Fission-AI/OpenSpec/commit/c9a8adef5021e9793ed9e104f65834fce0fb16cb) Thanks [@clay-good](https://github.com/clay-good)! - Preview the built docs site with a first-party static server instead of `serve`.
|
||||
`pnpm start` in `website/` now runs `scripts/preview.mjs`, a dependency-free
|
||||
server that resolves routes in a Next static export (it does not apply
|
||||
`out/_redirects`). This
|
||||
drops the `serve` dev dependency whose transitive tree kept publishing
|
||||
advisories (brace-expansion x3, fast-uri, compression) that had to be pinned by
|
||||
hand even though none of that code ever reached the published site.
|
||||
|
||||
## 1.14.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#2052](https://github.com/Fission-AI/OpenSpec/pull/2052) [`90c95c2`](https://github.com/Fission-AI/OpenSpec/commit/90c95c2a235e0d4088006a879c19cd1febff2a9e) Thanks [@drakeo338](https://github.com/drakeo338)! - ### Bug Fixes
|
||||
|
||||
- **Archive accepts `openspec/`, `openspec/changes` or `openspec/specs` as a symlink** — `openspec archive` no longer refuses a project-owned symlink that points to another directory, such as a shared docs repository. An `archive/` directory that resolves outside the changes directory is still refused.
|
||||
|
||||
- [#2061](https://github.com/Fission-AI/OpenSpec/pull/2061) [`7acf282`](https://github.com/Fission-AI/OpenSpec/commit/7acf282050c0b2a27bd18f30c48fbcc0986bfc45) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes
|
||||
|
||||
- **Guard against deeply nested brace patterns** — An artifact `generates` or `apply.tracks` pattern that nests braces or parentheses more than 16 levels deep is now rejected with a clear error before glob matching, so a schema cannot overflow the stack through the unpatched `braces` advisory GHSA-vfj7-8cjw-p6xm.
|
||||
|
||||
- [#2086](https://github.com/Fission-AI/OpenSpec/pull/2086) [`9cb5ba8`](https://github.com/Fission-AI/OpenSpec/commit/9cb5ba8cec4bc47227dcac4974a74b4adf1819f6) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes
|
||||
|
||||
- **Explore no longer offers to write default-schema files under a custom schema** — The explore workflow's "where to capture" table and example offers now say they describe the default spec-driven schema. With another schema, the agent captures each insight in an artifact that schema defines, names it in the offer, and never creates a file such as `design.md` that the schema does not have.
|
||||
|
||||
- [#2064](https://github.com/Fission-AI/OpenSpec/pull/2064) [`76d49a1`](https://github.com/Fission-AI/OpenSpec/commit/76d49a1d8176ad2bdb3696e29c4ffe222402f2da) Thanks [@ryandemelo](https://github.com/ryandemelo)! - Shell Tab completion is faster. `openspec __complete`, which the completion scripts run on every Tab press, now loads 48 modules instead of 344 when completing changes or specs, and no longer records usage, so a press never waits on the telemetry request and can't use up the first-run telemetry notice where nobody sees it. Completion output is unchanged.
|
||||
|
||||
- [#2083](https://github.com/Fission-AI/OpenSpec/pull/2083) [`3fea2a9`](https://github.com/Fission-AI/OpenSpec/commit/3fea2a96b2e04e01097f4617573734c47a57589b) Thanks [@clay-good](https://github.com/clay-good)! - The update workflow now states the glob new-file exception at both places that state the scope rule (step 2 and the guardrails), not only where the rule is restated. It said to edit only the concrete files in `existingOutputPaths`, while the file step 4 creates to fill a gap under a partly written glob artifact is by definition not in that list, so an agent applying the blanket rule refused the operation the flow exists to perform.
|
||||
|
||||
- [#2083](https://github.com/Fission-AI/OpenSpec/pull/2083) [`3fea2a9`](https://github.com/Fission-AI/OpenSpec/commit/3fea2a96b2e04e01097f4617573734c47a57589b) Thanks [@clay-good](https://github.com/clay-good)! - The verify workflow now reads a REMOVED requirement's baseline from the main spec before searching the codebase for it. A REMOVED delta block carries only the requirement name plus its Reason and Migration notes, so the search started from the heading alone - and because finding nothing is the passing result for a removal, an implementation written in different vocabulary read as a clean removal. When the baseline cannot be recovered from the main spec or that file's history - including a baseline that only restates its own title - Spec Coverage is now reported as not verified for that entry and the search is skipped, instead of passing.
|
||||
|
||||
- [#2054](https://github.com/Fission-AI/OpenSpec/pull/2054) [`1bce87f`](https://github.com/Fission-AI/OpenSpec/commit/1bce87f858c2591b2cb58108597ae6f0a9bb5d7c) Thanks [@runsonmypc](https://github.com/runsonmypc)! - `openspec validate` and `openspec archive` now warn when a delta spec holds text archive does not carry into the main spec: a `## ` section other than Purpose and the four delta sections, text above the first `## ` header, or prose before the first requirement in ADDED or MODIFIED. That text was dropped without notice before. Like other validation warnings, these fail `openspec validate --strict`. What archive carries is unchanged.
|
||||
|
||||
- [#2063](https://github.com/Fission-AI/OpenSpec/pull/2063) [`e0a2f00`](https://github.com/Fission-AI/OpenSpec/commit/e0a2f00f95731af7cf22a30c2c4a8b7ff910b509) Thanks [@gkinter](https://github.com/gkinter)! - Keys in a change's `.openspec.yaml` that start with a lowercase `x-` (for example `x-goal: G-12` or `x-jiraKey`) are now treated as your own extension metadata: OpenSpec no longer reports them as unknown, so `openspec validate --strict` passes on a change that carries them. Any other unrecognized key, including `X-Tracker` or a bare `x-`, is still reported.
|
||||
|
||||
## 1.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#2047](https://github.com/Fission-AI/OpenSpec/pull/2047) [`7358306`](https://github.com/Fission-AI/OpenSpec/commit/73583067dd6d5e774a2567741e37b2ccc3334395) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes
|
||||
|
||||
- **Apply no longer calls a change ready to archive too early** — When every tracked task is checked, `openspec instructions apply` now says the tracked tasks are complete and asks you to review or verify the change before archiving. The task guidance keeps steps that can only happen after archive in an optional `## Workflow follow-up` section of plain bullets, so they no longer block task completion.
|
||||
- **Accurate archive cleanup errors** — When a change is archived but cleaning up a capability retirement fails, the error now says the change was archived and cleanup did not complete, instead of claiming every backup was kept. `--json` reports the new code `archive_retirement_cleanup_failed` instead of the generic `archive_error`.
|
||||
|
||||
- [#2025](https://github.com/Fission-AI/OpenSpec/pull/2025) [`bfa670e`](https://github.com/Fission-AI/OpenSpec/commit/bfa670eda91c6cd998d42248ceab2b565db932ff) Thanks [@TabishB](https://github.com/TabishB)! - The CLI starts faster: each command now loads its implementation only when it runs. `openspec --version` and `--help` load 24 modules instead of 485, and commands such as `config list`, `store list` and `doctor` load only what they use, which matters most where Node loads modules slowly, such as Windows. Output, help text, shell completions, exit codes and telemetry are unchanged.
|
||||
|
||||
- [#2020](https://github.com/Fission-AI/OpenSpec/pull/2020) [`760584b`](https://github.com/Fission-AI/OpenSpec/commit/760584ba9a6e2aa5906edf082ecd465a53cf6618) Thanks [@clay-good](https://github.com/clay-good)! - A requirement description over 500 characters is now a warning instead of an informational hint, so `openspec validate --strict` fails on it and CI can enforce the limit. The check also covers ADDED requirements in a change, so `openspec validate <change> --strict` catches a new overlong requirement before archive. Normal validation and archive are unchanged: they still pass when this is the only finding. The specs instruction now explains how to split an existing long requirement without losing its scenarios.
|
||||
|
||||
- [#2037](https://github.com/Fission-AI/OpenSpec/pull/2037) [`852a073`](https://github.com/Fission-AI/OpenSpec/commit/852a0738832645f0549aa035f836ae09bea71ee2) Thanks [@vyhuholl](https://github.com/vyhuholl)! - The specs instruction now tells the agent to write each capability's spec file as soon as it has drafted it, announcing each one first, instead of planning all of them before writing any. A change with many capabilities no longer sits silent until every file is planned, and an interrupted run keeps the files it already wrote. Because `specs` reads `done` once any spec file exists, `/opsx:continue` now checks for capabilities in the proposal that still have no spec file and writes those before moving on.
|
||||
|
||||
- [#2028](https://github.com/Fission-AI/OpenSpec/pull/2028) [`43d23cc`](https://github.com/Fission-AI/OpenSpec/commit/43d23ccb96495d1a2ac910ae74b44020f4f5c188) Thanks [@drakeo338](https://github.com/drakeo338)! - The verify workflow now finds a change's spec and design artifacts by their output path (`specs/` and `design.md`) instead of the hardcoded artifact ids `specs` and `design`, so it works with custom schemas whose artifacts use other ids.
|
||||
|
||||
- [#2031](https://github.com/Fission-AI/OpenSpec/pull/2031) [`2500d6d`](https://github.com/Fission-AI/OpenSpec/commit/2500d6da971336167548b53731a35b2127df35ac) Thanks [@TabishB](https://github.com/TabishB)! - `openspec view` no longer lists or counts archived changes. In projects with many archived changes, the list pushed active work off the screen. The dashboard shows current work again, and `openspec list --archived` still shows archived changes on request.
|
||||
|
||||
## 1.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -37,6 +37,15 @@ Those four commands are what CI runs, so a green local run means a green CI run.
|
||||
|
||||
Run `pnpm changeset` if your change affects users, and commit the file it generates.
|
||||
|
||||
### Keep the CLI's startup fast
|
||||
|
||||
Editors, agents and OpenSpec Desktop run the CLI many times, and each call pays for every module it loads before the command runs. Before this rule, `openspec --version` loaded 485 modules: about 0.5 s per call on a Windows machine. So a command loads only the command definitions and its own code:
|
||||
|
||||
- **Definitions** (name, options, help text) go in `src/cli/index.ts` or `src/cli/commands/<name>.ts`. Import nothing heavy there: no zod, yaml, tinyglobby, ora, and no other command's code.
|
||||
- **The command's code** goes in `src/commands/<name>.ts` or `src/core/`, loaded inside the action with `await import()`.
|
||||
|
||||
`test/cli-e2e/startup-modules.test.ts` checks which modules each command loads, and fails if a definition starts pulling in an implementation. When you add a command, add it to that test's list.
|
||||
|
||||
## 4. Open the PR
|
||||
|
||||
- Branch off `main` in your fork.
|
||||
|
||||
@@ -33,11 +33,6 @@ Our philosophy:
|
||||
→ scalable from personal projects to enterprises
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **New workflow now available!** We've rebuilt OpenSpec with a new artifact-guided workflow.
|
||||
>
|
||||
> Run `/opsx:propose "your idea"` to get started. → [Learn more here](docs/opsx.md)
|
||||
|
||||
<p align="center">
|
||||
Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates · Join the <a href="https://discord.gg/YctCnvvshC">OpenSpec Discord</a> for help and questions.
|
||||
</p>
|
||||
@@ -112,13 +107,13 @@ OpenSpec is built with OpenSpec — browse this repo's live [specs](openspec/spe
|
||||
|
||||
Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the hard part moves: a feature spans the API server, the web app, and a shared library; requirements are owned by one team and consumed by others; planning starts before any code exists.
|
||||
|
||||
**[Stores](docs/stores-beta/user-guide.md)** are the answer — planning in a repo of its own. The same `openspec/` shape you already know (specs and changes), shared by `git push` like anything else. One source of truth your whole team and every coding agent can read, across every repo.
|
||||
**[Stores](https://openspec.dev/docs/stores)** are the answer — planning in a repo of its own. The same `openspec/` shape you already know (specs and changes), shared by `git push` like anything else. One source of truth your whole team and every coding agent can read, across every repo.
|
||||
|
||||
- **Cross-repo features** — one change, one plan, even when the code lands in three repos.
|
||||
- **Shared requirements** — a platform team owns the specs; product teams reference them read-only, right where their coding agent can read them. No drifting wiki.
|
||||
- **Plan before code** — capture the plan in the store now; the code repos catch up later.
|
||||
|
||||
> Stores are in **beta**. Start with the [Stores User Guide](docs/stores-beta/user-guide.md).
|
||||
> Stores are in **beta**. Start with the [Stores guide](https://openspec.dev/docs/stores).
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -143,44 +138,33 @@ cd your-project
|
||||
openspec init
|
||||
```
|
||||
|
||||
> **Want your AI to do it?** Paste the [setup prompt](docs-lab/start/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
|
||||
> **Want your AI to do it?** Paste the [setup prompt](https://openspec.dev/docs/installation#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
|
||||
|
||||
Now talk to your AI:
|
||||
|
||||
- **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before any code gets written. ([Explore guide](docs/explore.md))
|
||||
- **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before any code gets written. ([Quickstart](https://openspec.dev/docs/quickstart))
|
||||
- **Already know what you want?** Go straight to `/opsx:propose <what-you-want-to-build>`.
|
||||
|
||||
Both are in the default profile. If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`.
|
||||
|
||||
`/opsx:propose` is the canonical name; your tool may spell it `/opsx-propose` (Cursor, GitHub Copilot), `@opsx-propose` (Amazon Q) or `$openspec-propose` (Codex). `openspec init` prints the right form for the tools you picked — see [How To Invoke](docs/supported-tools.md#how-to-invoke).
|
||||
`/opsx:propose` is the canonical name; your tool may spell it `/opsx-propose` (Cursor, GitHub Copilot), `@opsx-propose` (Amazon Q) or `$openspec-propose` (Codex). `openspec init` prints the right form for the tools you picked — see [Supported Tools](https://openspec.dev/docs/supported-tools).
|
||||
|
||||
> [!NOTE]
|
||||
> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 30+ tools and growing.
|
||||
> Not sure if your tool is supported? [View the full list](https://openspec.dev/docs/supported-tools) – we support 30+ tools and growing.
|
||||
>
|
||||
> Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](docs-lab/start/installation.md).
|
||||
> Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](https://openspec.dev/docs/installation).
|
||||
|
||||
## Docs
|
||||
|
||||
**Start here:** the **[Documentation Home](docs/README.md)** maps everything. New to OpenSpec? Read [Getting Started](docs/getting-started.md), then [How Commands Work](docs/how-commands-work.md) (where you actually type `/opsx:propose`).
|
||||
|
||||
→ **[Getting Started](docs/getting-started.md)**: first steps<br>
|
||||
→ **[Explore First](docs/explore.md)**: think it through with `/opsx:explore` before you commit<br>
|
||||
→ **[How Commands Work](docs/how-commands-work.md)**: where slash commands run vs the CLI<br>
|
||||
→ **[Core Concepts at a Glance](docs/overview.md)**: the whole mental model, one page<br>
|
||||
→ **[Examples & Recipes](docs/examples.md)**: real changes, start to finish<br>
|
||||
→ **[Workflows](docs/workflows.md)**: combos and patterns<br>
|
||||
→ **[Existing Projects](docs/existing-projects.md)**: adopt OpenSpec on a brownfield codebase<br>
|
||||
→ **[Editing a Change](docs/editing-changes.md)**: update artifacts, go back, reconcile manual edits<br>
|
||||
→ **[Commands](docs/commands.md)**: slash commands & skills<br>
|
||||
→ **[CLI](docs/cli.md)**: terminal reference<br>
|
||||
→ **[Stores](docs/stores-beta/user-guide.md)**: plan in a separate repo, shared across your team (beta)<br>
|
||||
→ **[Supported Tools](docs/supported-tools.md)**: tool integrations & install paths<br>
|
||||
→ **[Concepts](docs/concepts.md)**: how it all fits<br>
|
||||
→ **[Multi-Language](docs/multi-language.md)**: multi-language support<br>
|
||||
→ **[Customization](docs/customization.md)**: make it yours<br>
|
||||
→ **[Community Showcase](docs/community.md)**: projects and resources built with and for OpenSpec<br>
|
||||
→ **[FAQ](docs/faq.md)** · **[Troubleshooting](docs/troubleshooting.md)** · **[Glossary](docs/glossary.md)**: quick help
|
||||
Full documentation lives at **[openspec.dev/docs](https://openspec.dev/docs)**.
|
||||
|
||||
→ **[Quickstart](https://openspec.dev/docs/quickstart)**: your first change, start to finish<br>
|
||||
→ **[Set up your project](https://openspec.dev/docs/setup)**: what `openspec init` installs<br>
|
||||
→ **[CLI](https://openspec.dev/docs/cli)**: terminal reference<br>
|
||||
→ **[Supported Tools](https://openspec.dev/docs/supported-tools)**: tool integrations & install paths<br>
|
||||
→ **[Customize](https://openspec.dev/docs/customize)**: profiles, project config, and schemas<br>
|
||||
→ **[Stores](https://openspec.dev/docs/stores)**: plan in a separate repo, shared across your team (beta)<br>
|
||||
→ **[Community Showcase](docs/community.md)**: projects and resources built with and for OpenSpec
|
||||
|
||||
## Community schemas
|
||||
|
||||
|
||||
@@ -643,9 +643,7 @@ Prints a one-screen dashboard of specs and changes.
|
||||
openspec view # project summary in one screen
|
||||
```
|
||||
|
||||
view prints the dashboard once and exits. It reads no keystrokes. Changes group by task progress: Draft (no tasks yet), Active (tasks underway, with a progress bar and percent), Completed (every task checked), and Archived. Specs list with requirement counts, largest first.
|
||||
|
||||
Archived changes appear by directory name in alphabetical order. They do not contribute to the Draft, Active, Completed, or Task Progress totals.
|
||||
view prints the dashboard once and exits. It reads no keystrokes. Changes group by task progress: Draft (no tasks yet), Active (tasks underway, with a progress bar and percent), Completed (every task checked). Specs list with requirement counts, largest first.
|
||||
|
||||
**Options**
|
||||
|
||||
@@ -664,16 +662,11 @@ Summary:
|
||||
● Draft Changes: 1
|
||||
● Active Changes: 0 in progress
|
||||
● Completed Changes: 0
|
||||
● Archived Changes: 1
|
||||
|
||||
Draft Changes
|
||||
────────────────────────────────────────────────────────────
|
||||
○ add-rate-limit
|
||||
|
||||
Archived Changes
|
||||
────────────────────────────────────────────────────────────
|
||||
◦ 2026-08-10-add-login
|
||||
|
||||
Specifications
|
||||
────────────────────────────────────────────────────────────
|
||||
▪ api 1 requirement
|
||||
|
||||
@@ -42,6 +42,24 @@ Declares the change intentionally makes no spec deltas: a pure refactor, tooling
|
||||
|
||||
Authorizes archive to retire a capability. When this change's REMOVED deltas take away the last requirement a capability has, archive deletes that capability's main spec instead of stopping. The flag exists because the deletion is only recoverable from git, so it stays the author's call.
|
||||
|
||||
### Extension keys
|
||||
|
||||
A top-level key that starts with a lowercase `x-` is yours, for data another tool or your team keeps beside the change, such as a tracker id or an owner:
|
||||
|
||||
```yaml
|
||||
schema: spec-driven
|
||||
x-tracker: SB-5497
|
||||
x-jiraKey: ABC-1
|
||||
x-owner:
|
||||
team: payments
|
||||
```
|
||||
|
||||
- **Name**: the lowercase `x-` prefix followed by at least one character, with no whitespace anywhere. Any case or spelling works after the prefix, so `x-jiraKey` and `x-trackerID` qualify. `X-Tracker`, a bare `x-`, and `x- tracker` do not, and are reported as unknown keys.
|
||||
- **Value**: any YAML value. OpenSpec does not read or validate it, and it changes no behavior.
|
||||
- **Scope**: top-level only. An `x-` key inside `initiative` is still rejected, like any other key there.
|
||||
- **Validation**: extension keys are never reported as unknown, so `openspec validate --strict` passes on a change that carries them.
|
||||
- **Archive**: the file moves with the change unchanged, extension keys included.
|
||||
|
||||
## Example
|
||||
|
||||
A filled-in .openspec.yaml:
|
||||
@@ -61,5 +79,5 @@ The file is validated whenever a command writes or reads it. A write that fails
|
||||
|
||||
Unlike [config.yaml](config-yaml.md), bad values are never dropped with a warning. A metadata error stops the command.
|
||||
|
||||
- **Unknown top-level keys**: OpenSpec ignores them. `status`, `instructions`, `validate`, and `archive` report that they have no effect. JSON output carries the warning in its structured result.
|
||||
- **Strict validation**: `openspec validate --strict` treats an unknown-key warning as a failure.
|
||||
- **Unknown top-level keys**: OpenSpec ignores them. `status`, `instructions`, `validate`, and `archive` report that they have no effect. JSON output carries the warning in its structured result. [Extension keys](#extension-keys) are not unknown and are never reported.
|
||||
- **Strict validation**: `openspec validate --strict` treats an unknown-key warning as a failure. A change whose only extra keys are extension keys passes.
|
||||
|
||||
@@ -84,7 +84,7 @@ OpenSpec recognizes these glob forms in `generates`:
|
||||
|
||||
**Literal filenames**: a leading `!` alone does not make a glob. Use `generates: '!review.md'` to name that file. Plain parentheses such as `(proposal|design).md` and single-element braces such as `review-{api}.md` also remain literal.
|
||||
|
||||
OpenSpec rejects absolute paths and paths containing a `..` segment.
|
||||
OpenSpec rejects absolute paths, paths containing a `..` segment, and globs that nest braces or parentheses more than 16 levels deep, such as `{a,{b,{c,...}}}`. Real patterns need one or two levels. It also rejects a glob whose braces expand to more than 1,024 patterns, such as `{a,b}` repeated eleven times or a range like `file-{1..5000}.md`. The error says `Artifact output pattern expands to more than 1024 patterns`. Real patterns expand to a handful.
|
||||
|
||||
#### Completion
|
||||
|
||||
@@ -140,7 +140,7 @@ The path starts from the change folder. For a change named `add-auth`, `tracks:
|
||||
openspec/changes/add-auth/tasks.md
|
||||
```
|
||||
|
||||
A glob such as `tracks: "**/tasks.md"` reads every matching file, such as `backend/tasks.md` and `frontend/tasks.md`. OpenSpec combines their tasks and progress. Use the same value for an artifact's `generates` field so status and list track the same files.
|
||||
A glob such as `tracks: "**/tasks.md"` reads every matching file, such as `backend/tasks.md` and `frontend/tasks.md`. OpenSpec combines their tasks and progress. Use the same value for an artifact's `generates` field so status and list track the same files. The brace limits from `generates` (16 levels deep, 1,024 expanded patterns) apply here too.
|
||||
|
||||
Apply stays blocked if no file matches or the matched files contain no checkbox with task text. OpenSpec counts these checkbox forms:
|
||||
|
||||
|
||||
@@ -191,6 +191,17 @@ Create one spec file per capability listed in the proposal's Capabilities sectio
|
||||
- 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`. Run `openspec list --specs` to confirm that path before writing the delta, appending `--store "<id>"` only for a registered standalone store - a mistyped or invented path targets a capability that does not exist rather than the one you meant. Do not move or rename the capability.
|
||||
|
||||
Write the spec files one at a time, so the user sees progress and an
|
||||
interrupted run keeps the files it already wrote:
|
||||
- Before writing anything, compare the capabilities the proposal lists
|
||||
with the `specs/<capability-path>/spec.md` files that already exist,
|
||||
and start with the first capability that has no file. Do not rewrite
|
||||
an existing file unless the user asks you to.
|
||||
- Before each file, say in one line which capability you are writing.
|
||||
- Write each file as soon as you have drafted it. Do not plan every file
|
||||
before writing the first.
|
||||
- Stop when every capability the proposal lists has its file.
|
||||
|
||||
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`
|
||||
rejects a zero-delta change without that marker. If the proposal lists no
|
||||
@@ -208,7 +219,7 @@ Format requirements:
|
||||
- Each scenario: `#### Scenario: <name>` with WHEN/THEN format
|
||||
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
|
||||
- Every requirement MUST have at least one scenario.
|
||||
- Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions once they reach the main spec. This is an informational hint, not an error. When writing a new requirement, state one behavior per requirement: move examples and edge cases into scenarios, and split a requirement that covers several behaviors into separate `### Requirement:` blocks, each with its own scenarios. Under MODIFIED, keep the existing requirement block whole; never split, trim or rewrite existing text just to meet the length.
|
||||
- Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions in ADDED requirements and in the main spec. This is a warning: normal validation still passes, but `openspec validate --strict` fails on it. When writing a new requirement, state one behavior per requirement: move examples and edge cases into scenarios, and split a requirement that covers several behaviors into separate `### Requirement:` blocks, each with its own scenarios. Under MODIFIED, keep the existing requirement block whole; never split, trim or rewrite existing text just to meet the length. Split an existing long requirement only when the user asks for it, in a change made for that purpose: under MODIFIED, keep its header and every scenario and cut its description down to one behavior without changing its meaning, then add each behavior you removed as its own ADDED requirement with its own scenarios.
|
||||
|
||||
New capabilities only: the delta spec's first section is `## Purpose` -
|
||||
one or two sentences (50+ characters, or `openspec validate --strict`
|
||||
@@ -361,17 +372,22 @@ Before writing tasks, check design.md for Open Questions. If any of them
|
||||
would change what gets built, resolve them with the user first - do not
|
||||
bake an unstated assumption into the task list.
|
||||
|
||||
**IMPORTANT: Follow the template below exactly.** The apply phase parses
|
||||
**IMPORTANT: Follow the template below for tracked tasks.** The apply phase parses
|
||||
checkbox format to track progress. A box holding only `x` counts as done,
|
||||
upper or lower case and with any spacing, so `- [ x]` is done too. Every
|
||||
other marker, including `- [~]`, `- [-]` and an empty `- []`, reads as
|
||||
unfinished. A line with no checkbox is not tracked at all.
|
||||
|
||||
Guidelines:
|
||||
- Group related tasks under ## numbered headings
|
||||
- Each task MUST be a checkbox: `- [ ] X.Y Task description`
|
||||
- Group related tracked tasks under ## numbered headings
|
||||
- Each tracked task MUST be a checkbox: `- [ ] X.Y Task description`
|
||||
- Tasks should be small enough to complete in one session
|
||||
- Order tasks by dependency (what must be done first?)
|
||||
- Track implementation and verification work that can be completed before
|
||||
archive. If the requested workflow includes archive or work that requires
|
||||
this change to be archived, preserve those steps as plain bullets in an
|
||||
optional `## Workflow follow-up` section at the end of tasks.md. These
|
||||
bullets are reference information outside tracked task progress.
|
||||
- Each task MUST state how to verify completion (a test, command,
|
||||
observable behavior, or delivered artifact). Put the verification in
|
||||
that task's checkbox description. Use a separate verification task only
|
||||
@@ -401,6 +417,14 @@ Example:
|
||||
- [ ] 2.3 Document the export API in docs/export.md and verify the documented command runs as written
|
||||
```
|
||||
|
||||
When applicable, append workflow follow-up as plain bullets, for example:
|
||||
```
|
||||
## Workflow follow-up
|
||||
|
||||
- Archive the change after the project's review requirements are satisfied.
|
||||
- Verify the archived result.
|
||||
```
|
||||
|
||||
Reference specs for what needs to be built, design for how to build it.
|
||||
````
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# OpenSpec Documentation
|
||||
|
||||
> [!WARNING]
|
||||
> These pages are legacy and some are out of date. The current docs are at **[openspec.dev/docs](https://openspec.dev/docs)** (source: [`docs-lab/`](../docs-lab/)).
|
||||
|
||||
Welcome. This is the home for everything OpenSpec.
|
||||
|
||||
OpenSpec helps you and your AI coding assistant **agree on what to build before any code is written.** You describe the change, the AI drafts a short spec and a task list, you both look at the same plan, and then the work happens. No more discovering halfway through that the AI built the wrong thing.
|
||||
|
||||
@@ -79,6 +79,10 @@ Success: `{ "change": { "id", "path", "metadataPath", "schema" }, "root" }`. Fai
|
||||
### 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 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.
|
||||
|
||||
- **`archive: null`**: the command failed. This is not a guarantee that files are unchanged.
|
||||
- **`archive_retirement_cleanup_failed`**: the change was archived, but retirement backup verification or cleanup failed. A listed backup path may have changed or disappeared. The message can also report a staged source left by a failed fallback-copy cleanup. Inspect the current archive and all reported recovery paths before cleanup. Preserve any needed content. Retrying archive does not clean up these paths.
|
||||
- **`archive_error`**: the fallback diagnostic does not identify whether files changed. Inspect the change, archive, and affected specs before retrying.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -124,7 +128,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_change_symlink`, `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_retirement_cleanup_failed`, `archive_error`.
|
||||
|
||||
### Context writes
|
||||
`context_file_exists`, `context_output_dir_missing`.
|
||||
|
||||
+17
@@ -678,6 +678,23 @@ Then archive the change normally:
|
||||
openspec archive retire-legacy --yes
|
||||
```
|
||||
|
||||
**Keep your own metadata:** a top-level key starting with a lowercase `x-` is
|
||||
yours. OpenSpec never reads, writes or reports it, so `openspec validate
|
||||
--strict` stays green on a change that carries tool-specific data:
|
||||
|
||||
```yaml
|
||||
# openspec/changes/add-dark-mode/.openspec.yaml
|
||||
schema: spec-driven
|
||||
x-tracker: SB-5497
|
||||
x-jiraKey: ABC-1
|
||||
```
|
||||
|
||||
The name after the prefix is yours to choose, in any case or spelling, as long
|
||||
as it is not empty and has no whitespace in it. Two limits: the prefix itself is
|
||||
lowercase, so `X-Tracker` is still reported, and extensions are top-level only —
|
||||
a key inside `initiative:` is rejected whatever it is called. Any other
|
||||
unrecognized top-level key is still reported as having no effect.
|
||||
|
||||
When the change removes the capability's last requirement, OpenSpec deletes its
|
||||
live `spec.md`. Other capability deltas in the same change still update their
|
||||
main specs. Without the marker, archive stops before changing any files and
|
||||
|
||||
+9
-1
@@ -7,9 +7,16 @@ Listed projects are maintained independently. Inclusion does not imply official
|
||||
## Projects and resources
|
||||
|
||||
- **[OpenSpec Workbench](https://github.com/VeryComplexAndLongName/OpenSpec-UI)**: Running and supervising agents on OpenSpec changes.
|
||||
- **[MySpec](https://myspec.dev)**: Cloud beta (account required) that conducts guided spec interviews and exports four-file bundles or OpenSpec-compatible changes. The service sends submitted content to third-party AI providers and is not designed for sensitive data.
|
||||
- **[openspec-guard](https://github.com/guillaume-flambard/spec-guard)**: CLI and GitHub Action that reports which OpenSpec scenarios are covered by a Vitest or Jest test, without running the tests.
|
||||
|
||||
## Hosted services
|
||||
|
||||
Hosted products that add an OpenSpec capability the CLI does not provide. Each entry states its account, payment, and data-handling terms.
|
||||
|
||||
- **[MySpec](https://myspec.dev)**: Cloud beta (account required; free tier, no credit card) that conducts guided spec interviews and exports four-file bundles or OpenSpec-compatible changes. Its MCP server (`@myspec/mcp-server`) and [Claude Code plugins](https://github.com/myspecs/claude-plugins) let coding agents read those specs, write OpenSpec changes, and implement them task by task. The service sends submitted content to third-party AI providers and is not designed for sensitive data.
|
||||
- **[Entalpa](https://entalpa.com/en/openspec)**: Imports a public GitHub repository's `openspec/specs/` (up to 300 requirements) into a browsable project, adds stakeholders, stories and open questions labeled as inferred, and exports the result as an `openspec/specs/` tree; it never writes to the repository. Importing and reading need no account; saving or editing needs one, and AI features use paid credits. Imported content is sent to third-party AI providers.
|
||||
- **[OpenSpec Review](https://skillgild.dev/skills/openspec-review) and [OpenSpec Compact](https://skillgild.dev/skills/openspec-compact)**: Hosted skills from SkillGild for Claude Code, Codex, Cursor, Gemini CLI or any MCP client. Review publishes a change as a link where anyone holding it can approve, request changes or comment per requirement without an account, shows the main spec as `openspec archive` would write it, and flags requirements that other open changes also touch. Compact finds lexically similar requirements and repeated scenarios across main specs and writes the cleanup as an OpenSpec change. Requires the SkillGild CLI and a free account (25 runs a month per skill). Both send spec content to SkillGild's servers, which run closed-source code and make no AI model call; Review also sends the proposal, design and tasks, and keeps the change for 30 days.
|
||||
|
||||
## Add your project
|
||||
|
||||
Open a pull request adding one line to this file with your project's name, a direct link, and a short description of how it relates to OpenSpec.
|
||||
@@ -17,5 +24,6 @@ Open a pull request adding one line to this file with your project's name, a dir
|
||||
- Keep entries focused on something built with OpenSpec or supporting its use, rather than general product advertising.
|
||||
- Describe what people can use. Avoid promotional claims, referral links, and tracking links.
|
||||
- Disclose paid features or required accounts in the entry, if any.
|
||||
- Hosted or paid services are listed under Hosted services only when they add an OpenSpec capability the CLI does not already provide and have a free way to try them. A service that repackages OpenSpec's own skills or commands is not listed.
|
||||
|
||||
Corrections and updates to existing entries are welcome too.
|
||||
|
||||
+1
-1
@@ -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, retire_capabilities
|
||||
├── .openspec.yaml # Change metadata (optional): schema, created, skip_specs, retire_capabilities, plus your own x-* keys
|
||||
└── specs/ # Delta specs
|
||||
└── ui/
|
||||
└── spec.md # What's changing in ui/spec.md
|
||||
|
||||
@@ -423,6 +423,7 @@ Community schemas are not vendored into OpenSpec core — they live in their own
|
||||
| `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. |
|
||||
| `anvil` | @jikkujoyce | [jikkujoyce/openspec-schemas](https://github.com/jikkujoyce/openspec-schemas/tree/main/schemas/anvil) | Spec-driven workflow with TDD discipline and an adversarial review step. Flow: `proposal` → `specs` → `design` → `review` → `test-plan` → `tasks` → `apply` → `verify`. `review` is written by a fresh-context, read-only reviewer (a second model when one is available) and emits a `VERDICT:` line telling the agent to gate `test-plan`, `tasks`, and `apply`; OpenSpec only checks that artifacts exist, so enforce the gate with your own CI or hook. `test-plan` maps every spec scenario to a named test and doubles as a red/green ledger that `verify` audits. |
|
||||
| `ml-research` | @tsenst | [idealo/openspec-schemas](https://github.com/idealo/openspec-schemas/tree/main/openspec/schemas/ml-research) | Machine-learning experiment workflow. Flow: `proposal` → `design` → `evaluation` → `tasks` → `apply` → `specs`. `evaluation` fixes metrics, success criteria, and the falsification condition before the run; `apply` writes `results.md`; `specs` records only evidence-backed findings (refuted hypotheses included) as `Finding:` requirements with linked `Evidence:` blocks. |
|
||||
|
||||
> Want to contribute a community schema? Open an issue with a link to your repository, or submit a PR adding a row to this table.
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
inherit (finalAttrs) pname version src;
|
||||
pnpm = final.pnpm_10;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-gPGdwmj4oLb/j3D/BGNCaI0hFfrHKCjH4I71UYzmhfk=";
|
||||
hash = "sha256-fKm4sBdTDeQfIiE0k301jtRbiuJrBE7YIEBu8RZxuXI=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with final; [
|
||||
|
||||
@@ -49,7 +49,7 @@ The fix lands in the shared helper `getTaskProgressForChange`, correcting all fo
|
||||
|
||||
Two corrections to an earlier draft, both load-bearing:
|
||||
|
||||
- **`apply.tracks` is a filename that *selects* the artifact; it is not the glob.** `apply.tracks` is typed `string | null` and is consumed elsewhere as a literal path (`path.join(changeDir, tracks)` + `existsSync`), so `apply.tracks: "**/tasks.md"` cannot match nested files. The glob `status` actually uses is the tracked artifact's **`generates`**, resolved by `resolveArtifactOutputs(changeDir, artifact.generates)`. So the fix identifies the tracked-tasks artifact (the artifact whose `generates` equals `apply.tracks`, falling back to artifact id `tasks` when no `apply` block is present), then counts checkboxes across `resolveArtifactOutputs(changeDir, thatArtifact.generates)`. `resolveArtifactOutputs` roots `fast-glob` at the change directory (so a sibling `changes/archive/` or another change's `tasks.md` cannot match) and de-dups via a `Set` (so no double counting).
|
||||
- **`apply.tracks` is a filename that *selects* the artifact; it is not the glob.** `apply.tracks` is typed `string | null` and is consumed elsewhere as a literal path (`path.join(changeDir, tracks)` + `existsSync`), so `apply.tracks: "**/tasks.md"` cannot match nested files. The glob `status` actually uses is the tracked artifact's **`generates`**, resolved by `resolveArtifactOutputs(changeDir, artifact.generates)`. So the fix identifies the tracked-tasks artifact (the artifact whose `generates` equals `apply.tracks`, falling back to artifact id `tasks` when no `apply` block is present), then counts checkboxes across `resolveArtifactOutputs(changeDir, thatArtifact.generates)`. `resolveArtifactOutputs` roots its glob at the change directory (so a sibling `changes/archive/` or another change's `tasks.md` cannot match) and de-dups via a `Set` (so no double counting).
|
||||
- **`status` checks file *existence*, not checkbox completion.** Empirically `status` calls a 3/5 change `4/4 complete, isComplete:true`. So the parity established here is **resolution-mechanism parity** (`view`/`archive` resolve the same set of files `status` resolves), not count parity — `view`/`archive` additionally count checkboxes. Any "view/archive task counts equal status" claim is false and is removed from the spec.
|
||||
|
||||
The signature gains `projectRoot` (needed to resolve project-local schemas via `resolveSchema`); all four call sites plus the two `change.ts` sites can derive it. `resolveSchema` **throws** on an unresolvable/misnamed schema, whereas the current helper never throws — so the helper MUST catch and fall back to single-file `tasks.md`, or `view`/`list`/`archive` would crash on a project whose config names a deleted schema. This fallback is specified and tested.
|
||||
|
||||
@@ -119,6 +119,14 @@ The system SHALL preserve literal filenames with a bare leading `!`, plain paren
|
||||
- **AND** "content/linked" is a symbolic link to a directory outside the change directory
|
||||
- **THEN** output resolution rejects traversal through that link even when no matching files exist
|
||||
|
||||
### Requirement: Glob Nesting Limit
|
||||
The system SHALL reject a glob `generates` or `apply.tracks` value that nests braces or parentheses more than 16 levels deep, counting them as the brace parser does, with an error naming the limit, before any glob matching runs.
|
||||
|
||||
#### Scenario: Brace nesting beyond the limit
|
||||
- **WHEN** an artifact generates a pattern whose braces nest 17 levels deep
|
||||
- **THEN** resolving its outputs fails with an error naming the 16-level limit
|
||||
- **AND** no glob matching runs for that pattern
|
||||
|
||||
### Requirement: Ready Artifact Query
|
||||
The system SHALL identify which artifacts are ready to be created based on dependency completion.
|
||||
|
||||
|
||||
@@ -224,12 +224,12 @@ The spec update confirmation SHALL provide clear visibility into changes before
|
||||
EXISTING specs to be updated:
|
||||
- cli-init (from changes/update-init-command/specs/cli-init/spec.md)
|
||||
|
||||
Update 2 specs and archive 'add-archive-command'? [y/N]:
|
||||
Proceed with spec updates? (Y/n)
|
||||
```
|
||||
#### Scenario: Handling confirmation response
|
||||
|
||||
- **WHEN** waiting for user confirmation
|
||||
- **THEN** default to "No" for safety (require explicit "y" or "yes")
|
||||
- **THEN** default to "Yes", so pressing Enter proceeds (declining requires an explicit "n" or "no")
|
||||
- **AND** skip confirmation when `--yes` or `-y` flag is provided
|
||||
|
||||
#### Scenario: User declines confirmation
|
||||
|
||||
@@ -103,7 +103,11 @@ The agent SHALL verify that implementation matches the specifications.
|
||||
|
||||
#### Scenario: Removed requirement
|
||||
- **WHEN** a requirement sits under `## REMOVED Requirements` in a delta spec
|
||||
- **THEN** the agent treats the absence of its implementation as the expected result
|
||||
- **THEN** the agent reads the baseline requirement (its body and scenarios in the main spec, matched by normalized name) before searching, rather than searching from the requirement name alone
|
||||
- **AND** recovers the baseline from the main spec file's history when that file no longer holds the requirement, for example because an earlier sync already applied the removal
|
||||
- **AND** treats a baseline that adds nothing to its own title as unusable, the same as a missing one
|
||||
- **AND** marks spec coverage as not verified for the entry, without searching, when no usable baseline can be found or read
|
||||
- **AND** treats the absence of an implementation of that baseline behavior as the expected result
|
||||
- **AND** does not report it as missing or suggest implementing it
|
||||
- **AND** reports it as CRITICAL only if the removed behavior is still present in the codebase
|
||||
- **AND** skips scenario coverage for it
|
||||
|
||||
@@ -92,14 +92,14 @@ The `openspec schemas` command SHALL display the source of each schema.
|
||||
|
||||
### Requirement: Use config schema as default for new changes
|
||||
|
||||
The system SHALL use the schema field from `openspec/config.yaml` as the default when creating new changes without explicit `--schema` flag and no planning-home default applies.
|
||||
The system SHALL use the schema field from `openspec/config.yaml` as the default when creating new changes without an explicit `--schema` flag, and SHALL fall back to the planning-home default when the config supplies no schema.
|
||||
|
||||
#### Scenario: Create change without --schema flag and config exists
|
||||
- **WHEN** user runs `openspec new change foo`, no planning-home default applies, and config contains `schema: "tdd"`
|
||||
- **THEN** system creates change with schema "tdd"
|
||||
- **WHEN** user runs `openspec new change foo` and config contains `schema: "tdd"`
|
||||
- **THEN** system creates change with schema "tdd", even when a planning-home default applies
|
||||
|
||||
#### Scenario: Create change without --schema flag and no config
|
||||
- **WHEN** user runs `openspec new change foo`, no planning-home default applies, and no config file exists
|
||||
- **WHEN** user runs `openspec new change foo`, no config file exists, and no planning-home default applies
|
||||
- **THEN** system creates change with default schema "spec-driven"
|
||||
|
||||
#### Scenario: Create change with explicit --schema flag
|
||||
@@ -108,7 +108,7 @@ The system SHALL use the schema field from `openspec/config.yaml` as the default
|
||||
|
||||
### Requirement: Resolve schema with updated precedence order
|
||||
|
||||
The system SHALL resolve the schema for a change using the following precedence order: CLI flag, change metadata, planning-home default, project config, hardcoded default.
|
||||
The system SHALL resolve the schema for a change using the following precedence order: CLI flag, change metadata, project config, planning-home default, hardcoded default.
|
||||
|
||||
#### Scenario: CLI flag is provided
|
||||
- **WHEN** user runs command with `--schema custom`
|
||||
@@ -119,11 +119,11 @@ The system SHALL resolve the schema for a change using the following precedence
|
||||
- **THEN** system uses "bound" from change metadata
|
||||
|
||||
#### Scenario: Only project config specifies schema
|
||||
- **WHEN** no CLI flag, change metadata, or planning-home default exists, but config has `schema: tdd`
|
||||
- **THEN** system uses "tdd" from project config
|
||||
- **WHEN** no CLI flag or change metadata exists, but config has `schema: tdd`
|
||||
- **THEN** system uses "tdd" from project config, even when a planning-home default applies
|
||||
|
||||
#### Scenario: No schema specified anywhere
|
||||
- **WHEN** no CLI flag, change metadata, planning-home default, or project config
|
||||
- **WHEN** no CLI flag, change metadata, project config, or planning-home default
|
||||
- **THEN** system uses hardcoded default "spec-driven"
|
||||
|
||||
### Requirement: Support project-local schema names in config
|
||||
|
||||
@@ -62,7 +62,7 @@ The system SHALL display a one-line telemetry disclosure notice on the first com
|
||||
#### Scenario: First command execution
|
||||
- **WHEN** a user runs their first openspec command
|
||||
- **AND** telemetry is enabled
|
||||
- **THEN** the system displays: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
|
||||
- **THEN** the system displays: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0 or openspec config set telemetry.enabled false"
|
||||
|
||||
#### Scenario: Subsequent command execution
|
||||
- **WHEN** a user has already seen the notice (noticeSeen: true in config)
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "1.14.0",
|
||||
"version": "1.14.3",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
@@ -63,6 +63,7 @@
|
||||
"@changesets/changelog-github": "^1.0.0",
|
||||
"@changesets/cli": "^3.0.1",
|
||||
"@types/node": "^20.19.43",
|
||||
"@types/picomatch": "^4.0.3",
|
||||
"@vitest/ui": "^4.1.11",
|
||||
"eslint": "^10.5.0",
|
||||
"smol-toml": "^1.7.1",
|
||||
@@ -73,12 +74,14 @@
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^12.0.0",
|
||||
"@inquirer/prompts": "^8.5.2",
|
||||
"brace-expansion": "^5.0.12",
|
||||
"chalk": "^5.6.2",
|
||||
"commander": "^14.0.0",
|
||||
"cross-spawn": "7.0.6",
|
||||
"diff": "^9.0.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ora": "^9.4.1",
|
||||
"picomatch": "^4.0.4",
|
||||
"tinyglobby": "^0.2.17",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
|
||||
Generated
+253
-393
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -10,7 +10,10 @@ allowBuilds:
|
||||
# plain-name entries in package.json when it bumps the same package, so a mirrored
|
||||
# copy there both drifts and silently takes precedence over these advisory pins.
|
||||
overrides:
|
||||
brace-expansion@<=5.0.8: '>=5.0.9 <6'
|
||||
# GHSA-6j4f-fj2g-mc7p, GHSA-qhr7-859c-m2p7, GHSA-q2hr-2g5m-vwhr — brace-expansion
|
||||
# below 5.0.12. Direct dependency, and minimatch. Remove once every copy is
|
||||
# >=5.0.12 (check: pnpm why brace-expansion).
|
||||
brace-expansion@<5.0.12: '>=5.0.12 <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
|
||||
@@ -26,3 +29,8 @@ overrides:
|
||||
# Dev-only (transitive via @vitest/ui); never in the published CLI. Remove once
|
||||
# transitive fflate is >=0.8.3 (check: pnpm why fflate).
|
||||
fflate@<0.8.3: '>=0.8.3 <0.9'
|
||||
# GHSA-pqg4-j6r4-53mv — shell-quote `quote()` command injection via a line
|
||||
# terminator after a `{ comment }` token. Dev-only (transitive via
|
||||
# @changesets/cli > launch-editor); never in the published CLI. Remove once
|
||||
# transitive shell-quote is >=1.11.0 (check: pnpm why shell-quote).
|
||||
shell-quote@>=1.8.4 <1.11.0: '>=1.11.0 <2'
|
||||
|
||||
@@ -77,6 +77,17 @@ artifacts:
|
||||
- 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`. Run `openspec list --specs` to confirm that path before writing the delta, appending `--store "<id>"` only for a registered standalone store - a mistyped or invented path targets a capability that does not exist rather than the one you meant. Do not move or rename the capability.
|
||||
|
||||
Write the spec files one at a time, so the user sees progress and an
|
||||
interrupted run keeps the files it already wrote:
|
||||
- Before writing anything, compare the capabilities the proposal lists
|
||||
with the `specs/<capability-path>/spec.md` files that already exist,
|
||||
and start with the first capability that has no file. Do not rewrite
|
||||
an existing file unless the user asks you to.
|
||||
- Before each file, say in one line which capability you are writing.
|
||||
- Write each file as soon as you have drafted it. Do not plan every file
|
||||
before writing the first.
|
||||
- Stop when every capability the proposal lists has its file.
|
||||
|
||||
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`
|
||||
rejects a zero-delta change without that marker. If the proposal lists no
|
||||
@@ -94,7 +105,7 @@ artifacts:
|
||||
- Each scenario: `#### Scenario: <name>` with WHEN/THEN format
|
||||
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
|
||||
- Every requirement MUST have at least one scenario.
|
||||
- Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions once they reach the main spec. This is an informational hint, not an error. When writing a new requirement, state one behavior per requirement: move examples and edge cases into scenarios, and split a requirement that covers several behaviors into separate `### Requirement:` blocks, each with its own scenarios. Under MODIFIED, keep the existing requirement block whole; never split, trim or rewrite existing text just to meet the length.
|
||||
- Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions in ADDED requirements and in the main spec. This is a warning: normal validation still passes, but `openspec validate --strict` fails on it. When writing a new requirement, state one behavior per requirement: move examples and edge cases into scenarios, and split a requirement that covers several behaviors into separate `### Requirement:` blocks, each with its own scenarios. Under MODIFIED, keep the existing requirement block whole; never split, trim or rewrite existing text just to meet the length. Split an existing long requirement only when the user asks for it, in a change made for that purpose: under MODIFIED, keep its header and every scenario and cut its description down to one behavior without changing its meaning, then add each behavior you removed as its own ADDED requirement with its own scenarios.
|
||||
|
||||
New capabilities only: the delta spec's first section is `## Purpose` -
|
||||
one or two sentences (50+ characters, or `openspec validate --strict`
|
||||
@@ -195,17 +206,22 @@ artifacts:
|
||||
would change what gets built, resolve them with the user first - do not
|
||||
bake an unstated assumption into the task list.
|
||||
|
||||
**IMPORTANT: Follow the template below exactly.** The apply phase parses
|
||||
**IMPORTANT: Follow the template below for tracked tasks.** The apply phase parses
|
||||
checkbox format to track progress. A box holding only `x` counts as done,
|
||||
upper or lower case and with any spacing, so `- [ x]` is done too. Every
|
||||
other marker, including `- [~]`, `- [-]` and an empty `- []`, reads as
|
||||
unfinished. A line with no checkbox is not tracked at all.
|
||||
|
||||
Guidelines:
|
||||
- Group related tasks under ## numbered headings
|
||||
- Each task MUST be a checkbox: `- [ ] X.Y Task description`
|
||||
- Group related tracked tasks under ## numbered headings
|
||||
- Each tracked task MUST be a checkbox: `- [ ] X.Y Task description`
|
||||
- Tasks should be small enough to complete in one session
|
||||
- Order tasks by dependency (what must be done first?)
|
||||
- Track implementation and verification work that can be completed before
|
||||
archive. If the requested workflow includes archive or work that requires
|
||||
this change to be archived, preserve those steps as plain bullets in an
|
||||
optional `## Workflow follow-up` section at the end of tasks.md. These
|
||||
bullets are reference information outside tracked task progress.
|
||||
- Each task MUST state how to verify completion (a test, command,
|
||||
observable behavior, or delivered artifact). Put the verification in
|
||||
that task's checkbox description. Use a separate verification task only
|
||||
@@ -235,6 +251,14 @@ artifacts:
|
||||
- [ ] 2.3 Document the export API in docs/export.md and verify the documented command runs as written
|
||||
```
|
||||
|
||||
When applicable, append workflow follow-up as plain bullets, for example:
|
||||
```
|
||||
## Workflow follow-up
|
||||
|
||||
- Archive the change after the project's review requirements are satisfied.
|
||||
- Verify the archived result.
|
||||
```
|
||||
|
||||
Reference specs for what needs to be built, design for how to build it.
|
||||
requires:
|
||||
- specs
|
||||
|
||||
@@ -65,7 +65,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
- If `state: "blocked"`: show the message and pause implementation.
|
||||
- If `missingArtifacts` is non-empty: suggest using `/openspec-continue-change` to create them.
|
||||
- Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- If `state: "all_done"`: report that all tracked tasks are complete and suggest review or verification as appropriate before archiving
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
Treat `context` as a required prompt-level input. Read and consider it, and
|
||||
@@ -124,7 +124,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If all done: report that tracked tasks are complete and suggest review or verification as appropriate before archiving
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
@@ -155,7 +155,8 @@ Working on task 4/7: <task description>
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! You can archive this change with `/openspec-archive-change`.
|
||||
All tracked tasks are complete. Review or verify the change as appropriate
|
||||
before archiving. You can archive this change with `/openspec-archive-change`.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
@@ -58,6 +58,13 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
|
||||
---
|
||||
|
||||
**Before anything else, finish a partly written artifact**:
|
||||
- An artifact whose output is a glob pattern (e.g. `specs/**/*.md`) reads `done` as soon as one matching file exists, so a run interrupted while writing its files leaves it `done` with some still missing
|
||||
- For each such `done` artifact, first get its instructions and read any completed dependencies needed to determine the expected files. Then compare those files with `artifactPaths.<id>.existingOutputPaths`. For spec-driven's `specs`, read the proposal and check for one `specs/<capability-path>/spec.md` per capability it lists; resolve each expected path against `changeRoot` before comparing path identity, and verify each one remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists
|
||||
- Never write an expected path that fails that check; stop and tell the user. If any are missing, write only the missing files, each at its checked path - when the `instruction` field delegates creation to a skill or command, invoke it only if it can write each file to its checked path; otherwise stop and tell the user - and STOP - that counts as this invocation's ONE artifact. If you cannot tell whether a file was left out on purpose, ask the user
|
||||
|
||||
---
|
||||
|
||||
**If all planning artifacts are complete (`isPlanningComplete: true`, or legacy `isComplete: true`)**:
|
||||
- Congratulate the user
|
||||
- Show final status including the schema used
|
||||
@@ -82,10 +89,13 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
- `skipped`/`warning`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact
|
||||
- **Create the artifact file**:
|
||||
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
|
||||
- If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath`
|
||||
- Otherwise use `template` as the structure - fill in its sections
|
||||
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
|
||||
- Write to the `resolvedOutputPath` specified in instructions. If it is a glob pattern, choose the concrete file path using the schema instruction and the change's context
|
||||
- Choose the output path before creating anything. If the `resolvedOutputPath` specified in instructions is a glob pattern, choose a concrete path inside `changeRoot` using the schema instruction and the change's context, and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`
|
||||
- If the `instruction` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user
|
||||
- Otherwise, write it yourself:
|
||||
- Use `template` as the structure - fill in its sections
|
||||
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
|
||||
- Write the file to that output path
|
||||
- Either way, verify the artifact file exists at that output path
|
||||
- Show what was created and what's now unlocked
|
||||
- STOP after creating ONE artifact
|
||||
|
||||
@@ -113,7 +123,7 @@ After each invocation, show:
|
||||
|
||||
The artifact types and their purpose depend on the schema. The `instruction` field from the instructions output is the authoritative guidance for each artifact - follow it even when the artifact has a familiar name (proposal.md, tasks.md, etc.), since custom schemas may define different content or a different process for the same file names.
|
||||
|
||||
If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly.
|
||||
If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there.
|
||||
|
||||
**Guardrails**
|
||||
- Create ONE artifact per invocation
|
||||
|
||||
@@ -156,7 +156,7 @@ If the user asks you to capture the exploration as a new change, that request is
|
||||
|
||||
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.
|
||||
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. Choose the output path before creating anything: when `resolvedOutputPath` is a glob, use the instruction to choose a concrete path inside `changeRoot` and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`. If the instruction delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Otherwise write the artifact to that output path. Verify that the selected concrete output exists at that output path.
|
||||
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 the requested capture is done, stop there and name where the work continues: `/openspec-propose` writes the remaining planning artifacts, and `/openspec-apply-change` implements the change once tasks exist. Capturing artifacts never starts implementing them.
|
||||
@@ -178,6 +178,8 @@ If the user mentions a change or you detect one is relevant:
|
||||
|
||||
`<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.
|
||||
|
||||
The files below are the default spec-driven schema's. With another schema, capture each insight in whichever artifact from `artifactPaths` fits it, and name that artifact in your offer; never create an artifact the schema does not define.
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|-------------------------------------|
|
||||
| New requirement discovered | `specs/<capability-path>/spec.md` |
|
||||
@@ -187,7 +189,7 @@ If the user mentions a change or you detect one is relevant:
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
Example offers (default schema):
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
@@ -316,7 +318,7 @@ You: That changes everything.
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? Run `/openspec-propose` and this becomes a change."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Result in artifact updates**: "Updated the design with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
|
||||
@@ -76,8 +76,9 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
|
||||
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
|
||||
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
|
||||
- If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath`
|
||||
- Otherwise create the artifact file using `template` as the structure and write it to `resolvedOutputPath`. If `resolvedOutputPath` is a glob, follow `instruction` to choose the concrete file path
|
||||
- Choose the output path before creating anything. If `resolvedOutputPath` is a glob, follow `instruction` to choose a concrete path inside `changeRoot` that matches the glob, and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`
|
||||
- If the `instruction` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
|
||||
- Otherwise create the artifact file using `template` as the structure and write it to that output path
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "✓ Created <artifact-id>"
|
||||
|
||||
@@ -111,7 +112,7 @@ After completing all artifacts, summarize:
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type - it is the authoritative guidance, even for familiar artifact names
|
||||
- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
|
||||
- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
|
||||
@@ -121,8 +121,9 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
|
||||
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
|
||||
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
|
||||
- If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath`
|
||||
- Otherwise create the artifact file using `template` as the structure and write it to `resolvedOutputPath`. If `resolvedOutputPath` is a glob, follow `instruction` to choose the concrete file path
|
||||
- Choose the output path before creating anything. If `resolvedOutputPath` is a glob, follow `instruction` to choose a concrete path inside `changeRoot` that matches the glob, and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`
|
||||
- If the `instruction` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
|
||||
- Otherwise create the artifact file using `template` as the structure and write it to that output path
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
@@ -156,7 +157,7 @@ After completing all artifacts, summarize:
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type - it is the authoritative guidance, even for familiar artifact names
|
||||
- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
|
||||
- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
|
||||
@@ -58,7 +58,7 @@ This workflow revises artifacts that already exist; `/openspec-continue-change`
|
||||
|
||||
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.
|
||||
|
||||
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
|
||||
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`), plus any confirmed concrete path allowed under a partly populated glob artifact (step 4). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
|
||||
|
||||
3. **Understand the request**
|
||||
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
|
||||
@@ -102,7 +102,7 @@ After each invocation, show:
|
||||
**Guardrails**
|
||||
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/openspec-apply-change`.
|
||||
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
|
||||
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
|
||||
- Edit only the concrete files in `existingOutputPaths`, plus any confirmed concrete path allowed under a partly populated glob artifact (step 4); never write to a glob `resolvedOutputPath`.
|
||||
- Do not advance the build frontier: if an artifact has empty `existingOutputPaths` and status `ready` or `blocked`, that is `/openspec-continue-change`'s job. Leave `skipped` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose `existingOutputPaths` is non-empty.
|
||||
- Confirm every edit with the user before writing.
|
||||
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic).
|
||||
|
||||
@@ -90,9 +90,9 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
||||
|
||||
**Spec Coverage**:
|
||||
- If status marks the spec artifact skipped by `skip_specs: true`, or the schema defines no spec artifact, report the spec-dependent checks as not applicable.
|
||||
- Otherwise, `contextFiles` is keyed by artifact id, and artifact ids come from the active schema. If `contextFiles.specs` is absent or empty, mark **Spec Coverage**, **Requirement Implementation Mapping**, and **Scenario Coverage** as not verified; do not treat any of them as clean.
|
||||
- If delta specs exist in `contextFiles.specs`:
|
||||
- If status marks the spec artifact skipped by `skip_specs: true`, or the schema defines no spec artifact (no artifact whose `artifactPaths.<id>.outputPath` is under `specs/`), report the spec-dependent checks as not applicable.
|
||||
- Otherwise, `contextFiles` is keyed by artifact id, and artifact ids come from the active schema, so do not assume an id such as `specs`. The spec artifacts are those whose `artifactPaths.<id>.outputPath` is under `specs/`; read their files from `contextFiles.<id>`. If those spec files are absent or empty, mark **Spec Coverage**, **Requirement Implementation Mapping**, and **Scenario Coverage** as not verified; do not treat any of them as clean.
|
||||
- If delta specs exist in those spec files:
|
||||
- Extract all requirements (marked with "### Requirement:", or listed as `FROM:`/`TO:` pairs under `## RENAMED Requirements`) and note the delta section each one sits under: `## ADDED`, `## MODIFIED`, `## REMOVED`, or `## RENAMED Requirements`. The section decides what the check looks for.
|
||||
- For each ADDED or MODIFIED requirement (for MODIFIED, check the text in the delta, not the old wording):
|
||||
- Search codebase for keywords related to the requirement
|
||||
@@ -101,8 +101,11 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
||||
- Recommendation: "Implement requirement X: <description>"
|
||||
- For each REMOVED requirement, the change asks for the behavior to be gone, so invert the check:
|
||||
- Search codebase for the removed behavior. Matches in `openspec/` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
||||
- Finding no implementation is the expected result. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
||||
- A REMOVED block carries only the requirement name and any `**Reason**`/`**Migration**` notes, so the delta does not describe the behavior itself. Before searching, read the baseline requirement in the main spec at `<planningHome.root>/openspec/specs/<capability-path>/spec.md`, using the same capability path as the delta spec. Match it by normalized name, scanning that file's requirement headings rather than string-matching the delta's exact title. Its body and scenarios are the evidence for the behavior that must be gone; never search from the requirement name alone.
|
||||
- If that file holds no such requirement under this or a near-equivalent name - for example because an earlier sync already applied the removal - recover the baseline from the same file's history, in the planning repository rather than the current directory: `git -C "<planningHome.root>" log -p -- openspec/specs/<capability-path>/spec.md`, or `git -C "<planningHome.root>" show <commit>^:./openspec/specs/<capability-path>/spec.md` once you have identified the commit that applied the removal. Empty output means the pathspec matched nothing, not that there is no baseline: `git log` reports an unmatched path as success, so re-check the path before drawing any conclusion from silence.
|
||||
- A baseline that adds nothing to its own title - no body beyond a restatement of the name, no scenarios - is unusable evidence, the same as a missing one. If no usable baseline is available in either place, mark **Spec Coverage** as not verified for that entry with the reason and stop there: do not search, and never count an unsearched removal as passing.
|
||||
- Search codebase for that behavior. Matches in `openspec/` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
||||
- Finding no implementation is the expected result, but only once the baseline behavior was the thing you searched for. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
||||
- If the behavior is still present:
|
||||
- Add CRITICAL issue: "Removed requirement still implemented: <requirement name>"
|
||||
- Recommendation: "Remove the remaining implementation at <file>:<lines>, following the requirement's Migration note if it has one"
|
||||
@@ -118,7 +121,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
|
||||
6. **Verify Correctness**
|
||||
|
||||
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each RENAMED entry is checked there against its baseline behavior), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
||||
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each REMOVED and RENAMED entry with a usable baseline is checked there against its baseline behavior, and an entry already marked not verified there stays not verified), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
||||
|
||||
**Requirement Implementation Mapping**:
|
||||
- For each ADDED or MODIFIED requirement from delta specs (REMOVED entries, and RENAMED entries without a MODIFIED block, were settled under Spec Coverage):
|
||||
@@ -141,14 +144,14 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
7. **Verify Coherence**
|
||||
|
||||
**Design Adherence**:
|
||||
- If the schema defines no design artifact, report **Design Adherence** as not applicable.
|
||||
- If `contextFiles.design` exists:
|
||||
- If the schema defines no design artifact (no artifact with id `design`, and none whose `artifactPaths.<id>.outputPath` is or ends in `design.md`), report **Design Adherence** as not applicable.
|
||||
- If the design artifact's `contextFiles.<id>` file exists:
|
||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
||||
- Verify implementation follows those decisions
|
||||
- If contradiction detected:
|
||||
- Add WARNING: "Design decision not followed: <decision>"
|
||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
||||
- Otherwise, if `contextFiles.design` is absent or empty: mark **Design Adherence** as not verified. With other supporting artifacts, **Code Pattern Consistency** still runs; the task-only case remains limited to task completion.
|
||||
- Otherwise, if the design artifact's `contextFiles.<id>` file is absent or empty: mark **Design Adherence** as not verified. With other supporting artifacts, **Code Pattern Consistency** still runs; the task-only case remains limited to task completion.
|
||||
|
||||
**Code Pattern Consistency**:
|
||||
- If implementation changes cannot be identified, mark **Code Pattern Consistency** as not verified and explain the missing evidence.
|
||||
@@ -172,7 +175,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
| Coherence | Followed/Issues |
|
||||
```
|
||||
|
||||
In each Status cell, report the results of checks that ran and `Not verified (<reason>)` for every skipped check. If all checks in a dimension were skipped, start the cell with `Not verified`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified"). For a change that only removes or renames requirements, the Correctness cell reads `Not applicable (no ADDED or MODIFIED requirements)`.
|
||||
In each Status cell, report the results of checks that ran and `Not verified (<reason>)` for every skipped check. If all checks in a dimension were skipped, start the cell with `Not verified`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified", or "1 removal not verified (baseline unavailable)"). For a change that only removes or renames requirements, the Correctness cell reads `Not applicable (no ADDED or MODIFIED requirements)`.
|
||||
|
||||
**Issues by Priority**:
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Command } from 'commander';
|
||||
|
||||
/**
|
||||
* Register the config command and all its subcommands.
|
||||
*
|
||||
* @param program - The Commander program instance
|
||||
*/
|
||||
export function registerConfigCommand(program: Command): void {
|
||||
const configCmd = program
|
||||
.command('config')
|
||||
.description('View and modify global OpenSpec configuration')
|
||||
.option('--scope <scope>', 'Config scope (only "global" supported currently)')
|
||||
.hook('preAction', (thisCommand) => {
|
||||
const opts = thisCommand.opts();
|
||||
if (opts.scope && opts.scope !== 'global') {
|
||||
console.error('Error: Project-local config is not yet implemented');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// config path
|
||||
configCmd
|
||||
.command('path')
|
||||
.description('Show config file location')
|
||||
.action(async () => {
|
||||
const { configPathCommand } = await import('../../commands/config.js');
|
||||
configPathCommand();
|
||||
});
|
||||
|
||||
// config list
|
||||
configCmd
|
||||
.command('list')
|
||||
.description('Show all current settings')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const { configListCommand } = await import('../../commands/config.js');
|
||||
configListCommand(options);
|
||||
});
|
||||
|
||||
// config get
|
||||
configCmd
|
||||
.command('get <key>')
|
||||
.description('Get a specific value (raw, scriptable)')
|
||||
.action(async (key: string) => {
|
||||
const { configGetCommand } = await import('../../commands/config.js');
|
||||
configGetCommand(key);
|
||||
});
|
||||
|
||||
// config set
|
||||
configCmd
|
||||
.command('set <key> <value>')
|
||||
.description('Set a value (auto-coerce types)')
|
||||
.option('--string', 'Force value to be stored as string')
|
||||
.option('--allow-unknown', 'Allow setting unknown keys')
|
||||
.action(async (key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }) => {
|
||||
const { configSetCommand } = await import('../../commands/config.js');
|
||||
configSetCommand(key, value, options);
|
||||
});
|
||||
|
||||
// config unset
|
||||
configCmd
|
||||
.command('unset <key>')
|
||||
.description('Remove a key (revert to default)')
|
||||
.action(async (key: string) => {
|
||||
const { configUnsetCommand } = await import('../../commands/config.js');
|
||||
configUnsetCommand(key);
|
||||
});
|
||||
|
||||
// config reset
|
||||
configCmd
|
||||
.command('reset')
|
||||
.description('Reset configuration to defaults')
|
||||
.option('--all', 'Reset all configuration (required)')
|
||||
.option('-y, --yes', 'Skip confirmation prompts')
|
||||
.action(async (options: { all?: boolean; yes?: boolean }) => {
|
||||
const { configResetCommand } = await import('../../commands/config.js');
|
||||
await configResetCommand(options);
|
||||
});
|
||||
|
||||
// config edit
|
||||
configCmd
|
||||
.command('edit')
|
||||
.description('Open config in $EDITOR')
|
||||
.action(async () => {
|
||||
const { configEditCommand } = await import('../../commands/config.js');
|
||||
await configEditCommand();
|
||||
});
|
||||
|
||||
// config profile [preset]
|
||||
configCmd
|
||||
.command('profile [preset]')
|
||||
.description('Configure workflow profile (interactive picker or preset shortcut)')
|
||||
.action(async (preset?: string) => {
|
||||
const { configProfileCommand } = await import('../../commands/config.js');
|
||||
await configProfileCommand(preset);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Command, Option } from 'commander';
|
||||
import { COMMAND_REGISTRY } from '../../core/completions/command-registry.js';
|
||||
import { COMMON_FLAGS } from '../../core/completions/shared-flags.js';
|
||||
import type { ContextOptions } from '../../commands/context.js';
|
||||
|
||||
export function registerContextCommand(program: Command): void {
|
||||
const description =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'context')?.description ??
|
||||
'Print the working context for the resolved OpenSpec root';
|
||||
|
||||
program
|
||||
.command('context')
|
||||
.description(description)
|
||||
.option('--store <id>', COMMON_FLAGS.store.description)
|
||||
.addOption(
|
||||
new Option('--store-path <path>', 'Removed; register the store and use --store').hideHelp()
|
||||
)
|
||||
.option('--json', 'Output the agent brief as JSON')
|
||||
.option('--code-workspace <path>', 'Also write a VS Code workspace file for the set')
|
||||
.option('--force', 'Overwrite an existing --code-workspace file')
|
||||
.action(async (options: ContextOptions) => {
|
||||
const { contextCommand } = await import('../../commands/context.js');
|
||||
await contextCommand(options);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Command, Option } from 'commander';
|
||||
import { COMMAND_REGISTRY } from '../../core/completions/command-registry.js';
|
||||
import { COMMON_FLAGS } from '../../core/completions/shared-flags.js';
|
||||
import type { DoctorOptions } from '../../commands/doctor.js';
|
||||
|
||||
export function registerDoctorCommand(program: Command): void {
|
||||
const description =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'doctor')?.description ??
|
||||
'Report relationship health for the resolved OpenSpec root';
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.description(description)
|
||||
.option('--store <id>', COMMON_FLAGS.store.description)
|
||||
.addOption(
|
||||
new Option('--store-path <path>', 'Removed; register the store and use --store').hideHelp()
|
||||
)
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: DoctorOptions) => {
|
||||
const { doctorCommand } = await import('../../commands/doctor.js');
|
||||
await doctorCommand(options);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Command } from 'commander';
|
||||
|
||||
/**
|
||||
* Register the schema command and all its subcommands.
|
||||
*/
|
||||
export function registerSchemaCommand(program: Command): void {
|
||||
const schemaCmd = program
|
||||
.command('schema')
|
||||
.description('Manage workflow schemas [experimental]');
|
||||
|
||||
// Experimental warning
|
||||
schemaCmd.hook('preAction', () => {
|
||||
console.error('Note: Schema commands are experimental and may change.');
|
||||
});
|
||||
|
||||
// schema which
|
||||
schemaCmd
|
||||
.command('which [name]')
|
||||
.description('Show where a schema resolves from')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--all', 'List all schemas with their resolution sources')
|
||||
.action(async (name?: string, options?: { json?: boolean; all?: boolean }) => {
|
||||
const { schemaWhichCommand } = await import('../../commands/schema.js');
|
||||
await schemaWhichCommand(name, options);
|
||||
});
|
||||
|
||||
// schema validate
|
||||
schemaCmd
|
||||
.command('validate [name]')
|
||||
.description('Validate a schema structure and templates')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--verbose', 'Show detailed validation steps')
|
||||
.action(async (name?: string, options?: { json?: boolean; verbose?: boolean }) => {
|
||||
const { schemaValidateCommand } = await import('../../commands/schema.js');
|
||||
await schemaValidateCommand(name, options);
|
||||
});
|
||||
|
||||
// schema fork
|
||||
schemaCmd
|
||||
.command('fork <source> [name]')
|
||||
.description('Copy an existing schema to project for customization')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--force', 'Overwrite existing destination')
|
||||
.action(async (source: string, name?: string, options?: { json?: boolean; force?: boolean }) => {
|
||||
const { schemaForkCommand } = await import('../../commands/schema.js');
|
||||
await schemaForkCommand(source, name, options);
|
||||
});
|
||||
|
||||
// schema init
|
||||
schemaCmd
|
||||
.command('init <name>')
|
||||
.description('Create a new project-local schema')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--description <text>', 'Schema description')
|
||||
.option('--artifacts <list>', 'Comma-separated artifact IDs (proposal,specs,design,tasks)')
|
||||
.option('--default', 'Set as project default schema')
|
||||
.option('--no-default', 'Do not prompt to set as default')
|
||||
.option('--force', 'Overwrite existing schema')
|
||||
.action(async (
|
||||
name: string,
|
||||
options?: {
|
||||
json?: boolean;
|
||||
description?: string;
|
||||
artifacts?: string;
|
||||
default?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
) => {
|
||||
const { schemaInitCommand } = await import('../../commands/schema.js');
|
||||
await schemaInitCommand(name, options);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ShowOptions } from '../../commands/spec.js';
|
||||
|
||||
export function registerSpecCommand(rootProgram: Command) {
|
||||
const specCommand = rootProgram
|
||||
.command('spec')
|
||||
.description('Manage and view OpenSpec specifications');
|
||||
|
||||
// Deprecation notice for noun-based commands
|
||||
specCommand.hook('preAction', () => {
|
||||
console.error('Warning: The "openspec spec ..." commands are deprecated. Prefer verb-first commands (e.g., "openspec show", "openspec validate --specs").');
|
||||
});
|
||||
|
||||
specCommand
|
||||
.command('show [spec-id]')
|
||||
.description('Display a specific specification')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--requirements', 'JSON only: Show only requirements (exclude scenarios)')
|
||||
.option('--no-scenarios', 'JSON only: Exclude scenario content')
|
||||
.option('-r, --requirement <id>', 'JSON only: Show specific requirement by ID (1-based)')
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (specId: string | undefined, options: ShowOptions & { noInteractive?: boolean }) => {
|
||||
const { specShowCommand } = await import('../../commands/spec.js');
|
||||
await specShowCommand(specId, options);
|
||||
});
|
||||
|
||||
specCommand
|
||||
.command('list')
|
||||
.description('List all available specifications')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--long', 'Show id and title with counts')
|
||||
.action(async (options: { json?: boolean; long?: boolean }) => {
|
||||
const { specListCommand } = await import('../../commands/spec.js');
|
||||
await specListCommand(options);
|
||||
});
|
||||
|
||||
specCommand
|
||||
.command('validate [spec-id]')
|
||||
.description('Validate a specification structure')
|
||||
.option('--strict', 'Enable strict validation mode')
|
||||
.option('--json', 'Output validation report as JSON')
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {
|
||||
const { specValidateCommand } = await import('../../commands/spec.js');
|
||||
await specValidateCommand(specId, options);
|
||||
});
|
||||
|
||||
return specCommand;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Command } from 'commander';
|
||||
import { COMMAND_REGISTRY } from '../../core/completions/command-registry.js';
|
||||
import { printJson } from '../../commands/shared-output.js';
|
||||
import type {
|
||||
StoreCommand,
|
||||
StoreJsonOptions,
|
||||
StoreRegisterOptions,
|
||||
StoreRemoveOptions,
|
||||
StoreSetupOptions,
|
||||
} from '../../commands/store.js';
|
||||
|
||||
async function loadStoreCommand(): Promise<StoreCommand> {
|
||||
const { StoreCommand } = await import('../../commands/store.js');
|
||||
return new StoreCommand();
|
||||
}
|
||||
|
||||
export function registerStoreCommand(program: Command): void {
|
||||
// One source for the locked group one-liner: the completions registry
|
||||
// entry, which shell completion scripts also consume.
|
||||
const storeGroupDescription =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'store')?.description ??
|
||||
'Create and manage stores - standalone OpenSpec repos you register on this machine';
|
||||
const store = program.command('store').description(storeGroupDescription);
|
||||
|
||||
store
|
||||
.command('setup [id]')
|
||||
.description('Create and register a local store')
|
||||
.option('--path <path>', 'Folder where the store should live (for example ~/openspec/<id>)')
|
||||
.option('--init-git', 'Initialize a Git repository with an initial commit (default)')
|
||||
.option('--no-init-git', 'Skip every Git action: no init, no initial commit')
|
||||
.option('--remote <url>', 'Canonical clone source recorded in store.yaml')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string | undefined, options: StoreSetupOptions) => {
|
||||
const storeCommand = await loadStoreCommand();
|
||||
await storeCommand.setup(id, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('register [path]')
|
||||
.description('Register an existing local store')
|
||||
.option('--id <id>', 'Store id; defaults to metadata or folder name')
|
||||
.option('--yes', 'Confirm creating store identity metadata for a healthy OpenSpec root')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (inputPath: string | undefined, options: StoreRegisterOptions) => {
|
||||
const storeCommand = await loadStoreCommand();
|
||||
await storeCommand.register(inputPath, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('unregister <id>')
|
||||
.description('Forget a local store registration without deleting files')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string, options: StoreJsonOptions) => {
|
||||
const storeCommand = await loadStoreCommand();
|
||||
await storeCommand.unregister(id, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('remove <id>')
|
||||
.description('Forget a local store registration and delete its local folder')
|
||||
.option('--yes', 'Confirm local store folder deletion')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string, options: StoreRemoveOptions) => {
|
||||
const storeCommand = await loadStoreCommand();
|
||||
await storeCommand.remove(id, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List locally registered stores')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: StoreJsonOptions) => {
|
||||
const storeCommand = await loadStoreCommand();
|
||||
await storeCommand.list(options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('doctor [id]')
|
||||
.description('Check local store registration and metadata')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string | undefined, options: StoreJsonOptions) => {
|
||||
const storeCommand = await loadStoreCommand();
|
||||
await storeCommand.doctor(id, options);
|
||||
});
|
||||
|
||||
const lifecycleRedirects = new Set(
|
||||
COMMAND_REGISTRY.filter(
|
||||
(entry) =>
|
||||
entry.flags.some((flag) => flag.name === 'store') ||
|
||||
(entry.subcommands ?? []).some((subcommand) =>
|
||||
subcommand.flags.some((flag) => flag.name === 'store')
|
||||
)
|
||||
).map((entry) => entry.name)
|
||||
);
|
||||
const storeSubcommandsLine = store.commands
|
||||
.map((subcommand) => {
|
||||
const aliases = subcommand.aliases();
|
||||
return aliases.length > 0 ? `${subcommand.name()} (${aliases.join(', ')})` : subcommand.name();
|
||||
})
|
||||
.join(', ');
|
||||
// One group action owns missing AND unknown subcommands. Known
|
||||
// subcommands dispatch above; everything else — including a bare
|
||||
// `store --json` with no operand — lands here, so the handler owns the
|
||||
// entire message and exit path (same text for human and --json). The
|
||||
// permissive flags route unknown operands/options here instead of
|
||||
// letting Commander emit a raw error before the action runs. We detect
|
||||
// `--json` in the residual args rather than declaring a group option,
|
||||
// which would otherwise shadow each subcommand's own `--json` flag.
|
||||
store.allowExcessArguments(true);
|
||||
store.allowUnknownOption(true);
|
||||
store.action(() => {
|
||||
const operands = store.args;
|
||||
// Flag values are indistinguishable from operands without a full
|
||||
// parse, so the verbatim echo only applies to plain-operand input.
|
||||
const attempted = operands.filter((operand) => !operand.startsWith('-'));
|
||||
const hasFlagLikeToken = operands.some((operand) => operand.startsWith('-'));
|
||||
// The agent contract: --json failures emit one JSON document.
|
||||
if (operands.includes('--json')) {
|
||||
const message =
|
||||
attempted.length > 0
|
||||
? `Unknown command '${attempted[0]}' for 'openspec store'. Store subcommands: ${storeSubcommandsLine}.`
|
||||
: `Missing subcommand for 'openspec store'. Store subcommands: ${storeSubcommandsLine}.`;
|
||||
printJson({
|
||||
status: [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'unknown_store_subcommand',
|
||||
message,
|
||||
fix: 'Run a store subcommand, or use the lifecycle command with --store <id>.',
|
||||
},
|
||||
],
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let example = 'openspec new change <change-id> --store <id>';
|
||||
if (!hasFlagLikeToken && attempted.length > 0 && lifecycleRedirects.has(attempted[0])) {
|
||||
if (attempted[0] === 'new') {
|
||||
const changeId = attempted[1] === 'change' && attempted[2] ? attempted[2] : '<change-id>';
|
||||
example = `openspec new change ${changeId} --store <id>`;
|
||||
} else {
|
||||
example = `openspec ${attempted.join(' ')} --store <id>`;
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
attempted.length > 0
|
||||
? `Error: unknown command '${attempted[0]}' for 'openspec store'.`
|
||||
: "Error: missing subcommand for 'openspec store'."
|
||||
);
|
||||
console.error(
|
||||
`Store subcommands manage store registration: ${storeSubcommandsLine}.`
|
||||
);
|
||||
console.error(
|
||||
'To create or work on a change in a store, use the normal command with --store, for example:'
|
||||
);
|
||||
console.error(` ${example}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Command, Option } from 'commander';
|
||||
import { COMMAND_REGISTRY } from '../../core/completions/command-registry.js';
|
||||
import type { StoreDiagnostic } from '../../core/store/errors.js';
|
||||
import { printJson } from '../../commands/shared-output.js';
|
||||
import type {
|
||||
WorksetCommand,
|
||||
WorksetCreateOptions,
|
||||
WorksetOpenOptions,
|
||||
WorksetRemoveOptions,
|
||||
} from '../../commands/workset.js';
|
||||
|
||||
async function loadWorksetCommand(): Promise<WorksetCommand> {
|
||||
const { WorksetCommand } = await import('../../commands/workset.js');
|
||||
return new WorksetCommand();
|
||||
}
|
||||
|
||||
function collectMember(value: string, previous: string[]): string[] {
|
||||
return [...previous, value];
|
||||
}
|
||||
|
||||
export function registerWorksetCommand(program: Command): void {
|
||||
const groupDescription =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'workset')?.description ??
|
||||
'Compose, keep, and open personal working views (purely local)';
|
||||
const workset = program.command('workset').description(groupDescription);
|
||||
// Parsed at the group level so `openspec workset --json` keeps the
|
||||
// one-JSON-document contract instead of a raw Commander error. The
|
||||
// parent option matches anywhere; actions read optsWithGlobals().
|
||||
workset.addOption(new Option('--json', 'Output as JSON').hideHelp());
|
||||
|
||||
workset
|
||||
.command('create [name]')
|
||||
.description('Compose and save a named working view of folders you choose')
|
||||
.option(
|
||||
'--member <member>',
|
||||
'Member folder as <path> or <name>=<path>; repeatable, first is the primary',
|
||||
collectMember,
|
||||
[] as string[]
|
||||
)
|
||||
.option('--tool <id>', 'Preferred tool to open this workset with')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (name: string | undefined, _options: WorksetCreateOptions, command: Command) => {
|
||||
const worksetCommand = await loadWorksetCommand();
|
||||
await worksetCommand.create(name, command.optsWithGlobals());
|
||||
});
|
||||
|
||||
workset
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('Show saved worksets with their members')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (_options: { json?: boolean }, command: Command) => {
|
||||
const worksetCommand = await loadWorksetCommand();
|
||||
await worksetCommand.list(command.optsWithGlobals());
|
||||
});
|
||||
|
||||
workset
|
||||
.command('open <name>')
|
||||
.description('Open a saved workset in your tool (editor window or agent session)')
|
||||
.option('--tool <id>', 'Open with this tool just this once')
|
||||
.addOption(
|
||||
// Parsed so Commander never owns the error; rejected in the
|
||||
// action with one JSON document. Hidden because help should not
|
||||
// advertise a mode that only rejects.
|
||||
new Option('--json', 'Not supported for open').hideHelp()
|
||||
)
|
||||
.action(async (name: string, _options: WorksetOpenOptions, command: Command) => {
|
||||
const worksetCommand = await loadWorksetCommand();
|
||||
await worksetCommand.open(name, command.optsWithGlobals());
|
||||
});
|
||||
|
||||
workset
|
||||
.command('remove <name>')
|
||||
.description('Delete a saved workset (member folders are never touched)')
|
||||
.option('--yes', 'Confirm removal non-interactively')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (name: string, _options: WorksetRemoveOptions, command: Command) => {
|
||||
const worksetCommand = await loadWorksetCommand();
|
||||
await worksetCommand.remove(name, command.optsWithGlobals());
|
||||
});
|
||||
|
||||
const subcommandsLine = workset.commands
|
||||
.map((subcommand) => {
|
||||
const aliases = subcommand.aliases();
|
||||
return aliases.length > 0
|
||||
? `${subcommand.name()} (${aliases.join(', ')})`
|
||||
: subcommand.name();
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
// One handler owns missing AND unknown subcommands: known
|
||||
// subcommands dispatch above; everything else lands in this action
|
||||
// (allowExcessArguments routes the unknown operand here), keeping
|
||||
// the one-JSON-document contract for `--json` probes.
|
||||
workset.allowExcessArguments(true);
|
||||
workset.action(() => {
|
||||
const attempted = workset.args.filter(
|
||||
(operand) => !operand.startsWith('-')
|
||||
);
|
||||
const message =
|
||||
attempted.length > 0
|
||||
? `Unknown command '${attempted[0]}' for 'openspec workset'. Workset subcommands: ${subcommandsLine}.`
|
||||
: `Missing subcommand for 'openspec workset'. Workset subcommands: ${subcommandsLine}.`;
|
||||
if (workset.opts().json) {
|
||||
printJson({
|
||||
status: [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'unknown_workset_subcommand',
|
||||
message,
|
||||
fix: 'Run one of the workset subcommands.',
|
||||
} satisfies StoreDiagnostic,
|
||||
],
|
||||
});
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
+118
-72
@@ -1,63 +1,31 @@
|
||||
import { asStatus } from '../commands/shared-output.js';
|
||||
import { Command, Option } from 'commander';
|
||||
import { createRequire } from 'module';
|
||||
import ora from 'ora';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { existsSync, promises as fs } from 'fs';
|
||||
import { AI_TOOLS, TOOL_ID_ALIASES } from '../core/config.js';
|
||||
import { UpdateCommand } from '../core/update.js';
|
||||
import {
|
||||
getAvailableCliUpdate,
|
||||
displayCliUpdateNote,
|
||||
shouldOfferUpgrade,
|
||||
getInstallDir,
|
||||
offerCliUpgrade,
|
||||
rerunUpdateWithUpgradedCli,
|
||||
displayUpgradeCommand,
|
||||
isSourceCheckout,
|
||||
checkForCliUpdate,
|
||||
getCliInstallInfo,
|
||||
getCliUpdateCommand,
|
||||
canSelfUpgrade,
|
||||
buildVersionReportLines,
|
||||
} from '../core/version-check.js';
|
||||
import { ListCommand } from '../core/list.js';
|
||||
import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js';
|
||||
import { ViewCommand } from '../core/view.js';
|
||||
import { resolveRootForCommand, toRootOutput } from '../core/root-selection.js';
|
||||
import { registerSpecCommand } from '../commands/spec.js';
|
||||
import { ChangeCommand } from '../commands/change.js';
|
||||
import { ValidateCommand } from '../commands/validate.js';
|
||||
import { ShowCommand } from '../commands/show.js';
|
||||
import { CompletionCommand } from '../commands/completion.js';
|
||||
import { FeedbackCommand } from '../commands/feedback.js';
|
||||
import { registerConfigCommand } from '../commands/config.js';
|
||||
import { registerSchemaCommand } from '../commands/schema.js';
|
||||
import { registerStoreCommand } from '../commands/store.js';
|
||||
import { registerDoctorCommand } from '../commands/doctor.js';
|
||||
import { registerContextCommand } from '../commands/context.js';
|
||||
import { registerWorksetCommand } from '../commands/workset.js';
|
||||
import {
|
||||
statusCommand,
|
||||
BATCH_STATUS_FAILURE_PAYLOAD,
|
||||
instructionsCommand,
|
||||
applyInstructionsCommand,
|
||||
archiveInstructionsCommand,
|
||||
templatesCommand,
|
||||
schemasCommand,
|
||||
newChangeCommand,
|
||||
DEFAULT_SCHEMA,
|
||||
type StatusOptions,
|
||||
type InstructionsOptions,
|
||||
type TemplatesOptions,
|
||||
type SchemasOptions,
|
||||
type NewChangeOptions,
|
||||
import type { ArchiveOptions } from '../core/archive.js';
|
||||
import { registerSpecCommand } from './commands/spec.js';
|
||||
import { registerConfigCommand } from './commands/config.js';
|
||||
import { registerSchemaCommand } from './commands/schema.js';
|
||||
import { registerStoreCommand } from './commands/store.js';
|
||||
import { registerDoctorCommand } from './commands/doctor.js';
|
||||
import { registerContextCommand } from './commands/context.js';
|
||||
import { registerWorksetCommand } from './commands/workset.js';
|
||||
import { DEFAULT_SCHEMA } from '../commands/workflow/default-schema.js';
|
||||
import type {
|
||||
StatusOptions,
|
||||
InstructionsOptions,
|
||||
TemplatesOptions,
|
||||
SchemasOptions,
|
||||
NewChangeOptions,
|
||||
} from '../commands/workflow/index.js';
|
||||
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
|
||||
import { maybeShowCompletionTip } from '../core/completion-tip.js';
|
||||
import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
|
||||
// Startup cost: every command's implementation, and the packages it uses,
|
||||
// loads with `await import()` inside its action. This module and
|
||||
// ./commands/ only define commands (names, options, help), so `--version`,
|
||||
// `--help`, and each command load no other command's implementation.
|
||||
|
||||
const STORE_OPTION_DESCRIPTION = COMMON_FLAGS.store.description;
|
||||
|
||||
@@ -72,13 +40,14 @@ function hiddenStorePathOption(): Option {
|
||||
).hideHelp();
|
||||
}
|
||||
|
||||
function failWithError(
|
||||
async function failWithError(
|
||||
error: unknown,
|
||||
json?: { enabled: boolean | undefined; payload?: Record<string, unknown>; fallbackCode?: string }
|
||||
): void {
|
||||
): Promise<void> {
|
||||
// The agent contract: every --json failure leaves exactly one JSON
|
||||
// document on stdout (the command's null-shape plus a status array).
|
||||
if (json?.enabled) {
|
||||
const { asStatus } = await import('../commands/shared-output.js');
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ ...(json.payload ?? {}), status: [asStatus(error, json.fallbackCode ?? 'command_error')] },
|
||||
@@ -89,6 +58,7 @@ function failWithError(
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const { default: ora } = await import('ora');
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
// Resolution and store errors carry a pasteable fix - never drop it.
|
||||
const fix = (error as { diagnostic?: { fix?: string } }).diagnostic?.fix;
|
||||
@@ -153,6 +123,14 @@ export function isCompletionRun(commandPath: string): boolean {
|
||||
return commandPath.split(':')[0] === 'completion' || commandPath === '__complete';
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the hidden `__complete` resolver alone. It runs on every Tab press,
|
||||
* so the hooks skip telemetry and the completion tip for it entirely.
|
||||
*/
|
||||
export function isTabCompletionRun(commandPath: string): boolean {
|
||||
return commandPath === '__complete';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the first-run completions tip must be deferred rather than shown.
|
||||
*
|
||||
@@ -180,6 +158,13 @@ program
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--check', 'Check the registry for a newer version')
|
||||
.action(async (options: { json?: boolean; check?: boolean }) => {
|
||||
const {
|
||||
getCliInstallInfo,
|
||||
checkForCliUpdate,
|
||||
getCliUpdateCommand,
|
||||
canSelfUpgrade,
|
||||
buildVersionReportLines,
|
||||
} = await import('../core/version-check.js');
|
||||
const install = getCliInstallInfo();
|
||||
const update = options.check ? await checkForCliUpdate() : undefined;
|
||||
const command = update?.status === 'available' ? getCliUpdateCommand(install) : null;
|
||||
@@ -219,9 +204,17 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
||||
process.env.NO_COLOR = '1';
|
||||
}
|
||||
|
||||
// `__complete` runs on every Tab press, with stderr sent to /dev/null by the
|
||||
// generated scripts. Tracking it would hold each press open for the request,
|
||||
// and the first-run notice would be marked seen without anyone reading it.
|
||||
if (isTabCompletionRun(getCommandPath(actionCommand))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show first-run telemetry notice (if not seen). It's written to stderr, so it
|
||||
// never pollutes stdout — but --json runs still defer it (see isJsonRun) so the
|
||||
// very first invocation stays free of any incidental output on either stream.
|
||||
const { maybeShowTelemetryNotice, trackCommand } = await import('../telemetry/index.js');
|
||||
await maybeShowTelemetryNotice({ silent: isJsonRun(actionCommand) });
|
||||
|
||||
// Track command execution (use actionCommand to get the actual subcommand)
|
||||
@@ -232,6 +225,12 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
||||
|
||||
// Shutdown telemetry after command completes
|
||||
program.hook('postAction', async (_thisCommand, actionCommand) => {
|
||||
// Nothing to flush (preAction tracked nothing), and the tip is always
|
||||
// deferred for completion runs, so skip loading either module.
|
||||
if (isTabCompletionRun(getCommandPath(actionCommand))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the first-run shell-completions tip (on stderr, so piped stdout stays
|
||||
// clean). postAction, not preAction: the tip trails the command's own output
|
||||
// instead of pushing an error message or `init`'s setup summary down the
|
||||
@@ -239,12 +238,14 @@ program.hook('postAction', async (_thisCommand, actionCommand) => {
|
||||
// `openspec completion ...`, and a stderr that is not a terminal (agents and
|
||||
// pipes would otherwise silently burn the user's one-shot tip).
|
||||
try {
|
||||
const { maybeShowCompletionTip } = await import('../core/completion-tip.js');
|
||||
await maybeShowCompletionTip({
|
||||
silent: shouldDeferCompletionTip(actionCommand, Boolean(process.stderr.isTTY)),
|
||||
});
|
||||
} finally {
|
||||
// The flush runs even if the hint throws: parse() is synchronous, so a
|
||||
// rejection here has no catch anywhere above it.
|
||||
const { shutdown } = await import('../telemetry/index.js');
|
||||
await shutdown();
|
||||
}
|
||||
});
|
||||
@@ -299,7 +300,7 @@ program
|
||||
});
|
||||
await initCommand.execute(targetPath);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -320,7 +321,7 @@ program
|
||||
});
|
||||
await initCommand.execute('.');
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -331,6 +332,24 @@ program
|
||||
.option('--force', 'Force update even when tools are up to date')
|
||||
.action(async (targetPath = '.', options?: { force?: boolean }) => {
|
||||
try {
|
||||
const [
|
||||
{
|
||||
getInstallDir,
|
||||
isSourceCheckout,
|
||||
getAvailableCliUpdate,
|
||||
shouldOfferUpgrade,
|
||||
displayCliUpdateNote,
|
||||
offerCliUpgrade,
|
||||
rerunUpdateWithUpgradedCli,
|
||||
displayUpgradeCommand,
|
||||
},
|
||||
{ isInteractive },
|
||||
{ UpdateCommand },
|
||||
] = await Promise.all([
|
||||
import('../core/version-check.js'),
|
||||
import('../utils/interactive.js'),
|
||||
import('../core/update.js'),
|
||||
]);
|
||||
const installDir = getInstallDir();
|
||||
// Running from a clone: the version is whatever the branch says, so any
|
||||
// upgrade advice would be noise. Decided before the request, so a
|
||||
@@ -385,7 +404,7 @@ program
|
||||
displayCliUpdateNote(latestVersion, targetPath);
|
||||
}
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -406,6 +425,10 @@ program
|
||||
if (options?.specs && (options.archived || options.all)) {
|
||||
throw new Error('--archived and --all can only be used when listing changes.');
|
||||
}
|
||||
const [{ resolveRootForCommand, toRootOutput }, { ListCommand }] = await Promise.all([
|
||||
import('../core/root-selection.js'),
|
||||
import('../core/list.js'),
|
||||
]);
|
||||
const root = await resolveRootForCommand(options ?? {}, {
|
||||
json: options?.json,
|
||||
failurePayload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
|
||||
@@ -427,7 +450,7 @@ program
|
||||
...(options?.json ? { root: toRootOutput(root) } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
failWithError(error, {
|
||||
await failWithError(error, {
|
||||
enabled: options?.json,
|
||||
payload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
|
||||
fallbackCode: 'list_error',
|
||||
@@ -446,6 +469,10 @@ program
|
||||
// Implicit cwd fallback stays enabled so `view` keeps accepting the same
|
||||
// directories as `list`/`status` — notably pre-config.yaml `openspec/`
|
||||
// dirs. ViewCommand still reports a missing openspec/ directory itself.
|
||||
const [{ resolveRootForCommand }, { ViewCommand }] = await Promise.all([
|
||||
import('../core/root-selection.js'),
|
||||
import('../core/view.js'),
|
||||
]);
|
||||
const root = await resolveRootForCommand(options ?? {});
|
||||
if (!root) {
|
||||
return;
|
||||
@@ -453,7 +480,7 @@ program
|
||||
const viewCommand = new ViewCommand();
|
||||
await viewCommand.execute(root.path);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -478,6 +505,7 @@ changeCmd
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; diff?: boolean; noInteractive?: boolean }) => {
|
||||
try {
|
||||
const { ChangeCommand } = await import('../commands/change.js');
|
||||
const changeCommand = new ChangeCommand();
|
||||
await changeCommand.show(changeName, options);
|
||||
} catch (error) {
|
||||
@@ -494,6 +522,7 @@ changeCmd
|
||||
.action(async (options?: { json?: boolean; long?: boolean }) => {
|
||||
try {
|
||||
console.error('Warning: "openspec change list" is deprecated. Use "openspec list".');
|
||||
const { ChangeCommand } = await import('../commands/change.js');
|
||||
const changeCommand = new ChangeCommand();
|
||||
await changeCommand.list(options);
|
||||
} catch (error) {
|
||||
@@ -510,6 +539,7 @@ changeCmd
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {
|
||||
try {
|
||||
const { ChangeCommand } = await import('../commands/change.js');
|
||||
const changeCommand = new ChangeCommand();
|
||||
// validate() already sets process.exitCode, and Node honours it at
|
||||
// natural exit. Calling process.exit() here would skip commander's
|
||||
@@ -534,10 +564,11 @@ program
|
||||
.addOption(hiddenStorePathOption())
|
||||
.action(async (changeName?: string, options?: ArchiveOptions) => {
|
||||
try {
|
||||
const { ArchiveCommand } = await import('../core/archive.js');
|
||||
const archiveCommand = new ArchiveCommand();
|
||||
await archiveCommand.execute(changeName, options);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -568,10 +599,11 @@ program
|
||||
.addOption(hiddenStorePathOption())
|
||||
.action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; archived?: boolean; report?: string; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string; store?: string; storePath?: string }) => {
|
||||
try {
|
||||
const { ValidateCommand } = await import('../commands/validate.js');
|
||||
const validateCommand = new ValidateCommand();
|
||||
await validateCommand.execute(itemName, options);
|
||||
} catch (error) {
|
||||
failWithError(error, { enabled: options?.json, fallbackCode: 'validate_error' });
|
||||
await failWithError(error, { enabled: options?.json, fallbackCode: 'validate_error' });
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -599,10 +631,11 @@ program
|
||||
.allowUnknownOption(true)
|
||||
.action(async (itemName?: string, options?: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any }) => {
|
||||
try {
|
||||
const { ShowCommand } = await import('../commands/show.js');
|
||||
const showCommand = new ShowCommand();
|
||||
await showCommand.execute(itemName, options ?? {});
|
||||
} catch (error) {
|
||||
failWithError(error, { enabled: options?.json, fallbackCode: 'show_error' });
|
||||
await failWithError(error, { enabled: options?.json, fallbackCode: 'show_error' });
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -614,10 +647,11 @@ program
|
||||
.option('--body <text>', 'Detailed description for the feedback')
|
||||
.action(async (message: string, options?: { body?: string }) => {
|
||||
try {
|
||||
const { FeedbackCommand } = await import('../commands/feedback.js');
|
||||
const feedbackCommand = new FeedbackCommand();
|
||||
await feedbackCommand.execute(message, options);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -632,10 +666,11 @@ completionCmd
|
||||
.description('Generate completion script for a shell (outputs to stdout)')
|
||||
.action(async (shell?: string) => {
|
||||
try {
|
||||
const { CompletionCommand } = await import('../commands/completion.js');
|
||||
const completionCommand = new CompletionCommand();
|
||||
await completionCommand.generate({ shell });
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -646,10 +681,11 @@ completionCmd
|
||||
.option('--verbose', 'Show detailed installation output')
|
||||
.action(async (shell?: string, options?: { verbose?: boolean }) => {
|
||||
try {
|
||||
const { CompletionCommand } = await import('../commands/completion.js');
|
||||
const completionCommand = new CompletionCommand();
|
||||
await completionCommand.install({ shell, verbose: options?.verbose });
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -660,10 +696,11 @@ completionCmd
|
||||
.option('-y, --yes', 'Skip confirmation prompts')
|
||||
.action(async (shell?: string, options?: { yes?: boolean }) => {
|
||||
try {
|
||||
const { CompletionCommand } = await import('../commands/completion.js');
|
||||
const completionCommand = new CompletionCommand();
|
||||
await completionCommand.uninstall({ shell, yes: options?.yes });
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -674,6 +711,7 @@ program
|
||||
.description('Output completion data in machine-readable format (internal use)')
|
||||
.action(async (type: string) => {
|
||||
try {
|
||||
const { CompletionCommand } = await import('../commands/completion.js');
|
||||
const completionCommand = new CompletionCommand();
|
||||
await completionCommand.complete({ type });
|
||||
} catch (error) {
|
||||
@@ -698,13 +736,16 @@ program
|
||||
.addOption(hiddenStorePathOption())
|
||||
.action(async (options: StatusOptions) => {
|
||||
try {
|
||||
const { statusCommand } = await import('../commands/workflow/status.js');
|
||||
await statusCommand(options);
|
||||
} catch (error) {
|
||||
failWithError(error, {
|
||||
await failWithError(error, {
|
||||
enabled: options.json,
|
||||
// The batch null-shape; the single-change failure shape is
|
||||
// pre-existing contract and stays payload-free.
|
||||
payload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined,
|
||||
payload: options.all
|
||||
? (await import('../commands/workflow/status.js')).BATCH_STATUS_FAILURE_PAYLOAD
|
||||
: undefined,
|
||||
fallbackCode: 'change_error',
|
||||
});
|
||||
process.exit(1);
|
||||
@@ -722,6 +763,8 @@ program
|
||||
.addOption(hiddenStorePathOption())
|
||||
.action(async (artifactId: string | undefined, options: InstructionsOptions) => {
|
||||
try {
|
||||
const { applyInstructionsCommand, archiveInstructionsCommand, instructionsCommand } =
|
||||
await import('../commands/workflow/instructions.js');
|
||||
// Workflow instruction surfaces are reserved command branches, not artifacts.
|
||||
if (artifactId === 'apply') {
|
||||
await applyInstructionsCommand(options);
|
||||
@@ -731,7 +774,7 @@ program
|
||||
await instructionsCommand(artifactId, options);
|
||||
}
|
||||
} catch (error) {
|
||||
failWithError(error, { enabled: options.json, fallbackCode: 'change_error' });
|
||||
await failWithError(error, { enabled: options.json, fallbackCode: 'change_error' });
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -744,9 +787,10 @@ program
|
||||
.option('--json', 'Output as JSON mapping artifact IDs to template paths')
|
||||
.action(async (options: TemplatesOptions) => {
|
||||
try {
|
||||
const { templatesCommand } = await import('../commands/workflow/templates.js');
|
||||
await templatesCommand(options);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -760,9 +804,10 @@ program
|
||||
.addOption(hiddenStorePathOption())
|
||||
.action(async (options: SchemasOptions) => {
|
||||
try {
|
||||
const { schemasCommand } = await import('../commands/workflow/schemas.js');
|
||||
await schemasCommand(options);
|
||||
} catch (error) {
|
||||
failWithError(error, {
|
||||
await failWithError(error, {
|
||||
enabled: options.json,
|
||||
payload: { schemas: [], root: null },
|
||||
fallbackCode: 'schemas_error',
|
||||
@@ -789,9 +834,10 @@ newCmd
|
||||
.addOption(new Option('--areas <names>', 'No longer supported').hideHelp())
|
||||
.action(async (name: string, options: NewChangeOptions) => {
|
||||
try {
|
||||
const { newChangeCommand } = await import('../commands/workflow/new-change.js');
|
||||
await newChangeCommand(name, options);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../utils/nested-change.js';
|
||||
import { getTaskProgressForChange } from '../utils/task-progress.js';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { assertDiscoveredSpecFile } from '../utils/spec-path-containment.js';
|
||||
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
||||
import {
|
||||
foldRequirementName,
|
||||
@@ -217,7 +218,7 @@ export class ChangeCommand {
|
||||
const mainSpecPath = path.join(mainSpecsDir, ...capability.split('/'), 'spec.md');
|
||||
let mainContent: string | null = null;
|
||||
try {
|
||||
FileSystemUtils.assertPathWithin(mainSpecsDir, mainSpecPath);
|
||||
assertDiscoveredSpecFile(mainSpecsDir, mainSpecPath);
|
||||
mainContent = await fs.readFile(mainSpecPath, 'utf-8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ora from 'ora';
|
||||
import { CompletionFactory } from '../core/completions/factory.js';
|
||||
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
||||
import { detectShell, SupportedShell } from '../utils/shell-detection.js';
|
||||
@@ -125,6 +124,9 @@ export class CompletionCommand {
|
||||
const generator = CompletionFactory.createGenerator(shell);
|
||||
const installer = CompletionFactory.createInstaller(shell);
|
||||
|
||||
// Loaded here, not at the top: `__complete` shares this module and runs on
|
||||
// every Tab press, so it must not pay for the spinner.
|
||||
const { default: ora } = await import('ora');
|
||||
const spinner = ora(`Installing ${shell} completion script...`).start();
|
||||
|
||||
try {
|
||||
@@ -234,6 +236,7 @@ export class CompletionCommand {
|
||||
}
|
||||
}
|
||||
|
||||
const { default: ora } = await import('ora');
|
||||
const spinner = ora(`Uninstalling ${shell} completion script...`).start();
|
||||
|
||||
try {
|
||||
|
||||
+402
-457
@@ -1,4 +1,3 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ChildProcess, spawn as nodeSpawn } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
@@ -25,8 +24,6 @@ import {
|
||||
} from '../core/config-schema.js';
|
||||
import { CORE_WORKFLOWS, ALL_WORKFLOWS, getProfileWorkflows } from '../core/profiles.js';
|
||||
import { OPENSPEC_DIR_NAME } from '../core/config.js';
|
||||
import { hasProjectConfigDrift } from '../core/profile-sync-drift.js';
|
||||
import { UpdateCommand } from '../core/update.js';
|
||||
import { asErrorMessage, isPromptCancellationError } from './shared-output.js';
|
||||
|
||||
type EditorOutcome =
|
||||
@@ -326,15 +323,18 @@ export function diffProfileState(before: ProfileState, after: ProfileState): Pro
|
||||
};
|
||||
}
|
||||
|
||||
function maybeWarnProjectConfigDrift(
|
||||
async function maybeWarnProjectConfigDrift(
|
||||
projectDir: string,
|
||||
state: ProfileState,
|
||||
colorize: (message: string) => string
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME);
|
||||
if (!fs.existsSync(openspecDir)) {
|
||||
return;
|
||||
}
|
||||
// Loaded here, not at the top: it pulls in every tool's command adapter,
|
||||
// which `config path` and `config list` never need.
|
||||
const { hasProjectConfigDrift } = await import('../core/profile-sync-drift.js');
|
||||
if (!hasProjectConfigDrift(projectDir, state.workflows, state.delivery)) {
|
||||
return;
|
||||
}
|
||||
@@ -345,468 +345,413 @@ function printConfigProfileApplyGuidance(): void {
|
||||
console.log('Config updated. Run `openspec update` in your projects to apply.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the config command and all its subcommands.
|
||||
*
|
||||
* @param program - The Commander program instance
|
||||
*/
|
||||
export function registerConfigCommand(program: Command): void {
|
||||
const configCmd = program
|
||||
.command('config')
|
||||
.description('View and modify global OpenSpec configuration')
|
||||
.option('--scope <scope>', 'Config scope (only "global" supported currently)')
|
||||
.hook('preAction', (thisCommand) => {
|
||||
const opts = thisCommand.opts();
|
||||
if (opts.scope && opts.scope !== 'global') {
|
||||
console.error('Error: Project-local config is not yet implemented');
|
||||
process.exit(1);
|
||||
export function configPathCommand(): void {
|
||||
console.log(getGlobalConfigPath());
|
||||
}
|
||||
|
||||
export function configListCommand(options: { json?: boolean }): void {
|
||||
const config = getGlobalConfig();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(config, null, 2));
|
||||
} else {
|
||||
// Read raw config to determine which values are explicit vs defaults
|
||||
const configPath = getGlobalConfigPath();
|
||||
let rawConfig: Record<string, unknown> = {};
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
// A non-object root holds no explicit settings, and reading a key
|
||||
// off `null` would crash this read-only command.
|
||||
if (isConfigRootObject(parsed)) {
|
||||
rawConfig = parsed as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If reading fails, treat all as defaults
|
||||
}
|
||||
|
||||
console.log(formatValueYaml(config));
|
||||
|
||||
// Annotate profile settings
|
||||
const profileSource = rawConfig.profile !== undefined ? '(explicit)' : '(default)';
|
||||
const deliverySource = rawConfig.delivery !== undefined ? '(explicit)' : '(default)';
|
||||
console.log(`\nProfile settings:`);
|
||||
console.log(` profile: ${config.profile} ${profileSource}`);
|
||||
console.log(` delivery: ${config.delivery} ${deliverySource}`);
|
||||
if (config.profile === 'core') {
|
||||
console.log(` workflows: ${CORE_WORKFLOWS.join(', ')} (from core profile)`);
|
||||
} else if (config.workflows && config.workflows.length > 0) {
|
||||
console.log(` workflows: ${config.workflows.join(', ')} (explicit)`);
|
||||
} else {
|
||||
console.log(` workflows: (none)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function configGetCommand(key: string): void {
|
||||
const config = getGlobalConfig();
|
||||
const value = getNestedValue(config as Record<string, unknown>, key);
|
||||
|
||||
if (value === undefined) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
console.log(JSON.stringify(value));
|
||||
} else {
|
||||
console.log(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
export function configSetCommand(key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }): void {
|
||||
const allowUnknown = Boolean(options.allowUnknown);
|
||||
const keyValidation = validateConfigKeyPath(key);
|
||||
// --allow-unknown relaxes the known-key check, but never the prototype-safety check.
|
||||
const unsafeKey = hasUnsafeKeySegment(key);
|
||||
if (!keyValidation.valid && (!allowUnknown || unsafeKey)) {
|
||||
const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : '';
|
||||
console.error(`Error: Invalid configuration key "${key}".${reason}`);
|
||||
console.error('Use "openspec config list" to see available keys.');
|
||||
if (!allowUnknown && !unsafeKey) {
|
||||
console.error('Pass --allow-unknown to bypass this check.');
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig() as Record<string, unknown>;
|
||||
const coercedValue = coerceValue(value, options.string || false);
|
||||
|
||||
// Create a copy to validate before saving
|
||||
const newConfig = JSON.parse(JSON.stringify(config));
|
||||
setNestedValue(newConfig, key, coercedValue);
|
||||
|
||||
// Validate the new config
|
||||
const validation = validateConfig(newConfig);
|
||||
if (!validation.success) {
|
||||
console.error(`Error: Invalid configuration - ${validation.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply changes and save
|
||||
setNestedValue(config, key, coercedValue);
|
||||
saveGlobalConfig(config as GlobalConfig);
|
||||
|
||||
const displayValue =
|
||||
typeof coercedValue === 'string' ? `"${coercedValue}"` : String(coercedValue);
|
||||
console.log(`Set ${key} = ${displayValue}`);
|
||||
}
|
||||
|
||||
export function configUnsetCommand(key: string): void {
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig() as Record<string, unknown>;
|
||||
const existed = deleteNestedValue(config, key);
|
||||
|
||||
if (existed) {
|
||||
saveGlobalConfig(config as GlobalConfig);
|
||||
console.log(`Unset ${key} (reverted to default)`);
|
||||
} else {
|
||||
console.log(`Key "${key}" was not set`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function configResetCommand(options: { all?: boolean; yes?: boolean }): Promise<void> {
|
||||
if (!options.all) {
|
||||
console.error('Error: --all flag is required for reset');
|
||||
console.error('Usage: openspec config reset --all [-y]');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
let confirmed: boolean;
|
||||
try {
|
||||
confirmed = await confirm({
|
||||
message: 'Reset all configuration to defaults?',
|
||||
default: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isPromptCancellationError(error)) {
|
||||
console.log('Reset cancelled.');
|
||||
process.exitCode = 130;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('Reset cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A reset is the one write meant to replace a file that cannot be parsed.
|
||||
saveGlobalConfig({ ...DEFAULT_CONFIG }, { replaceUnreadable: true });
|
||||
console.log('Configuration reset to defaults');
|
||||
}
|
||||
|
||||
export async function configEditCommand(): Promise<void> {
|
||||
const editor = process.env.EDITOR || process.env.VISUAL;
|
||||
|
||||
if (!editor) {
|
||||
console.error('Error: No editor configured');
|
||||
console.error('Set the EDITOR or VISUAL environment variable to your preferred editor');
|
||||
console.error('Example: export EDITOR=vim');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = getGlobalConfigPath();
|
||||
|
||||
// Ensure config file exists with defaults
|
||||
if (!fs.existsSync(configPath)) {
|
||||
saveGlobalConfig({ ...DEFAULT_CONFIG });
|
||||
}
|
||||
|
||||
// Wait for the editor to close; a failure is reported, never thrown.
|
||||
const outcome = await runEditor(editor, configPath);
|
||||
if ('error' in outcome || outcome.code !== 0) {
|
||||
reportEditorFailure(editor, outcome);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawConfig = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsedConfig = JSON.parse(rawConfig);
|
||||
const validation = validateConfig(parsedConfig);
|
||||
|
||||
if (!validation.success) {
|
||||
console.error(`Error: Invalid configuration - ${validation.error}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
console.error(`Error: Config file not found at ${configPath}`);
|
||||
} else if (error instanceof SyntaxError) {
|
||||
console.error(`Error: Invalid JSON in ${configPath}`);
|
||||
console.error(error.message);
|
||||
} else {
|
||||
console.error(`Error: Unable to validate configuration - ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function configProfileCommand(preset?: string): Promise<void> {
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preset shortcut: `openspec config profile core`
|
||||
if (preset === 'core') {
|
||||
const config = getGlobalConfig();
|
||||
config.profile = 'core';
|
||||
config.workflows = [...CORE_WORKFLOWS];
|
||||
// Preserve delivery setting
|
||||
saveGlobalConfig(config);
|
||||
printConfigProfileApplyGuidance();
|
||||
return;
|
||||
}
|
||||
|
||||
if (preset) {
|
||||
console.error(`Error: Unknown profile preset "${preset}". Available presets: core`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-interactive check
|
||||
if (!process.stdout.isTTY) {
|
||||
console.error('Interactive mode required. Use `openspec config profile core` or set config via environment/flags.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Interactive picker
|
||||
const { select, checkbox, confirm } = await import('@inquirer/prompts');
|
||||
const chalk = (await import('chalk')).default;
|
||||
|
||||
try {
|
||||
const config = getGlobalConfig();
|
||||
const currentState = resolveCurrentProfileState(config);
|
||||
|
||||
console.log(chalk.bold('\nCurrent profile settings'));
|
||||
console.log(` Delivery: ${currentState.delivery}`);
|
||||
console.log(` Workflows: ${formatWorkflowSummary(currentState.workflows, currentState.profile)}`);
|
||||
console.log(chalk.dim(' Delivery = where workflows are installed (skills, commands, or both)'));
|
||||
console.log(chalk.dim(' Workflows = which actions are available (propose, explore, apply, etc.)'));
|
||||
console.log();
|
||||
|
||||
const action = await select<ProfileAction>({
|
||||
message: 'What do you want to configure?',
|
||||
choices: [
|
||||
{
|
||||
value: 'both',
|
||||
name: 'Delivery and workflows',
|
||||
description: 'Update install mode and available actions together',
|
||||
},
|
||||
{
|
||||
value: 'delivery',
|
||||
name: 'Delivery only',
|
||||
description: 'Change where workflows are installed',
|
||||
},
|
||||
{
|
||||
value: 'workflows',
|
||||
name: 'Workflows only',
|
||||
description: 'Change which workflow actions are available',
|
||||
},
|
||||
{
|
||||
value: 'keep',
|
||||
name: 'Keep current settings (exit)',
|
||||
description: 'Leave configuration unchanged and exit',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// config path
|
||||
configCmd
|
||||
.command('path')
|
||||
.description('Show config file location')
|
||||
.action(() => {
|
||||
console.log(getGlobalConfigPath());
|
||||
});
|
||||
if (action === 'keep') {
|
||||
console.log('No config changes.');
|
||||
await maybeWarnProjectConfigDrift(process.cwd(), currentState, chalk.yellow);
|
||||
return;
|
||||
}
|
||||
|
||||
// config list
|
||||
configCmd
|
||||
.command('list')
|
||||
.description('Show all current settings')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action((options: { json?: boolean }) => {
|
||||
const config = getGlobalConfig();
|
||||
const nextState: ProfileState = {
|
||||
profile: currentState.profile,
|
||||
delivery: currentState.delivery,
|
||||
workflows: [...currentState.workflows],
|
||||
};
|
||||
let workflowSelectionChanged = false;
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(config, null, 2));
|
||||
} else {
|
||||
// Read raw config to determine which values are explicit vs defaults
|
||||
const configPath = getGlobalConfigPath();
|
||||
let rawConfig: Record<string, unknown> = {};
|
||||
if (action === 'both' || action === 'delivery') {
|
||||
const deliveryChoices: { value: Delivery; name: string; description: string }[] = [
|
||||
{
|
||||
value: 'both' as Delivery,
|
||||
name: 'Both (skills + commands)',
|
||||
description: 'Install workflows as both skills and slash commands',
|
||||
},
|
||||
{
|
||||
value: 'skills' as Delivery,
|
||||
name: 'Skills only',
|
||||
description: 'Install workflows only as skills',
|
||||
},
|
||||
{
|
||||
value: 'commands' as Delivery,
|
||||
name: 'Commands only',
|
||||
description: 'Install workflows only as slash commands',
|
||||
},
|
||||
];
|
||||
for (const choice of deliveryChoices) {
|
||||
if (choice.value === currentState.delivery) {
|
||||
choice.name += ' [current]';
|
||||
}
|
||||
}
|
||||
|
||||
nextState.delivery = await select<Delivery>({
|
||||
message: 'Delivery mode (how workflows are installed):',
|
||||
choices: deliveryChoices,
|
||||
default: currentState.delivery,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'both' || action === 'workflows') {
|
||||
const formatWorkflowChoice = (workflow: string) => {
|
||||
const metadata = WORKFLOW_PROMPT_META[workflow] ?? {
|
||||
name: workflow,
|
||||
description: `Workflow: ${workflow}`,
|
||||
};
|
||||
return {
|
||||
value: workflow,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
short: metadata.name,
|
||||
checked: currentState.workflows.includes(workflow),
|
||||
};
|
||||
};
|
||||
|
||||
const selectedWorkflows = await checkbox<string>({
|
||||
// The `instructions` option was removed in @inquirer/checkbox v5.
|
||||
// Its replacement, the built-in keys help tip, renders
|
||||
// "↑↓ navigate • space select • ⏎ submit" by default — a superset of
|
||||
// the hint this used to pass — so no theme override is needed here.
|
||||
message: 'Select workflows to make available:',
|
||||
pageSize: ALL_WORKFLOWS.length,
|
||||
theme: {
|
||||
icon: {
|
||||
checked: '[x]',
|
||||
unchecked: '[ ]',
|
||||
},
|
||||
},
|
||||
choices: ALL_WORKFLOWS.map(formatWorkflowChoice),
|
||||
});
|
||||
nextState.workflows = selectedWorkflows;
|
||||
workflowSelectionChanged =
|
||||
selectedWorkflows.length !== currentState.workflows.length ||
|
||||
selectedWorkflows.some((workflow) => !currentState.workflows.includes(workflow));
|
||||
nextState.profile = workflowSelectionChanged
|
||||
? deriveProfileFromWorkflowSelection(selectedWorkflows)
|
||||
: currentState.profile;
|
||||
}
|
||||
|
||||
const diff = diffProfileState(currentState, nextState);
|
||||
if (!diff.hasChanges) {
|
||||
console.log('No config changes.');
|
||||
await maybeWarnProjectConfigDrift(process.cwd(), nextState, chalk.yellow);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nConfig changes:'));
|
||||
for (const line of diff.lines) {
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
config.profile = nextState.profile;
|
||||
config.delivery = nextState.delivery;
|
||||
if (currentState.profile !== 'custom' || workflowSelectionChanged) {
|
||||
config.workflows = nextState.workflows;
|
||||
}
|
||||
saveGlobalConfig(config);
|
||||
|
||||
// Check if inside an OpenSpec project
|
||||
const projectDir = process.cwd();
|
||||
const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME);
|
||||
if (fs.existsSync(openspecDir)) {
|
||||
const applyNow = await confirm({
|
||||
message: 'Apply changes to this project now?',
|
||||
default: true,
|
||||
});
|
||||
|
||||
if (applyNow) {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
// A non-object root holds no explicit settings, and reading a key
|
||||
// off `null` would crash this read-only command.
|
||||
if (isConfigRootObject(parsed)) {
|
||||
rawConfig = parsed as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If reading fails, treat all as defaults
|
||||
}
|
||||
|
||||
console.log(formatValueYaml(config));
|
||||
|
||||
// Annotate profile settings
|
||||
const profileSource = rawConfig.profile !== undefined ? '(explicit)' : '(default)';
|
||||
const deliverySource = rawConfig.delivery !== undefined ? '(explicit)' : '(default)';
|
||||
console.log(`\nProfile settings:`);
|
||||
console.log(` profile: ${config.profile} ${profileSource}`);
|
||||
console.log(` delivery: ${config.delivery} ${deliverySource}`);
|
||||
if (config.profile === 'core') {
|
||||
console.log(` workflows: ${CORE_WORKFLOWS.join(', ')} (from core profile)`);
|
||||
} else if (config.workflows && config.workflows.length > 0) {
|
||||
console.log(` workflows: ${config.workflows.join(', ')} (explicit)`);
|
||||
} else {
|
||||
console.log(` workflows: (none)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// config get
|
||||
configCmd
|
||||
.command('get <key>')
|
||||
.description('Get a specific value (raw, scriptable)')
|
||||
.action((key: string) => {
|
||||
const config = getGlobalConfig();
|
||||
const value = getNestedValue(config as Record<string, unknown>, key);
|
||||
|
||||
if (value === undefined) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
console.log(JSON.stringify(value));
|
||||
} else {
|
||||
console.log(String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// config set
|
||||
configCmd
|
||||
.command('set <key> <value>')
|
||||
.description('Set a value (auto-coerce types)')
|
||||
.option('--string', 'Force value to be stored as string')
|
||||
.option('--allow-unknown', 'Allow setting unknown keys')
|
||||
.action((key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }) => {
|
||||
const allowUnknown = Boolean(options.allowUnknown);
|
||||
const keyValidation = validateConfigKeyPath(key);
|
||||
// --allow-unknown relaxes the known-key check, but never the prototype-safety check.
|
||||
const unsafeKey = hasUnsafeKeySegment(key);
|
||||
if (!keyValidation.valid && (!allowUnknown || unsafeKey)) {
|
||||
const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : '';
|
||||
console.error(`Error: Invalid configuration key "${key}".${reason}`);
|
||||
console.error('Use "openspec config list" to see available keys.');
|
||||
if (!allowUnknown && !unsafeKey) {
|
||||
console.error('Pass --allow-unknown to bypass this check.');
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig() as Record<string, unknown>;
|
||||
const coercedValue = coerceValue(value, options.string || false);
|
||||
|
||||
// Create a copy to validate before saving
|
||||
const newConfig = JSON.parse(JSON.stringify(config));
|
||||
setNestedValue(newConfig, key, coercedValue);
|
||||
|
||||
// Validate the new config
|
||||
const validation = validateConfig(newConfig);
|
||||
if (!validation.success) {
|
||||
console.error(`Error: Invalid configuration - ${validation.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply changes and save
|
||||
setNestedValue(config, key, coercedValue);
|
||||
saveGlobalConfig(config as GlobalConfig);
|
||||
|
||||
const displayValue =
|
||||
typeof coercedValue === 'string' ? `"${coercedValue}"` : String(coercedValue);
|
||||
console.log(`Set ${key} = ${displayValue}`);
|
||||
});
|
||||
|
||||
// config unset
|
||||
configCmd
|
||||
.command('unset <key>')
|
||||
.description('Remove a key (revert to default)')
|
||||
.action((key: string) => {
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig() as Record<string, unknown>;
|
||||
const existed = deleteNestedValue(config, key);
|
||||
|
||||
if (existed) {
|
||||
saveGlobalConfig(config as GlobalConfig);
|
||||
console.log(`Unset ${key} (reverted to default)`);
|
||||
} else {
|
||||
console.log(`Key "${key}" was not set`);
|
||||
}
|
||||
});
|
||||
|
||||
// config reset
|
||||
configCmd
|
||||
.command('reset')
|
||||
.description('Reset configuration to defaults')
|
||||
.option('--all', 'Reset all configuration (required)')
|
||||
.option('-y, --yes', 'Skip confirmation prompts')
|
||||
.action(async (options: { all?: boolean; yes?: boolean }) => {
|
||||
if (!options.all) {
|
||||
console.error('Error: --all flag is required for reset');
|
||||
console.error('Usage: openspec config reset --all [-y]');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
let confirmed: boolean;
|
||||
try {
|
||||
confirmed = await confirm({
|
||||
message: 'Reset all configuration to defaults?',
|
||||
default: false,
|
||||
});
|
||||
const { UpdateCommand } = await import('../core/update.js');
|
||||
await new UpdateCommand().execute(projectDir);
|
||||
console.log('Run `openspec update` in your other projects to apply.');
|
||||
} catch (error) {
|
||||
if (isPromptCancellationError(error)) {
|
||||
console.log('Reset cancelled.');
|
||||
process.exitCode = 130;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('Reset cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A reset is the one write meant to replace a file that cannot be parsed.
|
||||
saveGlobalConfig({ ...DEFAULT_CONFIG }, { replaceUnreadable: true });
|
||||
console.log('Configuration reset to defaults');
|
||||
});
|
||||
|
||||
// config edit
|
||||
configCmd
|
||||
.command('edit')
|
||||
.description('Open config in $EDITOR')
|
||||
.action(async () => {
|
||||
const editor = process.env.EDITOR || process.env.VISUAL;
|
||||
|
||||
if (!editor) {
|
||||
console.error('Error: No editor configured');
|
||||
console.error('Set the EDITOR or VISUAL environment variable to your preferred editor');
|
||||
console.error('Example: export EDITOR=vim');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = getGlobalConfigPath();
|
||||
|
||||
// Ensure config file exists with defaults
|
||||
if (!fs.existsSync(configPath)) {
|
||||
saveGlobalConfig({ ...DEFAULT_CONFIG });
|
||||
}
|
||||
|
||||
// Wait for the editor to close; a failure is reported, never thrown.
|
||||
const outcome = await runEditor(editor, configPath);
|
||||
if ('error' in outcome || outcome.code !== 0) {
|
||||
reportEditorFailure(editor, outcome);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawConfig = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsedConfig = JSON.parse(rawConfig);
|
||||
const validation = validateConfig(parsedConfig);
|
||||
|
||||
if (!validation.success) {
|
||||
console.error(`Error: Invalid configuration - ${validation.error}`);
|
||||
console.error(`\`openspec update\` failed: ${asErrorMessage(error)}`);
|
||||
console.error('Please run it manually to apply the profile changes.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
console.error(`Error: Config file not found at ${configPath}`);
|
||||
} else if (error instanceof SyntaxError) {
|
||||
console.error(`Error: Invalid JSON in ${configPath}`);
|
||||
console.error(error.message);
|
||||
} else {
|
||||
console.error(`Error: Unable to validate configuration - ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
// config profile [preset]
|
||||
configCmd
|
||||
.command('profile [preset]')
|
||||
.description('Configure workflow profile (interactive picker or preset shortcut)')
|
||||
.action(async (preset?: string) => {
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Preset shortcut: `openspec config profile core`
|
||||
if (preset === 'core') {
|
||||
const config = getGlobalConfig();
|
||||
config.profile = 'core';
|
||||
config.workflows = [...CORE_WORKFLOWS];
|
||||
// Preserve delivery setting
|
||||
saveGlobalConfig(config);
|
||||
printConfigProfileApplyGuidance();
|
||||
return;
|
||||
}
|
||||
|
||||
if (preset) {
|
||||
console.error(`Error: Unknown profile preset "${preset}". Available presets: core`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-interactive check
|
||||
if (!process.stdout.isTTY) {
|
||||
console.error('Interactive mode required. Use `openspec config profile core` or set config via environment/flags.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Interactive picker
|
||||
const { select, checkbox, confirm } = await import('@inquirer/prompts');
|
||||
const chalk = (await import('chalk')).default;
|
||||
|
||||
try {
|
||||
const config = getGlobalConfig();
|
||||
const currentState = resolveCurrentProfileState(config);
|
||||
|
||||
console.log(chalk.bold('\nCurrent profile settings'));
|
||||
console.log(` Delivery: ${currentState.delivery}`);
|
||||
console.log(` Workflows: ${formatWorkflowSummary(currentState.workflows, currentState.profile)}`);
|
||||
console.log(chalk.dim(' Delivery = where workflows are installed (skills, commands, or both)'));
|
||||
console.log(chalk.dim(' Workflows = which actions are available (propose, explore, apply, etc.)'));
|
||||
console.log();
|
||||
|
||||
const action = await select<ProfileAction>({
|
||||
message: 'What do you want to configure?',
|
||||
choices: [
|
||||
{
|
||||
value: 'both',
|
||||
name: 'Delivery and workflows',
|
||||
description: 'Update install mode and available actions together',
|
||||
},
|
||||
{
|
||||
value: 'delivery',
|
||||
name: 'Delivery only',
|
||||
description: 'Change where workflows are installed',
|
||||
},
|
||||
{
|
||||
value: 'workflows',
|
||||
name: 'Workflows only',
|
||||
description: 'Change which workflow actions are available',
|
||||
},
|
||||
{
|
||||
value: 'keep',
|
||||
name: 'Keep current settings (exit)',
|
||||
description: 'Leave configuration unchanged and exit',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (action === 'keep') {
|
||||
console.log('No config changes.');
|
||||
maybeWarnProjectConfigDrift(process.cwd(), currentState, chalk.yellow);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState: ProfileState = {
|
||||
profile: currentState.profile,
|
||||
delivery: currentState.delivery,
|
||||
workflows: [...currentState.workflows],
|
||||
};
|
||||
let workflowSelectionChanged = false;
|
||||
|
||||
if (action === 'both' || action === 'delivery') {
|
||||
const deliveryChoices: { value: Delivery; name: string; description: string }[] = [
|
||||
{
|
||||
value: 'both' as Delivery,
|
||||
name: 'Both (skills + commands)',
|
||||
description: 'Install workflows as both skills and slash commands',
|
||||
},
|
||||
{
|
||||
value: 'skills' as Delivery,
|
||||
name: 'Skills only',
|
||||
description: 'Install workflows only as skills',
|
||||
},
|
||||
{
|
||||
value: 'commands' as Delivery,
|
||||
name: 'Commands only',
|
||||
description: 'Install workflows only as slash commands',
|
||||
},
|
||||
];
|
||||
for (const choice of deliveryChoices) {
|
||||
if (choice.value === currentState.delivery) {
|
||||
choice.name += ' [current]';
|
||||
}
|
||||
}
|
||||
|
||||
nextState.delivery = await select<Delivery>({
|
||||
message: 'Delivery mode (how workflows are installed):',
|
||||
choices: deliveryChoices,
|
||||
default: currentState.delivery,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'both' || action === 'workflows') {
|
||||
const formatWorkflowChoice = (workflow: string) => {
|
||||
const metadata = WORKFLOW_PROMPT_META[workflow] ?? {
|
||||
name: workflow,
|
||||
description: `Workflow: ${workflow}`,
|
||||
};
|
||||
return {
|
||||
value: workflow,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
short: metadata.name,
|
||||
checked: currentState.workflows.includes(workflow),
|
||||
};
|
||||
};
|
||||
|
||||
const selectedWorkflows = await checkbox<string>({
|
||||
// The `instructions` option was removed in @inquirer/checkbox v5.
|
||||
// Its replacement, the built-in keys help tip, renders
|
||||
// "↑↓ navigate • space select • ⏎ submit" by default — a superset of
|
||||
// the hint this used to pass — so no theme override is needed here.
|
||||
message: 'Select workflows to make available:',
|
||||
pageSize: ALL_WORKFLOWS.length,
|
||||
theme: {
|
||||
icon: {
|
||||
checked: '[x]',
|
||||
unchecked: '[ ]',
|
||||
},
|
||||
},
|
||||
choices: ALL_WORKFLOWS.map(formatWorkflowChoice),
|
||||
});
|
||||
nextState.workflows = selectedWorkflows;
|
||||
workflowSelectionChanged =
|
||||
selectedWorkflows.length !== currentState.workflows.length ||
|
||||
selectedWorkflows.some((workflow) => !currentState.workflows.includes(workflow));
|
||||
nextState.profile = workflowSelectionChanged
|
||||
? deriveProfileFromWorkflowSelection(selectedWorkflows)
|
||||
: currentState.profile;
|
||||
}
|
||||
|
||||
const diff = diffProfileState(currentState, nextState);
|
||||
if (!diff.hasChanges) {
|
||||
console.log('No config changes.');
|
||||
maybeWarnProjectConfigDrift(process.cwd(), nextState, chalk.yellow);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nConfig changes:'));
|
||||
for (const line of diff.lines) {
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
config.profile = nextState.profile;
|
||||
config.delivery = nextState.delivery;
|
||||
if (currentState.profile !== 'custom' || workflowSelectionChanged) {
|
||||
config.workflows = nextState.workflows;
|
||||
}
|
||||
saveGlobalConfig(config);
|
||||
|
||||
// Check if inside an OpenSpec project
|
||||
const projectDir = process.cwd();
|
||||
const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME);
|
||||
if (fs.existsSync(openspecDir)) {
|
||||
const applyNow = await confirm({
|
||||
message: 'Apply changes to this project now?',
|
||||
default: true,
|
||||
});
|
||||
|
||||
if (applyNow) {
|
||||
try {
|
||||
await new UpdateCommand().execute(projectDir);
|
||||
console.log('Run `openspec update` in your other projects to apply.');
|
||||
} catch (error) {
|
||||
console.error(`\`openspec update\` failed: ${asErrorMessage(error)}`);
|
||||
console.error('Please run it manually to apply the profile changes.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
printConfigProfileApplyGuidance();
|
||||
} catch (error) {
|
||||
if (isPromptCancellationError(error)) {
|
||||
console.log('Config profile cancelled.');
|
||||
process.exitCode = 130;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
printConfigProfileApplyGuidance();
|
||||
} catch (error) {
|
||||
if (isPromptCancellationError(error)) {
|
||||
console.log('Config profile cancelled.');
|
||||
process.exitCode = 130;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+32
-51
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { Command, Option } from 'commander';
|
||||
|
||||
import {
|
||||
resolveRootForCommand,
|
||||
@@ -22,8 +21,6 @@ import {
|
||||
type WorkingSetMember,
|
||||
} from '../core/working-set.js';
|
||||
import { StoreError } from '../core/store/errors.js';
|
||||
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
||||
import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
|
||||
import { emitFailure, printJson } from './shared-output.js';
|
||||
import { gatherRelationshipData } from './shared-gather.js';
|
||||
|
||||
@@ -157,56 +154,40 @@ function writeCodeWorkspace(
|
||||
console.error(summary);
|
||||
}
|
||||
|
||||
export function registerContextCommand(program: Command): void {
|
||||
const description =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'context')?.description ??
|
||||
'Print the working context for the resolved OpenSpec root';
|
||||
export interface ContextOptions {
|
||||
store?: string;
|
||||
storePath?: string;
|
||||
json?: boolean;
|
||||
codeWorkspace?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
program
|
||||
.command('context')
|
||||
.description(description)
|
||||
.option('--store <id>', COMMON_FLAGS.store.description)
|
||||
.addOption(
|
||||
new Option('--store-path <path>', 'Removed; register the store and use --store').hideHelp()
|
||||
)
|
||||
.option('--json', 'Output the agent brief as JSON')
|
||||
.option('--code-workspace <path>', 'Also write a VS Code workspace file for the set')
|
||||
.option('--force', 'Overwrite an existing --code-workspace file')
|
||||
.action(
|
||||
async (options: {
|
||||
store?: string;
|
||||
storePath?: string;
|
||||
json?: boolean;
|
||||
codeWorkspace?: string;
|
||||
force?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const root = await resolveRootForCommand(
|
||||
{ store: options.store, storePath: options.storePath },
|
||||
{ json: options.json, failurePayload: FAILURE_PAYLOAD, allowImplicitRoot: false }
|
||||
);
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
export async function contextCommand(options: ContextOptions): Promise<void> {
|
||||
try {
|
||||
const root = await resolveRootForCommand(
|
||||
{ store: options.store, storePath: options.storePath },
|
||||
{ json: options.json, failurePayload: FAILURE_PAYLOAD, allowImplicitRoot: false }
|
||||
);
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { workingSet, declaredReferenceCount } = await gatherWorkingSet(root);
|
||||
const { workingSet, declaredReferenceCount } = await gatherWorkingSet(root);
|
||||
|
||||
if (options.json) {
|
||||
// The write runs FIRST: a write failure must leave stdout
|
||||
// holding exactly one JSON document (the failure payload).
|
||||
if (options.codeWorkspace) {
|
||||
writeCodeWorkspace(workingSet, options.codeWorkspace, options.force === true);
|
||||
}
|
||||
printJson(workingSet);
|
||||
} else {
|
||||
printHumanWorkingSet(workingSet, declaredReferenceCount);
|
||||
if (options.codeWorkspace) {
|
||||
writeCodeWorkspace(workingSet, options.codeWorkspace, options.force === true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
emitFailure(options.json, FAILURE_PAYLOAD, error, 'context_failed');
|
||||
}
|
||||
if (options.json) {
|
||||
// The write runs FIRST: a write failure must leave stdout
|
||||
// holding exactly one JSON document (the failure payload).
|
||||
if (options.codeWorkspace) {
|
||||
writeCodeWorkspace(workingSet, options.codeWorkspace, options.force === true);
|
||||
}
|
||||
printJson(workingSet);
|
||||
} else {
|
||||
printHumanWorkingSet(workingSet, declaredReferenceCount);
|
||||
if (options.codeWorkspace) {
|
||||
writeCodeWorkspace(workingSet, options.codeWorkspace, options.force === true);
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
emitFailure(options.json, FAILURE_PAYLOAD, error, 'context_failed');
|
||||
}
|
||||
}
|
||||
|
||||
+23
-35
@@ -3,8 +3,6 @@
|
||||
* report. Read-only — it answers "are the roots this work relates to
|
||||
* available on this machine?" and never clones, syncs, or repairs.
|
||||
*/
|
||||
import { Command, Option } from 'commander';
|
||||
|
||||
import {
|
||||
resolveRootForCommand,
|
||||
type ResolvedOpenSpecRoot,
|
||||
@@ -23,8 +21,6 @@ import {
|
||||
type InspectRelationshipsInput,
|
||||
type RelationshipHealth,
|
||||
} from '../core/relationship-health.js';
|
||||
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
||||
import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
|
||||
import { emitFailure, printJson } from './shared-output.js';
|
||||
import * as path from 'node:path';
|
||||
|
||||
@@ -182,38 +178,30 @@ function printHumanHealth(health: RelationshipHealth, declaredReferenceCount: nu
|
||||
}
|
||||
}
|
||||
|
||||
export function registerDoctorCommand(program: Command): void {
|
||||
const description =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'doctor')?.description ??
|
||||
'Report relationship health for the resolved OpenSpec root';
|
||||
export interface DoctorOptions {
|
||||
store?: string;
|
||||
storePath?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.description(description)
|
||||
.option('--store <id>', COMMON_FLAGS.store.description)
|
||||
.addOption(
|
||||
new Option('--store-path <path>', 'Removed; register the store and use --store').hideHelp()
|
||||
)
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: { store?: string; storePath?: string; json?: boolean }) => {
|
||||
try {
|
||||
const root = await resolveRootForCommand(
|
||||
{ store: options.store, storePath: options.storePath },
|
||||
{ json: options.json, failurePayload: FAILURE_PAYLOAD, allowImplicitRoot: false }
|
||||
);
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
export async function doctorCommand(options: DoctorOptions): Promise<void> {
|
||||
try {
|
||||
const root = await resolveRootForCommand(
|
||||
{ store: options.store, storePath: options.storePath },
|
||||
{ json: options.json, failurePayload: FAILURE_PAYLOAD, allowImplicitRoot: false }
|
||||
);
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { health, declaredReferenceCount } = await gatherHealth(root);
|
||||
const { health, declaredReferenceCount } = await gatherHealth(root);
|
||||
|
||||
if (options.json) {
|
||||
printJson(health);
|
||||
return;
|
||||
}
|
||||
printHumanHealth(health, declaredReferenceCount);
|
||||
} catch (error) {
|
||||
emitFailure(options.json, FAILURE_PAYLOAD, error, 'doctor_failed');
|
||||
}
|
||||
});
|
||||
if (options.json) {
|
||||
printJson(health);
|
||||
return;
|
||||
}
|
||||
printHumanHealth(health, declaredReferenceCount);
|
||||
} catch (error) {
|
||||
emitFailure(options.json, FAILURE_PAYLOAD, error, 'doctor_failed');
|
||||
}
|
||||
}
|
||||
|
||||
+814
-857
File diff suppressed because it is too large
Load Diff
+104
-138
@@ -1,4 +1,3 @@
|
||||
import { program } from 'commander';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
|
||||
@@ -8,7 +7,7 @@ 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';
|
||||
import { assertDiscoveredSpecFile } from '../utils/spec-path-containment.js';
|
||||
|
||||
const SPECS_DIR = 'openspec/specs';
|
||||
|
||||
@@ -22,17 +21,10 @@ function assertSpecPath(specsDir: string, specPath: string): void {
|
||||
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);
|
||||
}
|
||||
assertDiscoveredSpecFile(specsDir, specPath);
|
||||
}
|
||||
|
||||
interface ShowOptions {
|
||||
export interface ShowOptions {
|
||||
json?: boolean;
|
||||
// JSON-only filters (raw-first text has no filters)
|
||||
requirements?: boolean;
|
||||
@@ -148,142 +140,116 @@ export class SpecCommand {
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSpecCommand(rootProgram: typeof program) {
|
||||
const specCommand = rootProgram
|
||||
.command('spec')
|
||||
.description('Manage and view OpenSpec specifications');
|
||||
export async function specShowCommand(
|
||||
specId: string | undefined,
|
||||
options: ShowOptions & { noInteractive?: boolean }
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cmd = new SpecCommand();
|
||||
await cmd.show(specId, options as any);
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecation notice for noun-based commands
|
||||
specCommand.hook('preAction', () => {
|
||||
console.error('Warning: The "openspec spec ..." commands are deprecated. Prefer verb-first commands (e.g., "openspec show", "openspec validate --specs").');
|
||||
});
|
||||
export async function specListCommand(options: { json?: boolean; long?: boolean }): Promise<void> {
|
||||
try {
|
||||
if (!existsSync(SPECS_DIR)) {
|
||||
console.log('No items found');
|
||||
return;
|
||||
}
|
||||
|
||||
specCommand
|
||||
.command('show [spec-id]')
|
||||
.description('Display a specific specification')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--requirements', 'JSON only: Show only requirements (exclude scenarios)')
|
||||
.option('--no-scenarios', 'JSON only: Exclude scenario content')
|
||||
.option('-r, --requirement <id>', 'JSON only: Show specific requirement by ID (1-based)')
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (specId: string | undefined, options: ShowOptions & { noInteractive?: boolean }) => {
|
||||
try {
|
||||
const cmd = new SpecCommand();
|
||||
await cmd.show(specId, options as any);
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
const discovered = await discoverSpecFiles(SPECS_DIR);
|
||||
const specs = discovered
|
||||
.map(({ id, specFile }) => {
|
||||
try {
|
||||
assertSpecPath(SPECS_DIR, specFile);
|
||||
const spec = parseSpecFromFile(SPECS_DIR, specFile, id);
|
||||
|
||||
specCommand
|
||||
.command('list')
|
||||
.description('List all available specifications')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--long', 'Show id and title with counts')
|
||||
.action(async (options: { json?: boolean; long?: boolean }) => {
|
||||
try {
|
||||
if (!existsSync(SPECS_DIR)) {
|
||||
console.log('No items found');
|
||||
return;
|
||||
return {
|
||||
id,
|
||||
title: spec.name,
|
||||
requirementCount: spec.requirements.length
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
requirementCount: 0
|
||||
};
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const discovered = await discoverSpecFiles(SPECS_DIR);
|
||||
const specs = discovered
|
||||
.map(({ id, specFile }) => {
|
||||
try {
|
||||
assertSpecPath(SPECS_DIR, specFile);
|
||||
const spec = parseSpecFromFile(SPECS_DIR, specFile, id);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(specs, null, 2));
|
||||
} else {
|
||||
if (specs.length === 0) {
|
||||
console.log('No items found');
|
||||
return;
|
||||
}
|
||||
if (!options.long) {
|
||||
specs.forEach(spec => console.log(spec.id));
|
||||
return;
|
||||
}
|
||||
specs.forEach(spec => {
|
||||
console.log(`${spec.id}: ${spec.title} [requirements ${spec.requirementCount}]`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: spec.name,
|
||||
requirementCount: spec.requirements.length
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
requirementCount: 0
|
||||
};
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(specs, null, 2));
|
||||
} else {
|
||||
if (specs.length === 0) {
|
||||
console.log('No items found');
|
||||
return;
|
||||
}
|
||||
if (!options.long) {
|
||||
specs.forEach(spec => console.log(spec.id));
|
||||
return;
|
||||
}
|
||||
specs.forEach(spec => {
|
||||
console.log(`${spec.id}: ${spec.title} [requirements ${spec.requirementCount}]`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
export async function specValidateCommand(
|
||||
specId: string | undefined,
|
||||
options: { strict?: boolean; json?: boolean; noInteractive?: boolean }
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!specId) {
|
||||
const canPrompt = isInteractive(options);
|
||||
const specIds = await getSpecIds();
|
||||
if (canPrompt && specIds.length > 0) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
specId = await select({
|
||||
message: 'Select a spec to validate',
|
||||
choices: specIds.map(id => ({ name: id, value: id })),
|
||||
});
|
||||
} else {
|
||||
throw new Error('Missing required argument <spec-id>');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
specCommand
|
||||
.command('validate [spec-id]')
|
||||
.description('Validate a specification structure')
|
||||
.option('--strict', 'Enable strict validation mode')
|
||||
.option('--json', 'Output validation report as JSON')
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {
|
||||
try {
|
||||
if (!specId) {
|
||||
const canPrompt = isInteractive(options);
|
||||
const specIds = await getSpecIds();
|
||||
if (canPrompt && specIds.length > 0) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
specId = await select({
|
||||
message: 'Select a spec to validate',
|
||||
choices: specIds.map(id => ({ name: id, value: id })),
|
||||
});
|
||||
} else {
|
||||
throw new Error('Missing required argument <spec-id>');
|
||||
}
|
||||
}
|
||||
|
||||
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 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);
|
||||
const validator = new Validator(options.strict);
|
||||
assertSpecPath(SPECS_DIR, specPath);
|
||||
const report = await validator.validateSpec(specPath);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
if (report.valid) {
|
||||
console.log(`Specification '${specId}' is valid`);
|
||||
} else {
|
||||
console.error(`Specification '${specId}' has issues`);
|
||||
report.issues.forEach(issue => {
|
||||
const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;
|
||||
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
|
||||
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
process.exitCode = report.valid ? 0 : 1;
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
if (report.valid) {
|
||||
console.log(`Specification '${specId}' is valid`);
|
||||
} else {
|
||||
console.error(`Specification '${specId}' has issues`);
|
||||
report.issues.forEach(issue => {
|
||||
const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;
|
||||
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
|
||||
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return specCommand;
|
||||
}
|
||||
process.exitCode = report.valid ? 0 : 1;
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-148
@@ -1,9 +1,6 @@
|
||||
import * as os from 'node:os';
|
||||
import { asErrorMessage, emitFailure, printJson } from './shared-output.js';
|
||||
import * as path from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
||||
|
||||
import {
|
||||
StoreError,
|
||||
@@ -28,25 +25,25 @@ import {
|
||||
} from '../core/store/index.js';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
|
||||
interface StoreSetupOptions {
|
||||
export interface StoreSetupOptions {
|
||||
path?: string;
|
||||
initGit?: boolean;
|
||||
json?: boolean;
|
||||
remote?: string;
|
||||
}
|
||||
|
||||
interface StoreRegisterOptions {
|
||||
export interface StoreRegisterOptions {
|
||||
id?: string;
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface StoreRemoveOptions {
|
||||
export interface StoreRemoveOptions {
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface StoreJsonOptions {
|
||||
export interface StoreJsonOptions {
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
@@ -516,7 +513,7 @@ function printDoctorHuman(payload: StoreDoctorOutput): void {
|
||||
}
|
||||
}
|
||||
|
||||
class StoreCommand {
|
||||
export class StoreCommand {
|
||||
async setup(id: string | undefined, options: StoreSetupOptions = {}): Promise<void> {
|
||||
try {
|
||||
const setupInput = await resolveSetupInput(id, options);
|
||||
@@ -660,143 +657,3 @@ class StoreCommand {
|
||||
emitFailure(json, payload, error, 'store_error');
|
||||
}
|
||||
}
|
||||
|
||||
export function registerStoreCommand(program: Command): void {
|
||||
const storeCommand = new StoreCommand();
|
||||
// One source for the locked group one-liner: the completions registry
|
||||
// entry, which shell completion scripts also consume.
|
||||
const storeGroupDescription =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'store')?.description ??
|
||||
'Create and manage stores - standalone OpenSpec repos you register on this machine';
|
||||
const store = program.command('store').description(storeGroupDescription);
|
||||
|
||||
store
|
||||
.command('setup [id]')
|
||||
.description('Create and register a local store')
|
||||
.option('--path <path>', 'Folder where the store should live (for example ~/openspec/<id>)')
|
||||
.option('--init-git', 'Initialize a Git repository with an initial commit (default)')
|
||||
.option('--no-init-git', 'Skip every Git action: no init, no initial commit')
|
||||
.option('--remote <url>', 'Canonical clone source recorded in store.yaml')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string | undefined, options: StoreSetupOptions) => {
|
||||
await storeCommand.setup(id, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('register [path]')
|
||||
.description('Register an existing local store')
|
||||
.option('--id <id>', 'Store id; defaults to metadata or folder name')
|
||||
.option('--yes', 'Confirm creating store identity metadata for a healthy OpenSpec root')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (inputPath: string | undefined, options: StoreRegisterOptions) => {
|
||||
await storeCommand.register(inputPath, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('unregister <id>')
|
||||
.description('Forget a local store registration without deleting files')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string, options: StoreJsonOptions) => {
|
||||
await storeCommand.unregister(id, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('remove <id>')
|
||||
.description('Forget a local store registration and delete its local folder')
|
||||
.option('--yes', 'Confirm local store folder deletion')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string, options: StoreRemoveOptions) => {
|
||||
await storeCommand.remove(id, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List locally registered stores')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: StoreJsonOptions) => {
|
||||
await storeCommand.list(options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('doctor [id]')
|
||||
.description('Check local store registration and metadata')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string | undefined, options: StoreJsonOptions) => {
|
||||
await storeCommand.doctor(id, options);
|
||||
});
|
||||
|
||||
const lifecycleRedirects = new Set(
|
||||
COMMAND_REGISTRY.filter(
|
||||
(entry) =>
|
||||
entry.flags.some((flag) => flag.name === 'store') ||
|
||||
(entry.subcommands ?? []).some((subcommand) =>
|
||||
subcommand.flags.some((flag) => flag.name === 'store')
|
||||
)
|
||||
).map((entry) => entry.name)
|
||||
);
|
||||
const storeSubcommandsLine = store.commands
|
||||
.map((subcommand) => {
|
||||
const aliases = subcommand.aliases();
|
||||
return aliases.length > 0 ? `${subcommand.name()} (${aliases.join(', ')})` : subcommand.name();
|
||||
})
|
||||
.join(', ');
|
||||
// One group action owns missing AND unknown subcommands. Known
|
||||
// subcommands dispatch above; everything else — including a bare
|
||||
// `store --json` with no operand — lands here, so the handler owns the
|
||||
// entire message and exit path (same text for human and --json). The
|
||||
// permissive flags route unknown operands/options here instead of
|
||||
// letting Commander emit a raw error before the action runs. We detect
|
||||
// `--json` in the residual args rather than declaring a group option,
|
||||
// which would otherwise shadow each subcommand's own `--json` flag.
|
||||
store.allowExcessArguments(true);
|
||||
store.allowUnknownOption(true);
|
||||
store.action(() => {
|
||||
const operands = store.args;
|
||||
// Flag values are indistinguishable from operands without a full
|
||||
// parse, so the verbatim echo only applies to plain-operand input.
|
||||
const attempted = operands.filter((operand) => !operand.startsWith('-'));
|
||||
const hasFlagLikeToken = operands.some((operand) => operand.startsWith('-'));
|
||||
// The agent contract: --json failures emit one JSON document.
|
||||
if (operands.includes('--json')) {
|
||||
const message =
|
||||
attempted.length > 0
|
||||
? `Unknown command '${attempted[0]}' for 'openspec store'. Store subcommands: ${storeSubcommandsLine}.`
|
||||
: `Missing subcommand for 'openspec store'. Store subcommands: ${storeSubcommandsLine}.`;
|
||||
printJson({
|
||||
status: [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'unknown_store_subcommand',
|
||||
message,
|
||||
fix: 'Run a store subcommand, or use the lifecycle command with --store <id>.',
|
||||
},
|
||||
],
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let example = 'openspec new change <change-id> --store <id>';
|
||||
if (!hasFlagLikeToken && attempted.length > 0 && lifecycleRedirects.has(attempted[0])) {
|
||||
if (attempted[0] === 'new') {
|
||||
const changeId = attempted[1] === 'change' && attempted[2] ? attempted[2] : '<change-id>';
|
||||
example = `openspec new change ${changeId} --store <id>`;
|
||||
} else {
|
||||
example = `openspec ${attempted.join(' ')} --store <id>`;
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
attempted.length > 0
|
||||
? `Error: unknown command '${attempted[0]}' for 'openspec store'.`
|
||||
: "Error: missing subcommand for 'openspec store'."
|
||||
);
|
||||
console.error(
|
||||
`Store subcommands manage store registration: ${storeSubcommandsLine}.`
|
||||
);
|
||||
console.error(
|
||||
'To create or work on a change in a store, use the normal command with --store, for example:'
|
||||
);
|
||||
console.error(` ${example}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type ResolvedOpenSpecRoot,
|
||||
isStoreSelectedRoot,
|
||||
} from '../core/root-selection.js';
|
||||
import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
|
||||
import { canRenderSpinner, 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';
|
||||
@@ -441,7 +441,7 @@ export class ValidateCommand {
|
||||
}
|
||||
|
||||
private async runBulkValidation(root: ResolvedOpenSpecRoot, scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean; findingsScope?: BulkScope }): Promise<void> {
|
||||
const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
|
||||
const spinner = !opts.json && isInteractive(opts) && canRenderSpinner() ? ora('Validating...').start() : undefined;
|
||||
const [changeIds, specIds] = await Promise.all([
|
||||
scope.changes ? this.listChangeIds(root) : Promise.resolve<string[]>([]),
|
||||
scope.specs ? getSpecIds(root.path) : Promise.resolve<string[]>([]),
|
||||
@@ -615,7 +615,7 @@ export class ValidateCommand {
|
||||
// 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;
|
||||
const spinner = !opts.json && isInteractive(opts) && canRenderSpinner() ? 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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Its own module so the CLI's command definitions can show it in help text
|
||||
// without loading the workflow implementation.
|
||||
export const DEFAULT_SCHEMA = 'spec-driven';
|
||||
@@ -672,7 +672,7 @@ export async function generateApplyInstructions(
|
||||
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.';
|
||||
instruction = 'All tracked tasks are complete.\nReview or verify the change as appropriate before archiving.';
|
||||
} else if (!tracksFile) {
|
||||
// No tracking file configured in schema - ready to apply
|
||||
state = 'ready';
|
||||
|
||||
@@ -83,7 +83,7 @@ export interface ArchiveInstructions {
|
||||
// Constants
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const DEFAULT_SCHEMA = 'spec-driven';
|
||||
export { DEFAULT_SCHEMA } from './default-schema.js';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Utility Functions
|
||||
|
||||
+4
-108
@@ -9,7 +9,6 @@
|
||||
import * as os from 'node:os';
|
||||
import { createRequire } from 'node:module';
|
||||
import type { spawn as nodeSpawn } from 'node:child_process';
|
||||
import { Command, Option } from 'commander';
|
||||
|
||||
import {
|
||||
buildWorksetCodeWorkspaceJson,
|
||||
@@ -64,7 +63,6 @@ import {
|
||||
promptOpenNow,
|
||||
promptToolFromChoices,
|
||||
} from './workset-prompts.js';
|
||||
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
||||
|
||||
// cross-spawn is CJS with no types and only `workset open` needs it -
|
||||
// loaded lazily so every other CLI invocation skips its module graph.
|
||||
@@ -77,18 +75,18 @@ function defaultSpawn(): typeof nodeSpawn {
|
||||
return cachedSpawn;
|
||||
}
|
||||
|
||||
interface WorksetCreateOptions {
|
||||
export interface WorksetCreateOptions {
|
||||
member?: string[];
|
||||
tool?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface WorksetOpenOptions {
|
||||
export interface WorksetOpenOptions {
|
||||
tool?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface WorksetRemoveOptions {
|
||||
export interface WorksetRemoveOptions {
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
@@ -198,7 +196,7 @@ interface PreparedOpen {
|
||||
codeWorkspacePath: string;
|
||||
}
|
||||
|
||||
class WorksetCommand {
|
||||
export class WorksetCommand {
|
||||
async create(
|
||||
name: string | undefined,
|
||||
options: WorksetCreateOptions = {}
|
||||
@@ -553,105 +551,3 @@ class WorksetCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectMember(value: string, previous: string[]): string[] {
|
||||
return [...previous, value];
|
||||
}
|
||||
|
||||
export function registerWorksetCommand(program: Command): void {
|
||||
const worksetCommand = new WorksetCommand();
|
||||
const groupDescription =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'workset')?.description ??
|
||||
'Compose, keep, and open personal working views (purely local)';
|
||||
const workset = program.command('workset').description(groupDescription);
|
||||
// Parsed at the group level so `openspec workset --json` keeps the
|
||||
// one-JSON-document contract instead of a raw Commander error. The
|
||||
// parent option matches anywhere; actions read optsWithGlobals().
|
||||
workset.addOption(new Option('--json', 'Output as JSON').hideHelp());
|
||||
|
||||
workset
|
||||
.command('create [name]')
|
||||
.description('Compose and save a named working view of folders you choose')
|
||||
.option(
|
||||
'--member <member>',
|
||||
'Member folder as <path> or <name>=<path>; repeatable, first is the primary',
|
||||
collectMember,
|
||||
[] as string[]
|
||||
)
|
||||
.option('--tool <id>', 'Preferred tool to open this workset with')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (name: string | undefined, _options: WorksetCreateOptions, command: Command) => {
|
||||
await worksetCommand.create(name, command.optsWithGlobals());
|
||||
});
|
||||
|
||||
workset
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('Show saved worksets with their members')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (_options: { json?: boolean }, command: Command) => {
|
||||
await worksetCommand.list(command.optsWithGlobals());
|
||||
});
|
||||
|
||||
workset
|
||||
.command('open <name>')
|
||||
.description('Open a saved workset in your tool (editor window or agent session)')
|
||||
.option('--tool <id>', 'Open with this tool just this once')
|
||||
.addOption(
|
||||
// Parsed so Commander never owns the error; rejected in the
|
||||
// action with one JSON document. Hidden because help should not
|
||||
// advertise a mode that only rejects.
|
||||
new Option('--json', 'Not supported for open').hideHelp()
|
||||
)
|
||||
.action(async (name: string, _options: WorksetOpenOptions, command: Command) => {
|
||||
await worksetCommand.open(name, command.optsWithGlobals());
|
||||
});
|
||||
|
||||
workset
|
||||
.command('remove <name>')
|
||||
.description('Delete a saved workset (member folders are never touched)')
|
||||
.option('--yes', 'Confirm removal non-interactively')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (name: string, _options: WorksetRemoveOptions, command: Command) => {
|
||||
await worksetCommand.remove(name, command.optsWithGlobals());
|
||||
});
|
||||
|
||||
const subcommandsLine = workset.commands
|
||||
.map((subcommand) => {
|
||||
const aliases = subcommand.aliases();
|
||||
return aliases.length > 0
|
||||
? `${subcommand.name()} (${aliases.join(', ')})`
|
||||
: subcommand.name();
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
// One handler owns missing AND unknown subcommands: known
|
||||
// subcommands dispatch above; everything else lands in this action
|
||||
// (allowExcessArguments routes the unknown operand here), keeping
|
||||
// the one-JSON-document contract for `--json` probes.
|
||||
workset.allowExcessArguments(true);
|
||||
workset.action(() => {
|
||||
const attempted = workset.args.filter(
|
||||
(operand) => !operand.startsWith('-')
|
||||
);
|
||||
const message =
|
||||
attempted.length > 0
|
||||
? `Unknown command '${attempted[0]}' for 'openspec workset'. Workset subcommands: ${subcommandsLine}.`
|
||||
: `Missing subcommand for 'openspec workset'. Workset subcommands: ${subcommandsLine}.`;
|
||||
if (workset.opts().json) {
|
||||
printJson({
|
||||
status: [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'unknown_workset_subcommand',
|
||||
message,
|
||||
fix: 'Run one of the workset subcommands.',
|
||||
} satisfies StoreDiagnostic,
|
||||
],
|
||||
});
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
+33
-13
@@ -341,6 +341,14 @@ function toArchiveDiagnostic(error: unknown): ArchiveDiagnostic {
|
||||
if (isRootSelectionError(error)) {
|
||||
return error.diagnostic;
|
||||
}
|
||||
if (error instanceof RetirementCleanupError) {
|
||||
return {
|
||||
severity: 'error',
|
||||
code: 'archive_retirement_cleanup_failed',
|
||||
message: error.message,
|
||||
fix: 'Inspect the archived change and all recovery paths in this diagnostic; preserve any needed content before cleanup.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
severity: 'error',
|
||||
code: 'archive_error',
|
||||
@@ -491,7 +499,7 @@ async function assertCopiedDirectoryUnchanged(
|
||||
* the source untouched rather than copying through a path we could not claim.
|
||||
*/
|
||||
class MoveDestinationRetainedError extends Error {}
|
||||
class RetirementBackupsRetainedError extends Error {}
|
||||
class RetirementCleanupError extends Error {}
|
||||
|
||||
function isFallbackRenameCode(code: string | undefined): boolean {
|
||||
return code === 'EPERM' || code === 'EXDEV';
|
||||
@@ -1273,14 +1281,14 @@ async function finalizeRetirementBackups(
|
||||
snapshot.displacedPath = undefined;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`Could not remove the committed retirement backup at ${displacedPath} ` +
|
||||
`Could not finalize the retirement backup at ${displacedPath} ` +
|
||||
`(${error instanceof Error ? error.message : String(error)}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new RetirementBackupsRetainedError(
|
||||
`${errors.join(' ')} The change remains archived and each listed backup was retained for recovery.`
|
||||
throw new RetirementCleanupError(
|
||||
`${errors.join(' ')} The change was archived, but retirement cleanup did not complete. Inspect all reported recovery paths before recovery or cleanup.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1350,13 +1358,22 @@ export class ArchiveCommand {
|
||||
const archiveDir = root.archiveDir;
|
||||
const mainSpecsDir = root.specsDir;
|
||||
|
||||
for (const [allowedDirectory, managedDir] of [
|
||||
[root.path, changesDir],
|
||||
[changesDir, archiveDir],
|
||||
[root.path, mainSpecsDir],
|
||||
const openspecDir = path.dirname(changesDir);
|
||||
|
||||
// openspec/, changes/ and specs/ may be project-owned symlinks to another
|
||||
// directory; archive/ may not leave the changes directory it lives in.
|
||||
for (const [allowedDirectory, managedDir, allowLinkedLeaf] of [
|
||||
[root.path, openspecDir, true],
|
||||
[openspecDir, changesDir, true],
|
||||
[changesDir, archiveDir, false],
|
||||
[openspecDir, mainSpecsDir, true],
|
||||
] as const) {
|
||||
try {
|
||||
FileSystemUtils.assertPathWithin(allowedDirectory, managedDir);
|
||||
if (allowLinkedLeaf) {
|
||||
FileSystemUtils.assertPathWithinAllowingLinkedLeaf(allowedDirectory, managedDir);
|
||||
} else {
|
||||
FileSystemUtils.assertPathWithin(allowedDirectory, managedDir);
|
||||
}
|
||||
} catch {
|
||||
throw new ArchiveBlockedError(
|
||||
'archive_path_outside_root',
|
||||
@@ -2182,6 +2199,9 @@ export class ArchiveCommand {
|
||||
retirementAuthorizationFingerprint!
|
||||
);
|
||||
}
|
||||
// update.source is canonical (symlinks resolved), so measure it from
|
||||
// the canonical change directory; changes/ may itself be a symlink.
|
||||
const canonicalChangeDir = FileSystemUtils.canonicalizeExistingPath(changeDir);
|
||||
const verifyArchivedDeltas = async (
|
||||
stagedSource?: string
|
||||
): Promise<void> => {
|
||||
@@ -2205,7 +2225,7 @@ export class ArchiveCommand {
|
||||
for (const proposed of prepared) {
|
||||
const archivedSource = path.join(
|
||||
archivePath,
|
||||
path.relative(changeDir, proposed.update.source)
|
||||
path.relative(canonicalChangeDir, proposed.update.source)
|
||||
);
|
||||
if (
|
||||
(await fingerprintPortableContent(archivedSource)) !==
|
||||
@@ -2218,7 +2238,7 @@ export class ArchiveCommand {
|
||||
if (stagedSource) {
|
||||
const stagedDelta = path.join(
|
||||
stagedSource,
|
||||
path.relative(changeDir, proposed.update.source)
|
||||
path.relative(canonicalChangeDir, proposed.update.source)
|
||||
);
|
||||
if (
|
||||
(await fingerprintPortableContent(stagedDelta)) !==
|
||||
@@ -2243,7 +2263,7 @@ export class ArchiveCommand {
|
||||
try {
|
||||
await finalizeRetirementBackups(specSnapshots, mainSpecsDir);
|
||||
} catch (cleanupError) {
|
||||
throw new RetirementBackupsRetainedError(
|
||||
throw new RetirementCleanupError(
|
||||
`${error.message} ${
|
||||
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
||||
}`
|
||||
@@ -2251,7 +2271,7 @@ export class ArchiveCommand {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof RetirementBackupsRetainedError) throw error;
|
||||
if (error instanceof RetirementCleanupError) throw error;
|
||||
const rollbackErrors: Error[] = [];
|
||||
try {
|
||||
await restoreSpecSnapshots(
|
||||
|
||||
@@ -1,10 +1,215 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import fg from 'fast-glob';
|
||||
import { expand } from 'brace-expansion';
|
||||
import picomatch from 'picomatch';
|
||||
import * as globby from 'tinyglobby';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
|
||||
const EXTGLOB_RE = /[!*+?@]\([^(]*\)/u;
|
||||
const BRACE_EXPANSION_SEPARATORS_RE = /,|\.\./u;
|
||||
const DUPLICATE_SLASH_RE = /(?!^)\/{2,}/gu;
|
||||
// Deeply nested braces can overflow a recursive expander. Reject nesting no real
|
||||
// artifact pattern needs before brace-expansion or tinyglobby see the pattern.
|
||||
const MAX_BRACE_NESTING = 16;
|
||||
// Nesting depth alone does not bound the work: `{a,b}` repeated 20 times is
|
||||
// only one level deep and expands to 1,048,576 patterns, which exhausts the
|
||||
// heap. fast-glob never expanded that far because micromatch compiled the
|
||||
// braces to a regex. No real artifact pattern needs more than a few hundred.
|
||||
const MAX_BRACE_EXPANSIONS = 1024;
|
||||
|
||||
const ARTIFACT_GLOB_OPTIONS = {
|
||||
absolute: true,
|
||||
// Braces are expanded first so `{draft}` left behind stays a literal name.
|
||||
braceExpansion: false,
|
||||
// fast-glob migration: directory patterns must not grow a trailing `/**`.
|
||||
expandDirectories: false,
|
||||
followSymbolicLinks: true,
|
||||
onlyFiles: true,
|
||||
} as const;
|
||||
|
||||
function assertBraceNesting(pattern: string): void {
|
||||
// Mirror the braces parser: `[...]` and quoted runs are literal, `(` nests like
|
||||
// `{`, and a closer only closes the innermost group of its own kind.
|
||||
const open: string[] = [];
|
||||
for (let index = 0; index < pattern.length; index += 1) {
|
||||
const char = pattern[index];
|
||||
if (char === '[') {
|
||||
for (let brackets = 1; brackets > 0 && index + 1 < pattern.length; ) {
|
||||
index += 1;
|
||||
if (pattern[index] === '[') brackets += 1;
|
||||
else if (pattern[index] === ']') brackets -= 1;
|
||||
}
|
||||
} else if (char === '"' || char === "'" || char === '`') {
|
||||
const end = pattern.indexOf(char, index + 1);
|
||||
index = end === -1 ? pattern.length : end;
|
||||
} else if (char === '{' || char === '(') {
|
||||
open.push(char);
|
||||
if (open.length > MAX_BRACE_NESTING) {
|
||||
// A pattern this deep can run to thousands of characters; quote only its start.
|
||||
const shown = pattern.length > 80 ? `${pattern.slice(0, 80)}...` : pattern;
|
||||
throw new Error(
|
||||
`Artifact output pattern nests braces more than ${MAX_BRACE_NESTING} levels deep: ${shown}`
|
||||
);
|
||||
}
|
||||
} else if ((char === '}' && open.at(-1) === '{') || (char === ')' && open.at(-1) === '(')) {
|
||||
open.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isDynamicSegment(segment: string): boolean {
|
||||
return segment.includes('*') || segment.includes('?') || segment.includes('[')
|
||||
|| EXTGLOB_RE.test(segment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static directory prefix of an expanded pattern, equivalent to the `base`
|
||||
* fast-glob's generateTasks used to reject `../` before walking.
|
||||
*/
|
||||
function globBase(pattern: string): string {
|
||||
const segments = pattern.split('/');
|
||||
if (segments.length <= 1) return '.';
|
||||
const base: string[] = [];
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
if (segment === '') continue;
|
||||
if (isDynamicSegment(segment)) break;
|
||||
base.push(segment);
|
||||
}
|
||||
// An absolute pattern must stay absolute, or `path.resolve(changeDir, base)`
|
||||
// rebases `/etc/*` inside the change directory and the containment check
|
||||
// passes for a pattern that globs the filesystem root.
|
||||
if (pattern.startsWith('/')) return `/${base.join('/')}`;
|
||||
return base.length > 0 ? base.join('/') : '.';
|
||||
}
|
||||
|
||||
// brace-expansion 5 leaves the rest of a pattern literal when a non-expanding
|
||||
// group such as `{draft}` comes before a range. Expand one real group at a time.
|
||||
// NUL-prefixed so no `generates` value can forge them: `resolveArtifactOutputPath`
|
||||
// runs `path.join` first, which rejects a NUL byte. A single-character sentinel
|
||||
// such as U+0001 is a legal filename character, and a pattern containing one
|
||||
// would come back out of the expander as a brace.
|
||||
const LITERAL_BRACE_OPEN = '\u0000<';
|
||||
const LITERAL_BRACE_CLOSE = '\u0000>';
|
||||
|
||||
function hasTopLevelExpansion(body: string): boolean {
|
||||
let depth = 0;
|
||||
for (let index = 0; index < body.length; index += 1) {
|
||||
const char = body[index];
|
||||
if (char === '{') depth += 1;
|
||||
else if (char === '}') depth = Math.max(0, depth - 1);
|
||||
else if (depth === 0) {
|
||||
if (char === ',') return true;
|
||||
if (char === '.' && body[index + 1] === '.') return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findTopLevelExpansion(pattern: string): { pre: string; body: string; post: string } | null {
|
||||
const stack: number[] = [];
|
||||
for (let index = 0; index < pattern.length; index += 1) {
|
||||
const char = pattern[index];
|
||||
if (char === '{') {
|
||||
stack.push(index);
|
||||
} else if (char === '}' && stack.length > 0) {
|
||||
const open = stack.pop();
|
||||
if (open === undefined) continue;
|
||||
// Only the outermost group that actually expands. brace-expansion expands
|
||||
// its nested groups in one linear pass. Peeling the innermost `{a,b}` first
|
||||
// turns `{a,{a,{a,b}}}` into a binary tree, so a 16-deep pattern does not
|
||||
// finish before the test timeout.
|
||||
if (stack.length > 0) continue;
|
||||
const body = pattern.slice(open + 1, index);
|
||||
if (!hasTopLevelExpansion(body)) {
|
||||
// `{prefix{a,b}}` has no separator of its own, so the outer braces stay
|
||||
// literal. Keep scanning inside them; a nested group may still expand.
|
||||
const nested = findTopLevelExpansion(body);
|
||||
if (!nested) continue;
|
||||
return {
|
||||
pre: `${pattern.slice(0, open + 1)}${nested.pre}`,
|
||||
body: nested.body,
|
||||
post: `${nested.post}${pattern.slice(index)}`,
|
||||
};
|
||||
}
|
||||
return { pre: pattern.slice(0, open), body, post: pattern.slice(index + 1) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertExpansionBudget(pattern: string, count: number): void {
|
||||
if (count <= MAX_BRACE_EXPANSIONS) return;
|
||||
// A pattern this wide can run to thousands of characters; quote only its start.
|
||||
const shown = pattern.length > 80 ? `${pattern.slice(0, 80)}...` : pattern;
|
||||
throw new Error(
|
||||
`Artifact output pattern expands to more than ${MAX_BRACE_EXPANSIONS} patterns: ${shown}`
|
||||
);
|
||||
}
|
||||
|
||||
function expandArtifactBraces(pattern: string): string[] {
|
||||
if (pattern.includes('\u0000')) {
|
||||
throw new Error('Artifact output pattern contains a NUL byte');
|
||||
}
|
||||
const pending = [pattern];
|
||||
const finished: string[] = [];
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
const group = findTopLevelExpansion(current);
|
||||
if (!group) {
|
||||
finished.push(current.replaceAll(LITERAL_BRACE_OPEN, '{').replaceAll(LITERAL_BRACE_CLOSE, '}'));
|
||||
continue;
|
||||
}
|
||||
const wrapped = `{${group.body}}`;
|
||||
// `max` makes this budget the binding one. Without it `{1..1000000000}`
|
||||
// allocates brace-expansion's own 100,000-result default before the check
|
||||
// below rejects it. `max` truncates silently, so ask for one result more
|
||||
// than the budget allows: a result that long means the pattern is over
|
||||
// budget rather than fully expanded. A truncated group never looks literal,
|
||||
// because its first alternative is not the group itself.
|
||||
const remaining = Math.max(MAX_BRACE_EXPANSIONS - (pending.length + finished.length), 0);
|
||||
const alternatives = expand(wrapped, { max: remaining + 1 });
|
||||
if (alternatives.length === 1 && alternatives[0] === wrapped) {
|
||||
pending.push(`${group.pre}${LITERAL_BRACE_OPEN}${group.body}${LITERAL_BRACE_CLOSE}${group.post}`);
|
||||
continue;
|
||||
}
|
||||
// Checked before the push so the budget bounds memory, not just the result.
|
||||
assertExpansionBudget(pattern, pending.length + finished.length + alternatives.length);
|
||||
for (const alternative of alternatives) {
|
||||
pending.push(`${group.pre}${alternative}${group.post}`);
|
||||
}
|
||||
}
|
||||
const normalized = finished
|
||||
.map((value) => value.replace(DUPLICATE_SLASH_RE, '/'))
|
||||
.filter((value) => value.length > 0);
|
||||
return normalized.length > 0 ? normalized : [pattern];
|
||||
}
|
||||
|
||||
/**
|
||||
* One directory level. picomatch matches the segment; readdir keeps symlink
|
||||
* entries tinyglobby would drop when followSymbolicLinks is false.
|
||||
*/
|
||||
function listDirectoryMatches(directory: string, segment: string): string[] {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isMatch = picomatch(segment, {
|
||||
dot: false,
|
||||
nobrace: true,
|
||||
noglobstar: true,
|
||||
posix: true,
|
||||
});
|
||||
const names: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (isMatch(entry.name)) names.push(entry.name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function hasBraceExpansion(pattern: string): boolean {
|
||||
const openings: number[] = [];
|
||||
@@ -79,12 +284,25 @@ function assertGlobDirectoryTraversal(
|
||||
);
|
||||
}
|
||||
|
||||
const matches = fg.sync(segment === '**' ? '*' : segment, {
|
||||
cwd: canonicalDir,
|
||||
onlyFiles: false,
|
||||
followSymbolicLinks: false,
|
||||
deep: 1,
|
||||
});
|
||||
// `.` and `..` are not directory entries. Resolve them directly so a
|
||||
// `**/../` pattern is confined before any glob runs.
|
||||
if (segment === '' || segment === '.' || segment === '..') {
|
||||
const candidate = path.resolve(canonicalDir, segment === '' ? '.' : segment);
|
||||
const canonicalCandidate = FileSystemUtils.canonicalizeExistingPath(candidate);
|
||||
FileSystemUtils.assertPathWithin(canonicalChangeDir, canonicalCandidate);
|
||||
assertGlobDirectoryTraversal(
|
||||
changeDir,
|
||||
canonicalCandidate,
|
||||
directorySegments,
|
||||
segmentIndex + 1,
|
||||
visited,
|
||||
canonicalChangeDir,
|
||||
ancestors
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = listDirectoryMatches(canonicalDir, segment === '**' ? '*' : segment);
|
||||
for (const match of matches) {
|
||||
const candidate = path.join(canonicalDir, match);
|
||||
try {
|
||||
@@ -115,9 +333,11 @@ function assertGlobDirectoryTraversal(
|
||||
* Returns absolute file paths. Glob matches are sorted for deterministic output.
|
||||
*/
|
||||
export function resolveArtifactOutputs(changeDir: string, generates: string): string[] {
|
||||
const isGlob = isGlobPattern(generates);
|
||||
if (isGlob) assertBraceNesting(FileSystemUtils.toPosixPath(generates));
|
||||
const outputPath = resolveArtifactOutputPath(changeDir, generates);
|
||||
|
||||
if (!isGlobPattern(generates)) {
|
||||
if (!isGlob) {
|
||||
try {
|
||||
return fs.statSync(outputPath).isFile()
|
||||
? [FileSystemUtils.canonicalizeExistingPath(outputPath)]
|
||||
@@ -127,31 +347,25 @@ export function resolveArtifactOutputs(changeDir: string, generates: string): st
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPattern = FileSystemUtils.toPosixPath(generates);
|
||||
const globOptions = {
|
||||
cwd: changeDir,
|
||||
onlyFiles: true,
|
||||
absolute: true,
|
||||
// Preserve linked artifact directories; confine traversal and concrete matches.
|
||||
followSymbolicLinks: true,
|
||||
};
|
||||
// Task generation expands braces without accessing the filesystem. Validate
|
||||
// every task base before globbing, including paths introduced by expansion.
|
||||
const tasks = fg.generateTasks(normalizedPattern, globOptions);
|
||||
for (const task of tasks) {
|
||||
FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, task.base));
|
||||
const normalizedPattern = FileSystemUtils.toPosixPath(generates).replace(DUPLICATE_SLASH_RE, '/');
|
||||
// Expand braces before any filesystem access. tinyglobby has no generateTasks,
|
||||
// and a `../` alternative is invisible until the braces are expanded.
|
||||
const patterns = expandArtifactBraces(normalizedPattern);
|
||||
for (const pattern of patterns) {
|
||||
FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, globBase(pattern)));
|
||||
}
|
||||
for (const task of tasks) {
|
||||
for (const positivePattern of task.positive) {
|
||||
assertGlobDirectoryTraversal(
|
||||
changeDir,
|
||||
changeDir,
|
||||
positivePattern.split('/').slice(0, -1)
|
||||
);
|
||||
}
|
||||
for (const pattern of patterns) {
|
||||
assertGlobDirectoryTraversal(
|
||||
changeDir,
|
||||
changeDir,
|
||||
pattern.split('/').slice(0, -1)
|
||||
);
|
||||
}
|
||||
const matches = fg
|
||||
.sync(normalizedPattern, globOptions)
|
||||
const matches = globby
|
||||
.globSync(patterns, {
|
||||
cwd: changeDir,
|
||||
...ARTIFACT_GLOB_OPTIONS,
|
||||
})
|
||||
.map((match) => {
|
||||
const normalizedMatch = path.normalize(match);
|
||||
FileSystemUtils.assertPathWithin(changeDir, normalizedMatch);
|
||||
|
||||
@@ -22,7 +22,6 @@ import { getGlobalConfigPath } from './global-config.js';
|
||||
import { writeFileAtomically } from './file-state.js';
|
||||
import { isCiEnvironment } from '../utils/ci.js';
|
||||
import { detectShell } from '../utils/shell-detection.js';
|
||||
import { CompletionFactory } from './completions/factory.js';
|
||||
|
||||
export const COMPLETION_TIP_MESSAGE =
|
||||
"Tip: Run 'openspec completion install' for shell completions";
|
||||
@@ -67,6 +66,9 @@ async function decideTip(): Promise<'show' | 'retire'> {
|
||||
if (!shell) {
|
||||
return 'retire';
|
||||
}
|
||||
// Loaded only here: every shell's generator and installer, needed only on
|
||||
// the rare run that still owes the tip.
|
||||
const { CompletionFactory } = await import('./completions/factory.js');
|
||||
return (await CompletionFactory.createInstaller(shell).isInstalled())
|
||||
? 'retire'
|
||||
: 'show';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getActiveChangeIds, getSpecIds } from '../../utils/item-discovery.js';
|
||||
import { listSchemas } from '../artifact-graph/index.js';
|
||||
|
||||
/**
|
||||
* Cache entry for completion data
|
||||
@@ -96,7 +95,10 @@ export class CompletionProvider {
|
||||
return this.schemaCache.data;
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
// Fetch fresh data. The resolver (and the yaml and zod it parses schemas
|
||||
// with) loads only here: completing changes or specs never needs it, and
|
||||
// the artifact-graph barrel would also pull in tinyglobby.
|
||||
const { listSchemas } = await import('../artifact-graph/resolver.js');
|
||||
const schemaNames = listSchemas(this.projectRoot);
|
||||
|
||||
// Update cache
|
||||
|
||||
@@ -169,6 +169,16 @@ export interface OrphanedRequirement {
|
||||
line: number; // 1-based line number in the delta file
|
||||
}
|
||||
|
||||
/**
|
||||
* Authored text archive does not carry into the main spec: a non-delta `## `
|
||||
* section, text above the first `## `, or prose before the first requirement
|
||||
* in ADDED/MODIFIED. `section` is null for text above the first `## `.
|
||||
*/
|
||||
export interface UnappliedContent {
|
||||
section: string | null;
|
||||
line: number; // 1-based: the section header, or the first line of leading prose
|
||||
}
|
||||
|
||||
export interface DeltaPlan {
|
||||
added: RequirementBlock[];
|
||||
modified: RequirementBlock[];
|
||||
@@ -182,6 +192,7 @@ export interface DeltaPlan {
|
||||
unpairedRenames: UnpairedRename[];
|
||||
/** Canonical requirement blocks written outside every delta section. */
|
||||
orphanedRequirements: OrphanedRequirement[];
|
||||
unappliedContent: UnappliedContent[];
|
||||
skippedHeaders: SkippedHeader[]; // non-canonical ### headers the reader skipped
|
||||
sectionPresence: {
|
||||
added: boolean;
|
||||
@@ -256,6 +267,7 @@ export function parseDeltaSpec(content: string): DeltaPlan {
|
||||
renamed: renamedPairs,
|
||||
unpairedRenames,
|
||||
orphanedRequirements: findOrphanedRequirements(lines, fenceMask),
|
||||
unappliedContent: findUnappliedContent(lines, fenceMask),
|
||||
skippedHeaders,
|
||||
sectionPresence: {
|
||||
added: addedLookup.found,
|
||||
@@ -315,6 +327,57 @@ function findOrphanedRequirements(
|
||||
return orphans;
|
||||
}
|
||||
|
||||
export function describeUnappliedContent(content: UnappliedContent, sentenceStart = false): string {
|
||||
const text = content.section === null
|
||||
? 'text above the first "## " section'
|
||||
: DELTA_SECTION_TITLES.has(content.section.toLowerCase())
|
||||
? `text before the first requirement in "## ${content.section}"`
|
||||
: `section "## ${content.section}"`;
|
||||
return sentenceStart ? text.charAt(0).toUpperCase() + text.slice(1) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where text the merge never reads begins. HTML comments, `# ` titles, Purpose
|
||||
* (warned about separately), REMOVED/RENAMED bodies, and stray `###` headers
|
||||
* before the first requirement (INFO since #498) are not reported.
|
||||
*/
|
||||
function findUnappliedContent(lines: string[], fenceMask: boolean[]): UnappliedContent[] {
|
||||
// Blank out comments outside fences, keeping line count; an unterminated one
|
||||
// runs to EOF. Comments only decide whether a line holds text: sections come
|
||||
// from the same split the merge reads, where a `## ` line inside a comment is
|
||||
// still a boundary. Fenced lines keep their text, so a `<!--` in a code sample
|
||||
// never hides what follows the fence.
|
||||
const comments = lines
|
||||
.map((line, i) => (fenceMask[i] ? '' : line))
|
||||
.join('\n')
|
||||
.replace(/<!--[\s\S]*?(?:--!?>|$)/g, (m) => m.replace(/[^\n]/g, ' '))
|
||||
.split('\n');
|
||||
const masked = lines.map((line, i) => (fenceMask[i] ? line : comments[i]));
|
||||
const sections = splitTopLevelSections(lines, fenceMask);
|
||||
const firstText = (from: number, to: number, skip?: RegExp) => {
|
||||
for (let i = from; i < to; i++) if (masked[i].trim() && !skip?.test(masked[i])) return i;
|
||||
return -1;
|
||||
};
|
||||
const found: UnappliedContent[] = [];
|
||||
const preamble = firstText(0, sections.length ? sections[0].body.bodyStartLine - 2 : lines.length, /^#\s/);
|
||||
if (preamble !== -1) found.push({ section: null, line: preamble + 1 });
|
||||
for (const { title, body } of sections) {
|
||||
const folded = title.toLowerCase();
|
||||
const start = body.bodyStartLine - 1;
|
||||
if (folded === 'added requirements' || folded === 'modified requirements') {
|
||||
const block = body.lines.findIndex((l, i) => !body.fenceMask[i] && REQUIREMENT_HEADER_REGEX.test(l));
|
||||
const first = firstText(start, start + (block === -1 ? body.lines.length : block), /^###\s/);
|
||||
if (first !== -1) found.push({ section: title, line: first + 1 });
|
||||
} else if (!DELTA_SECTION_TITLES.has(folded)) {
|
||||
// The Purpose carry-over reads past commented-out headers, so only a
|
||||
// `## Purpose` outside a comment is the one archive handles.
|
||||
if (folded === 'purpose' && masked[start - 1].trim()) continue;
|
||||
if (firstText(start, start + body.lines.length) !== -1) found.push({ section: title, line: start });
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** One `## ` section of a delta file, in the order it was written. */
|
||||
interface DeltaSection {
|
||||
title: string;
|
||||
|
||||
+31
-5
@@ -13,6 +13,7 @@ import {
|
||||
extractRequirementsSection,
|
||||
diffScenarioNames,
|
||||
describeScenarioBalance,
|
||||
describeUnappliedContent,
|
||||
foldRequirementName,
|
||||
parseDeltaSpec,
|
||||
normalizeRequirementName,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
} from './validation/constants.js';
|
||||
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { assertSpecFileWithin } from '../utils/spec-path-containment.js';
|
||||
import { matchLineEnding } from '../utils/line-endings.js';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -71,7 +73,7 @@ function resolveTrustedSpecPath(
|
||||
|
||||
try {
|
||||
// Preserve spec.md links that remain inside the overall specs tree.
|
||||
FileSystemUtils.assertPathWithin(specsRoot, specPath);
|
||||
assertSpecFileWithin(specsRoot, specPath);
|
||||
const root = FileSystemUtils.canonicalizeExistingPath(specsRoot);
|
||||
return {
|
||||
root,
|
||||
@@ -96,7 +98,7 @@ function resolveTrustedSpecPath(
|
||||
);
|
||||
}
|
||||
const file = path.join(root, path.basename(specPath));
|
||||
FileSystemUtils.assertPathWithin(root, file);
|
||||
assertSpecFileWithin(root, file);
|
||||
return { root, file };
|
||||
}
|
||||
}
|
||||
@@ -105,7 +107,7 @@ function assertTrustedSpecPath(root: string, specPath: string): void {
|
||||
if (FileSystemUtils.canonicalizeExistingPath(root) !== path.resolve(root)) {
|
||||
throw new Error(`Path is outside the allowed directory: ${specPath}`);
|
||||
}
|
||||
FileSystemUtils.assertPathWithin(root, specPath);
|
||||
assertSpecFileWithin(root, specPath);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -251,6 +253,11 @@ export async function buildUpdatedSpec(
|
||||
);
|
||||
}
|
||||
|
||||
// Archive carries requirement blocks only; name any other text it leaves behind.
|
||||
for (const lost of plan.unappliedContent) {
|
||||
warn(`${specName} - ${describeUnappliedContent(lost)} (line ${lost.line}) is not carried into the main spec.`);
|
||||
}
|
||||
|
||||
// Pre-validate duplicates within sections
|
||||
const addedNames = new Set<string>();
|
||||
for (const add of plan.added) {
|
||||
@@ -1241,6 +1248,14 @@ export async function writeUpdatedSpec(
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
assertTrustedSpecPath(update.targetRoot, update.target);
|
||||
// Pin what the assertion just checked. A capability's spec.md may be a link
|
||||
// to the file the project keeps elsewhere, so the write follows a link by
|
||||
// design - and resolving it once, here, is what keeps that from becoming a
|
||||
// redirect: a link repointed after this line, including from inside
|
||||
// `beforeMutate`, cannot send the write to a file nothing verified. The
|
||||
// bytes still land in the same place a path-based write would have put them,
|
||||
// which keeps the in-place semantics below intact.
|
||||
const verifiedTarget = FileSystemUtils.canonicalizeExistingPath(update.target);
|
||||
|
||||
// Create target directory if needed
|
||||
const targetDir = path.dirname(update.target);
|
||||
@@ -1252,17 +1267,28 @@ export async function writeUpdatedSpec(
|
||||
// requirement moved. A spec that does not exist yet stays LF.
|
||||
// Only a missing file means "no convention to match". Swallowing every error
|
||||
// would read an existing but unreadable spec as absent and rewrite it as LF.
|
||||
const previous = await fs.readFile(update.target, 'utf-8').catch((error) => {
|
||||
const previous = await fs.readFile(verifiedTarget, 'utf-8').catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
});
|
||||
const toWrite = previous === undefined ? rebuilt : matchLineEnding(rebuilt, previous);
|
||||
|
||||
await options.beforeMutate?.();
|
||||
// `beforeMutate` awaits arbitrary work, so it is the widest gap between the
|
||||
// check above and the write below. Re-check there, and refuse unless the
|
||||
// target still resolves to the very file that was verified: pinning a
|
||||
// pathname is not enough on its own, because the pinned path can itself be
|
||||
// replaced by a link to somewhere else.
|
||||
assertTrustedSpecPath(update.targetRoot, update.target);
|
||||
if (FileSystemUtils.canonicalizeExistingPath(update.target) !== verifiedTarget) {
|
||||
throw new Error(
|
||||
`Spec target for '${update.id}' changed before it could be written: ${update.target}`
|
||||
);
|
||||
}
|
||||
// Preserve the established in-place write semantics: symlink referents,
|
||||
// hard-linked specs, ACLs, extended attributes, and filesystems without hard
|
||||
// links must continue to behave as they did before capability retirement.
|
||||
await fs.writeFile(update.target, toWrite);
|
||||
await fs.writeFile(verifiedTarget, toWrite);
|
||||
if (options.silent) return;
|
||||
|
||||
const specName = update.id;
|
||||
|
||||
@@ -84,7 +84,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
- If \`state: "blocked"\`: show the message and pause implementation.
|
||||
- If \`missingArtifacts\` is non-empty: ${BLOCKED_STATE_HANDOFF}
|
||||
- Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
|
||||
- If \`state: "all_done"\`: congratulate, suggest archive
|
||||
- If \`state: "all_done"\`: report that all tracked tasks are complete and suggest review or verification as appropriate before archiving
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
Treat \`context\` as a required prompt-level input. Read and consider it, and
|
||||
@@ -143,7 +143,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If all done: report that tracked tasks are complete and suggest review or verification as appropriate before archiving
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
@@ -174,7 +174,8 @@ Working on task 4/7: <task description>
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! ${ARCHIVE_HANDOFF}
|
||||
All tracked tasks are complete. Review or verify the change as appropriate
|
||||
before archiving. ${ARCHIVE_HANDOFF}
|
||||
\`\`\`
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
@@ -24,6 +24,16 @@ const PLANNING_COMPLETE_HANDOFF = optionalWorkflow(
|
||||
'Once implementation and any tracked work are complete, archive it with `openspec archive "<name>"`.'
|
||||
);
|
||||
|
||||
/**
|
||||
* A glob artifact reads `done` once one matching file exists, so a run
|
||||
* interrupted while writing several files leaves it `done` with files missing,
|
||||
* and the next run would move on without reading its instruction again (#2034).
|
||||
*/
|
||||
const PARTIAL_ARTIFACT_CHECK = `**Before anything else, finish a partly written artifact**:
|
||||
- An artifact whose output is a glob pattern (e.g. \`specs/**/*.md\`) reads \`done\` as soon as one matching file exists, so a run interrupted while writing its files leaves it \`done\` with some still missing
|
||||
- For each such \`done\` artifact, first get its instructions and read any completed dependencies needed to determine the expected files. Then compare those files with \`artifactPaths.<id>.existingOutputPaths\`. For spec-driven's \`specs\`, read the proposal and check for one \`specs/<capability-path>/spec.md\` per capability it lists; resolve each expected path against \`changeRoot\` before comparing path identity, and verify each one remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists
|
||||
- Never write an expected path that fails that check; stop and tell the user. If any are missing, write only the missing files, each at its checked path - when the \`instruction\` field delegates creation to a skill or command, invoke it only if it can write each file to its checked path; otherwise stop and tell the user - and STOP - that counts as this invocation's ONE artifact. If you cannot tell whether a file was left out on purpose, ask the user`;
|
||||
|
||||
export function getContinueChangeSkillTemplate(): SkillTemplate {
|
||||
return {
|
||||
name: 'openspec-continue-change',
|
||||
@@ -68,6 +78,10 @@ ${PROJECT_ROOT_GUARD}
|
||||
|
||||
---
|
||||
|
||||
${PARTIAL_ARTIFACT_CHECK}
|
||||
|
||||
---
|
||||
|
||||
**If all planning artifacts are complete (\`isPlanningComplete: true\`, or legacy \`isComplete: true\`)**:
|
||||
- Congratulate the user
|
||||
- Show final status including the schema used
|
||||
@@ -92,10 +106,13 @@ ${PROJECT_ROOT_GUARD}
|
||||
- \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact
|
||||
- **Create the artifact file**:
|
||||
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
|
||||
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
|
||||
- Otherwise use \`template\` as the structure - fill in its sections
|
||||
- Apply \`context\` and \`rules\` as constraints when writing - but do NOT copy them into the file
|
||||
- Write to the \`resolvedOutputPath\` specified in instructions. If it is a glob pattern, choose the concrete file path using the schema instruction and the change's context
|
||||
- Choose the output path before creating anything. If the \`resolvedOutputPath\` specified in instructions is a glob pattern, choose a concrete path inside \`changeRoot\` using the schema instruction and the change's context, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
|
||||
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user
|
||||
- Otherwise, write it yourself:
|
||||
- Use \`template\` as the structure - fill in its sections
|
||||
- Apply \`context\` and \`rules\` as constraints when writing - but do NOT copy them into the file
|
||||
- Write the file to that output path
|
||||
- Either way, verify the artifact file exists at that output path
|
||||
- Show what was created and what's now unlocked
|
||||
- STOP after creating ONE artifact
|
||||
|
||||
@@ -123,7 +140,7 @@ After each invocation, show:
|
||||
|
||||
The artifact types and their purpose depend on the schema. The \`instruction\` field from the instructions output is the authoritative guidance for each artifact - follow it even when the artifact has a familiar name (proposal.md, tasks.md, etc.), since custom schemas may define different content or a different process for the same file names.
|
||||
|
||||
If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly.
|
||||
If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there.
|
||||
|
||||
**Guardrails**
|
||||
- Create ONE artifact per invocation
|
||||
@@ -187,6 +204,10 @@ ${PROJECT_ROOT_GUARD}
|
||||
|
||||
---
|
||||
|
||||
${PARTIAL_ARTIFACT_CHECK}
|
||||
|
||||
---
|
||||
|
||||
**If all planning artifacts are complete (\`isPlanningComplete: true\`, or legacy \`isComplete: true\`)**:
|
||||
- Congratulate the user
|
||||
- Show final status including the schema used
|
||||
@@ -211,10 +232,13 @@ ${PROJECT_ROOT_GUARD}
|
||||
- \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact
|
||||
- **Create the artifact file**:
|
||||
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
|
||||
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
|
||||
- Otherwise use \`template\` as the structure - fill in its sections
|
||||
- Apply \`context\` and \`rules\` as constraints when writing - but do NOT copy them into the file
|
||||
- Write to the \`resolvedOutputPath\` specified in instructions. If it is a glob pattern, choose the concrete file path using the schema instruction and the change's context
|
||||
- Choose the output path before creating anything. If the \`resolvedOutputPath\` specified in instructions is a glob pattern, choose a concrete path inside \`changeRoot\` using the schema instruction and the change's context, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
|
||||
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user
|
||||
- Otherwise, write it yourself:
|
||||
- Use \`template\` as the structure - fill in its sections
|
||||
- Apply \`context\` and \`rules\` as constraints when writing - but do NOT copy them into the file
|
||||
- Write the file to that output path
|
||||
- Either way, verify the artifact file exists at that output path
|
||||
- Show what was created and what's now unlocked
|
||||
- STOP after creating ONE artifact
|
||||
|
||||
@@ -242,7 +266,7 @@ After each invocation, show:
|
||||
|
||||
The artifact types and their purpose depend on the schema. The \`instruction\` field from the instructions output is the authoritative guidance for each artifact - follow it even when the artifact has a familiar name (proposal.md, tasks.md, etc.), since custom schemas may define different content or a different process for the same file names.
|
||||
|
||||
If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly.
|
||||
If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there.
|
||||
|
||||
**Guardrails**
|
||||
- Create ONE artifact per invocation
|
||||
|
||||
@@ -195,7 +195,7 @@ If the user asks you to capture the exploration as a new change, that request is
|
||||
|
||||
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.
|
||||
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. Choose the output path before creating anything: when \`resolvedOutputPath\` is a glob, use the instruction to choose a concrete path inside \`changeRoot\` and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`. If the instruction delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Otherwise write the artifact to that output path. Verify that the selected concrete output exists at that output path.
|
||||
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 the requested capture is done, stop there and name where the work continues: ${CAPTURE_PLANNING_HANDOFF}, and ${CAPTURE_APPLY_HANDOFF}. Capturing artifacts never starts implementing them.
|
||||
@@ -217,6 +217,8 @@ If the user mentions a change or you detect one is relevant:
|
||||
|
||||
\`<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.
|
||||
|
||||
The files below are the default spec-driven schema's. With another schema, capture each insight in whichever artifact from \`artifactPaths\` fits it, and name that artifact in your offer; never create an artifact the schema does not define.
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|-------------------------------------|
|
||||
| New requirement discovered | \`specs/<capability-path>/spec.md\` |
|
||||
@@ -226,7 +228,7 @@ If the user mentions a change or you detect one is relevant:
|
||||
| New work identified | \`tasks.md\` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
Example offers (default schema):
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
@@ -355,7 +357,7 @@ You: That changes everything.
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "${DISCOVERY_END_HANDOFF}"
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Result in artifact updates**: "Updated the design with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
@@ -529,7 +531,7 @@ If the user asks you to capture the exploration as a new change, that request is
|
||||
|
||||
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.
|
||||
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. Choose the output path before creating anything: when \`resolvedOutputPath\` is a glob, use the instruction to choose a concrete path inside \`changeRoot\` and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`. If the instruction delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Otherwise write the artifact to that output path. Verify that the selected concrete output exists at that output path.
|
||||
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 the requested capture is done, stop there and name where the work continues: ${CAPTURE_PLANNING_HANDOFF}, and ${CAPTURE_APPLY_HANDOFF}. Capturing artifacts never starts implementing them.
|
||||
@@ -551,6 +553,8 @@ If the user mentions a change or you detect one is relevant:
|
||||
|
||||
\`<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.
|
||||
|
||||
The files below are the default spec-driven schema's. With another schema, capture each insight in whichever artifact from \`artifactPaths\` fits it, and name that artifact in your offer; never create an artifact the schema does not define.
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|-------------------------------------|
|
||||
| New requirement discovered | \`specs/<capability-path>/spec.md\` |
|
||||
@@ -560,7 +564,7 @@ If the user mentions a change or you detect one is relevant:
|
||||
| New work identified | \`tasks.md\` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
Example offers (default schema):
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
@@ -585,7 +589,7 @@ If the user mentions a change or you detect one is relevant:
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "${DISCOVERY_END_HANDOFF}"
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Result in artifact updates**: "Updated the design with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
|
||||
@@ -91,8 +91,9 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
|
||||
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
|
||||
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
|
||||
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
|
||||
- Otherwise create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose the concrete file path
|
||||
- Choose the output path before creating anything. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose a concrete path inside \`changeRoot\` that matches the glob, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
|
||||
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
|
||||
- Otherwise create the artifact file using \`template\` as the structure and write it to that output path
|
||||
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "✓ Created <artifact-id>"
|
||||
|
||||
@@ -126,7 +127,7 @@ After completing all artifacts, summarize:
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names
|
||||
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
|
||||
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use \`template\` as the structure for your output file - fill in its sections
|
||||
@@ -210,8 +211,9 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
|
||||
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
|
||||
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
|
||||
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
|
||||
- Otherwise create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose the concrete file path
|
||||
- Choose the output path before creating anything. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose a concrete path inside \`changeRoot\` that matches the glob, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
|
||||
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
|
||||
- Otherwise create the artifact file using \`template\` as the structure and write it to that output path
|
||||
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "✓ Created <artifact-id>"
|
||||
|
||||
@@ -245,7 +247,7 @@ After completing all artifacts, summarize:
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names
|
||||
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
|
||||
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use \`template\` as the structure for your output file - fill in its sections
|
||||
|
||||
@@ -137,8 +137,9 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
|
||||
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
|
||||
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
|
||||
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
|
||||
- Otherwise create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose the concrete file path
|
||||
- Choose the output path before creating anything. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose a concrete path inside \`changeRoot\` that matches the glob, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
|
||||
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
|
||||
- Otherwise create the artifact file using \`template\` as the structure and write it to that output path
|
||||
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
@@ -172,7 +173,7 @@ After completing all artifacts, summarize:
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names
|
||||
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
|
||||
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use \`template\` as the structure for your output file - fill in its sections
|
||||
@@ -302,8 +303,9 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
|
||||
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
|
||||
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
|
||||
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
|
||||
- Otherwise create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose the concrete file path
|
||||
- Choose the output path before creating anything. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose a concrete path inside \`changeRoot\` that matches the glob, and verify it remains inside \`changeRoot\` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is \`resolvedOutputPath\`
|
||||
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
|
||||
- Otherwise create the artifact file using \`template\` as the structure and write it to that output path
|
||||
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
@@ -337,7 +339,7 @@ After completing all artifacts, summarize:
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names
|
||||
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
|
||||
- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use \`template\` as the structure for your output file - fill in its sections
|
||||
|
||||
@@ -117,7 +117,7 @@ ${CONTINUE_SCOPE_NOTE}
|
||||
|
||||
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.
|
||||
|
||||
The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
|
||||
The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`), plus any confirmed concrete path allowed under a partly populated glob artifact (step 4). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
|
||||
|
||||
3. **Understand the request**
|
||||
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
|
||||
@@ -161,7 +161,7 @@ After each invocation, show:
|
||||
**Guardrails**
|
||||
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, ${APPLY_GUARDRAIL}.
|
||||
- Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names.
|
||||
- Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`.
|
||||
- Edit only the concrete files in \`existingOutputPaths\`, plus any confirmed concrete path allowed under a partly populated glob artifact (step 4); never write to a glob \`resolvedOutputPath\`.
|
||||
- Do not advance the build frontier: if an artifact has empty \`existingOutputPaths\` and status \`ready\` or \`blocked\`, ${CONTINUE_FRONTIER}. Leave \`skipped\` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose \`existingOutputPaths\` is non-empty.
|
||||
- Confirm every edit with the user before writing.
|
||||
- If the request changes the change's *intent* rather than refining it, ${INTENT_CHANGE_GUARDRAIL}.`,
|
||||
@@ -217,7 +217,7 @@ ${CONTINUE_SCOPE_NOTE}
|
||||
|
||||
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.
|
||||
|
||||
The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
|
||||
The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`), plus any confirmed concrete path allowed under a partly populated glob artifact (step 4). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
|
||||
|
||||
3. **Understand the request**
|
||||
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
|
||||
@@ -261,7 +261,7 @@ After each invocation, show:
|
||||
**Guardrails**
|
||||
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, ${APPLY_GUARDRAIL}.
|
||||
- Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names.
|
||||
- Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`.
|
||||
- Edit only the concrete files in \`existingOutputPaths\`, plus any confirmed concrete path allowed under a partly populated glob artifact (step 4); never write to a glob \`resolvedOutputPath\`.
|
||||
- Do not advance the build frontier: if an artifact has empty \`existingOutputPaths\` and status \`ready\` or \`blocked\`, ${CONTINUE_FRONTIER}. Leave \`skipped\` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose \`existingOutputPaths\` is non-empty.
|
||||
- Confirm every edit with the user before writing.
|
||||
- If the request changes the change's *intent* rather than refining it, ${INTENT_CHANGE_GUARDRAIL}.`
|
||||
|
||||
@@ -84,9 +84,9 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
||||
|
||||
**Spec Coverage**:
|
||||
- If status marks the spec artifact skipped by \`skip_specs: true\`, or the schema defines no spec artifact, report the spec-dependent checks as not applicable.
|
||||
- Otherwise, \`contextFiles\` is keyed by artifact id, and artifact ids come from the active schema. If \`contextFiles.specs\` is absent or empty, mark **Spec Coverage**, **Requirement Implementation Mapping**, and **Scenario Coverage** as not verified; do not treat any of them as clean.
|
||||
- If delta specs exist in \`contextFiles.specs\`:
|
||||
- If status marks the spec artifact skipped by \`skip_specs: true\`, or the schema defines no spec artifact (no artifact whose \`artifactPaths.<id>.outputPath\` is under \`specs/\`), report the spec-dependent checks as not applicable.
|
||||
- Otherwise, \`contextFiles\` is keyed by artifact id, and artifact ids come from the active schema, so do not assume an id such as \`specs\`. The spec artifacts are those whose \`artifactPaths.<id>.outputPath\` is under \`specs/\`; read their files from \`contextFiles.<id>\`. If those spec files are absent or empty, mark **Spec Coverage**, **Requirement Implementation Mapping**, and **Scenario Coverage** as not verified; do not treat any of them as clean.
|
||||
- If delta specs exist in those spec files:
|
||||
- Extract all requirements (marked with "### Requirement:", or listed as \`FROM:\`/\`TO:\` pairs under \`## RENAMED Requirements\`) and note the delta section each one sits under: \`## ADDED\`, \`## MODIFIED\`, \`## REMOVED\`, or \`## RENAMED Requirements\`. The section decides what the check looks for.
|
||||
- For each ADDED or MODIFIED requirement (for MODIFIED, check the text in the delta, not the old wording):
|
||||
- Search codebase for keywords related to the requirement
|
||||
@@ -95,8 +95,11 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
||||
- Recommendation: "Implement requirement X: <description>"
|
||||
- For each REMOVED requirement, the change asks for the behavior to be gone, so invert the check:
|
||||
- Search codebase for the removed behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
||||
- Finding no implementation is the expected result. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
||||
- A REMOVED block carries only the requirement name and any \`**Reason**\`/\`**Migration**\` notes, so the delta does not describe the behavior itself. Before searching, read the baseline requirement in the main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\`, using the same capability path as the delta spec. Match it by normalized name, scanning that file's requirement headings rather than string-matching the delta's exact title. Its body and scenarios are the evidence for the behavior that must be gone; never search from the requirement name alone.
|
||||
- If that file holds no such requirement under this or a near-equivalent name - for example because an earlier sync already applied the removal - recover the baseline from the same file's history, in the planning repository rather than the current directory: \`git -C "<planningHome.root>" log -p -- openspec/specs/<capability-path>/spec.md\`, or \`git -C "<planningHome.root>" show <commit>^:./openspec/specs/<capability-path>/spec.md\` once you have identified the commit that applied the removal. Empty output means the pathspec matched nothing, not that there is no baseline: \`git log\` reports an unmatched path as success, so re-check the path before drawing any conclusion from silence.
|
||||
- A baseline that adds nothing to its own title - no body beyond a restatement of the name, no scenarios - is unusable evidence, the same as a missing one. If no usable baseline is available in either place, mark **Spec Coverage** as not verified for that entry with the reason and stop there: do not search, and never count an unsearched removal as passing.
|
||||
- Search codebase for that behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
||||
- Finding no implementation is the expected result, but only once the baseline behavior was the thing you searched for. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
||||
- If the behavior is still present:
|
||||
- Add CRITICAL issue: "Removed requirement still implemented: <requirement name>"
|
||||
- Recommendation: "Remove the remaining implementation at <file>:<lines>, following the requirement's Migration note if it has one"
|
||||
@@ -112,7 +115,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
|
||||
6. **Verify Correctness**
|
||||
|
||||
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each RENAMED entry is checked there against its baseline behavior), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
||||
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each REMOVED and RENAMED entry with a usable baseline is checked there against its baseline behavior, and an entry already marked not verified there stays not verified), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
||||
|
||||
**Requirement Implementation Mapping**:
|
||||
- For each ADDED or MODIFIED requirement from delta specs (REMOVED entries, and RENAMED entries without a MODIFIED block, were settled under Spec Coverage):
|
||||
@@ -135,14 +138,14 @@ ${PROJECT_ROOT_GUARD}
|
||||
7. **Verify Coherence**
|
||||
|
||||
**Design Adherence**:
|
||||
- If the schema defines no design artifact, report **Design Adherence** as not applicable.
|
||||
- If \`contextFiles.design\` exists:
|
||||
- If the schema defines no design artifact (no artifact with id \`design\`, and none whose \`artifactPaths.<id>.outputPath\` is or ends in \`design.md\`), report **Design Adherence** as not applicable.
|
||||
- If the design artifact's \`contextFiles.<id>\` file exists:
|
||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
||||
- Verify implementation follows those decisions
|
||||
- If contradiction detected:
|
||||
- Add WARNING: "Design decision not followed: <decision>"
|
||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
||||
- Otherwise, if \`contextFiles.design\` is absent or empty: mark **Design Adherence** as not verified. With other supporting artifacts, **Code Pattern Consistency** still runs; the task-only case remains limited to task completion.
|
||||
- Otherwise, if the design artifact's \`contextFiles.<id>\` file is absent or empty: mark **Design Adherence** as not verified. With other supporting artifacts, **Code Pattern Consistency** still runs; the task-only case remains limited to task completion.
|
||||
|
||||
**Code Pattern Consistency**:
|
||||
- If implementation changes cannot be identified, mark **Code Pattern Consistency** as not verified and explain the missing evidence.
|
||||
@@ -166,7 +169,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
| Coherence | Followed/Issues |
|
||||
\`\`\`
|
||||
|
||||
In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
|
||||
In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified", or "1 removal not verified (baseline unavailable)"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
|
||||
|
||||
**Issues by Priority**:
|
||||
|
||||
@@ -294,9 +297,9 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
||||
|
||||
**Spec Coverage**:
|
||||
- If status marks the spec artifact skipped by \`skip_specs: true\`, or the schema defines no spec artifact, report the spec-dependent checks as not applicable.
|
||||
- Otherwise, \`contextFiles\` is keyed by artifact id, and artifact ids come from the active schema. If \`contextFiles.specs\` is absent or empty, mark **Spec Coverage**, **Requirement Implementation Mapping**, and **Scenario Coverage** as not verified; do not treat any of them as clean.
|
||||
- If delta specs exist in \`contextFiles.specs\`:
|
||||
- If status marks the spec artifact skipped by \`skip_specs: true\`, or the schema defines no spec artifact (no artifact whose \`artifactPaths.<id>.outputPath\` is under \`specs/\`), report the spec-dependent checks as not applicable.
|
||||
- Otherwise, \`contextFiles\` is keyed by artifact id, and artifact ids come from the active schema, so do not assume an id such as \`specs\`. The spec artifacts are those whose \`artifactPaths.<id>.outputPath\` is under \`specs/\`; read their files from \`contextFiles.<id>\`. If those spec files are absent or empty, mark **Spec Coverage**, **Requirement Implementation Mapping**, and **Scenario Coverage** as not verified; do not treat any of them as clean.
|
||||
- If delta specs exist in those spec files:
|
||||
- Extract all requirements (marked with "### Requirement:", or listed as \`FROM:\`/\`TO:\` pairs under \`## RENAMED Requirements\`) and note the delta section each one sits under: \`## ADDED\`, \`## MODIFIED\`, \`## REMOVED\`, or \`## RENAMED Requirements\`. The section decides what the check looks for.
|
||||
- For each ADDED or MODIFIED requirement (for MODIFIED, check the text in the delta, not the old wording):
|
||||
- Search codebase for keywords related to the requirement
|
||||
@@ -305,8 +308,11 @@ ${PROJECT_ROOT_GUARD}
|
||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
||||
- Recommendation: "Implement requirement X: <description>"
|
||||
- For each REMOVED requirement, the change asks for the behavior to be gone, so invert the check:
|
||||
- Search codebase for the removed behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
||||
- Finding no implementation is the expected result. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
||||
- A REMOVED block carries only the requirement name and any \`**Reason**\`/\`**Migration**\` notes, so the delta does not describe the behavior itself. Before searching, read the baseline requirement in the main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\`, using the same capability path as the delta spec. Match it by normalized name, scanning that file's requirement headings rather than string-matching the delta's exact title. Its body and scenarios are the evidence for the behavior that must be gone; never search from the requirement name alone.
|
||||
- If that file holds no such requirement under this or a near-equivalent name - for example because an earlier sync already applied the removal - recover the baseline from the same file's history, in the planning repository rather than the current directory: \`git -C "<planningHome.root>" log -p -- openspec/specs/<capability-path>/spec.md\`, or \`git -C "<planningHome.root>" show <commit>^:./openspec/specs/<capability-path>/spec.md\` once you have identified the commit that applied the removal. Empty output means the pathspec matched nothing, not that there is no baseline: \`git log\` reports an unmatched path as success, so re-check the path before drawing any conclusion from silence.
|
||||
- A baseline that adds nothing to its own title - no body beyond a restatement of the name, no scenarios - is unusable evidence, the same as a missing one. If no usable baseline is available in either place, mark **Spec Coverage** as not verified for that entry with the reason and stop there: do not search, and never count an unsearched removal as passing.
|
||||
- Search codebase for that behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
|
||||
- Finding no implementation is the expected result, but only once the baseline behavior was the thing you searched for. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
|
||||
- If the behavior is still present:
|
||||
- Add CRITICAL issue: "Removed requirement still implemented: <requirement name>"
|
||||
- Recommendation: "Remove the remaining implementation at <file>:<lines>, following the requirement's Migration note if it has one"
|
||||
@@ -322,7 +328,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
|
||||
6. **Verify Correctness**
|
||||
|
||||
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each RENAMED entry is checked there against its baseline behavior), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
||||
If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each REMOVED and RENAMED entry with a usable baseline is checked there against its baseline behavior, and an entry already marked not verified there stays not verified), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
|
||||
|
||||
**Requirement Implementation Mapping**:
|
||||
- For each ADDED or MODIFIED requirement from delta specs (REMOVED entries, and RENAMED entries without a MODIFIED block, were settled under Spec Coverage):
|
||||
@@ -345,14 +351,14 @@ ${PROJECT_ROOT_GUARD}
|
||||
7. **Verify Coherence**
|
||||
|
||||
**Design Adherence**:
|
||||
- If the schema defines no design artifact, report **Design Adherence** as not applicable.
|
||||
- If \`contextFiles.design\` exists:
|
||||
- If the schema defines no design artifact (no artifact with id \`design\`, and none whose \`artifactPaths.<id>.outputPath\` is or ends in \`design.md\`), report **Design Adherence** as not applicable.
|
||||
- If the design artifact's \`contextFiles.<id>\` file exists:
|
||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
||||
- Verify implementation follows those decisions
|
||||
- If contradiction detected:
|
||||
- Add WARNING: "Design decision not followed: <decision>"
|
||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
||||
- Otherwise, if \`contextFiles.design\` is absent or empty: mark **Design Adherence** as not verified. With other supporting artifacts, **Code Pattern Consistency** still runs; the task-only case remains limited to task completion.
|
||||
- Otherwise, if the design artifact's \`contextFiles.<id>\` file is absent or empty: mark **Design Adherence** as not verified. With other supporting artifacts, **Code Pattern Consistency** still runs; the task-only case remains limited to task completion.
|
||||
|
||||
**Code Pattern Consistency**:
|
||||
- If implementation changes cannot be identified, mark **Code Pattern Consistency** as not verified and explain the missing evidence.
|
||||
@@ -376,7 +382,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
| Coherence | Followed/Issues |
|
||||
\`\`\`
|
||||
|
||||
In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
|
||||
In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified", or "1 removal not verified (baseline unavailable)"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
|
||||
|
||||
**Issues by Priority**:
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
extractRequirementsSection,
|
||||
diffScenarioNames,
|
||||
describeScenarioBalance,
|
||||
describeUnappliedContent,
|
||||
type RequirementBlock,
|
||||
} from '../parsers/requirement-blocks.js';
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from '../parsers/requirement-text.js';
|
||||
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { assertDiscoveredSpecFile } from '../../utils/spec-path-containment.js';
|
||||
import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
|
||||
import {
|
||||
METADATA_FILENAME,
|
||||
@@ -257,6 +259,17 @@ export class Validator {
|
||||
});
|
||||
}
|
||||
|
||||
// Archive carries requirement blocks only, by design. Warn so text
|
||||
// written elsewhere is moved rather than lost without notice.
|
||||
for (const lost of plan.unappliedContent) {
|
||||
issues.push({
|
||||
level: 'WARNING',
|
||||
path: entryPath,
|
||||
line: lost.line,
|
||||
message: `${describeUnappliedContent(lost, true)} is not carried into the main spec by archive. Move it into a requirement, or into proposal.md or design.md.`,
|
||||
});
|
||||
}
|
||||
|
||||
const sectionNames: string[] = [];
|
||||
if (plan.sectionPresence.added) sectionNames.push('## ADDED Requirements');
|
||||
if (plan.sectionPresence.modified) sectionNames.push('## MODIFIED Requirements');
|
||||
@@ -304,6 +317,17 @@ export class Validator {
|
||||
),
|
||||
});
|
||||
}
|
||||
// Same limit the main spec enforces after archive (#1976), so
|
||||
// `validate <change> --strict` catches a new overlong requirement
|
||||
// before it lands. MODIFIED is left alone: its text is the existing
|
||||
// requirement, which the specs instruction says to keep whole.
|
||||
if (requirementText && requirementText.length > MAX_REQUIREMENT_TEXT_LENGTH) {
|
||||
issues.push({
|
||||
level: 'WARNING',
|
||||
path: entryPath,
|
||||
message: `ADDED "${block.name}": ${VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG}`,
|
||||
});
|
||||
}
|
||||
const scenarioCount = this.countScenarios(block.raw);
|
||||
if (scenarioCount < 1) {
|
||||
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
|
||||
@@ -353,14 +377,13 @@ export class Validator {
|
||||
...specId.split('/'),
|
||||
'spec.md'
|
||||
);
|
||||
FileSystemUtils.assertPathWithin(path.dirname(mainSpecFile), mainSpecFile);
|
||||
issues.push(
|
||||
...(await this.findScenarioLossIssues(
|
||||
plan.modified,
|
||||
plan.renamed,
|
||||
mainSpecFile,
|
||||
entryPath,
|
||||
path.dirname(mainSpecFile)
|
||||
options.mainSpecsDir
|
||||
))
|
||||
);
|
||||
}
|
||||
@@ -687,7 +710,7 @@ export class Validator {
|
||||
mainSpecRoot: string
|
||||
): Promise<ValidationIssue[]> {
|
||||
let mainContent: string;
|
||||
FileSystemUtils.assertPathWithin(mainSpecRoot, mainSpecFile);
|
||||
assertDiscoveredSpecFile(mainSpecRoot, mainSpecFile);
|
||||
try {
|
||||
mainContent = await fs.readFile(mainSpecFile, 'utf-8');
|
||||
} catch (error) {
|
||||
@@ -820,7 +843,7 @@ export class Validator {
|
||||
spec.requirements.forEach((req, index) => {
|
||||
if (req.text.length > MAX_REQUIREMENT_TEXT_LENGTH) {
|
||||
issues.push({
|
||||
level: 'INFO',
|
||||
level: 'WARNING',
|
||||
path: `requirements[${index}]`,
|
||||
message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG,
|
||||
});
|
||||
|
||||
+3
-29
@@ -68,15 +68,6 @@ export class ViewCommand {
|
||||
});
|
||||
}
|
||||
|
||||
// Display archived changes
|
||||
if (changesData.archived.length > 0) {
|
||||
console.log(chalk.bold.gray('\nArchived Changes'));
|
||||
console.log('─'.repeat(60));
|
||||
changesData.archived.forEach((change) => {
|
||||
console.log(chalk.gray(` ◦ ${change.name}`));
|
||||
});
|
||||
}
|
||||
|
||||
// Display specifications
|
||||
if (specsData.length > 0) {
|
||||
console.log(chalk.bold.blue('\nSpecifications'));
|
||||
@@ -101,32 +92,17 @@ export class ViewCommand {
|
||||
draft: Array<{ name: string }>;
|
||||
active: Array<{ name: string; progress: { total: number; completed: number }; workflowStatus?: ChangeStatus }>;
|
||||
completed: Array<{ name: string }>;
|
||||
archived: Array<{ name: string }>;
|
||||
}> {
|
||||
const changesDir = path.join(openspecDir, 'changes');
|
||||
const projectRoot = path.dirname(openspecDir);
|
||||
|
||||
if (!fs.existsSync(changesDir)) {
|
||||
return { draft: [], active: [], completed: [], archived: [] };
|
||||
return { draft: [], active: [], completed: [] };
|
||||
}
|
||||
|
||||
const draft: Array<{ name: string }> = [];
|
||||
const active: Array<{ name: string; progress: { total: number; completed: number }; workflowStatus?: ChangeStatus }> = [];
|
||||
const completed: Array<{ name: string }> = [];
|
||||
let archived: Array<{ name: string }> = [];
|
||||
|
||||
try {
|
||||
archived = fs.readdirSync(path.join(changesDir, 'archive'), { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
||||
.map((entry) => ({ name: entry.name }));
|
||||
} catch (error) {
|
||||
// A missing archive, or an `archive` path that is a file, has no archived
|
||||
// changes to show; neither should break the rest of the dashboard.
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(changesDir, { withFileTypes: true });
|
||||
|
||||
@@ -169,9 +145,8 @@ export class ViewCommand {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
completed.sort((a, b) => a.name.localeCompare(b.name));
|
||||
archived.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { draft, active, completed, archived };
|
||||
return { draft, active, completed };
|
||||
}
|
||||
|
||||
private async getSpecsData(openspecDir: string): Promise<Array<{ name: string; requirementCount: number }>> {
|
||||
@@ -200,7 +175,7 @@ export class ViewCommand {
|
||||
}
|
||||
|
||||
private displaySummary(
|
||||
changesData: { draft: any[]; active: any[]; completed: any[]; archived: any[] },
|
||||
changesData: { draft: any[]; active: any[]; completed: any[] },
|
||||
specsData: any[]
|
||||
): void {
|
||||
const totalChanges =
|
||||
@@ -233,7 +208,6 @@ export class ViewCommand {
|
||||
` ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress`
|
||||
);
|
||||
console.log(` ${chalk.green('●')} Completed Changes: ${chalk.bold(changesData.completed.length)}`);
|
||||
console.log(` ${chalk.gray('●')} Archived Changes: ${chalk.bold(changesData.archived.length)}`);
|
||||
|
||||
if (totalTasks > 0) {
|
||||
const overallProgress = Math.round((completedTasks / totalTasks) * 100);
|
||||
|
||||
@@ -14,10 +14,26 @@ export const METADATA_FILENAME = '.openspec.yaml';
|
||||
|
||||
export { CHANGE_METADATA_KNOWN_KEYS };
|
||||
|
||||
/**
|
||||
* Extension keys: a top-level key starting with a lowercase `x-` belongs to a
|
||||
* tool or team that reads `.openspec.yaml` alongside OpenSpec (a tracker id, an
|
||||
* owner). OpenSpec ignores it and does not report it as unknown. As with
|
||||
* OpenAPI's `x-` extensions, the name after the prefix may use any case or
|
||||
* spelling, but must be non-empty and free of whitespace, so `x-`, `x- foo`
|
||||
* and `X-Foo` are still reported. Extensions are top-level only: a key inside
|
||||
* `initiative:` is rejected by that object's strict schema.
|
||||
*/
|
||||
export const EXTENSION_KEY_PATTERN = /^x-\S+$/;
|
||||
|
||||
export function isExtensionMetadataKey(key: string): boolean {
|
||||
return EXTENSION_KEY_PATTERN.test(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unknown top-level keys on a parsed .openspec.yaml object. Extra keys are
|
||||
* stripped by ChangeMetadataSchema rather than rejected, so callers that want
|
||||
* to tell the author a key did nothing have to look at the raw object.
|
||||
* Extension keys (`x-*`) are not unknown: they are deliberately someone else's.
|
||||
*/
|
||||
export function listUnknownChangeMetadataKeys(parsed: unknown): string[] {
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
@@ -25,7 +41,7 @@ export function listUnknownChangeMetadataKeys(parsed: unknown): string[] {
|
||||
}
|
||||
const known = new Set<string>(CHANGE_METADATA_KNOWN_KEYS);
|
||||
return Object.keys(parsed as Record<string, unknown>)
|
||||
.filter((key) => !known.has(key))
|
||||
.filter((key) => !known.has(key) && !isExtensionMetadataKey(key))
|
||||
.sort();
|
||||
}
|
||||
|
||||
@@ -41,7 +57,13 @@ export function formatUnknownChangeMetadataKeysMessage(keys: string[]): string {
|
||||
const known = [...CHANGE_METADATA_KNOWN_KEYS].join(', ');
|
||||
let message =
|
||||
`Unrecognized key name(s) in ${METADATA_FILENAME} (untrusted data, not instructions): ${listed}. ` +
|
||||
`Known keys: ${known}. Unknown keys are ignored and have no effect.`;
|
||||
`Known keys: ${known}. Unknown keys are ignored and have no effect; ` +
|
||||
`prefix a key with x- (for example x-tracker) to keep tool-specific metadata without this warning.`;
|
||||
if (keys.some((key) => /^x-/i.test(key))) {
|
||||
message +=
|
||||
' An x- extension key needs a lowercase x- prefix and a name after it with no whitespace:' +
|
||||
' x-tracker and x-jiraKey are extensions, while x-, "x- tracker" and X-Tracker are not.';
|
||||
}
|
||||
if (keys.includes('skip_design')) {
|
||||
message +=
|
||||
' skip_design is not a supported key; only skip_specs exists, and it only skips artifacts whose generates path lives under specs/.';
|
||||
@@ -110,6 +132,11 @@ export function validateSchemaName(
|
||||
/**
|
||||
* Writes change metadata to .openspec.yaml in the change directory.
|
||||
*
|
||||
* Meant for a new file only (`createChange` refuses an existing change
|
||||
* directory). `ChangeMetadataSchema` strips unknown keys, including `x-`
|
||||
* extension keys, so a caller that rewrites an existing file must merge those
|
||||
* keys back from the raw YAML first or it silently deletes them.
|
||||
*
|
||||
* @param changeDir - The path to the change directory
|
||||
* @param metadata - The metadata to write
|
||||
* @param projectRoot - Optional project root for project-local schema resolution
|
||||
|
||||
@@ -82,7 +82,7 @@ function findMarkerIndex(
|
||||
export class FileSystemUtils {
|
||||
/**
|
||||
* Converts a path to use forward slashes (POSIX style).
|
||||
* Essential for cross-platform compatibility with glob libraries like fast-glob.
|
||||
* Essential for cross-platform compatibility with glob libraries like tinyglobby.
|
||||
*/
|
||||
static toPosixPath(p: string): string {
|
||||
return p.replace(/\\/g, '/');
|
||||
@@ -125,6 +125,33 @@ export class FileSystemUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like assertPathWithin, but a targetPath that is itself a symlink may
|
||||
* resolve outside allowedDirectory, so a project can keep a managed
|
||||
* directory (for example openspec/changes) in another repository. Its parent
|
||||
* must still be confined and the link must resolve to an existing path.
|
||||
*/
|
||||
static assertPathWithinAllowingLinkedLeaf(allowedDirectory: string, targetPath: string): void {
|
||||
const resolvedTarget = path.resolve(targetPath);
|
||||
let isLink = false;
|
||||
try {
|
||||
isLink = nodeFs.lstatSync(resolvedTarget).isSymbolicLink();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLink) {
|
||||
this.assertPathWithin(allowedDirectory, resolvedTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
this.assertPathWithin(allowedDirectory, path.dirname(resolvedTarget));
|
||||
// Throws for a dangling link, which cannot be proven to be a directory.
|
||||
nodeFs.realpathSync.native(resolvedTarget);
|
||||
}
|
||||
|
||||
static resolveProjectArtifactPath(projectPath: string, artifactPath: string): string {
|
||||
if (path.isAbsolute(artifactPath)) {
|
||||
throw new Error(`Refusing to manage an artifact outside the project: ${artifactPath}`);
|
||||
|
||||
@@ -176,3 +176,22 @@ function readYesNo(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a spinner can safely render to `stream`.
|
||||
*
|
||||
* ora decides how many lines to erase with `ceil(width / stream.columns)`, so a
|
||||
* TTY that reports zero columns makes that count `Infinity` and `clear()` never
|
||||
* ends: the spinner emits cursor-up/clear-line escapes as fast as it can and the
|
||||
* command never finishes (#2106). Zero-width TTYs are real — a pty inherited
|
||||
* from a pre-commit runner such as lefthook, or `script -qec` — and nothing can
|
||||
* be drawn in zero columns anyway, so refuse the spinner and let the caller fall
|
||||
* back to its plain output.
|
||||
*/
|
||||
export function canRenderSpinner(
|
||||
stream: { isTTY?: boolean; columns?: number } = process.stderr
|
||||
): boolean {
|
||||
// A non-TTY stream never enters the clear loop: ora disables itself and
|
||||
// prints one plain line instead.
|
||||
if (!stream.isTTY) return true;
|
||||
return (stream.columns ?? 0) > 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { FileSystemUtils } from './file-system.js';
|
||||
import { assertDiscoveredSpecFile } from './spec-path-containment.js';
|
||||
import { parseDeltaSpec } from '../core/parsers/requirement-blocks.js';
|
||||
|
||||
export interface DiscoveredSpec {
|
||||
@@ -10,26 +10,16 @@ export interface DiscoveredSpec {
|
||||
specFile: string;
|
||||
}
|
||||
|
||||
function assertDiscoveredSpecPath(specsRoot: string, capabilityDir: string, specFile: string): void {
|
||||
try {
|
||||
FileSystemUtils.assertPathWithin(specsRoot, specFile);
|
||||
} catch {
|
||||
// Direct capability directories may intentionally be external monorepo
|
||||
// links. In that case, confine the file to the capability itself.
|
||||
FileSystemUtils.assertPathWithin(capabilityDir, specFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively discover every `spec.md` under a specs root, so both the flat
|
||||
* `specs/<id>/spec.md` layout and nested `specs/<area>/<id>/spec.md` layouts
|
||||
* are found (#1353). A `spec.md` sitting directly in the root is ignored,
|
||||
* matching the historical requirement that specs live in a capability folder.
|
||||
* Dot-directories are skipped and symlinked directories are not followed.
|
||||
* An in-capability symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the
|
||||
* artifact graph's globs both count it as content, so dropping it here would
|
||||
* silently lose the delta on archive. A link outside its capability is
|
||||
* rejected and a dangling link is skipped. Results are sorted by id.
|
||||
* A symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the artifact
|
||||
* graph's globs both count it as content, so dropping it here would silently
|
||||
* lose the delta on archive. `assertSpecFilePathAllowed` decides how far such
|
||||
* a link may point; a dangling link is skipped. Results are sorted by id.
|
||||
*
|
||||
* A missing root (ENOENT) yields an empty list, but any other read failure
|
||||
* (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the
|
||||
@@ -53,12 +43,12 @@ export async function discoverSpecFiles(specsRoot: string): Promise<DiscoveredSp
|
||||
} else if (entry.name === 'spec.md' && segments.length > 0) {
|
||||
const specFile = path.join(dir, entry.name);
|
||||
if (entry.isFile()) {
|
||||
assertDiscoveredSpecPath(specsRoot, dir, specFile);
|
||||
assertDiscoveredSpecFile(specsRoot, specFile);
|
||||
results.push({ id: segments.join('/'), specFile });
|
||||
} else if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
if ((await fs.stat(specFile)).isFile()) {
|
||||
assertDiscoveredSpecPath(specsRoot, dir, specFile);
|
||||
assertDiscoveredSpecFile(specsRoot, specFile);
|
||||
results.push({ id: segments.join('/'), specFile });
|
||||
}
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as nodeFs from 'fs';
|
||||
import path from 'path';
|
||||
import { OPENSPEC_DIR_NAME } from '../core/config.js';
|
||||
import { FileSystemUtils } from './file-system.js';
|
||||
|
||||
/**
|
||||
* The project a specs root belongs to, or null when it cannot be named.
|
||||
*
|
||||
* Every specs root OpenSpec reads is addressed through the project's
|
||||
* `openspec` directory - `<project>/openspec/specs` for main specs and
|
||||
* `<project>/openspec/changes/<change>/specs` (or `.../changes/archive/...`)
|
||||
* for a change's deltas - so the project is the parent of that `openspec`
|
||||
* directory.
|
||||
*
|
||||
* The directory below the match has to be `specs` or `changes`, which is what
|
||||
* makes this a planning root rather than a directory that merely shares the
|
||||
* name: a store kept at `~/openspec/my-store` would otherwise hand out `~` as
|
||||
* its boundary. A specs root reached some other way (a store given by path)
|
||||
* has no project to name, and containment stays at the specs root.
|
||||
*/
|
||||
function findProjectRoot(specsRoot: string): string | null {
|
||||
let current = path.resolve(specsRoot);
|
||||
let below: string | null = null;
|
||||
while (true) {
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
path.basename(current) === OPENSPEC_DIR_NAME &&
|
||||
(below === 'specs' || below === 'changes')
|
||||
) {
|
||||
return parent;
|
||||
}
|
||||
below = path.basename(current);
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/** Path-segment containment on the paths as written, without resolving links. */
|
||||
function isLexicallyWithin(allowedDirectory: string, targetPath: string): boolean {
|
||||
const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
|
||||
return (
|
||||
relative === '' ||
|
||||
(relative !== '..' &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A project may keep a capability's spec file elsewhere - beside the product
|
||||
* documentation it belongs to - and leave a relative symlink at
|
||||
* `openspec/specs/<capability>/spec.md`, so OpenSpec and the docs read and
|
||||
* write the same bytes. Accept such a link, and refuse one that leaves the
|
||||
* project, so a link swapped underneath a repo cannot turn a spec read or an
|
||||
* archive write into an arbitrary file access.
|
||||
*
|
||||
* `outsideError` is rethrown when the file is not a link at all, so a path
|
||||
* that simply escapes its root keeps reporting that.
|
||||
*/
|
||||
function assertLinkedSpecFileInProject(
|
||||
root: string,
|
||||
specFile: string,
|
||||
outsideError: unknown
|
||||
): void {
|
||||
const projectRoot = findProjectRoot(root);
|
||||
if (projectRoot === null) {
|
||||
throw outsideError;
|
||||
}
|
||||
|
||||
const resolvedFile = path.resolve(specFile);
|
||||
let isLink = false;
|
||||
try {
|
||||
isLink = nodeFs.lstatSync(resolvedFile).isSymbolicLink();
|
||||
} catch {
|
||||
isLink = false;
|
||||
}
|
||||
if (!isLink) {
|
||||
throw outsideError;
|
||||
}
|
||||
|
||||
// Only where the link points is the project's choice. The directory holding
|
||||
// it must still be reached from inside the root.
|
||||
FileSystemUtils.assertPathWithin(root, path.dirname(resolvedFile));
|
||||
// Canonicalizes through the link, so a target outside the project is refused
|
||||
// and a dangling link - which cannot be proven contained - fails loudly.
|
||||
FileSystemUtils.assertPathWithin(projectRoot, resolvedFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Containment for a `spec.md` under a trust root already established for it:
|
||||
* inside the root, or a link out of the root that stays inside the project.
|
||||
*
|
||||
* Deliberately does not re-derive a trust root from the file's own capability
|
||||
* directory. Callers use this to recheck a path immediately before reading or
|
||||
* writing it, and a capability directory swapped for a link since the path was
|
||||
* resolved has to be refused rather than trusted afresh.
|
||||
*/
|
||||
export function assertSpecFileWithin(root: string, specFile: string): void {
|
||||
try {
|
||||
FileSystemUtils.assertPathWithin(root, specFile);
|
||||
} catch (error) {
|
||||
assertLinkedSpecFileInProject(root, specFile, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Containment for a `spec.md` as it is discovered, where the capability
|
||||
* directory itself may deliberately be a link into another repository and is
|
||||
* then the file's own trust root.
|
||||
*/
|
||||
export function assertDiscoveredSpecFile(specsRoot: string, specFile: string): void {
|
||||
const capabilityDir = path.dirname(path.resolve(specFile));
|
||||
try {
|
||||
FileSystemUtils.assertPathWithin(specsRoot, specFile);
|
||||
return;
|
||||
} catch (error) {
|
||||
// The capability directory is the file's own trust root only while it is
|
||||
// addressed from inside the specs root: the link it may be is deliberate,
|
||||
// a `..` out of the root is not.
|
||||
if (isLexicallyWithin(specsRoot, capabilityDir)) {
|
||||
try {
|
||||
FileSystemUtils.assertPathWithin(capabilityDir, specFile);
|
||||
return;
|
||||
} catch {
|
||||
// Not a file of its own capability either; the link rule decides.
|
||||
}
|
||||
}
|
||||
assertLinkedSpecFileInProject(specsRoot, specFile, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { runCLI } from '../helpers/run-cli.js';
|
||||
|
||||
/**
|
||||
* The layout reported in #2100: a project keeps its main specs beside the
|
||||
* product documentation they belong to and links
|
||||
* `openspec/specs/<capability>/spec.md` to them, so OpenSpec and the docs read
|
||||
* and write the same bytes.
|
||||
*
|
||||
* Driven through the real CLI on purpose. The regression this guards against
|
||||
* was introduced by a containment check several layers down, and the unit
|
||||
* tests of the day could not see it because their fixtures placed "outside the
|
||||
* specs root" and "outside the project" at the same path. Exercising the
|
||||
* commands a user actually runs does not care which layer the boundary moves
|
||||
* in: it fails if any of them stops accepting the link, or starts accepting a
|
||||
* link that leaves the project.
|
||||
*/
|
||||
const describeWithSymlinks = describe.skipIf(process.platform === 'win32');
|
||||
|
||||
describeWithSymlinks('a main spec that is a symlink into the project (#2100)', () => {
|
||||
let base: string;
|
||||
let projectDir: string;
|
||||
let docsSpec: string;
|
||||
let linkedSpec: string;
|
||||
|
||||
const MAIN_SPEC = [
|
||||
'# login Specification',
|
||||
'',
|
||||
'## Purpose',
|
||||
'Users sign in with their credentials to reach their account.',
|
||||
'',
|
||||
'## Requirements',
|
||||
'',
|
||||
'### Requirement: Sign in',
|
||||
'The system SHALL let a user sign in with valid credentials.',
|
||||
'',
|
||||
'#### Scenario: Valid credentials',
|
||||
'- **WHEN** the user submits valid credentials',
|
||||
'- **THEN** the user is signed in',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const DELTA = [
|
||||
'## ADDED Requirements',
|
||||
'',
|
||||
'### Requirement: Second factor',
|
||||
'The system SHALL require a second factor.',
|
||||
'',
|
||||
'#### Scenario: Prompted',
|
||||
'- **WHEN** a user signs in',
|
||||
'- **THEN** a second factor is requested',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const write = async (relative: string, content: string) => {
|
||||
const file = path.join(projectDir, relative);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, content);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// The project sits one level below the temp root, so a link written to the
|
||||
// temp root genuinely leaves the project.
|
||||
base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-spec-symlink-e2e-'));
|
||||
projectDir = path.join(base, 'repo');
|
||||
docsSpec = path.join(projectDir, 'docs', 'auth', 'specs', 'login.md');
|
||||
linkedSpec = path.join(projectDir, 'openspec', 'specs', 'login', 'spec.md');
|
||||
|
||||
await write(path.join('docs', 'auth', 'specs', 'login.md'), MAIN_SPEC);
|
||||
await write(path.join('openspec', 'changes', 'add-mfa', 'proposal.md'),
|
||||
'# Add MFA\n\n## Why\nAccounts need a second factor.\n\n## What Changes\n- Require a second factor\n');
|
||||
await write(path.join('openspec', 'changes', 'add-mfa', 'tasks.md'), '## 1. Work\n- [x] 1.1 Ship it\n');
|
||||
await write(path.join('openspec', 'changes', 'add-mfa', 'specs', 'login', 'spec.md'), DELTA);
|
||||
await fs.mkdir(path.dirname(linkedSpec), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const linkInto = (target: string) =>
|
||||
fs.symlink(path.relative(path.dirname(linkedSpec), target), linkedSpec);
|
||||
|
||||
describe('when the link stays inside the project', () => {
|
||||
beforeEach(async () => {
|
||||
await linkInto(docsSpec);
|
||||
});
|
||||
|
||||
it('lists the capability', async () => {
|
||||
const result = await runCLI(['list', '--specs'], { cwd: projectDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('login');
|
||||
});
|
||||
|
||||
it('shows the linked file as the spec', async () => {
|
||||
const result = await runCLI(['show', 'login', '--type', 'spec'], { cwd: projectDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Users sign in with their credentials');
|
||||
});
|
||||
|
||||
it('validates the capability, by id and as part of --specs', async () => {
|
||||
const byId = await runCLI(['validate', 'login', '--type', 'spec'], { cwd: projectDir });
|
||||
expect(byId.exitCode).toBe(0);
|
||||
|
||||
const all = await runCLI(['validate', '--specs'], { cwd: projectDir });
|
||||
expect(all.exitCode).toBe(0);
|
||||
expect(`${all.stdout}${all.stderr}`).not.toContain('outside the allowed directory');
|
||||
});
|
||||
|
||||
it('archives through the link, leaving the link and the docs file in place', async () => {
|
||||
const result = await runCLI(['archive', 'add-mfa', '--yes'], { cwd: projectDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
// The merge landed in the file the docs own, and kept what was there.
|
||||
const merged = await fs.readFile(docsSpec, 'utf-8');
|
||||
expect(merged).toContain('Second factor');
|
||||
expect(merged).toContain('Sign in');
|
||||
// ...by writing through the link rather than replacing it.
|
||||
expect((await fs.lstat(linkedSpec)).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the link leaves the project', () => {
|
||||
let outside: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
outside = path.join(base, 'outside.md');
|
||||
await fs.writeFile(outside, MAIN_SPEC);
|
||||
await linkInto(outside);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['list', ['list', '--specs']],
|
||||
['show', ['show', 'login', '--type', 'spec']],
|
||||
['validate by id', ['validate', 'login', '--type', 'spec']],
|
||||
['validate --specs', ['validate', '--specs']],
|
||||
])('refuses it in %s', async (_label, args) => {
|
||||
const result = await runCLI(args, { cwd: projectDir });
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(`${result.stdout}${result.stderr}`).toContain('outside the allowed directory');
|
||||
});
|
||||
|
||||
it('refuses to archive, and writes nothing', async () => {
|
||||
const result = await runCLI(['archive', 'add-mfa', '--yes'], { cwd: projectDir });
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(`${result.stdout}${result.stderr}`).toContain('outside the allowed directory');
|
||||
await expect(fs.readFile(outside, 'utf-8')).resolves.toBe(MAIN_SPEC);
|
||||
// The change is still there to retry once the link is corrected.
|
||||
await expect(
|
||||
fs.access(path.join(projectDir, 'openspec', 'changes', 'add-mfa'))
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { cliProjectRoot, ensureCliBuilt } from '../helpers/run-cli.js';
|
||||
|
||||
// Every CLI call pays for the modules it loads before the command runs, and
|
||||
// callers such as editors and agents run the CLI many times. A command loads
|
||||
// only the command definitions (names, options, help text) and its own
|
||||
// implementation; everything else loads on demand.
|
||||
|
||||
const binPath = path.join(cliProjectRoot, 'bin', 'openspec.js');
|
||||
const hookUrl = pathToFileURL(
|
||||
path.join(cliProjectRoot, 'test', 'helpers', 'record-loaded-modules.mjs')
|
||||
).href;
|
||||
|
||||
// The module that implements each command, relative to dist/.
|
||||
const COMMAND_IMPLEMENTATIONS: Record<string, string[]> = {
|
||||
init: ['core/init.js'],
|
||||
update: ['core/update.js'],
|
||||
list: ['core/list.js'],
|
||||
view: ['core/view.js'],
|
||||
archive: ['core/archive.js'],
|
||||
change: ['commands/change.js'],
|
||||
spec: ['commands/spec.js'],
|
||||
validate: ['commands/validate.js'],
|
||||
show: ['commands/show.js'],
|
||||
feedback: ['commands/feedback.js'],
|
||||
completion: ['commands/completion.js'],
|
||||
config: ['commands/config.js'],
|
||||
schema: ['commands/schema.js'],
|
||||
store: ['commands/store.js'],
|
||||
doctor: ['commands/doctor.js'],
|
||||
context: ['commands/context.js'],
|
||||
workset: ['commands/workset.js'],
|
||||
status: ['commands/workflow/status.js'],
|
||||
instructions: ['commands/workflow/instructions.js'],
|
||||
templates: ['commands/workflow/templates.js'],
|
||||
schemas: ['commands/workflow/schemas.js'],
|
||||
new: ['commands/workflow/new-change.js'],
|
||||
};
|
||||
|
||||
const workDirs: string[] = [];
|
||||
|
||||
afterAll(() => {
|
||||
for (const dir of workDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function loadedModules(args: string[]): {
|
||||
own: string[];
|
||||
packages: Set<string>;
|
||||
status: number | null;
|
||||
} {
|
||||
const workDir = mkdtempSync(path.join(os.tmpdir(), 'openspec-startup-'));
|
||||
workDirs.push(workDir);
|
||||
const logFile = path.join(workDir, 'modules.log');
|
||||
const result = spawnSync(process.execPath, ['--import', hookUrl, binPath, ...args], {
|
||||
cwd: workDir,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENSPEC_TELEMETRY: '0',
|
||||
OPEN_SPEC_INTERACTIVE: '0',
|
||||
XDG_CONFIG_HOME: path.join(workDir, 'config'),
|
||||
XDG_DATA_HOME: path.join(workDir, 'data'),
|
||||
OPENSPEC_TEST_MODULE_LOG: logFile,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
// Loaded module URLs use real paths, so compare against the real dist/ path
|
||||
// in case the checkout path goes through a symlink or a Windows short name.
|
||||
const distRoot = realpathSync.native(path.join(cliProjectRoot, 'dist'));
|
||||
|
||||
const urls = readFileSync(logFile, 'utf-8').split('\n').filter(Boolean);
|
||||
const own: string[] = [];
|
||||
const packages = new Set<string>();
|
||||
for (const url of urls) {
|
||||
if (!url.startsWith('file:')) continue;
|
||||
const file = fileURLToPath(url);
|
||||
const segments = file.split(path.sep);
|
||||
const lastNodeModules = segments.lastIndexOf('node_modules');
|
||||
if (lastNodeModules !== -1) {
|
||||
const name = segments[lastNodeModules + 1];
|
||||
packages.add(name.startsWith('@') ? `${name}/${segments[lastNodeModules + 2]}` : name);
|
||||
} else if (file.startsWith(distRoot + path.sep)) {
|
||||
own.push(path.relative(distRoot, file).split(path.sep).join('/'));
|
||||
}
|
||||
}
|
||||
// An empty list means the paths didn't match, and every absence check
|
||||
// below would pass without checking anything.
|
||||
expect(own).not.toEqual([]);
|
||||
return { own, packages, status: result.status };
|
||||
}
|
||||
|
||||
function implementationsLoaded(own: string[]): string[] {
|
||||
return Object.entries(COMMAND_IMPLEMENTATIONS)
|
||||
.filter(([, modules]) => modules.some((module) => own.includes(module)))
|
||||
.map(([command]) => command);
|
||||
}
|
||||
|
||||
describe('CLI startup loads only what the command needs', () => {
|
||||
it.each(['--version', '--help', 'validate --help'])(
|
||||
'`openspec %s` loads the command definitions and nothing else',
|
||||
async (invocation) => {
|
||||
await ensureCliBuilt();
|
||||
const { own, packages, status } = loadedModules(invocation.split(' '));
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect([...packages]).toEqual(['commander']);
|
||||
expect(implementationsLoaded(own)).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
// Shell completion scripts run `__complete` on every Tab press. It needs no
|
||||
// package beyond commander, and never telemetry: tracking would hold each
|
||||
// press open for the request.
|
||||
it.each(['changes', 'specs'])(
|
||||
'`openspec __complete %s` loads only commander and no telemetry',
|
||||
async (type) => {
|
||||
await ensureCliBuilt();
|
||||
const { own, packages, status } = loadedModules(['__complete', type]);
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect([...packages]).toEqual(['commander']);
|
||||
expect(implementationsLoaded(own)).toEqual(['completion']);
|
||||
expect(own).not.toContain('telemetry/index.js');
|
||||
}
|
||||
);
|
||||
|
||||
it('`openspec __complete schemas` loads the schema reader but not tinyglobby or ora', async () => {
|
||||
await ensureCliBuilt();
|
||||
const { own, packages, status } = loadedModules(['__complete', 'schemas']);
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect([...packages]).not.toContain('fast-glob');
|
||||
expect([...packages]).not.toContain('tinyglobby');
|
||||
expect([...packages]).not.toContain('brace-expansion');
|
||||
expect([...packages]).not.toContain('ora');
|
||||
expect(own).not.toContain('telemetry/index.js');
|
||||
// The name promises a schema reader, so pin it: the resolver directly, not
|
||||
// the artifact-graph barrel, which re-exports it but also pulls outputs.js
|
||||
// and with it tinyglobby.
|
||||
expect(own).toContain('core/artifact-graph/resolver.js');
|
||||
expect(own).not.toContain('core/artifact-graph/index.js');
|
||||
});
|
||||
|
||||
// Negative control for the two cases above. `isTabCompletionRun` matches the
|
||||
// command path exactly, unlike the adjacent `isCompletionRun`, which
|
||||
// prefix-matches `completion:*`. Widening one into the other would silently
|
||||
// stop tracking every user-facing command in that group, and every assertion
|
||||
// above would still pass.
|
||||
it.each([['list --json'], ['completion generate zsh']])(
|
||||
'`openspec %s` still reaches the telemetry hooks',
|
||||
async (invocation) => {
|
||||
await ensureCliBuilt();
|
||||
const { own } = loadedModules(invocation.split(' '));
|
||||
|
||||
expect(own).toContain('telemetry/index.js');
|
||||
expect(own).toContain('core/completion-tip.js');
|
||||
}
|
||||
);
|
||||
|
||||
// The module-count assertions above pin that telemetry is not LOADED. They do
|
||||
// not pin the user-visible consequence, which is the reason the skip exists:
|
||||
// the first-run disclosure goes to stderr, every generated completion script
|
||||
// sends `__complete` stderr to /dev/null, and `maybeShowTelemetryNotice` marks
|
||||
// `noticeSeen` before `trackCommand` re-reads it. So before this change a
|
||||
// user's first-ever interaction being a Tab press burned the disclosure where
|
||||
// nobody could read it and began collecting on that same press.
|
||||
//
|
||||
// Telemetry is deliberately left ENABLED here: the point is that an enabled
|
||||
// run still records nothing. HOME must be isolated as well as XDG_CONFIG_HOME,
|
||||
// or `migrateLegacyTelemetryConfig` imports the developer's real config from
|
||||
// ~/.config/openspec/config.json and the test passes vacuously.
|
||||
it('`openspec __complete` leaves the first-run telemetry notice unconsumed', async () => {
|
||||
await ensureCliBuilt();
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), 'openspec-notice-'));
|
||||
workDirs.push(home);
|
||||
const configHome = path.join(home, 'config');
|
||||
|
||||
// These have to be DELETED, not blanked. Both opt-outs read as "set to
|
||||
// anything that is not an explicit on-value", so `OPENSPEC_TELEMETRY: ''`
|
||||
// and `DO_NOT_TRACK: ''` would switch telemetry OFF and the assertions
|
||||
// below would hold on any build, passing vacuously.
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
XDG_CONFIG_HOME: configHome,
|
||||
XDG_DATA_HOME: path.join(home, 'data'),
|
||||
};
|
||||
delete env.OPENSPEC_TELEMETRY;
|
||||
delete env.DO_NOT_TRACK;
|
||||
delete env.CI;
|
||||
|
||||
const result = spawnSync(process.execPath, [binPath, '__complete', 'changes'], {
|
||||
cwd: home,
|
||||
env,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
// No disclosure written to a stream the completion scripts discard.
|
||||
expect(result.stderr).toBe('');
|
||||
// And no config file, so neither noticeSeen nor an anonymousId was minted.
|
||||
expect(existsSync(path.join(configHome, 'openspec', 'config.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['config list --json', 'config'],
|
||||
['config path', 'config'],
|
||||
['store list --json', 'store'],
|
||||
['doctor --json', 'doctor'],
|
||||
['schemas --json', 'schemas'],
|
||||
['list --json', 'list'],
|
||||
])('`openspec %s` loads only the %s implementation', async (invocation, command) => {
|
||||
await ensureCliBuilt();
|
||||
const { own } = loadedModules(invocation.split(' '));
|
||||
|
||||
expect(implementationsLoaded(own)).toEqual([command]);
|
||||
});
|
||||
});
|
||||
@@ -152,8 +152,8 @@ describe('openspec view root resolution', () => {
|
||||
expect(result.stdout).toContain('billing');
|
||||
expect(result.stdout).toContain('Active Changes: 2 in progress');
|
||||
expect(result.stdout).toContain('Task Progress: 2/4 (50% complete)');
|
||||
expect(result.stdout).toContain('Archived Changes: 1');
|
||||
expect(result.stdout).toContain('2026-08-27-store-history');
|
||||
expect(result.stdout).not.toContain('Archived');
|
||||
expect(result.stdout).not.toContain('2026-08-27-store-history');
|
||||
const lines = result.stdout.split(/\r?\n/);
|
||||
|
||||
for (const changeName of ['billing-update', 'billing-refactor']) {
|
||||
@@ -228,8 +228,8 @@ describe('openspec view root resolution', () => {
|
||||
cwd: base, env: aliasEnv, timeoutMs: TIMEOUT_MS,
|
||||
});
|
||||
expect(viewed.exitCode, viewed.stderr).toBe(0);
|
||||
expect(viewed.stdout).toContain('Archived Changes: 1');
|
||||
expect(viewed.stdout).toContain('2026-08-27-store-history');
|
||||
expect(viewed.stdout).toContain('Active Changes: 2 in progress');
|
||||
expect(viewed.stdout).not.toContain('2026-08-27-store-history');
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
it(
|
||||
|
||||
@@ -1246,9 +1246,23 @@ operations:
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('complete ✓');
|
||||
expect(result.stdout).toContain('ready to be archived');
|
||||
expect(result.stdout).toContain('All tracked tasks are complete');
|
||||
expect(result.stdout).toContain('as appropriate before archiving');
|
||||
expect(result.stdout).not.toContain('ready to be archived');
|
||||
expect(result.stdout).toContain('### Project Context (required instruction input)');
|
||||
expect(result.stdout).toContain('### Operation Guidance (advisory)');
|
||||
|
||||
const jsonResult = await runCLI(
|
||||
['instructions', 'apply', '--change', 'done-apply', '--json'],
|
||||
{ cwd: tempDir }
|
||||
);
|
||||
expect(jsonResult.exitCode).toBe(0);
|
||||
expect(jsonResult.stderr).toBe('');
|
||||
|
||||
const json = JSON.parse(jsonResult.stdout);
|
||||
expect(json.state).toBe('all_done');
|
||||
expect(json.progress).toEqual({ total: 2, complete: 2, remaining: 0 });
|
||||
expect(json.instruction).toContain('All tracked tasks are complete');
|
||||
});
|
||||
|
||||
it('uses spec-driven schema apply configuration', async () => {
|
||||
|
||||
@@ -33,7 +33,7 @@ const T = 30_000;
|
||||
const quote = (value: string) => `"${value}"`;
|
||||
|
||||
async function runConfigCommand(args: string[]): Promise<void> {
|
||||
const { registerConfigCommand } = await import('../../src/commands/config.js');
|
||||
const { registerConfigCommand } = await import('../../src/cli/commands/config.js');
|
||||
const program = new Command();
|
||||
registerConfigCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'config', ...args]);
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock('@inquirer/prompts', () => ({
|
||||
}));
|
||||
|
||||
async function runConfigCommand(args: string[]): Promise<void> {
|
||||
const { registerConfigCommand } = await import('../../src/commands/config.js');
|
||||
const { registerConfigCommand } = await import('../../src/cli/commands/config.js');
|
||||
const program = new Command();
|
||||
registerConfigCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'config', ...args]);
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
async function runConfigCommand(args: string[]): Promise<void> {
|
||||
const { registerConfigCommand } = await import('../../src/commands/config.js');
|
||||
const { registerConfigCommand } = await import('../../src/cli/commands/config.js');
|
||||
const program = new Command();
|
||||
registerConfigCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'config', ...args]);
|
||||
|
||||
@@ -97,7 +97,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
// block-scalar style; the new implementation edits a yaml Document in place.
|
||||
|
||||
async function runSchemaCommand(args: string[]): Promise<void> {
|
||||
const { registerSchemaCommand } = await import('../../src/commands/schema.js');
|
||||
const { registerSchemaCommand } = await import('../../src/cli/commands/schema.js');
|
||||
const program = new Command();
|
||||
registerSchemaCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'schema', ...args]);
|
||||
|
||||
@@ -5,12 +5,8 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { runCLI } from '../helpers/run-cli.js';
|
||||
|
||||
async function runSchemaCommand(
|
||||
args: string[],
|
||||
schemaModule?: typeof import('../../src/commands/schema.js')
|
||||
): Promise<void> {
|
||||
const { registerSchemaCommand } =
|
||||
schemaModule ?? (await import('../../src/commands/schema.js'));
|
||||
async function runSchemaCommand(args: string[]): Promise<void> {
|
||||
const { registerSchemaCommand } = await import('../../src/cli/commands/schema.js');
|
||||
const program = new Command();
|
||||
registerSchemaCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'schema', ...args]);
|
||||
@@ -442,10 +438,7 @@ artifacts:
|
||||
return { schemaDir, before: snapshotTree(schemaDir) };
|
||||
}
|
||||
|
||||
async function runDefaultInit(
|
||||
force: boolean,
|
||||
schemaModule?: typeof import('../../src/commands/schema.js')
|
||||
): Promise<void> {
|
||||
async function runDefaultInit(force: boolean): Promise<void> {
|
||||
await runSchemaCommand(
|
||||
[
|
||||
'init',
|
||||
@@ -455,8 +448,7 @@ artifacts:
|
||||
'proposal,specs,tasks',
|
||||
'--default',
|
||||
'--json',
|
||||
],
|
||||
schemaModule
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -630,8 +622,7 @@ artifacts:
|
||||
const configPath = path.join(tempDir, 'openspec', 'config.yaml');
|
||||
const configBytes = Buffer.from('schema: existing\ncontext: keep me\n');
|
||||
fs.writeFileSync(configPath, configBytes);
|
||||
const schemaModule = await import('../../src/commands/schema.js');
|
||||
const { schemaInitFileOperations } = schemaModule;
|
||||
const { schemaInitFileOperations } = await import('../../src/commands/schema.js');
|
||||
const renameSync = schemaInitFileOperations.renameSync;
|
||||
const renameCalls: Array<[string, string]> = [];
|
||||
schemaInitFileOperations.renameSync = (source, destination) => {
|
||||
@@ -643,7 +634,7 @@ artifacts:
|
||||
};
|
||||
|
||||
try {
|
||||
await runDefaultInit(true, schemaModule);
|
||||
await runDefaultInit(true);
|
||||
} finally {
|
||||
schemaInitFileOperations.renameSync = renameSync;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock('@inquirer/prompts', () => ({
|
||||
}));
|
||||
|
||||
async function runStoreCommand(args: string[]): Promise<void> {
|
||||
const { registerStoreCommand } = await import('../../src/commands/store.js');
|
||||
const { registerStoreCommand } = await import('../../src/cli/commands/store.js');
|
||||
const program = new Command();
|
||||
registerStoreCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'store', ...args]);
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock('@inquirer/prompts', () => ({
|
||||
}));
|
||||
|
||||
async function runStoreCommand(args: string[]): Promise<void> {
|
||||
const { registerStoreCommand } = await import('../../src/commands/store.js');
|
||||
const { registerStoreCommand } = await import('../../src/cli/commands/store.js');
|
||||
const program = new Command();
|
||||
registerStoreCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'store', ...args]);
|
||||
|
||||
@@ -760,7 +760,18 @@ describe('openspec workset (7.1)', () => {
|
||||
fs.writeFileSync(path.join(binDir, 'claude.exe'), 'not a real image\n');
|
||||
const fakeCode = createFakeTool(tempDir, 'code');
|
||||
const launchEnv = envWithFakeTools(env, [fakeCode]);
|
||||
launchEnv.PATH = `${binDir}${path.delimiter}${launchEnv.PATH}`;
|
||||
// On ENOENT the PATH search moves on to the next entry, so a real
|
||||
// claude later on PATH (a developer's install) would launch instead
|
||||
// of failing. Keep only the directories without one.
|
||||
const pathWithoutClaude = (launchEnv.PATH ?? '')
|
||||
.split(path.delimiter)
|
||||
.filter(
|
||||
(dir) =>
|
||||
!['claude', 'claude.exe', 'claude.cmd'].some((name) =>
|
||||
fs.existsSync(path.join(dir, name))
|
||||
)
|
||||
);
|
||||
launchEnv.PATH = [binDir, ...pathWithoutClaude].join(path.delimiter);
|
||||
|
||||
const result = await runCLI(['workset', 'open', 'platform'], {
|
||||
cwd: tempDir,
|
||||
@@ -897,7 +908,7 @@ describe('interactive compose cancellation (in-process)', () => {
|
||||
async function runCreate(promptsModule: Record<string, unknown>): Promise<void> {
|
||||
vi.doMock('@inquirer/prompts', () => promptsModule);
|
||||
const { registerWorksetCommand } = await import(
|
||||
'../../src/commands/workset.js'
|
||||
'../../src/cli/commands/workset.js'
|
||||
);
|
||||
const { Command } = await import('commander');
|
||||
const program = new Command();
|
||||
@@ -1043,7 +1054,7 @@ describe('interactive compose cancellation (in-process)', () => {
|
||||
confirm: vi.fn(async () => false),
|
||||
}));
|
||||
const { registerWorksetCommand } = await import(
|
||||
'../../src/commands/workset.js'
|
||||
'../../src/cli/commands/workset.js'
|
||||
);
|
||||
const { Command } = await import('commander');
|
||||
|
||||
|
||||
+450
-22
@@ -6,7 +6,7 @@ import { MarkdownParser } from '../../src/core/parsers/markdown-parser.js';
|
||||
import { findMainSpecStructureIssues } from '../../src/core/parsers/spec-structure.js';
|
||||
import { VALIDATION_MESSAGES } from '../../src/core/validation/constants.js';
|
||||
import { formatLocalDate } from '../../src/utils/date.js';
|
||||
import { promises as fs } from 'fs';
|
||||
import { constants as fsConstants, promises as fs, realpathSync } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
@@ -44,8 +44,10 @@ describe('ArchiveCommand', () => {
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temp directory
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-archive-test-'));
|
||||
// Match archive's canonical root across temporary-directory aliases.
|
||||
tempDir = realpathSync.native(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-archive-test-'))
|
||||
);
|
||||
|
||||
// Change to temp directory
|
||||
process.chdir(tempDir);
|
||||
@@ -151,6 +153,31 @@ describe('ArchiveCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('archives x- extension keys without a warning and keeps them byte-for-byte', async () => {
|
||||
const changeName = 'extension-metadata';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n');
|
||||
const metadata =
|
||||
'schema: spec-driven\nx-goal: G-12\nx-tracker:\n system: linear\n id: SB-5497\n';
|
||||
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), metadata);
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true, json: true });
|
||||
|
||||
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls
|
||||
.flat()
|
||||
.map(String);
|
||||
const jsonLine = logCalls.find((entry) => entry.trimStart().startsWith('{'));
|
||||
expect(jsonLine).toBeDefined();
|
||||
expect(JSON.parse(jsonLine!).archive.warnings).toBeUndefined();
|
||||
|
||||
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
||||
const [archived] = await fs.readdir(archiveDir);
|
||||
expect(
|
||||
await fs.readFile(path.join(archiveDir, archived, '.openspec.yaml'), 'utf-8')
|
||||
).toBe(metadata);
|
||||
});
|
||||
|
||||
describe('a namespace folder holding nested changes (#1846)', () => {
|
||||
async function seedNamespaceFolder(): Promise<string> {
|
||||
const nested = path.join(tempDir, 'openspec', 'changes', 'mobile', 'refresh-token');
|
||||
@@ -617,6 +644,180 @@ describe('ArchiveCommand', () => {
|
||||
await expect(fs.readdir(outsideDir)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('archives when openspec/changes is a project-owned symlink to another directory', async () => {
|
||||
const changeName = 'linked-change';
|
||||
const docsRepo = realpathSync.native(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-docs-repo-'))
|
||||
);
|
||||
onTestFinished(() => fs.rm(docsRepo, { recursive: true, force: true }));
|
||||
const sharedChanges = path.join(docsRepo, 'changes');
|
||||
const changesLink = path.join(tempDir, 'openspec', 'changes');
|
||||
await fs.rm(changesLink, { recursive: true, force: true });
|
||||
await fs.mkdir(path.join(sharedChanges, changeName), { recursive: true });
|
||||
await fs.writeFile(path.join(sharedChanges, changeName, 'tasks.md'), '- [x] Task 1\n');
|
||||
await fs.symlink(sharedChanges, changesLink, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
|
||||
await archiveCommand.execute(changeName, {
|
||||
yes: true,
|
||||
noValidate: true,
|
||||
skipSpecs: true,
|
||||
});
|
||||
|
||||
await expect(fs.access(path.join(sharedChanges, changeName))).rejects.toThrow();
|
||||
const archived = await fs.readdir(path.join(sharedChanges, 'archive'));
|
||||
expect(archived.some((name) => name.endsWith(`-${changeName}`))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ linkChanges: true, linkSpecs: false },
|
||||
{ linkChanges: false, linkSpecs: true },
|
||||
{ linkChanges: true, linkSpecs: true },
|
||||
])(
|
||||
'applies a spec delta and archives with symlinked changes=$linkChanges specs=$linkSpecs',
|
||||
async ({ linkChanges, linkSpecs }) => {
|
||||
const changeName = 'linked-delta';
|
||||
const linkType = process.platform === 'win32' ? 'junction' : 'dir';
|
||||
const docsRepo = realpathSync.native(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-docs-repo-'))
|
||||
);
|
||||
onTestFinished(() => fs.rm(docsRepo, { recursive: true, force: true }));
|
||||
const openspecDir = path.join(tempDir, 'openspec');
|
||||
const changesDir = linkChanges ? path.join(docsRepo, 'changes') : path.join(openspecDir, 'changes');
|
||||
const specsDir = linkSpecs ? path.join(docsRepo, 'specs') : path.join(openspecDir, 'specs');
|
||||
if (linkChanges) {
|
||||
await fs.rm(path.join(openspecDir, 'changes'), { recursive: true, force: true });
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
await fs.symlink(changesDir, path.join(openspecDir, 'changes'), linkType);
|
||||
}
|
||||
if (linkSpecs) {
|
||||
await fs.rm(path.join(openspecDir, 'specs'), { recursive: true, force: true });
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
await fs.symlink(specsDir, path.join(openspecDir, 'specs'), linkType);
|
||||
}
|
||||
const deltaDir = path.join(changesDir, changeName, 'specs', 'linked-capability');
|
||||
await fs.mkdir(deltaDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(deltaDir, 'spec.md'),
|
||||
`# Linked Capability - Changes
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: The system SHALL archive through linked directories
|
||||
|
||||
#### Scenario: Linked directories
|
||||
- **WHEN** the change is archived
|
||||
- **THEN** the delta is applied and the change is archived
|
||||
`
|
||||
);
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
|
||||
const mainSpec = await fs.readFile(
|
||||
path.join(specsDir, 'linked-capability', 'spec.md'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(mainSpec).toContain('### Requirement: The system SHALL archive through linked directories');
|
||||
await expect(fs.access(path.join(changesDir, changeName))).rejects.toThrow();
|
||||
const archived = await fs.readdir(path.join(changesDir, 'archive'));
|
||||
const archivedName = archived.find((name) => name.endsWith(`-${changeName}`));
|
||||
expect(archivedName).toBeDefined();
|
||||
await expect(
|
||||
fs.access(path.join(changesDir, 'archive', archivedName!, 'specs', 'linked-capability', 'spec.md'))
|
||||
).resolves.not.toThrow();
|
||||
}
|
||||
);
|
||||
|
||||
it('archives when the whole openspec directory is a project-owned symlink', async () => {
|
||||
const changeName = 'linked-openspec';
|
||||
const docsRepo = realpathSync.native(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-docs-repo-'))
|
||||
);
|
||||
onTestFinished(() => fs.rm(docsRepo, { recursive: true, force: true }));
|
||||
const sharedOpenspec = path.join(docsRepo, 'openspec');
|
||||
await fs.rm(path.join(tempDir, 'openspec'), { recursive: true, force: true });
|
||||
await fs.mkdir(path.join(sharedOpenspec, 'changes', changeName), { recursive: true });
|
||||
await fs.mkdir(path.join(sharedOpenspec, 'specs'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sharedOpenspec, 'changes', changeName, 'tasks.md'),
|
||||
'- [x] Task 1\n'
|
||||
);
|
||||
await fs.symlink(
|
||||
sharedOpenspec,
|
||||
path.join(tempDir, 'openspec'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
);
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true, skipSpecs: true });
|
||||
|
||||
const archived = await fs.readdir(path.join(sharedOpenspec, 'changes', 'archive'));
|
||||
expect(archived.some((name) => name.endsWith(`-${changeName}`))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an archive directory that escapes a symlinked changes directory', async () => {
|
||||
const changeName = 'linked-escape';
|
||||
const docsRepo = realpathSync.native(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-docs-repo-'))
|
||||
);
|
||||
onTestFinished(() => fs.rm(docsRepo, { recursive: true, force: true }));
|
||||
const sharedChanges = path.join(docsRepo, 'changes');
|
||||
const outsideDir = path.join(docsRepo, 'outside');
|
||||
const linkType = process.platform === 'win32' ? 'junction' : 'dir';
|
||||
await fs.rm(path.join(tempDir, 'openspec', 'changes'), { recursive: true, force: true });
|
||||
await fs.mkdir(path.join(sharedChanges, changeName), { recursive: true });
|
||||
await fs.mkdir(outsideDir);
|
||||
await fs.writeFile(path.join(sharedChanges, changeName, 'tasks.md'), '- [x] Task 1\n');
|
||||
await fs.symlink(sharedChanges, path.join(tempDir, 'openspec', 'changes'), linkType);
|
||||
await fs.symlink(outsideDir, path.join(sharedChanges, 'archive'), linkType);
|
||||
|
||||
await expect(
|
||||
archiveCommand.execute(changeName, { yes: true, noValidate: true, skipSpecs: true })
|
||||
).rejects.toThrow(/outside the OpenSpec root/u);
|
||||
await expect(fs.access(path.join(sharedChanges, changeName))).resolves.not.toThrow();
|
||||
await expect(fs.readdir(outsideDir)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('verifies deltas through a symlinked changes directory on the cross-device fallback', async () => {
|
||||
const changeName = 'linked-exdev';
|
||||
const docsRepo = realpathSync.native(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-docs-repo-'))
|
||||
);
|
||||
onTestFinished(() => fs.rm(docsRepo, { recursive: true, force: true }));
|
||||
const sharedChanges = path.join(docsRepo, 'changes');
|
||||
const deltaDir = path.join(sharedChanges, changeName, 'specs', 'linked-capability');
|
||||
await fs.rm(path.join(tempDir, 'openspec', 'changes'), { recursive: true, force: true });
|
||||
await fs.mkdir(deltaDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(deltaDir, 'spec.md'),
|
||||
`## ADDED Requirements
|
||||
|
||||
### Requirement: The system SHALL survive EXDEV
|
||||
The system SHALL archive.
|
||||
|
||||
#### Scenario: Cross device
|
||||
- **WHEN** rename fails with EXDEV
|
||||
- **THEN** the copy fallback archives the change
|
||||
`
|
||||
);
|
||||
await fs.symlink(
|
||||
sharedChanges,
|
||||
path.join(tempDir, 'openspec', 'changes'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
);
|
||||
|
||||
const rename = vi.spyOn(fs, 'rename').mockRejectedValueOnce(
|
||||
Object.assign(new Error('cross-device move'), { code: 'EXDEV' })
|
||||
);
|
||||
try {
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
} finally {
|
||||
rename.mockRestore();
|
||||
}
|
||||
|
||||
await expect(fs.access(path.join(sharedChanges, changeName))).rejects.toThrow();
|
||||
const archived = await fs.readdir(path.join(sharedChanges, 'archive'));
|
||||
expect(archived.some((name) => name.endsWith(`-${changeName}`))).toBe(true);
|
||||
});
|
||||
|
||||
it('archives normally when the project root is reached through a symlink alias', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
@@ -6566,7 +6767,7 @@ The system SHALL provide a replacement behavior.
|
||||
});
|
||||
|
||||
await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow(
|
||||
/displaced spec changed.*backup was retained for recovery/s
|
||||
/displaced spec changed.*change was archived/s
|
||||
);
|
||||
|
||||
expect(edited).toBe(true);
|
||||
@@ -7359,7 +7560,7 @@ The system SHALL provide a new behavior.
|
||||
await expect(fs.access(changeDir)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps committed retirement state when one backup cleanup fails', async () => {
|
||||
it.each([false, true])('keeps committed retirement state when one backup cleanup fails (json=%s)', async (json) => {
|
||||
const changeName = 'retire-backup-cleanup-failure';
|
||||
const changeDir = await createChange(changeName, 'a-layer', REMOVE_ALL);
|
||||
const secondDelta = path.join(changeDir, 'specs', 'z-layer');
|
||||
@@ -7386,9 +7587,24 @@ The system SHALL provide a new behavior.
|
||||
return realUnlink(candidate);
|
||||
});
|
||||
|
||||
await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow(
|
||||
/change remains archived.*backup was retained for recovery/s
|
||||
);
|
||||
if (json) {
|
||||
await archiveCommand.execute(changeName, { yes: true, json: true });
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(console.log).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse((console.log as any).mock.calls[0][0]);
|
||||
expect(payload.archive).toBeNull();
|
||||
expect(payload.root).toBeDefined();
|
||||
expect(payload.status).toEqual([{
|
||||
severity: 'error',
|
||||
code: 'archive_retirement_cleanup_failed',
|
||||
message: expect.stringMatching(/change was archived.*Inspect all reported recovery paths/s),
|
||||
fix: 'Inspect the archived change and all recovery paths in this diagnostic; preserve any needed content before cleanup.',
|
||||
}]);
|
||||
} else {
|
||||
await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow(
|
||||
/change was archived.*Inspect all reported recovery paths/s
|
||||
);
|
||||
}
|
||||
|
||||
await expect(fs.access(changeDir)).rejects.toThrow();
|
||||
await expect(
|
||||
@@ -7406,11 +7622,198 @@ The system SHALL provide a new behavior.
|
||||
await expect(fs.access(target)).rejects.toThrow();
|
||||
}
|
||||
await expect(fs.access(path.dirname(targets[0]))).rejects.toThrow();
|
||||
expect(
|
||||
(await fs.readdir(path.dirname(targets[1]))).some((entry) =>
|
||||
const backups = (await fs.readdir(path.dirname(targets[1]))).filter((entry) =>
|
||||
entry.includes('.openspec-retire-')
|
||||
);
|
||||
expect(backups).toHaveLength(1);
|
||||
await expect(
|
||||
fs.readFile(path.join(path.dirname(targets[1]), backups[0]), 'utf-8')
|
||||
).resolves.toBe(mainSpec('z-layer'));
|
||||
});
|
||||
|
||||
it('keeps a pre-mutation archive failure generic in JSON', async () => {
|
||||
const changeName = 'retire-claim-denied-json';
|
||||
const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL);
|
||||
const target = path.join(tempDir, 'openspec', 'specs', 'legacy-layer', 'spec.md');
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, mainSpec('legacy-layer'));
|
||||
const delta = await fs.readFile(path.join(changeDir, 'specs', 'legacy-layer', 'spec.md'), 'utf-8');
|
||||
|
||||
const realOpen = fs.open.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
let claimDenied = false;
|
||||
vi.spyOn(fs, 'open').mockImplementation(async (candidate, flags, mode) => {
|
||||
if (String(candidate) === archiveClaimPath(changeName)) {
|
||||
claimDenied = true;
|
||||
throw Object.assign(new Error('claim open denied'), { code: 'EACCES' });
|
||||
}
|
||||
return realOpen(candidate, flags, mode);
|
||||
});
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, json: true });
|
||||
|
||||
expect(claimDenied).toBe(true);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(console.log).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse((console.log as any).mock.calls[0][0]);
|
||||
expect(payload.archive).toBeNull();
|
||||
expect(payload.status).toEqual([{
|
||||
severity: 'error',
|
||||
code: 'archive_error',
|
||||
message: expect.stringContaining('claim open denied'),
|
||||
}]);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(mainSpec('legacy-layer'));
|
||||
await expect(
|
||||
fs.readFile(path.join(changeDir, 'specs', 'legacy-layer', 'spec.md'), 'utf-8')
|
||||
).resolves.toBe(delta);
|
||||
expect(await fs.readdir(path.dirname(target))).toEqual(['spec.md']);
|
||||
expect(await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive'))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['edited', 'replaced', 'removed'] as const)(
|
||||
'reports a concurrently %s retirement backup in JSON without losing surviving content',
|
||||
async (backupChange) => {
|
||||
const changeName = 'retire-backup-edited-json';
|
||||
const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL);
|
||||
const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer');
|
||||
const target = path.join(targetDir, 'spec.md');
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
await fs.writeFile(target, mainSpec('legacy-layer'));
|
||||
|
||||
const archivePath = path.join(
|
||||
tempDir, 'openspec', 'changes', 'archive', `${formatLocalDate()}-${changeName}`
|
||||
);
|
||||
const realRename = fs.rename.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
let editedBackup: string | undefined;
|
||||
let backupChanged = false;
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => {
|
||||
const result = await realRename(source, destination);
|
||||
if (String(source) === changeDir && String(destination) === archivePath) {
|
||||
const backup = (await fs.readdir(targetDir)).find((entry) =>
|
||||
entry.includes('.openspec-retire-')
|
||||
);
|
||||
expect(backup).toBeDefined();
|
||||
editedBackup = path.join(targetDir, backup!);
|
||||
if (backupChange !== 'edited') await fs.unlink(editedBackup);
|
||||
if (backupChange !== 'removed') {
|
||||
await fs.writeFile(editedBackup, 'concurrent content in retirement backup\n');
|
||||
}
|
||||
backupChanged = true;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, json: true });
|
||||
|
||||
expect(backupChanged).toBe(true);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(console.log).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse((console.log as any).mock.calls[0][0]);
|
||||
expect(payload.archive).toBeNull();
|
||||
if (backupChange === 'removed') {
|
||||
expect(payload.status[0].message).not.toContain('each listed backup was retained');
|
||||
}
|
||||
expect(payload.status).toEqual([{
|
||||
severity: 'error',
|
||||
code: 'archive_retirement_cleanup_failed',
|
||||
message: expect.stringMatching(/displaced spec changed.*change was archived/s),
|
||||
fix: 'Inspect the archived change and all recovery paths in this diagnostic; preserve any needed content before cleanup.',
|
||||
}]);
|
||||
expect(editedBackup).toBeDefined();
|
||||
expect(payload.status[0].message).toContain(editedBackup);
|
||||
if (backupChange === 'removed') {
|
||||
await expect(fs.access(editedBackup!)).rejects.toThrow();
|
||||
} else {
|
||||
await expect(fs.readFile(editedBackup!, 'utf-8')).resolves.toBe(
|
||||
'concurrent content in retirement backup\n'
|
||||
);
|
||||
}
|
||||
await expect(fs.access(target)).rejects.toThrow();
|
||||
await expect(fs.access(changeDir)).rejects.toThrow();
|
||||
await expect(fs.access(archivePath)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('reports all recovery paths when fallback source and multiple backup cleanups fail', async () => {
|
||||
const changeName = 'retire-combined-cleanup-failure';
|
||||
const changeDir = await createChange(changeName, 'a-layer', REMOVE_ALL);
|
||||
const secondDelta = path.join(changeDir, 'specs', 'z-layer');
|
||||
await fs.mkdir(secondDelta, { recursive: true });
|
||||
await fs.writeFile(path.join(secondDelta, 'spec.md'), REMOVE_ALL);
|
||||
const targets = ['a-layer', 'z-layer'].map((capability) =>
|
||||
path.join(tempDir, 'openspec', 'specs', capability, 'spec.md')
|
||||
);
|
||||
for (const [index, target] of targets.entries()) {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, mainSpec(index === 0 ? 'a-layer' : 'z-layer'));
|
||||
}
|
||||
|
||||
const archivePath = path.join(
|
||||
tempDir, 'openspec', 'changes', 'archive', `${formatLocalDate()}-${changeName}`
|
||||
);
|
||||
const realRename = fs.rename.bind(fs);
|
||||
const realRmdir = fs.rmdir.bind(fs);
|
||||
const realUnlink = fs.unlink.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
let stagedSource: string | undefined;
|
||||
let fallbackInjected = false;
|
||||
let sourceCleanupDenied = false;
|
||||
let backupCleanupsDenied = 0;
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => {
|
||||
if (String(source) === changeDir && String(destination) === archivePath) {
|
||||
fallbackInjected = true;
|
||||
throw Object.assign(new Error('cross-device move'), { code: 'EXDEV' });
|
||||
}
|
||||
return realRename(source, destination);
|
||||
});
|
||||
// Source removal claims and deletes each verified entry, then removes the
|
||||
// staged root last; deny that final step so a partly removed staged
|
||||
// source is left behind.
|
||||
vi.spyOn(fs, 'rmdir').mockImplementation(async (candidate, options) => {
|
||||
if (path.basename(String(candidate)).startsWith('.openspec-move-')) {
|
||||
stagedSource = String(candidate);
|
||||
sourceCleanupDenied = true;
|
||||
throw Object.assign(new Error('partial source cleanup'), { code: 'EACCES' });
|
||||
}
|
||||
return realRmdir(candidate, options);
|
||||
});
|
||||
vi.spyOn(fs, 'unlink').mockImplementation(async (candidate) => {
|
||||
if (String(candidate).includes('.openspec-retire-')) {
|
||||
backupCleanupsDenied += 1;
|
||||
throw Object.assign(new Error('backup cleanup denied'), { code: 'EACCES' });
|
||||
}
|
||||
return realUnlink(candidate);
|
||||
});
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, json: true });
|
||||
|
||||
expect(fallbackInjected).toBe(true);
|
||||
expect(sourceCleanupDenied).toBe(true);
|
||||
expect(backupCleanupsDenied).toBe(2);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(console.log).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse(vi.mocked(console.log).mock.calls[0][0]);
|
||||
expect(payload.archive).toBeNull();
|
||||
expect(payload.status).toHaveLength(1);
|
||||
expect(payload.status[0].fix).toContain('all recovery paths');
|
||||
expect(stagedSource).toBeDefined();
|
||||
expect(payload.status[0].message).toContain(stagedSource);
|
||||
expect(payload.status[0].message).toContain('complete destination was retained');
|
||||
await expect(fs.access(path.join(stagedSource!, 'tasks.md'))).rejects.toThrow();
|
||||
await expect(fs.readFile(path.join(archivePath, 'tasks.md'), 'utf-8')).resolves.toContain('[x]');
|
||||
for (const [index, target] of targets.entries()) {
|
||||
await expect(fs.access(target)).rejects.toThrow();
|
||||
const backup = (await fs.readdir(path.dirname(target))).find((entry) =>
|
||||
entry.includes('.openspec-retire-')
|
||||
)
|
||||
).toBe(true);
|
||||
);
|
||||
expect(backup).toBeDefined();
|
||||
const backupPath = path.join(path.dirname(target), backup!);
|
||||
expect(payload.status[0].message).toContain(backupPath);
|
||||
await expect(fs.readFile(backupPath, 'utf-8')).resolves.toBe(
|
||||
mainSpec(index === 0 ? 'a-layer' : 'z-layer')
|
||||
);
|
||||
}
|
||||
await expect(fs.access(changeDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
@@ -7460,12 +7863,15 @@ The system SHALL provide a new behavior.
|
||||
});
|
||||
|
||||
await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow(
|
||||
/Path is outside the allowed directory/
|
||||
/changed while archive was securing it for retirement/
|
||||
);
|
||||
|
||||
expect((await fs.lstat(linkedSpec)).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.readlink(linkedSpec)).toBe(shared);
|
||||
await expect(fs.readFile(shared, 'utf-8')).resolves.toBe(mainSpec('a-layer'));
|
||||
// The link is back, and the update that landed on its target while the
|
||||
// retirement was in flight is still there - restoring the link must not
|
||||
// write through it.
|
||||
await expect(fs.readFile(shared, 'utf-8')).resolves.toBe('concurrent update\n');
|
||||
await expect(fs.access(changeDir)).resolves.not.toThrow();
|
||||
}
|
||||
);
|
||||
@@ -7509,11 +7915,31 @@ The system SHALL provide a new behavior.
|
||||
});
|
||||
|
||||
await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow(
|
||||
/Path is outside the allowed directory/
|
||||
/A concurrent file appeared at/
|
||||
);
|
||||
|
||||
expect((await fs.lstat(linkedSpec)).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.readlink(linkedSpec)).toBe(shared);
|
||||
// The occupant that appeared at the link's path is left exactly as it
|
||||
// was found; the displaced link is retained beside it instead.
|
||||
// O_NOFOLLOW makes the open itself fail if the path is a link again, so
|
||||
// one handle both proves the occupant is a regular file and carries its
|
||||
// bytes - no second lookup that could land on a different file.
|
||||
const occupant = await fs.open(
|
||||
linkedSpec,
|
||||
fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW
|
||||
);
|
||||
try {
|
||||
expect((await occupant.stat()).isFile()).toBe(true);
|
||||
await expect(occupant.readFile('utf-8')).resolves.toBe('concurrent occupant\n');
|
||||
} finally {
|
||||
await occupant.close();
|
||||
}
|
||||
const retained = (await fs.readdir(path.dirname(linkedSpec))).find((name) =>
|
||||
name.includes('.openspec-retire-')
|
||||
);
|
||||
expect(retained).toBeDefined();
|
||||
expect(
|
||||
(await fs.lstat(path.join(path.dirname(linkedSpec), retained!))).isSymbolicLink()
|
||||
).toBe(true);
|
||||
await expect(fs.readFile(shared, 'utf-8')).resolves.toBe(mainSpec('a-layer'));
|
||||
await expect(fs.access(changeDir)).resolves.not.toThrow();
|
||||
}
|
||||
@@ -7570,7 +7996,7 @@ The system SHALL provide a new behavior.
|
||||
});
|
||||
|
||||
await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow(
|
||||
/Path is outside the allowed directory/
|
||||
/permission denied/
|
||||
);
|
||||
|
||||
await expect(fs.readFile(shared, 'utf-8')).resolves.toBe(original);
|
||||
@@ -7969,10 +8395,12 @@ The system SHALL provide a new behavior.
|
||||
await archiveCommand.execute(changeName, { yes: true, json: true });
|
||||
|
||||
const payload = JSON.parse(lastJsonPayload());
|
||||
expect(payload.archive).toBeNull();
|
||||
expect(payload.status[0].message).toContain('Path is outside the allowed directory');
|
||||
expect((await fs.lstat(path.join(mainSpecDir, 'spec.md'))).isSymbolicLink()).toBe(true);
|
||||
// The shared file really is still there.
|
||||
expect(payload.archive.specsUpdated).toBe(true);
|
||||
const notes = payload.archive.warnings.join('\n');
|
||||
expect(notes).toContain('openspec/specs/legacy-layer/spec.md');
|
||||
expect(notes).not.toContain('shared-legacy.md');
|
||||
// The link is gone, and the shared file it pointed at really is still there.
|
||||
await expect(fs.lstat(path.join(mainSpecDir, 'spec.md'))).rejects.toThrow();
|
||||
await expect(fs.readFile(shared, 'utf-8')).resolves.toContain('### Requirement:');
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fg from 'fast-glob';
|
||||
import * as globby from 'tinyglobby';
|
||||
|
||||
vi.mock('tinyglobby', async () => {
|
||||
const actual = await vi.importActual<typeof import('tinyglobby')>('tinyglobby');
|
||||
return {
|
||||
...actual,
|
||||
globSync: vi.fn((...args: Parameters<typeof actual.globSync>) => actual.globSync(...args)),
|
||||
};
|
||||
});
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
@@ -18,6 +26,7 @@ describe('artifact-graph/outputs', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-outputs-test-'));
|
||||
vi.mocked(globby.globSync).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -138,7 +147,7 @@ describe('artifact-graph/outputs', () => {
|
||||
const outsideDir = fs.mkdtempSync(path.join(path.dirname(tempDir), 'openspec-outside-'));
|
||||
const pattern = `{safe,../${path.basename(outsideDir)}}/review.md`;
|
||||
if (exists) fs.writeFileSync(path.join(outsideDir, 'review.md'), 'private');
|
||||
const glob = vi.spyOn(fg, 'sync');
|
||||
const glob = vi.mocked(globby.globSync);
|
||||
try {
|
||||
expect(() => resolveArtifactOutputs(tempDir, pattern)).toThrow(
|
||||
/outside the allowed directory/u
|
||||
@@ -160,6 +169,39 @@ describe('artifact-graph/outputs', () => {
|
||||
expect(resolveArtifactOutputs(tempDir, pattern)).toEqual([canonical(filePath)]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['{prefix{a,b}}.md', ['{prefixa}.md', '{prefixb}.md']],
|
||||
['{prefix{1..2}}.md', ['{prefix1}.md', '{prefix2}.md']],
|
||||
['{pre{a,b}post}.md', ['{preapost}.md', '{prebpost}.md']],
|
||||
['{{a,b}}.md', ['{a}.md', '{b}.md']],
|
||||
['{draft{nope}{a,b}}.md', ['{draft{nope}a}.md', '{draft{nope}b}.md']],
|
||||
])('expands a nested group inside non-expanding braces in %s', (pattern, filenames) => {
|
||||
for (const filename of filenames) {
|
||||
fs.writeFileSync(path.join(tempDir, filename), 'content');
|
||||
}
|
||||
fs.writeFileSync(path.join(tempDir, pattern), 'unexpanded');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, pattern)).toEqual(
|
||||
filenames.map((filename) => canonical(path.join(tempDir, filename)))
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a brace group literal when neither it nor its nested groups expand', () => {
|
||||
const literalPath = path.join(tempDir, 'notes-{prefix{draft}}-a.md');
|
||||
fs.writeFileSync(literalPath, 'content');
|
||||
fs.writeFileSync(path.join(tempDir, 'notes-{prefix{draft}}-b.md'), 'content');
|
||||
fs.writeFileSync(path.join(tempDir, 'notes-{prefixdraft}-a.md'), 'stripped');
|
||||
fs.writeFileSync(path.join(tempDir, '{prefix{draft}}.md'), 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, 'notes-{prefix{draft}}-{a,b}.md')).toEqual([
|
||||
canonical(literalPath),
|
||||
canonical(path.join(tempDir, 'notes-{prefix{draft}}-b.md')),
|
||||
]);
|
||||
expect(resolveArtifactOutputs(tempDir, '{prefix{draft}}.md')).toEqual([
|
||||
canonical(path.join(tempDir, '{prefix{draft}}.md')),
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves a brace range after a literal brace group', () => {
|
||||
const filenames = [1, 2, 3, 4].map((index) => `report-{draft}-${index}.md`);
|
||||
for (const filename of filenames) {
|
||||
@@ -272,6 +314,119 @@ describe('artifact-graph/outputs', () => {
|
||||
expect(artifactOutputExists(tempDir, 'file-{1..3}.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects brace nesting deeper than any artifact pattern needs before it can overflow', () => {
|
||||
const fileA = path.join(tempDir, 'a.md');
|
||||
fs.writeFileSync(fileA, 'content');
|
||||
const nested = (depth: number) => `${'{a,'.repeat(depth)}b${'}'.repeat(depth)}.md`;
|
||||
const globSpy = vi.mocked(globby.globSync);
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, nested(16))).toEqual([canonical(fileA)]);
|
||||
globSpy.mockClear();
|
||||
expect(() => resolveArtifactOutputs(tempDir, nested(17))).toThrow(
|
||||
'Artifact output pattern nests braces more than 16 levels deep'
|
||||
);
|
||||
// A pattern deep enough to crash braces is also too long for the filesystem;
|
||||
// the guard must still be the error the user sees.
|
||||
expect(() => resolveArtifactOutputs(tempDir, nested(4000))).toThrow(
|
||||
'Artifact output pattern nests braces more than 16 levels deep'
|
||||
);
|
||||
// The error quotes only the start of a pattern that long.
|
||||
expect(() => resolveArtifactOutputs(tempDir, nested(4000))).toThrow(
|
||||
`16 levels deep: ${nested(4000).slice(0, 80)}...`
|
||||
);
|
||||
expect(() => resolveArtifactOutputs(tempDir, nested(4000))).not.toThrow(nested(4000));
|
||||
expect(() => resolveArtifactOutputs(tempDir, nested(4000))).not.toThrow(
|
||||
/Maximum call stack size exceeded/u
|
||||
);
|
||||
expect(globSpy).not.toHaveBeenCalled();
|
||||
globSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects a brace pattern that is wide rather than deep before it exhausts the heap', () => {
|
||||
// Nesting depth is 1 here, so the depth guard never fires. `{a,b}` repeated
|
||||
// 20 times is 1,048,576 patterns and crashed the expander with
|
||||
// "JavaScript heap out of memory"; fast-glob compiled it to a regex instead.
|
||||
const wide = (groups: number) => `${'{a,b}'.repeat(groups)}.md`;
|
||||
const globSpy = vi.mocked(globby.globSync);
|
||||
|
||||
expect(() => resolveArtifactOutputs(tempDir, wide(20))).toThrow(
|
||||
'Artifact output pattern expands to more than 1024 patterns'
|
||||
);
|
||||
// The error quotes only the start of a pattern that long.
|
||||
expect(() => resolveArtifactOutputs(tempDir, wide(20))).toThrow(
|
||||
`1024 patterns: ${wide(20).slice(0, 80)}...`
|
||||
);
|
||||
// A range is bounded by the same budget. brace-expansion caps itself at
|
||||
// 100,000 results, so the budget is passed down as its `max` and the
|
||||
// allocation stops just past 1024 instead.
|
||||
expect(() => resolveArtifactOutputs(tempDir, 'file-{1..200000}.md')).toThrow(
|
||||
'Artifact output pattern expands to more than 1024 patterns'
|
||||
);
|
||||
// Far past brace-expansion's own cap, so only our `max` can stop it early.
|
||||
expect(() => resolveArtifactOutputs(tempDir, 'file-{1..1000000000}.md')).toThrow(
|
||||
'Artifact output pattern expands to more than 1024 patterns'
|
||||
);
|
||||
expect(globSpy).not.toHaveBeenCalled();
|
||||
globSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('still resolves the widest pattern inside the expansion budget', () => {
|
||||
// 2^10 = 1024 alternatives: at the budget, not over it.
|
||||
const file = path.join(tempDir, `${'a'.repeat(10)}.md`);
|
||||
fs.writeFileSync(file, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, `${'{a,b}'.repeat(10)}.md`)).toEqual([canonical(file)]);
|
||||
});
|
||||
|
||||
it('rejects an absolute pattern before globbing, however little it matches', () => {
|
||||
// fast-glob's generateTasks reported `/etc` as the task base, so the
|
||||
// containment check rejected the pattern. A base that silently drops the
|
||||
// leading slash resolves inside the change directory and lets the glob read
|
||||
// a directory the change does not own.
|
||||
const globSpy = vi.mocked(globby.globSync);
|
||||
|
||||
expect(() => resolveArtifactOutputs(tempDir, '/etc/openspec-nope-*.md')).toThrow(
|
||||
/outside the allowed directory/u
|
||||
);
|
||||
expect(() => resolveArtifactOutputs(tempDir, '/openspec-nope-*.md')).toThrow(
|
||||
/outside the allowed directory/u
|
||||
);
|
||||
expect(globSpy).not.toHaveBeenCalled();
|
||||
globSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps a control character in a filename literal instead of reading it as a brace', () => {
|
||||
// The expander parks a non-expanding brace group on a sentinel. A
|
||||
// single-character sentinel is a legal filename character on POSIX, so a
|
||||
// pattern containing one came back out as `{` or `}` and matched the wrong
|
||||
// file. The decoy is what the bug returned, and it must never match on any
|
||||
// platform; Windows forbids U+0001 in a filename, so there the real file
|
||||
// cannot exist and the pattern has to come back empty.
|
||||
fs.writeFileSync(path.join(tempDir, 'x}y.md'), 'wrong');
|
||||
const literal = path.join(tempDir, 'x\u0001y.md');
|
||||
const literalIsCreatable = process.platform !== 'win32';
|
||||
if (literalIsCreatable) fs.writeFileSync(literal, 'content');
|
||||
|
||||
expect(resolveArtifactOutputs(tempDir, 'x\u0001y*.md')).toEqual(
|
||||
literalIsCreatable ? [canonical(literal)] : []
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a bracket', (depth: number) => `*${'{a,[}]'.repeat(depth)}${'}'.repeat(depth)}`],
|
||||
['a quote', (depth: number) => `*${'{a,"}"'.repeat(depth)}${'}'.repeat(depth)}`],
|
||||
['an open parenthesis', (depth: number) => `*${'{a,(}'.repeat(depth)}${')}'.repeat(depth)}`],
|
||||
['parentheses alone', (depth: number) => `*{a,b}${'('.repeat(depth)}${')'.repeat(depth)}`],
|
||||
])('counts nesting the braces parser sees past %s', (_label, nested) => {
|
||||
const globSpy = vi.mocked(globby.globSync);
|
||||
|
||||
expect(() => resolveArtifactOutputs(tempDir, nested(17))).toThrow(
|
||||
'Artifact output pattern nests braces more than 16 levels deep'
|
||||
);
|
||||
expect(globSpy).not.toHaveBeenCalled();
|
||||
globSpy.mockRestore();
|
||||
});
|
||||
|
||||
it.each(['@(proposal|design).md', '+(proposal|design).md'])('supports extglob %s', (pattern) => {
|
||||
const proposalPath = path.join(tempDir, 'proposal.md');
|
||||
fs.writeFileSync(proposalPath, 'content');
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Command, Option } from 'commander';
|
||||
|
||||
import { isJsonRun, isCompletionRun, shouldDeferCompletionTip } from '../../src/cli/index.js';
|
||||
import {
|
||||
isJsonRun,
|
||||
isCompletionRun,
|
||||
isTabCompletionRun,
|
||||
shouldDeferCompletionTip,
|
||||
} from '../../src/cli/index.js';
|
||||
|
||||
/**
|
||||
* Reproduce the three ways `--json` reaches a command in the real CLI, so a
|
||||
@@ -106,6 +111,23 @@ describe('isCompletionRun', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('isTabCompletionRun', () => {
|
||||
/**
|
||||
* Only the hidden resolver skips telemetry. The user-facing `completion`
|
||||
* commands are run by a person and are tracked like any other command.
|
||||
*/
|
||||
it('matches the hidden resolver', () => {
|
||||
expect(isTabCompletionRun('__complete')).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['completion', 'completion:install', 'completion:generate', 'list'])(
|
||||
'does not match "%s"',
|
||||
(commandPath) => {
|
||||
expect(isTabCompletionRun(commandPath)).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('shouldDeferCompletionTip', () => {
|
||||
/**
|
||||
* The tip must survive every run that cannot display it. Deferring (rather
|
||||
|
||||
@@ -6,13 +6,17 @@ import { SpecCommand } from '../../../src/commands/spec.js';
|
||||
|
||||
describe('SpecCommand path boundaries', () => {
|
||||
let tempDir: string;
|
||||
let projectDir: string;
|
||||
let originalCwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalCwd = process.cwd();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-spec-command-security-'));
|
||||
await fs.mkdir(path.join(tempDir, 'openspec', 'specs'), { recursive: true });
|
||||
process.chdir(tempDir);
|
||||
// A spec file may link to another file in the same project, so the escape
|
||||
// these cases guard against is one that leaves the project entirely.
|
||||
projectDir = path.join(tempDir, 'repo');
|
||||
await fs.mkdir(path.join(projectDir, 'openspec', 'specs'), { recursive: true });
|
||||
process.chdir(projectDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -31,10 +35,10 @@ describe('SpecCommand path boundaries', () => {
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rejects a spec file symlink that leaves the specs root',
|
||||
'rejects a spec file symlink that leaves the project',
|
||||
async () => {
|
||||
const outsideSpec = path.join(tempDir, 'outside.md');
|
||||
const linkedSpec = path.join(tempDir, 'openspec', 'specs', 'linked', 'spec.md');
|
||||
const linkedSpec = path.join(projectDir, 'openspec', 'specs', 'linked', 'spec.md');
|
||||
await fs.writeFile(outsideSpec, '# Outside sentinel');
|
||||
await fs.mkdir(path.dirname(linkedSpec), { recursive: true });
|
||||
await fs.symlink(outsideSpec, linkedSpec);
|
||||
@@ -45,6 +49,28 @@ describe('SpecCommand path boundaries', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'shows a spec.md linked to a file elsewhere in the project',
|
||||
async () => {
|
||||
// The reported layout (#2100): the spec lives beside the product docs it
|
||||
// belongs to and openspec/specs/<capability>/spec.md links to it.
|
||||
const docsSpec = path.join(projectDir, 'docs', 'auth', 'specs', 'login.md');
|
||||
await fs.mkdir(path.dirname(docsSpec), { recursive: true });
|
||||
await fs.writeFile(
|
||||
docsSpec,
|
||||
'# login\n\n## Purpose\n\nUsers sign in.\n\n## Requirements\n'
|
||||
);
|
||||
const linkedSpec = path.join(projectDir, 'openspec', 'specs', 'login', 'spec.md');
|
||||
await fs.mkdir(path.dirname(linkedSpec), { recursive: true });
|
||||
await fs.symlink(
|
||||
path.relative(path.dirname(linkedSpec), docsSpec),
|
||||
linkedSpec
|
||||
);
|
||||
|
||||
await expect(new SpecCommand().show('login')).resolves.toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'allows a linked capability directory as its own trust root',
|
||||
async () => {
|
||||
@@ -56,7 +82,7 @@ describe('SpecCommand path boundaries', () => {
|
||||
);
|
||||
await fs.symlink(
|
||||
sharedCapability,
|
||||
path.join(tempDir, 'openspec', 'specs', 'shared')
|
||||
path.join(projectDir, 'openspec', 'specs', 'shared')
|
||||
);
|
||||
|
||||
await expect(new SpecCommand().show('shared')).resolves.toBeUndefined();
|
||||
@@ -66,7 +92,7 @@ describe('SpecCommand path boundaries', () => {
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'allows a spec file symlink elsewhere in the specs root',
|
||||
async () => {
|
||||
const specsDir = path.join(tempDir, 'openspec', 'specs');
|
||||
const specsDir = path.join(projectDir, 'openspec', 'specs');
|
||||
const sharedSpec = path.join(specsDir, 'shared.md');
|
||||
const linkedSpec = path.join(specsDir, 'linked', 'spec.md');
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -30,6 +30,11 @@ describe('core/completion-tip', () => {
|
||||
process.env.HOME = tempDir;
|
||||
process.env.USERPROFILE = tempDir;
|
||||
process.env.SHELL = '/bin/zsh';
|
||||
// Oh My Zsh exports $ZSH, and the zsh installer looks for completions
|
||||
// under it (or $ZSH_CUSTOM) instead of HOME, so a real install leaks
|
||||
// through the HOME sandbox above.
|
||||
delete process.env.ZSH;
|
||||
delete process.env.ZSH_CUSTOM;
|
||||
delete process.env.CI;
|
||||
delete process.env.OPENSPEC_NO_COMPLETIONS;
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
@@ -421,5 +421,35 @@ describe('ZshGenerator', () => {
|
||||
expect(script).toContain('_openspec_view() {');
|
||||
expect(script).toContain('_arguments');
|
||||
});
|
||||
|
||||
// The bash, fish and powershell generator tests each pin this redirect;
|
||||
// zsh was the one that did not, and it has the most `__complete` call
|
||||
// sites and is the default shell on macOS. The redirect is what keeps a
|
||||
// Tab press silent, so anything `__complete` ever writes to stderr - a
|
||||
// deprecation notice, a Node experimental warning - would otherwise print
|
||||
// into the middle of the user's prompt with no test objecting.
|
||||
it('sends every __complete call stderr to /dev/null', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{ name: 'archive', description: 'Archive a change', flags: [] },
|
||||
{ name: 'show', description: 'Show an item', flags: [] },
|
||||
{ name: 'new', description: 'Create a change', flags: [] },
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('openspec __complete changes 2>/dev/null');
|
||||
expect(script).toContain('openspec __complete specs 2>/dev/null');
|
||||
expect(script).toContain('openspec __complete schemas 2>/dev/null');
|
||||
// No invocation may survive a template edit that drops a redirect. Only
|
||||
// real call sites count: the script also explains itself in comments
|
||||
// that name the command without running it.
|
||||
const invocations = script
|
||||
.split('\n')
|
||||
.filter((line) => line.includes('openspec __complete') && !line.trim().startsWith('#'));
|
||||
expect(invocations.length).toBeGreaterThan(0);
|
||||
for (const line of invocations) {
|
||||
expect(line, `unredirected __complete call: ${line.trim()}`).toContain('2>/dev/null');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,11 +292,11 @@ describe('an unparseable global config', () => {
|
||||
});
|
||||
|
||||
describe('openspec config', () => {
|
||||
let registerConfigCommand: typeof import('../../src/commands/config.js').registerConfigCommand;
|
||||
let registerConfigCommand: typeof import('../../src/cli/commands/config.js').registerConfigCommand;
|
||||
|
||||
// Imported once: the command module pulls in most of the CLI.
|
||||
beforeAll(async () => {
|
||||
({ registerConfigCommand } = await import('../../src/commands/config.js'));
|
||||
({ registerConfigCommand } = await import('../../src/cli/commands/config.js'));
|
||||
}, 60_000);
|
||||
|
||||
async function runConfig(args: string[]): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { parseDeltaSpec } from '../../../src/core/parsers/requirement-blocks.js';
|
||||
import { buildUpdatedSpec, findSpecUpdates } from '../../../src/core/specs-apply.js';
|
||||
import { Validator } from '../../../src/core/validation/validator.js';
|
||||
|
||||
// Archive carries requirement blocks only. Text written anywhere else in a delta
|
||||
// used to vanish with no warning while `validate --strict` passed.
|
||||
const DELTA = [
|
||||
'# billing delta', // 1 - a title is not reported
|
||||
'Context above the first section.', // 2
|
||||
'## ADDED Requirements', // 3
|
||||
'Intro prose.', // 4
|
||||
'### Requirement: Invoices', // 5
|
||||
'The system SHALL generate invoices.', // 6
|
||||
'', // 7
|
||||
'#### Scenario: Period closes', // 8
|
||||
'- **WHEN** a', // 9
|
||||
'- **THEN** b', // 10
|
||||
'Trailing note, carried with the block.', // 11
|
||||
'## Acceptance Criteria', // 12
|
||||
'- Invoices reconcile.', // 13
|
||||
].join('\n');
|
||||
|
||||
describe('parseDeltaSpec unappliedContent', () => {
|
||||
it('reports text above the first section, leading prose, and non-delta sections', () => {
|
||||
expect(parseDeltaSpec(DELTA).unappliedContent).toEqual([
|
||||
{ section: null, line: 2 },
|
||||
{ section: 'ADDED Requirements', line: 4 },
|
||||
{ section: 'Acceptance Criteria', line: 12 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not exempt a commented-out Purpose, and honours the --!> terminator', () => {
|
||||
const plan = parseDeltaSpec(
|
||||
['<!--', '## Purpose', '--!>', 'Real prose.', '## ADDED Requirements', '### Requirement: A', 'a', '## Notes', 'n'].join(
|
||||
'\n'
|
||||
)
|
||||
);
|
||||
expect(plan.unappliedContent).toEqual([
|
||||
{ section: 'Purpose', line: 2 },
|
||||
{ section: 'Notes', line: 8 },
|
||||
]);
|
||||
});
|
||||
|
||||
// The merge splits sections on every `## ` line outside a fence, comment or not.
|
||||
// Reading boundaries from comment-masked lines disagreed with it both ways.
|
||||
it('reads section boundaries exactly as the merge does', () => {
|
||||
// `<!--` in inline code and a later `-->` are not a comment to the merge;
|
||||
// masking across them hid `## ADDED Requirements` and blamed a carried requirement.
|
||||
const inlineOpener = parseDeltaSpec(
|
||||
['# Delta: strip `<!--` openers', '## ADDED Requirements', '### Requirement: A', 'Flow: ingest --> render', 'The system SHALL a.'].join('\n')
|
||||
);
|
||||
expect(inlineOpener.added.map((b) => b.name)).toEqual(['A']);
|
||||
expect(inlineOpener.unappliedContent).toEqual([]);
|
||||
|
||||
// A header inside a comment is still a section to the merge, so the
|
||||
// requirement after the comment is carried, not text above the first section.
|
||||
const commentedHeader = parseDeltaSpec(
|
||||
['<!-- add requirements below', '## ADDED Requirements', '-->', '### Requirement: A', 'The system SHALL a.'].join('\n')
|
||||
);
|
||||
expect(commentedHeader.added.map((b) => b.name)).toEqual(['A']);
|
||||
expect(commentedHeader.unappliedContent).toEqual([]);
|
||||
|
||||
// ...and text after a commented-out `## Notes` is dropped by the merge.
|
||||
const commentedNotes = parseDeltaSpec(
|
||||
['## ADDED Requirements', '### Requirement: A', 'a', '<!--', '## Notes', '-->', 'Dropped.'].join('\n')
|
||||
);
|
||||
expect(commentedNotes.added[0].raw).not.toContain('Dropped.');
|
||||
expect(commentedNotes.unappliedContent).toEqual([{ section: 'Notes', line: 5 }]);
|
||||
});
|
||||
|
||||
it('does not let a `<!--` inside a code fence hide later sections', () => {
|
||||
const plan = parseDeltaSpec(
|
||||
['## ADDED Requirements', '### Requirement: A', 'a', '```html', '<!-- unterminated sample', '```', '## Notes', 'Dropped.'].join('\n')
|
||||
);
|
||||
expect(plan.unappliedContent).toEqual([{ section: 'Notes', line: 7 }]);
|
||||
});
|
||||
|
||||
it('ignores Purpose, REMOVED/RENAMED bodies, HTML comments, fences, and stray ### headers', () => {
|
||||
const plan = parseDeltaSpec(
|
||||
[
|
||||
'<!--',
|
||||
'Multi-line instructions.',
|
||||
'-->',
|
||||
'## Purpose',
|
||||
'A new capability.',
|
||||
'## ADDED Requirements',
|
||||
'<!-- add requirements below -->',
|
||||
'### Documentation Requirements',
|
||||
'### Requirement: A',
|
||||
'a',
|
||||
'```markdown',
|
||||
'## Notes',
|
||||
'```',
|
||||
'## REMOVED Requirements',
|
||||
'### Requirement: B',
|
||||
'**Reason**: obsolete',
|
||||
'## RENAMED Requirements',
|
||||
'- FROM: `### Requirement: C`',
|
||||
'- TO: `### Requirement: D`',
|
||||
].join('\n')
|
||||
);
|
||||
expect(plan.unappliedContent).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive and validate report unapplied content', () => {
|
||||
let tempDir: string;
|
||||
let changeDir: string;
|
||||
let specsRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-unapplied-'));
|
||||
specsRoot = path.join(tempDir, 'openspec', 'specs');
|
||||
changeDir = path.join(tempDir, 'openspec', 'changes', 'c');
|
||||
await fs.mkdir(path.join(changeDir, 'specs', 'billing'), { recursive: true });
|
||||
await fs.mkdir(specsRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(changeDir, 'specs', 'billing', 'spec.md'), DELTA);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('archive warns and still carries only the requirement', async () => {
|
||||
const [update] = await findSpecUpdates(changeDir, specsRoot);
|
||||
const built = await buildUpdatedSpec(update, 'c', { silent: true });
|
||||
|
||||
expect(built.warnings).toContain(
|
||||
'billing - section "## Acceptance Criteria" (line 12) is not carried into the main spec.'
|
||||
);
|
||||
expect(built.rebuilt).toContain('### Requirement: Invoices');
|
||||
expect(built.rebuilt).not.toContain('Acceptance Criteria');
|
||||
});
|
||||
|
||||
it('validate warns, which fails --strict', async () => {
|
||||
const report = await new Validator(true).validateChangeDeltaSpecs(changeDir);
|
||||
const lost = report.issues.filter((issue) => /not carried into the main spec/.test(issue.message));
|
||||
|
||||
expect(lost.map((issue) => [issue.level, issue.line])).toEqual([
|
||||
['WARNING', 2],
|
||||
['WARNING', 4],
|
||||
['WARNING', 12],
|
||||
]);
|
||||
expect(report.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
buildUpdatedSpec,
|
||||
findSpecUpdates,
|
||||
writeUpdatedSpec,
|
||||
} from '../../src/core/specs-apply.js';
|
||||
|
||||
const itWithSymlinks = it.skipIf(process.platform === 'win32');
|
||||
|
||||
/**
|
||||
* A project may keep its main specs beside the product documentation they
|
||||
* belong to and link `openspec/specs/<capability>/spec.md` to them, so the
|
||||
* docs and OpenSpec read and write the same bytes (#2100). The merge has to
|
||||
* write through such a link, and still refuse one that leaves the project.
|
||||
*/
|
||||
describe('a spec.md linked to a file elsewhere in the project', () => {
|
||||
let tempDir: string;
|
||||
let projectDir: string;
|
||||
let changeDir: string;
|
||||
let mainSpecsDir: string;
|
||||
let docsSpec: string;
|
||||
let linkedSpec: string;
|
||||
|
||||
const DELTA = [
|
||||
'## ADDED Requirements',
|
||||
'',
|
||||
'### Requirement: Second factor',
|
||||
'The system SHALL require a second factor.',
|
||||
'',
|
||||
'#### Scenario: Prompted',
|
||||
'- **WHEN** a user signs in',
|
||||
'- **THEN** a second factor is requested',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const MAIN_SPEC = [
|
||||
'# login Specification',
|
||||
'',
|
||||
'## Purpose',
|
||||
'Users sign in with their credentials to reach their account.',
|
||||
'',
|
||||
'## Requirements',
|
||||
'',
|
||||
'### Requirement: Sign in',
|
||||
'The system SHALL let a user sign in with valid credentials.',
|
||||
'',
|
||||
'#### Scenario: Valid credentials',
|
||||
'- **WHEN** the user submits valid credentials',
|
||||
'- **THEN** the user is signed in',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-project-linked-spec-'));
|
||||
projectDir = path.join(tempDir, 'repo');
|
||||
changeDir = path.join(projectDir, 'openspec', 'changes', 'add-mfa');
|
||||
mainSpecsDir = path.join(projectDir, 'openspec', 'specs');
|
||||
docsSpec = path.join(projectDir, 'docs', 'auth', 'specs', 'login.md');
|
||||
linkedSpec = path.join(mainSpecsDir, 'login', 'spec.md');
|
||||
|
||||
await fs.mkdir(path.join(changeDir, 'specs', 'login'), { recursive: true });
|
||||
await fs.writeFile(path.join(changeDir, 'specs', 'login', 'spec.md'), DELTA);
|
||||
await fs.mkdir(path.dirname(docsSpec), { recursive: true });
|
||||
await fs.writeFile(docsSpec, MAIN_SPEC);
|
||||
await fs.mkdir(path.dirname(linkedSpec), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
itWithSymlinks('merges through the link and leaves the link in place', async () => {
|
||||
await fs.symlink(path.relative(path.dirname(linkedSpec), docsSpec), linkedSpec);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const onWarning = (warning: Error) => warnings.push(warning.message);
|
||||
process.on('warning', onWarning);
|
||||
let updates;
|
||||
try {
|
||||
updates = await findSpecUpdates(changeDir, mainSpecsDir);
|
||||
// process.emitWarning dispatches on the next tick.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
} finally {
|
||||
process.off('warning', onWarning);
|
||||
}
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
const [update] = updates;
|
||||
// The link path is what the CLI reports, and the write goes through it.
|
||||
// Rebased onto the canonical specs root, so /var and /private/var agree.
|
||||
const canonicalLinkedSpec = path.join(
|
||||
await fs.realpath(path.dirname(linkedSpec)),
|
||||
'spec.md'
|
||||
);
|
||||
expect(update.target).toBe(canonicalLinkedSpec);
|
||||
expect(update.exists).toBe(true);
|
||||
// Nothing leaves the project, so there is nothing to announce.
|
||||
expect(warnings).toEqual([]);
|
||||
|
||||
const built = await buildUpdatedSpec(update, 'add-mfa', { silent: true });
|
||||
await writeUpdatedSpec(update, built.rebuilt, built.counts, { silent: true });
|
||||
|
||||
// The file the docs own received the merge, and kept what it already had.
|
||||
const merged = await fs.readFile(docsSpec, 'utf-8');
|
||||
expect(merged).toContain('Second factor');
|
||||
expect(merged).toContain('Sign in');
|
||||
expect((await fs.lstat(linkedSpec)).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
itWithSymlinks('refuses a link that leaves the project', async () => {
|
||||
const outsideSpec = path.join(tempDir, 'outside.md');
|
||||
await fs.writeFile(outsideSpec, MAIN_SPEC);
|
||||
await fs.symlink(outsideSpec, linkedSpec);
|
||||
|
||||
await expect(findSpecUpdates(changeDir, mainSpecsDir)).rejects.toThrow(
|
||||
'Path is outside the allowed directory'
|
||||
);
|
||||
await expect(fs.readFile(outsideSpec, 'utf-8')).resolves.toBe(MAIN_SPEC);
|
||||
});
|
||||
|
||||
itWithSymlinks(
|
||||
'refuses a link repointed out of the project between resolve and write',
|
||||
async () => {
|
||||
await fs.symlink(path.relative(path.dirname(linkedSpec), docsSpec), linkedSpec);
|
||||
const [update] = await findSpecUpdates(changeDir, mainSpecsDir);
|
||||
const built = await buildUpdatedSpec(update, 'add-mfa', { silent: true });
|
||||
|
||||
// The link now points out of the project, after the path was resolved and
|
||||
// the merge was built. The recheck before the write has to catch it.
|
||||
const outsideSpec = path.join(tempDir, 'outside.md');
|
||||
await fs.writeFile(outsideSpec, 'outside sentinel');
|
||||
await fs.rm(linkedSpec);
|
||||
await fs.symlink(outsideSpec, linkedSpec);
|
||||
|
||||
await expect(
|
||||
writeUpdatedSpec(update, built.rebuilt, built.counts, { silent: true })
|
||||
).rejects.toThrow('Path is outside the allowed directory');
|
||||
await expect(fs.readFile(outsideSpec, 'utf-8')).resolves.toBe('outside sentinel');
|
||||
}
|
||||
);
|
||||
|
||||
itWithSymlinks(
|
||||
'refuses a link repointed out of the project between resolve and read',
|
||||
async () => {
|
||||
await fs.symlink(path.relative(path.dirname(linkedSpec), docsSpec), linkedSpec);
|
||||
const [update] = await findSpecUpdates(changeDir, mainSpecsDir);
|
||||
|
||||
const outsideSpec = path.join(tempDir, 'outside.md');
|
||||
await fs.writeFile(outsideSpec, 'outside sentinel');
|
||||
await fs.rm(linkedSpec);
|
||||
await fs.symlink(outsideSpec, linkedSpec);
|
||||
|
||||
await expect(buildUpdatedSpec(update, 'add-mfa', { silent: true })).rejects.toThrow(
|
||||
'Path is outside the allowed directory'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
itWithSymlinks(
|
||||
'cannot be redirected by a link repointed while the write is in flight',
|
||||
async () => {
|
||||
await fs.symlink(path.relative(path.dirname(linkedSpec), docsSpec), linkedSpec);
|
||||
const [update] = await findSpecUpdates(changeDir, mainSpecsDir);
|
||||
const built = await buildUpdatedSpec(update, 'add-mfa', { silent: true });
|
||||
|
||||
const outsideSpec = path.join(tempDir, 'outside.md');
|
||||
await fs.writeFile(outsideSpec, 'outside sentinel');
|
||||
|
||||
// `beforeMutate` runs after the containment check, so it is the one
|
||||
// window a swap could still use.
|
||||
await expect(
|
||||
writeUpdatedSpec(update, built.rebuilt, built.counts, {
|
||||
silent: true,
|
||||
beforeMutate: async () => {
|
||||
await fs.rm(linkedSpec);
|
||||
await fs.symlink(outsideSpec, linkedSpec);
|
||||
},
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
// Nothing was written: not to where the link now points, and not to the
|
||||
// file that was verified either, since the target is no longer it.
|
||||
await expect(fs.readFile(outsideSpec, 'utf-8')).resolves.toBe('outside sentinel');
|
||||
await expect(fs.readFile(docsSpec, 'utf-8')).resolves.not.toContain('Second factor');
|
||||
}
|
||||
);
|
||||
|
||||
itWithSymlinks(
|
||||
'refuses an ordinary target replaced by a link while the write is in flight',
|
||||
async () => {
|
||||
// Not specific to the linked layout: the canonical file at the end of the
|
||||
// chain can be swapped for a link too, so the re-check has to catch that
|
||||
// for a plain `openspec/specs/<capability>/spec.md` as well.
|
||||
await fs.writeFile(linkedSpec, MAIN_SPEC);
|
||||
const [update] = await findSpecUpdates(changeDir, mainSpecsDir);
|
||||
const built = await buildUpdatedSpec(update, 'add-mfa', { silent: true });
|
||||
const outsideSpec = path.join(tempDir, 'outside.md');
|
||||
await fs.writeFile(outsideSpec, 'outside sentinel');
|
||||
|
||||
await expect(
|
||||
writeUpdatedSpec(update, built.rebuilt, built.counts, {
|
||||
silent: true,
|
||||
beforeMutate: async () => {
|
||||
await fs.rm(linkedSpec);
|
||||
await fs.symlink(outsideSpec, linkedSpec);
|
||||
},
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect(fs.readFile(outsideSpec, 'utf-8')).resolves.toBe('outside sentinel');
|
||||
}
|
||||
);
|
||||
|
||||
itWithSymlinks('merges through a linked delta spec inside the project', async () => {
|
||||
await fs.symlink(path.relative(path.dirname(linkedSpec), docsSpec), linkedSpec);
|
||||
const sharedDelta = path.join(projectDir, 'docs', 'auth', 'deltas', 'login.md');
|
||||
await fs.mkdir(path.dirname(sharedDelta), { recursive: true });
|
||||
await fs.writeFile(sharedDelta, DELTA);
|
||||
const deltaPath = path.join(changeDir, 'specs', 'login', 'spec.md');
|
||||
await fs.rm(deltaPath);
|
||||
await fs.symlink(path.relative(path.dirname(deltaPath), sharedDelta), deltaPath);
|
||||
|
||||
const [update] = await findSpecUpdates(changeDir, mainSpecsDir);
|
||||
const built = await buildUpdatedSpec(update, 'add-mfa', { silent: true });
|
||||
|
||||
expect(built.rebuilt).toContain('Second factor');
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ const itWithSymlinks = it.skipIf(process.platform === 'win32');
|
||||
|
||||
describe('spec apply path boundaries', () => {
|
||||
let tempDir: string;
|
||||
let projectDir: string;
|
||||
let changeDir: string;
|
||||
let changeSpecsDir: string;
|
||||
let mainSpecsDir: string;
|
||||
@@ -19,9 +20,12 @@ describe('spec apply path boundaries', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-spec-apply-security-'));
|
||||
changeDir = path.join(tempDir, 'openspec', 'changes', 'test-change');
|
||||
// A spec file may link to another file in the same project, so the escape
|
||||
// these cases guard against is one that leaves the project entirely.
|
||||
projectDir = path.join(tempDir, 'repo');
|
||||
changeDir = path.join(projectDir, 'openspec', 'changes', 'test-change');
|
||||
changeSpecsDir = path.join(changeDir, 'specs');
|
||||
mainSpecsDir = path.join(tempDir, 'openspec', 'specs');
|
||||
mainSpecsDir = path.join(projectDir, 'openspec', 'specs');
|
||||
outsideDir = path.join(tempDir, 'outside');
|
||||
await fs.mkdir(changeSpecsDir, { recursive: true });
|
||||
await fs.mkdir(mainSpecsDir, { recursive: true });
|
||||
@@ -52,7 +56,7 @@ describe('spec apply path boundaries', () => {
|
||||
return deltaPath;
|
||||
}
|
||||
|
||||
itWithSymlinks('rejects a delta spec symlink that leaves the change specs root', async () => {
|
||||
itWithSymlinks('rejects a delta spec symlink that leaves the project', async () => {
|
||||
const outsideSpec = path.join(outsideDir, 'spec.md');
|
||||
await fs.writeFile(outsideSpec, 'outside sentinel');
|
||||
const linkedSpec = path.join(changeSpecsDir, 'widgets', 'spec.md');
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fg from 'fast-glob';
|
||||
import { globSync } from 'tinyglobby';
|
||||
import { findMissingTaskCheckboxIssues } from '../../src/core/validation/task-checkboxes.js';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
@@ -189,9 +189,10 @@ describe('findMissingTaskCheckboxIssues', () => {
|
||||
it('accepts every packaged tasks template', async () => {
|
||||
// An agent writing a task file follows these. If one ever loses its
|
||||
// checkboxes, every change built from it starts life counting zero tasks.
|
||||
const templates = await fg('schemas/*/templates/tasks.md', {
|
||||
const templates = globSync('schemas/*/templates/tasks.md', {
|
||||
cwd: repoRoot,
|
||||
absolute: true,
|
||||
expandDirectories: false,
|
||||
});
|
||||
expect(templates.length).toBeGreaterThan(0);
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// Every workflow that turns a glob output into a concrete write path must keep
|
||||
// that path inside `changeRoot` (#2099). update-change always did; propose,
|
||||
// ff-change and continue-change chose a path with no containment rule, so a
|
||||
// capability path such as `../../x`, or a symlinked directory under `specs/`,
|
||||
// would be written outside the change.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getContinueChangeSkillTemplate,
|
||||
getExploreSkillTemplate,
|
||||
getFfChangeSkillTemplate,
|
||||
getOpsxContinueCommandTemplate,
|
||||
getOpsxExploreCommandTemplate,
|
||||
getOpsxFfCommandTemplate,
|
||||
getOpsxProposeCommandTemplate,
|
||||
getOpsxProposeSkillTemplate,
|
||||
getOpsxUpdateCommandTemplate,
|
||||
getUpdateChangeSkillTemplate,
|
||||
} from '../../../src/core/templates/skill-templates.js';
|
||||
import { resolveOptionalWorkflows } from '../../../src/core/templates/optional-workflow.js';
|
||||
import { ALL_WORKFLOWS } from '../../../src/core/profiles.js';
|
||||
|
||||
const withAllWorkflows = (body: string) =>
|
||||
resolveOptionalWorkflows(body, new Set<string>(ALL_WORKFLOWS));
|
||||
|
||||
/** Lines that tell the agent to pick a concrete path for a glob output. */
|
||||
const PATH_CHOICE = /choose a concrete path/i;
|
||||
|
||||
/**
|
||||
* The containment rule for a write that may land on an existing file. A
|
||||
* destination that is itself a symlink to a file outside `changeRoot` passes a
|
||||
* parent-only check, so the file is resolved too.
|
||||
*/
|
||||
const BOUND =
|
||||
'remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists';
|
||||
|
||||
/**
|
||||
* update-change creates its file with an operation that fails if the target
|
||||
* exists - a symlink included - so resolving the parents is enough there.
|
||||
*/
|
||||
const CREATE_ONLY_BOUND =
|
||||
'remains inside `changeRoot` after resolving any symlinked parent directories';
|
||||
|
||||
const boundFor = (label: string) => (label.startsWith('update-change') ? CREATE_ONLY_BOUND : BOUND);
|
||||
|
||||
/** Every surface that writes glob artifacts, skill and slash-command alike. */
|
||||
const SURFACES: Array<[label: string, body: string]> = [
|
||||
['propose skill', withAllWorkflows(getOpsxProposeSkillTemplate().instructions)],
|
||||
['propose command', withAllWorkflows(getOpsxProposeCommandTemplate().content)],
|
||||
['ff-change skill', withAllWorkflows(getFfChangeSkillTemplate().instructions)],
|
||||
['ff-change command', withAllWorkflows(getOpsxFfCommandTemplate().content)],
|
||||
['continue-change skill', withAllWorkflows(getContinueChangeSkillTemplate().instructions)],
|
||||
['continue-change command', withAllWorkflows(getOpsxContinueCommandTemplate().content)],
|
||||
['update-change skill', withAllWorkflows(getUpdateChangeSkillTemplate().instructions)],
|
||||
['update-change command', withAllWorkflows(getOpsxUpdateCommandTemplate().content)],
|
||||
['explore skill', withAllWorkflows(getExploreSkillTemplate().instructions)],
|
||||
['explore command', withAllWorkflows(getOpsxExploreCommandTemplate().content)],
|
||||
];
|
||||
|
||||
/** Where a line tells the agent to write. The bound must come before it. */
|
||||
const WRITE = /\b(?:write|Write)\b(?! anything)(?! an expected path)(?! only the missing)/;
|
||||
|
||||
/**
|
||||
* The bound has to be checked before the write, or an escaping path is already
|
||||
* written by the time it is verified. A line with no write (update-change
|
||||
* writes in a later step) only needs the bound.
|
||||
*/
|
||||
function expectBoundBeforeWrite(label: string, line: string): void {
|
||||
const boundAt = line.indexOf(boundFor(label));
|
||||
expect(boundAt, label).toBeGreaterThanOrEqual(0);
|
||||
const writeAt = line.search(WRITE);
|
||||
if (writeAt !== -1) {
|
||||
expect(writeAt, `${label}: writes before checking the bound`).toBeGreaterThan(boundAt);
|
||||
expect(line, `${label}: no instruction to refuse an escaping path`).toContain(
|
||||
'stop and tell the user'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Every place a body hands artifact creation to another skill or command. */
|
||||
const DELEGATION = /delegates creation/g;
|
||||
|
||||
/**
|
||||
* A delegated writer is a write too. It may run only after the path it writes
|
||||
* to has been chosen and checked, and only if it can write exactly there.
|
||||
* Returns each delegation as [offset, the sentence that states it].
|
||||
*/
|
||||
function delegations(body: string): Array<[number, string]> {
|
||||
return [...body.matchAll(DELEGATION)].map((match) => {
|
||||
const start = match.index ?? 0;
|
||||
const end = body.slice(start).search(/[.\n]/);
|
||||
return [start, body.slice(start, end === -1 ? undefined : start + end)];
|
||||
});
|
||||
}
|
||||
|
||||
describe('glob output paths stay inside changeRoot (#2099)', () => {
|
||||
it.each(SURFACES)('%s bounds every concrete-path choice', (label, body) => {
|
||||
const choices = body.split('\n').filter((line) => PATH_CHOICE.test(line));
|
||||
|
||||
expect(choices, `${label}: no concrete-path line found`).toHaveLength(1);
|
||||
expectBoundBeforeWrite(label, choices[0]);
|
||||
});
|
||||
|
||||
it.each(SURFACES)('%s chooses and checks the path before any delegated writer runs', (label, body) => {
|
||||
const choiceAt = body.search(PATH_CHOICE);
|
||||
const found = delegations(body);
|
||||
expect(found.length, `${label}: no delegated-creation rule found`).toBeGreaterThan(0);
|
||||
|
||||
for (const [at, sentence] of found) {
|
||||
// Bound to one path, and refuses rather than letting it write elsewhere.
|
||||
expect(sentence, label).toMatch(/only if it can/);
|
||||
expect(sentence, label).toMatch(/stop/);
|
||||
// The creation step's delegation comes after the choice it depends on.
|
||||
// The only one allowed earlier is continue-change's backfill, which
|
||||
// names the path its own preceding bullet checked.
|
||||
if (at < choiceAt) {
|
||||
expect(sentence, `${label}: delegates before choosing the path`).toContain(
|
||||
'its checked path'
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
found.some(([at]) => at > choiceAt),
|
||||
`${label}: the creation step does not bound its delegated writer`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// A delegated writer has already created the artifact; writing the template
|
||||
// over it afterwards would replace what it made.
|
||||
it.each(SURFACES.filter(([label]) => !label.startsWith('update-change')))(
|
||||
'%s writes the template only when creation was not delegated',
|
||||
(label, body) => {
|
||||
const templateWrite = /\b[Ww]rite (?:it|the file|the artifact) to that output path/u;
|
||||
const lines = body.split('\n');
|
||||
let writes = 0;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
// Judged sentence by sentence: explore states both branches in one
|
||||
// paragraph, and the delegation sentence names the same path for the
|
||||
// delegated writer.
|
||||
for (const sentence of line.split(/(?<=\.)\s+(?=[A-Z])/u)) {
|
||||
if (!templateWrite.test(sentence) || sentence.includes('delegates creation')) continue;
|
||||
writes++;
|
||||
if (/otherwise/iu.test(sentence.slice(0, sentence.search(templateWrite)))) continue;
|
||||
// Otherwise nested under a bullet that opens the Otherwise branch.
|
||||
const indent = line.search(/\S/u);
|
||||
const parent = lines
|
||||
.slice(0, index)
|
||||
.reverse()
|
||||
.find((candidate) => candidate.trim() !== '' && candidate.search(/\S/u) < indent);
|
||||
expect(parent, `${label}: template write outside the Otherwise branch`).toMatch(
|
||||
/^\s*- Otherwise/u
|
||||
);
|
||||
}
|
||||
});
|
||||
expect(writes, `${label}: no template write found`).toBeGreaterThan(0);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['continue-change skill', withAllWorkflows(getContinueChangeSkillTemplate().instructions)],
|
||||
['continue-change command', withAllWorkflows(getOpsxContinueCommandTemplate().content)],
|
||||
])('%s verifies the output after either creation branch', (label, body) => {
|
||||
const lines = body.split('\n');
|
||||
const delegation = lines.findIndex((line) => line.includes('delegates creation to a specific skill'));
|
||||
const verify = lines.findIndex((line) =>
|
||||
line.includes('Either way, verify the artifact file exists at that output path')
|
||||
);
|
||||
expect(verify, label).toBeGreaterThan(delegation);
|
||||
// Same depth as the two branches, so it runs after whichever was taken.
|
||||
expect(lines[verify].search(/\S/u), label).toBe(lines[delegation].search(/\S/u));
|
||||
});
|
||||
|
||||
it.each(SURFACES)('%s never targets the glob itself', (label, body) => {
|
||||
// A glob is a pattern, not a file: nothing may be written to, or verified
|
||||
// at, `resolvedOutputPath` unless it is a concrete path.
|
||||
expect(body, label).not.toContain('exists at `resolvedOutputPath`');
|
||||
expect(body, label).not.toContain('write it to `resolvedOutputPath`');
|
||||
});
|
||||
|
||||
it.each(SURFACES)('%s restates delegation only with the same bound', (label, body) => {
|
||||
// The artifact-creation guidelines repeat the delegation rule where agents
|
||||
// skim; a bare "invoke it instead" there would undo the ordering above.
|
||||
for (const line of body
|
||||
.split('\n')
|
||||
.filter((candidate) => candidate.includes('directs you to use a specific skill or command'))) {
|
||||
expect(line, label).toContain('only once you have chosen and checked the output path');
|
||||
}
|
||||
});
|
||||
|
||||
// continue-change alone backfills a half-written glob artifact: it compares
|
||||
// expected paths against what exists instead of choosing a new one, so the
|
||||
// bound has to sit on that bullet too.
|
||||
it.each([
|
||||
['continue-change skill', withAllWorkflows(getContinueChangeSkillTemplate().instructions)],
|
||||
['continue-change command', withAllWorkflows(getOpsxContinueCommandTemplate().content)],
|
||||
])('%s bounds the partly-written-artifact check', (label, body) => {
|
||||
const bullets = body
|
||||
.split('\n')
|
||||
.filter((line) => line.includes('resolve each expected path against `changeRoot`'));
|
||||
|
||||
expect(bullets, `${label}: partial-artifact bullet missing`).toHaveLength(1);
|
||||
expect(bullets[0], label).toContain(BOUND);
|
||||
// The write is the next bullet, so the refusal has to sit there.
|
||||
const lines = body.split('\n');
|
||||
const next = lines[lines.indexOf(bullets[0]) + 1];
|
||||
expect(next, label).toContain('Never write an expected path that fails that check');
|
||||
expect(next.indexOf('Never write'), label).toBeLessThan(next.indexOf('write only the missing'));
|
||||
// A delegated writer filling the gap is held to the same checked paths.
|
||||
expect(next, label).toContain('invoke it only if it can write each file to its checked path');
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { getCommandContents } from '../../../src/core/shared/skill-generation.js
|
||||
import { MAX_CONTEXT_SIZE } from '../../../src/core/project-config.js';
|
||||
import { resolveOptionalWorkflows } from '../../../src/core/templates/optional-workflow.js';
|
||||
import { ALL_WORKFLOWS } from '../../../src/core/profiles.js';
|
||||
import { parseTaskLines } from '../../../src/utils/task-progress.js';
|
||||
|
||||
// Templates carry optional-workflow conditionals; a body only means anything
|
||||
// once resolved against a workflow set. Unless a test says otherwise, these are
|
||||
@@ -117,6 +118,28 @@ describe('default proposal guidance', () => {
|
||||
});
|
||||
|
||||
describe('default task guidance', () => {
|
||||
it('keeps tracked tasks within the pre-archive workflow (#1790)', () => {
|
||||
const tasks = defaultSchema.artifacts.find(artifact => artifact.id === 'tasks');
|
||||
expect(tasks).toBeDefined();
|
||||
expect(tasks!.instruction).toMatch(
|
||||
/Track implementation and verification work that can be completed before\s+archive/
|
||||
);
|
||||
expect(tasks!.instruction).toMatch(
|
||||
/preserve those steps as plain bullets in an\s+optional `## Workflow follow-up` section at the end of tasks.md/
|
||||
);
|
||||
|
||||
const examples = [...tasks!.instruction.matchAll(/```\s*([\s\S]*?)```/g)];
|
||||
expect(examples).toHaveLength(2);
|
||||
const implementation = examples[0][1];
|
||||
const followUp = examples[1][1];
|
||||
expect(followUp).toContain('## Workflow follow-up');
|
||||
expect(followUp).toContain('- Verify the archived result.');
|
||||
expect(parseTaskLines(followUp)).toEqual([]);
|
||||
expect(parseTaskLines(`${implementation}\n${followUp}`)).toEqual(
|
||||
parseTaskLines(implementation)
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a concrete verification method in each task (#345)', () => {
|
||||
const tasks = defaultSchema.artifacts.find(artifact => artifact.id === 'tasks');
|
||||
expect(tasks).toBeDefined();
|
||||
@@ -655,7 +678,7 @@ describe('artifact loop guards (propose and ff)', () => {
|
||||
it('tells the agent how to resolve a glob output path', () => {
|
||||
for (const [label, body] of loopBodies) {
|
||||
expect(body, label).toContain(
|
||||
'is a glob, follow `instruction` to choose the concrete file path'
|
||||
'is a glob, follow `instruction` to choose a concrete path'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,8 +22,12 @@ describe('specs instruction requirement length (#1976)', () => {
|
||||
expect(instruction).toContain(`${MAX_REQUIREMENT_TEXT_LENGTH} characters or fewer`);
|
||||
expect(instruction).toContain('split a requirement that covers several behaviors');
|
||||
// Existing requirements under MODIFIED must be copied whole (scenario-loss
|
||||
// validation rejects a split), and the limit is only an INFO hint.
|
||||
expect(instruction).toContain('This is an informational hint, not an error.');
|
||||
// validation rejects a split), and the limit is a warning that fails --strict.
|
||||
expect(instruction).toContain('`openspec validate --strict` fails on it.');
|
||||
expect(instruction).toContain('Under MODIFIED, keep the existing requirement block whole');
|
||||
// Strict CI catches new requirements before archive, and an existing long
|
||||
// requirement has a split path that keeps every scenario (#1976).
|
||||
expect(instruction).toContain('flags longer descriptions in ADDED requirements and in the main spec');
|
||||
expect(instruction).toContain('keep its header and every scenario');
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user