Compare commits

..
Author SHA1 Message Date
openspec-release-bot[bot]andgithub-actions[bot] e062b9572b Version Packages (#1766)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-03 00:00:56 +00:00
Tabish BidiwaleandClay Good fbd4160b37 docs: reroute unfinished store reference links (#1767)
Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 23:34:01 +00:00
Clay Good 9ec0a090b8 fix(security): patch fast-uri advisories (#1768) 2026-09-02 22:50:29 +00:00
dependabot[bot]andClay Good 9dfffd87b3 chore(deps): bump the website-dependencies group across 1 directory with 7 updates (#1765)
* chore(deps): bump the website-dependencies group across 1 directory with 7 updates

Bumps the website-dependencies group with 7 updates in the /website directory:

| Package | From | To |
| --- | --- | --- |
| [fumadocs-core](https://github.com/fuma-nama/fumadocs) | `16.14.5` | `16.15.2` |
| [fumadocs-mdx](https://github.com/fuma-nama/fumadocs) | `15.2.3` | `15.3.1` |
| [fumadocs-ui](https://github.com/fuma-nama/fumadocs) | `16.14.5` | `16.15.2` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.31.0` | `1.34.0` |
| [next](https://github.com/vercel/next.js) | `16.3.1` | `16.3.3` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.2.0` | `26.3.0` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.4` | `19.2.5` |



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

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

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

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

Updates `next` from 16.3.1 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.1...v16.3.3)

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

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

---
updated-dependencies:
- dependency-name: fumadocs-core
  dependency-version: 16.15.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: website-dependencies
- dependency-name: fumadocs-mdx
  dependency-version: 15.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: website-dependencies
- dependency-name: fumadocs-ui
  dependency-version: 16.15.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: website-dependencies
- dependency-name: lucide-react
  dependency-version: 1.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: website-dependencies
- dependency-name: next
  dependency-version: 16.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: website-dependencies
- dependency-name: "@types/node"
  dependency-version: 26.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: website-dependencies
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: website-dependencies
...

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

* fix(website): align esbuild build approval

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 22:22:14 +00:00
dependabot[bot]andClay Good cb5ae2cd16 ci: bump changesets/action from 1.9.0 to 2.1.1 in the github-actions group (#1746)
* ci: bump changesets/action in the github-actions group

Bumps the github-actions group with 1 update: [changesets/action](https://github.com/changesets/action).


Updates `changesets/action` from 1.9.0 to 2.1.1
- [Release notes](https://github.com/changesets/action/releases)
- [Changelog](https://github.com/changesets/action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/changesets/action/compare/a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d...8488615a623b1b9c987934bb89eae8af6a946ac1)

---
updated-dependencies:
- dependency-name: changesets/action
  dependency-version: 2.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

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

* fix(ci): complete changesets action v2 migration

* fix(nix): invalidate pnpm dependency hash

* fix(nix): use calculated dependency hash

* fix(nix): refresh pnpm dependency hash

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 21:55:12 +00:00
Marzx13andClay Good db03c6c4b0 feat(validate): add findings-only bulk reports (#1713)
* feat(validate): propose findings report

* docs(validate): clarify findings report contract

* feat(validate): implement and harden bulk findings reports

* test(validate): canonicalize store paths natively

---------

Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 21:07:46 +00:00
Ryan de MeloandClay Good a4fcdbece6 feat(validate): report the deltas archive would refuse (#1710)
* feat(validate): report the deltas archive would refuse

validate checked a change's deltas against themselves and, for MODIFIED
blocks, against the main spec's scenarios. It never checked whether the
main spec can supply the target a delta acts on, so a MODIFIED naming a
requirement that is not there, a RENAMED whose source is gone, or an
ADDED whose name already exists all validated clean and failed at
archive instead - typically weeks later, after the implementing PR had
shipped and the authoring session was gone.

Run the merge archive runs and report what it refuses. buildUpdatedSpec
returns the rebuilt content without writing it, so the preflight is the
same function on the same inputs with the result discarded, and cannot
disagree with the code that does the writing. That matters here: several
of those preconditions deliberately read a missing target as
already-synced rather than as a failure, and a second copy of the rules
would be free to drift.

Reported as INFO so no verdict changes in any mode. A MODIFIED whose
target is missing is also what a change modifying a sibling's unarchived
requirement looks like, and validate stays valid for that case today;
telling the two apart needs the opt-in marker #1112 asks for. What is
missing until then is the information, not the verdict.

Refs #1112

* fix(validate): skip preflight for deltas whose errors come after the loop

missingHeaderSpecs and emptySectionSpecs are collected inside the
per-spec loop but only become issues after it, so a suppression set
built from the issues raised so far could not see them. A headerless or
empty-section delta has nothing for the merge to apply, so the preflight
reported that as a blocker of its own, on top of the error that names
the actual mistake.

* fix(validate): harden archive preflight diagnostics

* fix(validate): preserve reports when archive preflight cannot start

---------

Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 20:59:09 +00:00
Br1anandClay Good 0296401b82 fix(init): add .gitkeep files to empty directories (#786)
* fix(init): add .gitkeep files to empty directories

After running openspec init, the specs/, changes/, and changes/archive/
directories are empty. Since git does not track empty directories, these
folders are lost when the repository is cloned, causing openspec list to
recommend re-initialization.

Added .gitkeep file creation to createDirectoryStructure() for both
normal and extend modes, ensuring empty directories are preserved in
version control.

Fixes #269

* fix(init): preserve directory anchors without overwriting user files

---------

Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 20:50:59 +00:00
Aron LeeandClay Good cd724449ac refactor(core): share the IDE restart hint between init and update (#1725)
* refactor(core): share the IDE restart hint between init and update

init named the surface it generated ("the new commands" / "the new skills");
update printed a generic "changes" for the same event, decided by the same
rule. Extract that rule and its wording into shared/ide-restart.ts so both
commands say the same thing, and update now names the surface too.

No condition changed: the hint still requires one tool that is both
IDE-resident and actually received a generated surface under the active
delivery, so a CLI tool's commands can never speak for an IDE tool that got
nothing.

Verified by mutation: dropping the IDE-resident filter turns 9 tests red
across the helper, init and update; swapping the commands/skills precedence
turns 5 red.

* test(core): harden shared IDE restart guidance

* fix(core): describe restart guidance for removed workflows

---------

Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 20:41:20 +00:00
Clay Good 954d4796a4 docs(community): add a community showcase (#1739)
* docs(readme): list the independent openspec ui project

* docs(community): move the showcase out of the readme
2026-09-02 20:32:42 +00:00
Clay Good 98bf53e59e fix(workflows): ground proposals in relevant project code (#1737) 2026-09-02 20:25:06 +00:00
HowardandClay Good 2fd175c8b0 docs(cli): document managed PowerShell completion setup (#1070)
* docs(cli): add Windows PowerShell completion example

The shell completion documentation only showed Unix/bash examples,
making it unusable for Windows users. Added platform-specific examples
for both Unix/macOS (bash) and Windows (PowerShell).

Changes:
- Add Unix/macOS (bash) example with ~/.bash_completion.d path
- Add Windows (PowerShell) example with C:\Users\y00031947\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 path
- Improve clarity with platform labels

Fixes: Windows users cannot use shell completion manual installation

* fix(cli): use append operator for PowerShell profile to avoid data loss

Critical fix: Using '>' operator would overwrite the user's PowerShell
profile, deleting existing configurations. Changed to '>>' to append
instead of overwrite, preserving user's existing settings.

* docs(cli): harden PowerShell completion setup

---------

Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 20:14:57 +00:00
Dan (Danilo) Rio (Ribeiro)andClay Good b976106d95 fix(explore): guide planning with focused discovery questions (#1017)
* feat: improve explore discovery questions

* chore: add changeset for explore guidance

---------

Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 20:07:20 +00:00
openspec-cloud[bot]andClay Good cdd06a0594 docs(specs): align four requirements with current behavior (#1707)
* docs(openspec): correct 4 requirements that the code has outgrown

- ai-tool-paths/path-configuration-for-supported-tools#2: Changed the windsurf scenario's required skillsDir from `.windsurf` to `.devin`.
- cli-update/slash-command-updates#6: Require $ARGUMENTS to be placed in the file body (not frontmatter) for OpenCode archive commands.
- rules-injection/validate-artifact-ids-during-instruction-loading#6: Updated the expected warning text to use double quotes and to state it matches no artifact in any available schema, listing known artifact IDs.
- specs-sync-skill/skill-output#3: Changed the expected no-changes message to 'Specs already in sync; no files changed.' to match the code.

None of these reduce what a requirement demands.

Scanned at 1ebddd17f4 by openai/gpt-5-mini.

* docs(openspec): correct 3 requirements that the code has outgrown

- ai-tool-paths/path-configuration-for-supported-tools#2: Updated the Windsurf scenario to expect skillsDir '.devin' instead of '.windsurf'.
- cli-artifact-workflow/experimental-isolation#8: Updated the file-path in the Single file implementation scenario from src/commands/artifact-workflow.ts to src/commands/workflow to match current code organization.
- command-generation/toolcommandadapter-interface#2: Updated the Windsurf adapter file path pattern to use '.devin/workflows/opsx-<id>.md' to match the implemented adapter.

None of these reduce what a requirement demands.

Scanned at 1ebddd17f4 by openai/gpt-5-mini.

* docs(openspec): correct 4 requirements that the code has outgrown

- ai-tool-paths/path-configuration-for-supported-tools#2: Updated the windsurf scenario to require skillsDir `.devin` instead of `.windsurf` to match current mapping.
- cli-artifact-workflow/experimental-isolation#8: Updated the path in the 'Single file implementation' scenario from src/commands/artifact-workflow.ts to src/commands/workflow/*.
- context-injection/format-context-with-xml-style-tags#2: Updated tag name from <context> to <project_context> in the requirement and scenarios to match implementation.
- specs-sync-skill/skill-output#3: Updated the No changes needed scenario message to match the actual output: changed text to 'Specs already in sync; no files changed.'

None of these reduce what a requirement demands.

Scanned at 1ebddd17f4 by openai/gpt-5-mini.

* docs(openspec): correct 5 requirements that the code has outgrown

- cli-artifact-workflow/experimental-isolation#8: Updated the single-file path from src/commands/artifact-workflow.ts to src/cli/index.ts to match code.
- ai-tool-paths/path-configuration-for-supported-tools#2: Updated the Windsurf scenario to require skillsDir `.devin` instead of `.windsurf` to match the alias to Devin.
- command-generation/toolcommandadapter-interface#2: Updated the Windsurf adapter file path requirement to use the .devin/workflows/opsx-<id>.md path.
- cli-artifact-workflow/schema-apply-block#9: Updated the default instruction text to include the word "required", matching the implemented string.
- opsx-onboard-skill/graceful-exit-handling#8: Updated the continuation command from `/opsx:continue <name>` to `/openspec-continue-change <name>` to match the implemented command.

None of these reduce what a requirement demands.

Scanned at f1b521dffa by openai/gpt-5-mini.

* docs(openspec): correct 5 requirements that the code has outgrown

- ai-tool-paths/path-configuration-for-supported-tools#2: Updated the 'windsurf' scenario to require skillsDir '.devin' to match the current mapping of 'windsurf' to 'devin'.
- cli-init/exit-codes#7: Updated the exit code for user-cancelled operations from 3 to 130.
- context-injection/format-context-with-xml-style-tags#2: Replaced <context> tag name with <project_context> in requirement text and both scenarios to match the implemented tag.
- specs-sync-skill/skill-output#3: Replaced the no-changes message text to match the actual logged message ('Specs already in sync; no files changed.').
- telemetry/first-run-telemetry-notice#5: Updated the quoted one-line notice text to include the additional opt-out instruction 'or openspec config set telemetry.enabled false' to match the implemented message.

None of these reduce what a requirement demands.

Scanned at f1b521dffa by openai/gpt-5-mini.

* docs(openspec): correct 4 requirements that the code has outgrown

- ai-tool-paths/path-configuration-for-supported-tools#2: Updated the Windsurf scenario to require skillsDir '.devin' instead of '.windsurf'.
- cli-init/progress-indicators#1: Replaced the grouped spinner text '⠋ Configuring AI tools...' with the per-tool spinner text 'Setting up <tool.name>...'.
- specs-sync-skill/skill-output#3: Replaced the no-changes message to match the code: "Specs already in sync; no files changed."
- telemetry/first-run-telemetry-notice#5: Updated the quoted first-run notice text to include the alternative opt-out command 'or openspec config set telemetry.enabled false'.

None of these reduce what a requirement demands.

Scanned at f1b521dffa by openai/gpt-5-mini.

* docs(openspec): correct 6 requirements that the code has outgrown

- ai-tool-paths/path-configuration-for-supported-tools#2: Updated the Windsurf scenario to require skillsDir '.devin' to match the code mapping.
- cli-change/legacy-compatibility#2: Changed the deprecated command in both scenarios from 'openspec list' to 'openspec change list' and updated the deprecation notice to point users to 'openspec list'.
- cli-init/exit-codes#7: Updated the exit code for user-cancelled operations from 3 to 130 to match implemented behavior.
- cli-artifact-workflow/schema-apply-block#9: Updated default instruction text to match code: changed "All artifacts complete. Proceed with implementation." to "All required artifacts complete. Proceed with implementation."
- cli-artifact-workflow/output-messaging#12: Updated the expected skipped-commands message to match the actual output format: "Commands skipped for: <tools> (no adapter)".
- specs-sync-skill/skill-output#3: Updated the exact no-changes message to match the code's wording.

None of these reduce what a requirement demands.

Scanned at a0ddb60d04 by openai/gpt-5-mini-2025-08-07.

* docs(specs): verify drift corrections against current behavior

---------

Co-authored-by: openspec-cloud[bot] <311461291+openspec-cloud[bot]@users.noreply.github.com>
Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 19:59:42 +00:00
1bcdf1b032 fix(build): prepare npm git installs without pnpm (#792)
* fix: handle npm git dep installation for GitHub installs

npm v11's git dep preparation runs `prepare` before node_modules exist
in the temp clone directory, causing TypeScript compilation to fail.

Changes:
- build.js: skip build gracefully when node_modules absent
- package.json: use `node build.js` directly in prepare/prepack for
  npm compatibility (avoids pnpm dependency during git dep install)

Note: postinstall.js already handles all errors internally via
main().catch(() => process.exit(0)), so no `|| true` wrapper needed.

Install from GitHub with:
  npm pack github:user/repo#branch
  npm install -g ./fission-ai-openspec-x.y.z.tgz

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

* fix(build): prepare npm git installs without pnpm

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 19:49:03 +00:00
Александр МелентьевandClay Good 44a39eb24b feat(core): add codeassistant support (#1171)
* feat(core): add codeassistant support

Signed-off-by: Александр Мелентьев <aleksandr4842@ya.ru>

* chore: change format file and add test

Signed-off-by: Александр Мелентьев <aleksandr4842@ya.ru>

* chore: add tests

Signed-off-by: Александр Мелентьев <aleksandr4842@ya.ru>

* fix: escaped description yaml values

Signed-off-by: Александр Мелентьев <aleksandr4842@ya.ru>

* chore: add test

Signed-off-by: Александр Мелентьев <aleksandr4842@ya.ru>

* chore: change adapter

Signed-off-by: Александр Мелентьев <aleksandr4842@ya.ru>

* chore: handle \r in description

Signed-off-by: Александр Мелентьев <aleksandr4842@ya.ru>

* chore: use common escapeYamlValue helper

Signed-off-by: Александр Мелентьев <aleksandr4842@ya.ru>

* chore(release): track sourcecraft support

---------

Signed-off-by: Александр Мелентьев <aleksandr4842@ya.ru>
Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 19:40:20 +00:00
Jason HindleyandClay Good 142b8a9203 fix(docs): route OpenSpec pixel through Pages (#1757)
* Route /openspec-pixel.svg to the docs Pages deployment

The docs nav logo is referenced via the root-relative path
/openspec-pixel.svg, which isn't matched by isDocsRoute() and so falls
through to the Astro landing site instead of the docs Pages project
that actually has the asset - a 404. /icon.svg already has this exact
special case; this adds the same for the pixel logo.

Fixes #1756

* fix(docs): route pixel logo through worker

---------

Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 19:31:59 +00:00
dependabot[bot]andClay Good 6911f55175 fix(deps): preserve Node 20 chalk compatibility (#1747)
* chore(deps): bump chalk from 5.6.2 to 6.0.0

Bumps [chalk](https://github.com/chalk/chalk) from 5.6.2 to 6.0.0.
- [Release notes](https://github.com/chalk/chalk/releases)
- [Commits](https://github.com/chalk/chalk/compare/v5.6.2...v6.0.0)

---
updated-dependencies:
- dependency-name: chalk
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

* chore(nix): refresh dependency hash

* fix(nix): use calculated dependency hash

* fix(deps): preserve Node 20 chalk compatibility

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 19:24:39 +00:00
dependabot[bot]andClay Good c5c38a7aca chore(deps-dev): bump eslint from 10.8.1 to 10.9.0 in the development-dependencies group (#1745)
* chore(deps-dev): bump eslint in the development-dependencies group

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


Updates `eslint` from 10.8.1 to 10.9.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.8.1...v10.9.0)

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

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

* chore(nix): refresh dependency hash

* fix(nix): use calculated dependency hash

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Clay Good <hi@claygood.com>
2026-09-02 19:15:51 +00:00
Clay Good d0071d7326 docs(archive): show how to retire capabilities (#1751) 2026-09-01 00:16:02 +00:00
70 changed files with 3749 additions and 1280 deletions
+4
View File
@@ -29,6 +29,10 @@ updates:
- dependency-name: "@types/node"
update-types:
- version-update:semver-major
# Chalk 6 requires Node 22, while the published CLI supports Node 20.19.
- dependency-name: "chalk"
update-types:
- version-update:semver-major
- dependency-name: "typescript"
update-types:
- version-update:semver-major
+1 -1
View File
@@ -260,7 +260,7 @@ jobs:
if: steps.changed-changesets.outputs.has_changesets == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20.19.0'
node-version: '24'
cache: 'pnpm'
- name: Install dependencies
+8 -4
View File
@@ -53,13 +53,17 @@ jobs:
# Opens/updates the Version Packages PR; publishes when the Version PR merges
- name: Create/Update Version PR
id: changesets
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1
uses: changesets/action@8488615a623b1b9c987934bb89eae8af6a946ac1 # v2.1.1
with:
title: 'chore(release): version packages'
createGithubReleases: true
github-token: ${{ steps.app-token.outputs.token }}
pr-title: 'chore(release): version packages'
create-github-releases: true
# Preserve the v1 release path: pushes use the GitHub App token from
# checkout so version PR updates trigger their normal CI workflows.
push-with-git-cli: true
# Use CI-specific release script: relies on version PR having been merged
# so package.json already contains the bumped version.
publish: pnpm run release:ci
publish-script: pnpm run release:ci
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
# npm authentication handled via OIDC trusted publishing (no token needed)
+24
View File
@@ -1,5 +1,29 @@
# @fission-ai/openspec
## 1.12.0
### Minor Changes
- [#1171](https://github.com/Fission-AI/OpenSpec/pull/1171) [`44a39eb`](https://github.com/Fission-AI/OpenSpec/commit/44a39eb24b7ca0f2cf08df697888c3b1e9818a5a) Thanks [@aleksandr4842](https://github.com/aleksandr4842)! - Add SourceCraft Code Assistant as a supported tool for project skills and commands in its VS Code extension.
- [#1713](https://github.com/Fission-AI/OpenSpec/pull/1713) [`db03c6c`](https://github.com/Fission-AI/OpenSpec/commit/db03c6c4b0ef8a05308497482bdc5fc4dd151569) Thanks [@Marzx13](https://github.com/Marzx13)! - ### New Features
- Add `openspec validate --report findings` for explicit bulk scopes. It returns only items with errors, warnings, or information while keeping full-run totals and exit codes. JSON output identifies the report and its scope; human output includes each finding's path and message. The default full report is unchanged.
### Patch Changes
- [#1710](https://github.com/Fission-AI/OpenSpec/pull/1710) [`a4fcdbe`](https://github.com/Fission-AI/OpenSpec/commit/a4fcdbece6f4f7ce86fbd57230be2753945020ba) Thanks [@ryandemelo](https://github.com/ryandemelo)! - Report delta merge conflicts during validation as informational findings, including in successful text reports, without changing validation exit codes. Preserve filesystem read errors so unreadable main specs are not mistaken for missing specs.
Keep the validation report intact when the advisory merge preflight cannot resolve its inputs.
- [#1017](https://github.com/Fission-AI/OpenSpec/pull/1017) [`b976106`](https://github.com/Fission-AI/OpenSpec/commit/b976106d954a0eebbf94ec26b056208968313a4d) Thanks [@DanRioDev](https://github.com/DanRioDev)! - Improve explore mode guidance so it asks more useful dependency-aware questions, recommends defaults, and checks the codebase before asking for facts the repo can answer.
- [#1737](https://github.com/Fission-AI/OpenSpec/pull/1737) [`98bf53e`](https://github.com/Fission-AI/OpenSpec/commit/98bf53e59ec91eb71de4ed0e8036459de7352585) Thanks [@clay-good](https://github.com/clay-good)! - Guide propose and fast-forward workflows to inspect relevant project code, tests, and documentation before drafting artifacts, so plans reflect the existing implementation instead of deferring basic discovery to implementation tasks.
- [#786](https://github.com/Fission-AI/OpenSpec/pull/786) [`0296401`](https://github.com/Fission-AI/OpenSpec/commit/0296401b823726ae6a8d8505104e95c7899b3056) Thanks [@Br1an67](https://github.com/Br1an67)! - Preserve empty OpenSpec directories in Git after initialization. Re-running init restores missing directory markers without overwriting existing files or following marker symlinks.
- [#1725](https://github.com/Fission-AI/OpenSpec/pull/1725) [`cd72444`](https://github.com/Fission-AI/OpenSpec/commit/cd724449aced1655eb513f3207600bec074c7588) Thanks [@aron-intframe](https://github.com/aron-intframe)! - `openspec init` and `openspec update` now share the IDE restart hint: "Restart your IDE to refresh commands." or "Restart your IDE to refresh skills." The message also covers removing workflows, without claiming that new files were generated.
## 1.11.0
### Minor Changes
+1
View File
@@ -172,6 +172,7 @@ Both are in the default profile. If you want the expanded workflow (`/opsx:new`,
→ **[Concepts](docs/concepts.md)**: how it all fits<br>
→ **[Multi-Language](docs/multi-language.md)**: multi-language support<br>
→ **[Customization](docs/customization.md)**: make it yours<br>
→ **[Community Showcase](docs/community.md)**: projects and resources built with and for OpenSpec<br>
→ **[FAQ](docs/faq.md)** · **[Troubleshooting](docs/troubleshooting.md)** · **[Glossary](docs/glossary.md)**: quick help
+1 -1
View File
@@ -196,7 +196,7 @@ OpenSpec writes artifacts to one of two places: your project's `openspec/` folde
3. **The `store:` line in your project.** How a store-only project records its store.
4. **`defaultStore` on your machine.** The fallback when none of the above applies.
Whichever applied, OpenSpec's first output line names the folder it acted on (`Using OpenSpec root: ...`). The exact rules, including the error cases, are in [Configuration › Stores](../reference/configuration/stores.md).
When OpenSpec selects a store, it prints `Using OpenSpec root: ...` before the command output.
### The `store:` line (store-only projects)
+152 -3
View File
@@ -648,16 +648,18 @@ With no name and no bulk flag, validate prompts you to pick items. Outside an in
| `--all` | Validate every change and spec. |
| `--changes` | Validate every change. |
| `--specs` | Validate every spec. |
| `--archived` | Check task completion in archived changes, without validating their applied spec deltas. |
| `--strict` | Treat warnings as failures. |
| `--type <change\|spec>` | Pick the type when a change and a spec share a name. |
| `--json` | Print a structured report instead of text. |
| `--report <mode>` | Bulk output: `full` (default) or `findings`. Requires an explicit bulk scope. |
| `--concurrency <n>` | Max parallel validations in bulk runs. Default: `OPENSPEC_CONCURRENCY`, else 6. |
| `--no-interactive` | Never prompt: a missing or ambiguous name becomes an error. |
| `--store <id>` | Use a registered store as the OpenSpec root instead of the current project. |
**Output**
One line per item. Bulk runs end with totals:
Bulk runs print one status line per item, followed by any findings, and end with totals:
```
✓ change/add-rate-limit
@@ -665,6 +667,20 @@ One line per item. Bulk runs end with totals:
Totals: 2 passed, 0 failed (2 items)
```
**Archive merge findings**
For changes, validate runs archive's merge builder against the current main specs without writing files. It reports merge conflicts, such as a missing `MODIFIED` target or a conflicting `ADDED` requirement, as `INFO`:
```text
ℹ [INFO] api/spec.md: Archive would refuse this delta: api MODIFIED failed for header "### Requirement: Rate limiting" - not found
```
These findings appear even when validation passes, in both text and JSON output. `INFO` never changes the exit code, including under `--strict`: a missing target may belong to a sibling change that has not archived yet. Deltas already synced into the main specs follow archive's existing merge rules.
This check does not run archive's later merged-spec validation or retirement checks. A clean report does not guarantee that archive will succeed.
If the merge preflight cannot start, an `INFO` finding explains why. Existing validation findings and the exit code stay unchanged.
A failing item lists each issue and the fix:
```
@@ -715,8 +731,141 @@ Next steps:
**Exit codes**
- `0`: every validated item passed.
- `1`: an item failed, or the run couldn't validate anything (unknown name, nothing to validate).
- `0`: every validated item passed, including an empty bulk scope.
- `1`: an item failed, the report request is invalid, or the run failed (for example an unknown name or no OpenSpec root).
### --report full|findings
Selects the output for an explicit bulk validation scope:
- **`full`**: every item. This is the default. Explicit `--report full` keeps the existing output shape without adding report metadata.
- **`findings`**: only items with issues, including passing items with warnings or information. Every item is still validated. Totals, strict-mode behavior, and exit codes are unchanged.
```bash
openspec validate --all --report findings
openspec validate --archived --report findings --json
```
**Scopes**
| Flags | Findings `report.scope` |
|---|---|
| `--all` | `all` |
| `--changes` | `changes` |
| `--specs` | `specs` |
| `--changes --specs`, or `--all` with either flag | `all` |
| `--archived` | `archived` |
Both explicit report modes reject a positional item name, a missing bulk scope, or archive and active scopes combined.
#### Findings text output
**Human output**: stdout prints `Scope: <scope> (<count> items)`, then totals. With no issue-bearing items:
```text
Scope: all (2 items)
No item findings.
Totals: 2 passed, 0 failed (2 items)
```
Issue-bearing item labels, severity labels, paths, and messages print to stderr. Active-scope failures keep the `Details:` rerun hint after totals. The existing root banner and progress output may precede the report.
#### Findings JSON output
`--report findings --json` prints one document. This example has two clean items:
```json
{
"report": {
"kind": "validation-findings",
"version": "1.0",
"scope": "all",
"returnedItems": 0,
"totalItems": 2
},
"itemFindings": [],
"summary": {
"totals": { "items": 2, "passed": 2, "failed": 0 },
"byType": {
"change": { "items": 1, "passed": 1, "failed": 0 },
"spec": { "items": 1, "passed": 1, "failed": 0 }
}
},
"root": { "path": "/Users/you/projects/my-app", "source": "nearest" }
}
```
- **`report.kind` and `report.version`**: identify the `validation-findings` shape, version `1.0`. There is no top-level `version` or `items`.
- **`report.returnedItems` and `report.totalItems`**: count the returned records and all validated items, respectively.
- **`itemFindings`**: complete item records whose `issues` array is nonempty. Includes `ERROR`, `WARNING`, and `INFO` issues. Each record retains `id`, `type`, `valid`, `issues`, and `durationMs`. Archived items use `type: "change"`.
- **`summary`**: full-run totals and per-type counts, not counts of the returned subset. An empty scope has zero totals and exits 0.
- **`root`**: the same selected-root metadata as the full report.
**Record preservation**: returned items keep their full-report order and any additive fields on items or issues. Filtering does not rewrite messages or locations, including optional `line` and `column` fields.
**Command failures**: root-selection or item-discovery failures retain the existing `status` diagnostic and exit 1. They do not return a completed findings report or a successful empty report.
#### Invalid report requests
Both explicit report modes reject these requests before root selection or item discovery:
- An unsupported report value, including an empty string.
- A positional item name, even with a bulk flag.
- No explicit bulk scope.
- `--archived` combined with `--all`, `--changes`, or `--specs`.
In JSON mode, a rejected request exits 1 with only a single-element `status` array. It has no `root` or report payload:
```bash
openspec validate --all --report bogus --json
```
```json
{
"status": [
{
"severity": "error",
"code": "invalid_validation_report_request",
"message": "Unknown validation report 'bogus'.",
"fix": "Use --report full|findings with --all, --changes, --specs, or --archived, without an item name. Do not combine archived and active scopes."
}
]
}
```
Human mode prints the error to stderr. A bare `--report` with no value is a Commander syntax error on stderr, including with `--json`; it does not use this diagnostic envelope.
#### Filter a full report externally
For a custom JSON view, filter the full report with `jq` or PowerShell. These script examples preserve the validation exit code and leave command-error documents intact.
In Bash with `jq`:
```bash
if validation_json=$(openspec validate --all --json); then
validation_exit=0
else
validation_exit=$?
fi
printf '%s\n' "$validation_json" |
jq 'if has("items") then .items |= map(select(.issues | length > 0)) else . end'
exit "$validation_exit"
```
In PowerShell:
```powershell
$validationJson = openspec validate --all --json
$validationExit = $LASTEXITCODE
$validationReport = $validationJson | ConvertFrom-Json
if ($validationReport.PSObject.Properties.Name -contains 'items') {
$validationReport.items = @($validationReport.items | Where-Object { $_.issues.Count -gt 0 })
}
$validationReport | ConvertTo-Json -Depth 100
exit $validationExit
```
These custom views keep the full report's keys but omit clean items. They are neither complete full-v1 reports nor the versioned `--report findings` shape.
## openspec archive
@@ -36,7 +36,7 @@ Boolean toggles keyed by flag name, set with `openspec config set featureFlags.<
### defaultStore
The machine-level fallback store id for root resolution, consulted only when no `--store` flag, local `openspec/`, or project `store:` pointer resolves. The full ladder is [Root resolution](stores.md#root-resolution).
The machine-level fallback store id for root resolution, consulted only when no `--store` flag, local `openspec/`, or project `store:` pointer resolves. The full ladder is [Root resolution](../../multi-repo/stores.md#where-artifacts-get-created-when-using-stores).
### openers
@@ -56,7 +56,7 @@ Only `apply` and `archive` are read.
### store
A store id used as the OpenSpec root, consulted only when this openspec/ directory is config-only (no specs/ or changes/). It is a fallback, never an override. The full ladder is [Root resolution](stores.md#root-resolution).
A store id used as the OpenSpec root, consulted only when this openspec/ directory is config-only (no specs/ or changes/). It is a fallback, never an override. The full ladder is [Root resolution](../../multi-repo/stores.md#where-artifacts-get-created-when-using-stores).
### references
+1 -1
View File
@@ -8,4 +8,4 @@
| [Change metadata (.openspec.yaml)](change-metadata.md) | `openspec/changes/<name>/.openspec.yaml` | The workflow schema, goal, scope, and spec exceptions for one change |
| [CLI settings (config.json)](config-json.md) | `~/.config/openspec/config.json` (Windows varies) | How the openspec CLI behaves on your machine |
| [Environment variables](environment-variables.md) | Your shell or CI environment | Telemetry opt-out, and where the config and data directories live |
| [Stores](stores.md) | `~/.local/share/openspec/stores/` (Windows varies) | The registry and metadata behind multi-repo stores |
| Stores | `~/.local/share/openspec/stores/` (Windows varies) | The registry and metadata behind multi-repo stores |
+2 -2
View File
@@ -20,11 +20,11 @@ OpenSpec reuses words that mean something else in git, CI, and agent tooling. Ea
| **Legacy workflow** | The pre-OPSX `/openspec:*` commands. | [Migration](../help/legacy/migration.md) |
| **Loop** | The cycle a change proposal moves through: explore, propose, review, apply, archive. | [Quickstart](../start/quickstart.md) |
| **Main specs** | The `openspec/specs/` tree: the current, agreed behavior of your system. Archiving merges deltas into it. | [Concepts](../guides/concepts.md) |
| **OpenSpec root** | The `openspec/` tree a command resolves to and operates on: your repo's, or a store's. | [Stores](configuration/stores.md) |
| **OpenSpec root** | The `openspec/` tree a command resolves to and operates on: your repo's, or a store's. | [Stores](../multi-repo/stores.md#where-artifacts-get-created-when-using-stores) |
| **OPSX** | The current OpenSpec workflow system, and the command prefix it installs (`/opsx:`). | [Architecture](architecture/index.md) |
| **Profile** | Which workflows init installs: `core` or `custom`. | [Profiles](../customize/profiles.md) |
| **Propose** | Create a change proposal and generate all its planning artifacts in one step. Skill: `openspec-propose`. | [Quickstart](../start/quickstart.md) |
| **Registry** | The machine-level list of registered stores, in `registry.yaml`. Not a package registry. | [Stores](configuration/stores.md) |
| **Registry** | The machine-level list of registered stores, in `registry.yaml`. Not a package registry. | [CLI](cli.md#openspec-store) |
| **Requirement** | One behavior the system must have, written with SHALL: `### Requirement:` in a spec. | [Delta specs](schemas/spec-driven/index.md#delta-specs-specmd) |
| **Scenario** | A testable example under a requirement, in WHEN/THEN form. | [Delta specs](schemas/spec-driven/index.md#delta-specs-specmd) |
| **Schema** | The definition of which artifacts a change proposal produces, and in what order. Not JSON Schema. | [Schemas](schemas/index.md) |
+1
View File
@@ -76,6 +76,7 @@ That second one matters more than it looks. OpenSpec has two halves: a command l
| [Customization](customization.md) | Project config, custom schemas, shared context |
| [Multi-Language](multi-language.md) | Generate artifacts in languages other than English |
| [Supported Tools](supported-tools.md) | The 30+ AI tools OpenSpec integrates with, and where files land |
| [Community Showcase](community.md) | Projects and resources built with and for OpenSpec |
### When you need help
+42 -2
View File
@@ -114,7 +114,7 @@ field so OpenSpec never overwrites project-specific guidance.
The welcome animation is also skipped when the `OPENSPEC_NO_ANIMATION` environment variable is set (any value, including empty), when `NO_COLOR` is set to a non-empty value, or when the OS reduced-motion preference is enabled (macOS Reduce Motion, GNOME animations disabled).
**Supported tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zed`, `zcode`, `agents`
**Supported tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `codeassistant`, `qoder`, `qwen`, `rovodev`, `roocode`, `trae`, `zed`, `zcode`, `agents`
> This list mirrors `AI_TOOLS` in `src/core/config.ts`. See [Supported Tools](supported-tools.md) for each tool's skill and command paths.
@@ -664,6 +664,25 @@ openspec archive add-dark-mode --yes
openspec archive update-ci-config --skip-specs
```
**Retire a capability:** Add the retirement marker to the change metadata:
```yaml
# openspec/changes/retire-legacy/.openspec.yaml
schema: spec-driven
retire_capabilities: true
```
Then archive the change normally:
```bash
openspec archive retire-legacy --yes
```
When the change removes the capability's last requirement, OpenSpec deletes its
live `spec.md`. Other capability deltas in the same change still update their
main specs. Without the marker, archive stops before changing any files and
tells you to add it.
**What it does:**
1. Validates the change (unless `--no-validate`)
@@ -1255,13 +1274,34 @@ openspec completion install
# Install for specific shell
openspec completion install zsh
# Generate script for manual installation
# Generate script for manual installation (bash)
openspec completion generate bash > ~/.bash_completion.d/openspec
# Uninstall
openspec completion uninstall
```
**Windows (PowerShell):** Install completions for the current PowerShell host:
```powershell
$env:PROFILE = $PROFILE
openspec completion install powershell
. $PROFILE
```
`$env:PROFILE` tells OpenSpec which profile to configure in this session. The
installer creates missing profile directories and adds a managed block that loads
`OpenSpecCompletion.ps1`. Reloading the profile enables completions immediately.
To uninstall from the current host, run:
```powershell
$env:PROFILE = $PROFILE
openspec completion uninstall powershell
```
Restart PowerShell after uninstalling to clear completions from the current session.
Completions are opt-in. The CLI mentions them once, on stderr, the first time you
run a command in an interactive terminal, and never again — it also stays quiet
if you already have completions installed. Set `OPENSPEC_NO_COMPLETIONS=1` to
+19
View File
@@ -0,0 +1,19 @@
# Community Showcase
A community-owned awesome list of projects and resources built with and for OpenSpec. Tools, integrations, workflows, and learning resources are welcome. Community members grow and maintain this showcase through pull requests.
Listed projects are maintained independently. Inclusion does not imply official support or endorsement by OpenSpec. See each project's documentation and issue tracker for setup and support.
## Projects and resources
- **[OpenSpec UI](https://github.com/VeryComplexAndLongName/OpenSpec-UI)**: A standalone web dashboard and VS Code extension for browsing OpenSpec changes, archives, specs, and tasks.
## Add your project
Open a pull request adding one line to this file with your project's name, a direct link, and a short description of how it relates to OpenSpec.
- Keep entries focused on something built with OpenSpec or supporting its use, rather than general product advertising.
- Describe what people can use. Avoid promotional claims, referral links, and tracking links.
- Disclose paid features or required accounts in the entry, if any.
Corrections and updates to existing entries are welcome too.
+6 -1
View File
@@ -95,6 +95,7 @@ to read the hint.
| Oh My Pi (`oh-my-pi`) | `.omp/skills/openspec-*/SKILL.md` | `.omp/commands/opsx-<id>.md` |
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-<id>.md` |
| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-<id>.md` |
| SourceCraft Code Assistant for VS Code (`codeassistant`) | `.codeassistant/skills/openspec-*/SKILL.md` | `.codeassistant/commands/opsx-<id>.md` |
| Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/<id>.md` |
| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-<id>.md` |
| [Rovo Dev CLI](https://support.atlassian.com/rovo/docs/use-rovo-dev-cli/) (`rovodev`) | `.rovodev/skills/openspec-*/SKILL.md` | Not generated. Rovo has no slash-command surface — it matches skills automatically or by prompt (e.g. "use the openspec-propose skill"); `/skills` only manages them. Generated content references skills by name, never as `/openspec-*` commands. |
@@ -110,6 +111,10 @@ to read the hint.
\*\*\*\* Windsurf was [rebranded to Devin Desktop](https://docs.devin.ai/desktop/devin-desktop-faq) on June 2, 2026, and its config directory moved: `.devin/` is the preferred read + write location, `.windsurf/` a legacy read-only fallback. OpenSpec follows the rename — the tool id is `devin`, and `--tools windsurf` still resolves to it so existing setup scripts keep working. A project still holding OpenSpec files in `.windsurf/` is offered the move on the next `openspec update`; declining leaves them in place, and files you wrote yourself are never touched. Workflows are invoked by filename, so `.devin/workflows/opsx-apply.md` is `/opsx-apply`. The [Devin Local agent does not support workflows](https://docs.devin.ai/desktop/devin-local) — only skills, and it does not read `.windsurf/` at all — so whenever OpenSpec writes Devin skills it keeps their bodies, and the getting-started hint, on `/openspec-*` skill invocations, which work on both agents. Under commands-only delivery no skills are written and both fall back to `/opsx-*`.
SourceCraft Code Assistant support targets its VS Code extension. Its [custom commands](https://sourcecraft.dev/portal/docs/en/code-assistant/operations/agent/slash-commands) and [skills](https://sourcecraft.dev/portal/docs/ru/code-assistant/operations/agent/skills) are available only in VS Code. This integration does not configure SourceCraft web or JetBrains.
With skills-only delivery, ask Code Assistant to use the `openspec-propose` skill with your idea. Skills activate through request matching; OpenSpec does not generate `/openspec-*` commands for this tool.
MiniMax Code is a global skills-only integration. OpenSpec writes only its
`openspec-*` directories under `~/.minimax/skills/`; it does not create
repo-local `.minimax` or `.mavis` directories. Commands-only delivery leaves
@@ -214,7 +219,7 @@ openspec init --tools none
openspec init --profile core
```
**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zed`, `zcode`, `agents`
**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `command-code`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `codeassistant`, `trae`, `zed`, `zcode`, `agents`
## Workflow-Dependent Installation
+4 -4
View File
@@ -50,16 +50,16 @@
pnpmDeps = pkgs.fetchPnpmDeps {
inherit (finalAttrs) pname version src;
pnpm = pkgs.pnpm_9;
pnpm = pkgs.pnpm_10;
fetcherVersion = 3;
hash = "sha256-+qGFLSVLJ9faZOmfO6ZVBP525i5LRgwhsJat2vT7Aw8=";
hash = "sha256-SNPeEUa+amkZYRO5tHeUwDBT4betXYPKnfZiEyhN7fE=";
};
nativeBuildInputs = with pkgs; [
nodejs_22
npmHooks.npmInstallHook
pnpmConfigHook
pnpm_9
pnpm_10
];
buildPhase = ''
@@ -99,7 +99,7 @@
default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs_22
pnpm_9
pnpm_10
];
shellHook = ''
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-21
@@ -0,0 +1,192 @@
## Context
See `proposal.md` for motivation and measured output size. Bulk validation currently has one documented JSON contract: top-level `version: "1.0"`, a complete `items` array for the requested scope, `summary`, and `root`. Human bulk output lists every item before totals.
The preserved feasibility candidate proves that completed validation results can be projected while retaining totals, severities, scope, and exit status. It is not the proposed contract: the candidate reused `items` under top-level version `1.0`, which could let a consumer interpret a subset as the complete scope.
Implementation measurement on August 27, 2026 used this repository's 83-change archive, not the original 895-change corpus (which is not available in this checkout). `openspec validate --archived --json` emitted 14,690 bytes; adding `--report findings` emitted 4,047 bytes, a 72.5% reduction. Both retained all 12 failing items, totals of 71 passed and 12 failed, the same root, and exit 1. Explicit `--report full` matched the default document after normalizing `durationMs`. The findings items exactly matched the issue-bearing full records after the same normalization. Byte counts can vary with timings and checkout paths. This measures output size, not runtime.
The implementation baseline was updated from main on August 27, 2026. Its full-result top-level inventory is `items`, `summary`, `version`, and `root`; there is no advisory collection outside item results. Existing `INFO` issues inside item records are retained by whole-record projection. A future top-level advisory such as `overlaps` requires an explicit contract update defining its JSON field and human section before inclusion.
## Goals / Non-Goals
**Goals:**
- Reduce human and agent-facing output when a bulk validation scope is dominated by clean items.
- Preserve the current complete report as the default and as explicit `full` mode.
- Give JSON findings an exact discriminator and a document that is intentionally distinct from full v1.
- Preserve complete item records, item order, issue detail and severity, requested scope, summary totals, root selection, and exit status.
- Reject ambiguous report requests before prompts, root selection, progress UI, or validation work.
**Non-Goals:**
- Improving validation runtime or skipping validation work for valid requests.
- Changing validation rules, strict-mode semantics, concurrency, full-report ordering, or exit codes.
- Adding summary-only output, alternate serializers, TOON, a general output framework, project defaults, or new dependencies.
- Changing omitted-`--report` targeted, interactive, or mixed-flag behavior.
- Automatically copying unknown future top-level report fields into the findings document.
## Decisions
### 1. Use one bulk report selector; keep serialization orthogonal
`--report` accepts `full` and `findings`. Omitting it preserves every existing command flow. Explicit `--report full` and `--report findings` are bulk-report selectors: both require an explicit, unambiguous bulk scope and neither is accepted with an item name. In particular, `openspec validate <item> --report full` is intentionally rejected rather than treated as a targeted alias.
This keeps report content separate from serialization: `--report findings` selects the findings contract, while `--json` serializes that contract. Help text is `Select bulk report content: full|findings; combine with --json for JSON`.
The existing CLI has command-specific projections (`--deltas-only`, `--requirements`, and `--no-scenarios`) but no generic `--only`, `--report`, or `--format` vocabulary. `--findings-only` and `--only findings` read like in-place filters on the existing JSON document. `--report findings` makes the separately versioned document intentional and avoids adding more booleans if another report contract is justified later.
### 2. Resolve active scope combinations and reject archive ambiguity
For an explicit report request, the canonical scope is resolved as follows:
| Input flags | Canonical scope |
|---|---|
| `--changes` | `changes` |
| `--specs` | `specs` |
| `--changes --specs` | `all` |
| `--all`, including `--all` plus either active subset | `all` |
| `--archived` | `archived` |
`--archived` combined with any active scope flag is rejected. An item name combined with any explicit report option is rejected, whether or not a bulk flag is also present. An explicit report option without a bulk scope and an unsupported report value are also rejected. Omitted `--report` retains current precedence and behavior, including existing mixed-flag behavior; this proposal does not retroactively tighten old invocations.
### 3. Fail invalid report requests before doing work
Report mode and scope are normalized before root resolution or validation. Invalid human requests write a targeted error to stderr, write nothing to stdout, render no prompt or spinner, perform no validation, and exit 1.
With `--json`, every parsed invalid report request writes exactly one JSON document to stdout, writes no human text to either stream, performs no root resolution or validation, and exits 1:
```json
{
"status": [
{
"severity": "error",
"code": "invalid_validation_report_request",
"message": "The requested validation report and scope cannot be combined.",
"fix": "Use --report full|findings with one active bulk scope or --archived, without an item name."
}
]
}
```
The `code` is stable. The message may identify the specific conflict while retaining that code and one-status-entry shape. Values are case-sensitive: only `full` and `findings` are supported. Missing option arguments, such as bare `--report`, are CLI syntax errors handled by the existing parser before command execution; they are outside this structured report-request contract. This change does not alter generic parser error handling.
A valid report request can still fail during root resolution or scope discovery. Those failures retain the existing command diagnostic, nonzero exit status, and JSON `status` envelope rather than emitting a findings document with misleading empty totals. Per-item validation failures remain item results and do produce a completed report.
### 4. Use a distinct item-findings JSON document
After root resolution, scope discovery, and validation complete, `--json --report findings` returns a document like this three-item example:
```json
{
"report": {
"kind": "validation-findings",
"version": "1.0",
"scope": "archived",
"returnedItems": 1,
"totalItems": 3
},
"itemFindings": [
{
"id": "example-change",
"type": "change",
"valid": false,
"issues": [
{
"level": "ERROR",
"path": "tasks.md",
"message": "4 incomplete tasks (15/19 completed)"
}
],
"durationMs": 3
}
],
"summary": {
"totals": { "items": 3, "passed": 2, "failed": 1 },
"byType": {
"change": { "items": 3, "passed": 2, "failed": 1 }
}
},
"root": {
"path": "<resolved-root>",
"source": "nearest"
}
}
```
The typed projection is exactly the full result's item records filtered by `item.issues.length > 0`. It preserves full-report order and returns each selected record whole rather than rebuilding a fixed field list, so current fields and future additive item fields survive. `report.returnedItems` equals `itemFindings.length`; `report.totalItems` equals `summary.totals.items`. `ERROR`, `WARNING`, and `INFO` all count as item findings, regardless of whether the item's `valid` field is true.
The findings document has no top-level `items` or top-level `version`, and it carries the exact `report.kind: "validation-findings"` discriminator and exact JSON-string `report.version: "1.0"`. Contract tests assert the version value and its string type. Tests also assert that the document does not conform to the documented full-v1 contract, which requires top-level `version: "1.0"` and a complete `items` array. No claim is made about how arbitrary permissive parsers behave.
The implementation explicitly maps the current full-result inventory: `items` becomes filtered `itemFindings`; `summary` and `root` are retained whole; top-level `version` is replaced by the findings discriminator and version under `report`. It does not generically spread unknown full-result fields. No top-level advisory collection exists in this baseline, so none is emitted. Any future advisory must be explicitly named in the contract, remain separate from `itemFindings`, and not affect `returnedItems`.
JSON findings emit exactly one document on stdout and no stderr text.
### 5. Define human findings sections and order each stream independently
Human findings preserve stream ownership, but stdout and stderr may be buffered or interleaved by the caller. The contract therefore defines ordering independently within each stream and makes no relative-order promise between a stdout section and a stderr section.
Within stdout, sections appear in this order:
1. `Scope:` line.
2. If `itemFindings` is empty, `No item findings.`; otherwise there is no item row or item block on stdout.
3. `Totals:` for the complete scope.
4. The existing first-failure `Details:` command for active scopes when one is currently provided; findings mode does not invent a details line for archived scope.
Within stderr, sections appear in this order:
1. Item-finding blocks in full-report item order. Each block prints its item heading once, followed by every issue in issue order with its original `ERROR`, `WARNING`, or `INFO` label, path, and message. All three severities use stderr.
2. Any future advisory section explicitly added to the contract would follow item-finding blocks on stderr and remain distinct from item findings. There is no such section in this implementation.
Clean item rows are omitted. `No item findings.` says nothing about separately rendered advisories. Tests capture and assert each stream independently rather than asserting a merged stdout/stderr sequence. A valid findings request may retain existing progress behavior, which is outside this final-report per-stream ordering contract; the invalid-request path never renders progress UI.
### 6. Use one typed projector for active and archived results
Active and archived validation currently assemble similar result/summary envelopes on separate paths. Implementation defines one typed findings projection over the shared full-result contract and routes both paths through it. This prevents scope, ordering, whole-record preservation, and returned/total count rules from drifting. Human and JSON renderers consume that same projection; they do not independently filter.
### 7. Keep verdict, root, and platform behavior unchanged
For valid requests, findings mode validates the same requested items as full mode. `summary` is the full-scope summary and exit status is identical for the same scope and strictness. Warning- and info-only records remain visible even when they do not fail a non-strict run.
The report uses the same resolved repo or store root and unchanged path values as full validation, including platform-native root paths and existing POSIX-normalized issue paths. No path construction or rewriting is introduced. The `--report` flag is registered on every currently supported completion surface: Bash, Zsh, Fish, and PowerShell. Only Zsh and Fish suggest the fixed `full` and `findings` values because only those existing generators consume registry value metadata. Bash and PowerShell remain unchanged beyond flag registration. This proposal does not add a completion capability or broaden the set of generators; any additional shell or agent completion surface requires separate justification.
## Alternatives Considered
### Reuse full-v1 `items` with only issue-bearing records
Rejected. Projection metadata does not undo the documented meaning of the complete `items` collection; a consumer can silently undercount clean items.
### Introduce projected `items` in a new full JSON version
Rejected for this contribution. A v2 union can be safe, but it creates a broader protocol migration for a narrow projection. A separate discriminator and `itemFindings` collection avoid changing full v1.
### Human-only compact output
Rejected as the recommendation. It is the smallest surface, but leaves the structured agent/log use case unsolved.
### Use `--findings-only` or `--only findings`
Rejected. Both frame the behavior as filtering the existing output shape. The report selector makes the distinct JSON contract intentional and composes with `--json` as content plus serialization.
### Document external filtering only
Safe and still supported. Callers can filter full JSON through `jq` or PowerShell, but the complete document still crosses the CLI boundary and each integration must recreate scope, summary, and exit-code discipline.
### Add summary mode or a general output framework
Rejected. Summary-only output omits actionable item findings. Alternate serializers, preferences, and frameworks expand maintenance and compatibility risk without evidence they are required.
## Risks / Trade-offs
- **A second JSON report contract is durable API surface.** Mitigation: one exact discriminator/version, one item projector, and reuse of full item records, summary, and root.
- **Output savings depend on corpus shape.** The measured matrix ranged from 4.9% on an issue-dense synthetic human case to 95.7% on the real 895-change archive. The 6,740-byte figure belongs to the feasibility candidate, not this exact envelope. Mitigation: claim output reduction only and remeasure the implemented envelope.
- **Item findings can be confused with top-level advisories.** Mitigation: `itemFindings`, `No item findings.`, separate advisory sections, and counts that cover item records only.
- **Unknown top-level fields could be dropped.** Mitigation: an explicit baseline inventory and contract updates for future named sections; no unbounded generic preservation promise.
- **Active and archived paths could drift.** Mitigation: one typed projector and shared contract tests.
## Migration Plan
- Ship as an additive option with no persisted configuration.
- Existing invocations and documented full-v1 parsers continue using the unchanged full report.
- New callers opt in and parse `report.kind: "validation-findings"` plus `itemFindings`.
- A rollback removes the option without migrating data or restoring files.
@@ -0,0 +1,29 @@
## Why
Bulk validation currently prints one result for every item in scope, including clean items. That complete report is useful for audit and automation, but it can dominate agent context and CI logs in large, mostly-clean repositories. In one real 895-change archive, the complete JSON report was 157,396 bytes while a feasibility candidate's projected-v1 envelope was 6,740 bytes (95.7% smaller) with all 19 failures and the same exit status. The proposed envelope is different and may have a slightly different byte count; savings vary with issue density. This is evidence about output volume, not validation runtime.
## What Changes
- Add an opt-in `--report <full|findings>` mode to explicit bulk validation scopes: `--all`, `--changes`, `--specs`, and `--archived`.
- Keep current behavior when `--report` is omitted, and preserve current human and JSON output for valid explicit bulk `--report full` requests.
- In findings mode, project complete item records whose `issues.length > 0` into `itemFindings`, preserving full-report order, every issue severity, and all current or future additive item fields.
- Give JSON findings an exact `report.kind: "validation-findings"` discriminator and exact JSON-string `report.version: "1.0"`. It does not reuse the full-v1 `items` field or claim conformance with that document.
- Use the current full-result inventory (`items`, `summary`, `version`, and `root`); there are no top-level advisory collections to project. Future advisory sections require an explicit contract decision.
- Require an explicit, non-conflicting bulk scope for either report value. Parsed invalid report requests return one stable structured JSON diagnostic before root selection, prompts, spinners, or validation. Missing option arguments retain existing CLI parser errors; root and discovery failures retain existing command diagnostics.
## Capabilities
### New Capabilities
_None._
### Modified Capabilities
- `cli-validate`: Add a compatibility-safe, opt-in findings report for bulk human and JSON validation output.
## Impact
- **Public CLI:** one additive report option on bulk `openspec validate`; no default behavior change.
- **JSON consumers:** the existing full-v1 complete-`items` document remains unchanged. Consumers choosing findings mode parse a separately identified schema with `itemFindings`.
- **Documentation and completions:** document the two report modes, their scope rules, and the findings JSON envelope; register `--report` on the existing Bash, Zsh, Fish, and PowerShell completion surfaces, with fixed `full`/`findings` value suggestions only in Zsh and Fish.
- **Implementation:** validation command output, CLI option registration, completions, documentation, focused tests, and a release changeset. No new dependency or project-level preference.
@@ -0,0 +1,260 @@
## ADDED Requirements
### Requirement: Bulk validation SHALL provide an opt-in item-findings report
The `validate` command SHALL support case-sensitive `--report full` and `--report findings` for explicit, unambiguous bulk scopes. Omitting `--report` SHALL retain current targeted, interactive, bulk, human, and JSON behavior. Findings mode SHALL return whole issue-bearing item records separately from top-level advisories while preserving full item order, complete requested-scope totals, root selection, issue severities, strict-mode semantics, and exit status. The current full-result fields are `items`, `summary`, `version`, and `root`; this implementation SHALL NOT invent advisory fields or copy unknown top-level fields. A future advisory section requires an explicit contract update.
#### Scenario: Default and explicit bulk full output remain compatible
- **WHEN** a user runs bulk validation without `--report` or with a valid explicit `--report full` request
- **THEN** human output SHALL retain the current complete item listing and totals, or the current empty-scope message when no items exist
- **AND** JSON output SHALL retain the documented full-v1 top-level `version: "1.0"` and complete `items` collection
- **AND** the two bulk invocations SHALL have equivalent observable output and exit status for the same scope
#### Scenario: Explicit report values select a bulk report
- **WHEN** a user supplies `--report full` or `--report findings` with exactly one resolvable bulk scope and no item name
- **THEN** validation SHALL run that bulk report without prompting for a scope
#### Scenario: Explicit report values do not alias targeted or interactive flows
- **WHEN** a user supplies an explicit report value with an item name or without a bulk scope
- **THEN** validation SHALL reject the request rather than treating explicit `full` as a targeted or interactive alias
#### Scenario: A changes-only report retains changes scope
- **WHEN** a findings report request uses `--changes` alone
- **THEN** `report.scope` SHALL be `changes`
#### Scenario: A specs-only report retains specs scope
- **WHEN** a findings report request uses `--specs` alone
- **THEN** `report.scope` SHALL be `specs`
#### Scenario: Combined active scopes normalize to all
- **WHEN** a findings report request uses `--changes --specs`, `--all`, or `--all` with either active subset flag
- **THEN** the complete active scope SHALL be validated and `report.scope` SHALL be `all`
#### Scenario: Archived and active scopes cannot be combined for a report
- **WHEN** a user supplies `--archived` with `--all`, `--changes`, or `--specs` and an explicit report value
- **THEN** validation SHALL reject the request rather than choosing one scope by precedence
- **AND** SHALL NOT validate either scope
#### Scenario: Invalid human report requests fail before work
- **WHEN** a non-JSON request has an item/report conflict, archived/active conflict, missing bulk scope, or unsupported report value
- **THEN** validation SHALL write a targeted diagnostic to stderr and nothing to stdout
- **AND** SHALL exit with code 1
- **AND** SHALL NOT resolve a root, prompt, render a spinner, or validate any item
#### Scenario: Invalid JSON report requests return one stable diagnostic
- **WHEN** a JSON request has an item/report conflict, archived/active conflict, missing bulk scope, or unsupported report value
- **THEN** stdout SHALL contain exactly one JSON document with exactly one `status` entry
- **AND** that entry SHALL have `severity: "error"` and stable `code: "invalid_validation_report_request"`
- **AND** it SHALL include a targeted `message` and corrective `fix`
- **AND** no human text SHALL be written to stdout or stderr
- **AND** validation SHALL exit with code 1 without resolving a root, prompting, rendering a spinner, or validating any item
#### Scenario: Missing report arguments retain parser errors
- **WHEN** the CLI parser rejects a missing required argument such as bare `--report`
- **THEN** the existing CLI syntax-error behavior SHALL remain unchanged
- **AND** the command SHALL NOT run or resolve a root
- **AND** generic parser errors SHALL NOT be covered by the structured `invalid_validation_report_request` contract
#### Scenario: Root and scope-discovery failures remain diagnostics
- **GIVEN** a syntactically valid report request with a supported scope
- **WHEN** root resolution fails or scope discovery encounters a fatal error
- **THEN** validation SHALL retain the existing diagnostic and nonzero exit status for that failure
- **AND** JSON output SHALL contain the existing `status` diagnostic envelope rather than a findings document with empty totals
- **AND** a per-item validation failure SHALL instead remain an item result in the completed findings report
#### Scenario: Findings JSON uses an exact distinct contract
- **WHEN** a valid `--json --report findings` request completes root resolution, scope discovery, and validation
- **THEN** stdout SHALL contain exactly one parseable JSON document and stderr SHALL be empty
- **AND** `report.kind` SHALL equal `validation-findings`
- **AND** `report.version` SHALL be the JSON string `"1.0"`
- **AND** `report` SHALL include canonical `scope`, `returnedItems`, and `totalItems`
- **AND** `summary` SHALL contain totals for the complete requested scope
- **AND** `root` SHALL retain the current resolved-root envelope
#### Scenario: Findings JSON is not the documented full-v1 document
- **WHEN** a valid `--json --report findings` request produces a completed report
- **THEN** the document SHALL NOT contain a top-level `items` field
- **AND** SHALL NOT contain the full-v1 top-level `version` field
- **AND** contract tests SHALL reject it against the documented full-v1 shape requiring top-level `version: "1.0"` and complete `items`
- **AND** compatibility assertions SHALL be limited to documented full-v1 conformance, leaving undocumented permissive parser behavior outside this contract
#### Scenario: Item findings project whole issue-bearing records
- **GIVEN** the corresponding full result has item records in a defined order
- **WHEN** findings JSON is produced
- **THEN** `itemFindings` SHALL equal those full item records filtered by `issues.length > 0`
- **AND** record order and issue order SHALL match the full result
- **AND** each selected record SHALL preserve every current field and future additive field from that full item record
- **AND** clean item records SHALL be omitted
#### Scenario: Every item issue severity counts as an item finding
- **GIVEN** separate item records containing only `ERROR`, only `WARNING`, or only `INFO` issues
- **WHEN** findings mode is produced
- **THEN** all three records SHALL appear in `itemFindings`
- **AND** every issue SHALL retain its original severity, path, and message
- **AND** `valid` and exit behavior SHALL remain whatever full mode reports under the same strictness
#### Scenario: Item counts exclude top-level advisories
- **WHEN** findings JSON is produced
- **THEN** `report.returnedItems` SHALL equal `itemFindings.length`
- **AND** `report.totalItems` SHALL equal `summary.totals.items`
- **AND** separately named top-level advisory records SHALL NOT increase either item count
#### Scenario: Zero item findings in a non-empty scope remain auditable
- **GIVEN** the requested bulk scope contains one or more items and none has an issue
- **WHEN** validation runs with `--json --report findings`
- **THEN** `itemFindings` SHALL be an empty array and `report.returnedItems` SHALL be `0`
- **AND** `report.totalItems`, `report.scope`, `summary`, and `root` SHALL still identify the complete validated scope
- **AND** the successful exit status SHALL match full mode for the same scope
#### Scenario: Empty JSON scope is explicit and successful
- **GIVEN** the selected bulk scope contains no items
- **WHEN** validation runs with `--json --report findings`
- **THEN** `itemFindings` SHALL be empty, item counts and summary totals SHALL be zero, and scope and root SHALL remain explicit
- **AND** validation SHALL preserve the current successful empty-scope exit status
#### Scenario: Human findings use independently ordered streams
- **GIVEN** a bulk scope with issue-bearing and clean item records
- **WHEN** validation runs with `--report findings` and without `--json`
- **THEN** within stdout the final report SHALL emit `Scope:` first, followed by complete-scope `Totals:`, followed by any existing active-scope first-failure `Details:` command
- **AND** within stderr the final report SHALL emit item-finding blocks in full item order, with each item heading followed by all issues in issue order
- **AND** `ERROR`, `WARNING`, and `INFO` labels, paths, and messages SHALL all be emitted to stderr
- **AND** clean item rows SHALL be omitted
- **AND** within stderr any explicitly named advisory section SHALL be emitted after item-finding blocks
- **AND** archived scope SHALL NOT gain a new details command
- **AND** no relative ordering between stdout and stderr sections SHALL be required
#### Scenario: Human output distinguishes no item findings from advisories
- **GIVEN** no item record has an issue
- **WHEN** validation runs with `--report findings` and without `--json`
- **THEN** within stdout `No item findings.` SHALL be emitted after `Scope:` and before `Totals:`
- **AND** any explicitly named advisory section SHALL still be emitted separately to stderr
- **AND** `No item findings.` SHALL NOT assert that no top-level advisory exists
- **AND** no relative ordering between that stderr advisory and stdout sections SHALL be required
#### Scenario: Human empty scope is explicit and successful
- **GIVEN** the selected bulk scope contains no items
- **WHEN** validation runs with `--report findings` and without `--json`
- **THEN** within stdout the report SHALL contain zero-item `Scope:`, `No item findings.`, and zero `Totals:` in that order
- **AND** validation SHALL preserve the current successful empty-scope exit status
#### Scenario: Full and findings verdicts remain equal
- **GIVEN** the same bulk scope, root, inputs, and strictness
- **WHEN** full mode and findings mode run
- **THEN** both modes SHALL validate the same items
- **AND** SHALL produce the same complete summary totals and exit status
- **AND** store and archived scopes SHALL inspect exactly the items their corresponding full invocations inspect
#### Scenario: Completion support follows existing shell capabilities
- **WHEN** completion output is generated for the currently supported Bash, Zsh, Fish, and PowerShell surfaces
- **THEN** the `--report` flag SHALL be registered on all four surfaces
- **AND** Zsh and Fish SHALL suggest the fixed values `full` and `findings`
- **AND** Bash and PowerShell SHALL remain unchanged beyond registering the flag and SHALL NOT be required to suggest fixed values
- **AND** this change SHALL NOT add another completion generator or completion capability
#### Scenario: Findings output is cross-platform
- **WHEN** the same findings validation scenario runs on Windows, macOS, and Linux
- **THEN** report selection, projection, totals, severities, streams, and exit status SHALL be equivalent
- **AND** paths in item records and the root envelope SHALL remain exactly as emitted by full validation, including native root paths and existing POSIX-normalized issue paths
## MODIFIED Requirements
### Requirement: Bulk and filtered validation
The validate command SHALL support flags for bulk validation (--all) and filtered validation by type (--changes, --specs). These flags SHALL select the same items for full and findings reports. Complete per-item listings SHALL apply when `--report` is omitted or is `full`; findings output SHALL follow the item-findings report contract.
#### Scenario: Validate everything
- **WHEN** executing `openspec validate --all`
- **THEN** validate all changes in openspec/changes/ (excluding archive)
- **AND** validate all specs in openspec/specs/
- **AND** display a summary showing passed/failed items
- **AND** exit with code 1 if any validation fails
#### Scenario: Scope of bulk validation
- **WHEN** validating with `--all` or `--changes`
- **THEN** include all change proposals under `openspec/changes/`
- **AND** exclude the `openspec/changes/archive/` directory
- **WHEN** validating with `--specs`
- **THEN** include all specs that have a `spec.md` under `openspec/specs/<capability-path>/spec.md`
#### Scenario: Validate all changes
- **WHEN** executing `openspec validate --changes` with `--report` omitted or set to `full`
- **THEN** validate all changes in openspec/changes/ (excluding archive)
- **AND** display results for each change
- **AND** show summary statistics
#### Scenario: Validate all specs
- **WHEN** executing `openspec validate --specs` with `--report` omitted or set to `full`
- **THEN** validate all specs in openspec/specs/
- **AND** display results for each spec
- **AND** show summary statistics
### Requirement: Validation options and progress indication
The validate command SHALL support standard validation options (--strict, --json) and display progress during bulk operations. Explicit bulk reports SHALL use `--report full` or `--report findings`, independently of JSON serialization. The complete JSON schema below SHALL apply when `--report` is omitted or is `full`; findings output SHALL follow the distinct item-findings report contract.
#### Scenario: Strict validation
- **WHEN** executing `openspec validate --all --strict`
- **THEN** apply strict validation to all items
- **AND** treat warnings as errors
- **AND** fail if any item has warnings or errors
#### Scenario: JSON output
- **WHEN** executing `openspec validate --all --json` with `--report` omitted or set to `full`
- **THEN** output validation results as JSON
- **AND** include detailed issues for each item
- **AND** include summary statistics
#### Scenario: JSON output schema for bulk validation
- **WHEN** executing `openspec validate --all --json` (or `--changes` / `--specs`) with `--report` omitted or set to `full`
- **THEN** output a JSON object with the following shape:
- `items`: Array of objects with fields `{ id: string, type: "change"|"spec", valid: boolean, issues: Issue[], durationMs: number }`
- `summary`: Object `{ totals: { items: number, passed: number, failed: number }, byType: { change?: { items: number, passed: number, failed: number }, spec?: { items: number, passed: number, failed: number } } }`
- `version`: String identifier for the schema (e.g., `"1.0"`)
- **AND** exit with code 1 if any `items[].valid === false`
Where `Issue` follows the existing per-item validation report shape `{ level: "ERROR"|"WARNING"|"INFO", path: string, message: string }`.
#### Scenario: Show validation progress
- **WHEN** validating multiple items (--all, --changes, or --specs)
- **THEN** show progress indicator or status updates
- **AND** indicate which item is currently being validated
- **AND** display running count of passed/failed items
#### Scenario: Concurrency limits for performance
- **WHEN** validating multiple items
- **THEN** run validations with a bounded concurrency (e.g., 4–8 in parallel)
- **AND** ensure progress indicators remain responsive
@@ -0,0 +1,42 @@
## 1. Request and scope contract
- [x] 1.1 Add `--report <full|findings>` to bulk `validate` help and registration, leave omitted-report behavior unchanged, and verify explicit `--report full` and `--report findings` require a bulk scope without an item name
- [x] 1.2 Implement one typed request normalizer before root resolution that maps `--changes` to `changes`, `--specs` to `specs`, `--changes --specs` and `--all` plus active subsets to `all`, and `--archived` to `archived`; verify archived+active, item+report, missing-scope, and unsupported-value requests are rejected before validation
- [x] 1.3 Emit invalid human requests only to stderr and invalid JSON requests as one stdout document with one `status` entry and stable code `invalid_validation_report_request`; verify exit 1, empty opposite streams, and absence of root resolution, prompts, spinners, and validator calls
- [x] 1.4 Register the `--report` flag on the existing Bash, Zsh, Fish, and PowerShell completion outputs; add fixed `full`/`findings` value suggestions only to Zsh and Fish, leave Bash and PowerShell unchanged beyond flag registration, and verify no completion capability or generator is added
- [x] 1.5 Verify case-sensitive report values, preserve parser errors for missing option arguments, and preserve root/discovery failure diagnostics without emitting a findings success envelope
## 2. Shared item projection and renderers
- [x] 2.1 Define one typed projector used by active and archived validation that derives `itemFindings` with `full.items.filter(item => item.issues.length > 0)`, preserving full item order, issue order, and whole item records including additive fields; verify both paths use it rather than filtering independently
- [x] 2.2 Produce the exact findings JSON contract with `report.kind: "validation-findings"`, JSON-string `report.version: "1.0"`, scope/item counts, `itemFindings`, complete `summary`, and `root`; omit full-v1 top-level `items` and `version`
- [x] 2.3 Implement human findings with independently ordered streams: stdout `Scope:` -> optional `No item findings.` -> `Totals:` -> existing active `Details:`; stderr item blocks/all severities -> explicitly named advisories; add tests that capture each stream independently and make no merged stdout/stderr ordering assertion
- [x] 2.4 Preserve full-scope validation work, totals, root, strictness, and exit status in findings mode, and verify ERROR-, WARNING-, INFO-only, no-item-finding, empty-scope, failure, active, archived, and selected-store cases
## 3. Baseline and compatibility gate
- [x] 3.1 Update from main before implementation and verify the full-result inventory is exactly `items`, `summary`, `version`, and `root`; explicitly map those fields without copying unknown top-level fields
- [x] 3.2 Verify existing INFO-bearing full item records appear unchanged in `itemFindings`, and no advisory field is invented when the full report has none
- [x] 3.3 Add human-byte and normalized-JSON compatibility tests proving omitted `--report` and explicit bulk `--report full` preserve current output for active, spec, archived, empty, and selected-store scopes while ignoring expected timing-field variation between runs
- [x] 3.4 Add contract tests proving `report.version` is exactly the JSON string `"1.0"` and findings output does not conform to the documented full-v1 shape requiring top-level `version: "1.0"` and complete `items`; do not assert failure behavior for arbitrary undocumented parsers
## 4. Documentation and release tracking
- [x] 4.1 Document report-versus-serialization semantics, canonical/invalid scope combinations, independent within-stream human section ordering, the exact findings JSON and invalid-request JSON documents, item/advisory distinction, exit codes, and the unchanged full-v1 contract
- [x] 4.2 Document external `jq` and PowerShell filtering as compatible alternatives for existing releases and explain that findings mode reduces emitted output but does not claim faster validation
- [x] 4.3 Add the appropriate release changeset for the implemented feature and verify release tracking passes
## 5. Verification
- [x] 5.1 Run focused validate command, archived validation, completion, store-root, structured-error, and CLI end-to-end tests and verify all pass
- [x] 5.2 Run build, full tests, TypeScript checks, lint, and `git diff --check`, and verify all repository checks pass
- [x] 5.3 Run `openspec validate add-validation-findings-report --strict` and reconcile implementation and documentation against every scenario before marking the change complete
- [x] 5.4 Measure the available repository archive (a replacement for the unavailable original 895-change corpus) against the implemented `itemFindings` envelope, verify default/full compatibility and complete item findings/totals/exit status, and report the new bytes separately from the 6,740-byte feasibility candidate without a runtime claim
## Verification results
- Build, TypeScript checks, lint, strict validation of this change, release tracking, and `git diff --check` pass.
- Full suite: 148 files and 4,273 tests pass. The build completed before the run. Local verification used a temporary `USERPROFILE`, unset inherited `ZSH`/`ZSH_CUSTOM`, and allowed localhost HTTP fixtures; the original environment-sensitive failures reproduced on unchanged main.
- The 83-change archive measurement retains all 12 failures, full totals, root, and exit 1 while reducing JSON output by 72.5%. See `design.md` for the measured bytes and corpus distinction.
- Independent implementation review found no remaining blockers.
- Documentation examples were checked against the built CLI. The Bash/jq alternatives were executed. PowerShell examples were source-reviewed only because `pwsh` is unavailable locally; rendered docs QA was unavailable because no browser was connected.
+9 -3
View File
@@ -32,10 +32,16 @@ The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent
- **WHEN** looking up the `cursor` tool
- **THEN** `skillsDir` SHALL be `.cursor`
#### Scenario: Windsurf paths defined
#### Scenario: Devin Desktop paths defined
- **WHEN** looking up the `windsurf` tool
- **THEN** `skillsDir` SHALL be `.windsurf`
- **WHEN** looking up the `devin` tool
- **THEN** `skillsDir` SHALL be `.devin`
#### Scenario: Legacy Windsurf tool ID
- **WHEN** initializing with `openspec init --tools windsurf`
- **THEN** the `windsurf` alias SHALL resolve to `devin`
- **AND** when skill delivery is enabled, skills SHALL be generated under `.devin/skills/`, not `.windsurf/skills/`
#### Scenario: Kimi Code paths defined
+11 -10
View File
@@ -208,8 +208,8 @@ The system SHALL support an `apply` block in schema definitions that controls wh
#### Scenario: Schema without apply block
- **WHEN** a schema has no `apply` block
- **THEN** the system requires all artifacts to exist before apply is available
- **AND** uses default instruction: "All artifacts complete. Proceed with implementation."
- **THEN** the system requires all non-skipped artifacts to exist before apply is available
- **AND** once those artifacts exist, uses default instruction: "All required artifacts complete. Proceed with implementation."
### Requirement: Apply Instructions Command
@@ -275,23 +275,24 @@ The `artifact-experimental-setup` command SHALL accept a `--tool <tool-id>` flag
### Requirement: Output messaging
The setup command SHALL display clear output about what was generated.
The `openspec init` command SHALL display clear output about what was generated.
#### Scenario: Show target tool in output
- **WHEN** setup command runs successfully
- **THEN** output includes the target tool name (e.g., "Setting up for Cursor...")
- **WHEN** initialization creates or refreshes a tool configuration
- **THEN** output includes the tool name under `Created:` or `Refreshed:`, respectively
#### Scenario: Show generated paths
- **WHEN** setup command completes
- **THEN** output lists all generated skill file paths
- **AND** lists all generated command file paths (if applicable)
- **WHEN** initialization generates skills or commands
- **THEN** output summarizes their counts and destination directories
- **AND** only reports the types enabled by the selected profile and delivery mode
#### Scenario: Show skipped commands message
- **WHEN** command generation is skipped due to missing adapter
- **THEN** output includes message: "Command generation skipped - no adapter for <tool>"
- **WHEN** initialization skips command generation due to a missing adapter
- **THEN** output includes message: "Commands skipped for: <tools> (no adapter)"
- **AND** `<tools>` lists the skipped tool IDs separated by commas
### Requirement: Status JSON provides planning context
The status command SHALL provide machine-readable planning context for changes.
+19 -9
View File
@@ -37,19 +37,29 @@ The system SHALL provide a `change` command with subcommands for displaying, lis
### Requirement: Legacy Compatibility
The system SHALL maintain backward compatibility with the existing `list` command while showing deprecation notices.
The system SHALL retain `openspec change list` as a deprecated alias for listing active changes and direct users to `openspec list`.
#### Scenario: Legacy list command
- **WHEN** executing `openspec change list`
- **THEN** display the current list of active changes on stdout
- **AND** write `Warning: "openspec change list" is deprecated. Use "openspec list".` to stderr
#### Scenario: Legacy list with JSON output
- **WHEN** executing `openspec change list --json`
- **THEN** output the active changes as a JSON array on stdout
- **AND** write the deprecation warning to stderr without corrupting the JSON output
#### Scenario: Unsupported legacy list flag
- **WHEN** executing `openspec change list --all`
- **THEN** reject the unknown option with a nonzero exit code
#### Scenario: Preferred list command
- **WHEN** executing `openspec list`
- **THEN** display current list of changes (existing behavior)
- **AND** show deprecation notice: "Note: 'openspec list' is deprecated. Use 'openspec change list' instead."
#### Scenario: Legacy list with --all flag
- **WHEN** executing `openspec list --all`
- **THEN** display all changes (existing behavior)
- **AND** show same deprecation notice
- **THEN** display the current list of active changes without a deprecation warning
### Requirement: Interactive show selection
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "1.11.0",
"version": "1.12.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
@@ -17,7 +17,7 @@
"license": "MIT",
"author": "OpenSpec Contributors",
"type": "module",
"packageManager": "pnpm@9.15.9",
"packageManager": "pnpm@10.34.5",
"publishConfig": {
"access": "public"
},
@@ -49,7 +49,7 @@
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"prepare": "pnpm run build",
"prepare": "node build.js",
"prepublishOnly": "pnpm run build",
"check:pack-version": "node scripts/pack-version-check.mjs",
"release": "pnpm run release:ci",
@@ -60,8 +60,8 @@
"node": ">=20.19.0"
},
"devDependencies": {
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.1",
"@changesets/changelog-github": "^1.0.0",
"@changesets/cli": "^3.0.1",
"@types/node": "^20.19.43",
"@vitest/ui": "^3.2.6",
"eslint": "^10.5.0",
+273 -597
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -30,6 +30,30 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
---
## Planning a Change
When the user is planning a change, guide them toward shared understanding with focused discovery questions. For open-ended discussion, follow the conversation without imposing an interview or a required output.
Before asking a factual question, follow the context discovery below and inspect relevant OpenSpec artifacts, source, tests, docs, and configuration. Do not ask the user to repeat facts you can verify. Summarize relevant findings without reproducing private context or rules. If evidence is missing, conflicting, or inaccessible, state that limitation and ask only for the clarification needed to proceed.
- **Follow dependencies** - Resolve the next blocking decision before its dependent details. For example, clarify the user's outcome and scope before choosing an API or data model. Revisit downstream assumptions when an earlier answer changes. Skip branches that do not matter to this goal.
- **Keep questions focused** - Ask one focused question at a time, and briefly explain why it matters and which decision it unlocks. Batch questions only if the user asks for a batch; keep them small and group related decisions.
- **Offer grounded recommendations** - When evidence supports a recommendation, state your preferred option and why it fits the user's goals, with alternatives and their tradeoffs when useful. Do not invent intent, priorities, or external constraints: ask the user when only they can answer. Avoid a fixed question format.
- **Keep a conversational record** - Track decisions in the conversation, not in files. Separate confirmed decisions from proposed defaults and unresolved questions. Silence is not acceptance. Accepting an answer or a batch of recommendations is not permission to write. Keep file-write confirmation separate from discovery questions and follow the guardrails below.
Stop asking when the user has enough clarity. Let them pause, pivot, or defer a decision; do not exhaust every branch or force a proposal.
For example, after inspecting the relevant code:
```text
The CLI already uses SQLite and has no remote service. Is sharing state
across devices in scope? That determines whether local storage is enough.
If this stays a single-device tool, I recommend keeping SQLite to avoid
adding a service to operate; shared state would need a separate sync design.
```
---
## What You Might Do
Depending on what the user brings, you might:
+4
View File
@@ -61,6 +61,10 @@ Fast-forward through artifact creation - generate everything needed to start imp
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- **Inspect the relevant project before drafting**: Read `context` and `rules` first, then inspect relevant implementation, nearby tests, configuration, and documentation outside `openspec/`. Keep inspection read-only and proportional to the change; reuse findings for later artifacts and inspect more only as needed.
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
- If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath`
- Otherwise create the artifact file using `template` as the structure and write it to `resolvedOutputPath`. If `resolvedOutputPath` is a glob, follow `instruction` to choose the concrete file path
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
+4
View File
@@ -96,6 +96,10 @@ When the user is ready to implement, they must start the apply workflow explicit
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- **Inspect the relevant project before drafting**: Read `context` and `rules` first, then inspect relevant implementation, nearby tests, configuration, and documentation outside `openspec/`. Keep inspection read-only and proportional to the change; reuse findings for later artifacts and inspect more only as needed.
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
- If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath`
- Otherwise create the artifact file using `template` as the structure and write it to `resolvedOutputPath`. If `resolvedOutputPath` is a glob, follow `instruction` to choose the concrete file path
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
+2 -1
View File
@@ -511,6 +511,7 @@ program
.option('--changes', 'Validate all changes')
.option('--specs', 'Validate all specs')
.option('--archived', 'Validate that archived changes have all tasks completed (for pre-commit linting)')
.option('--report <full|findings>', 'Select bulk report content: full|findings; combine with --json for JSON')
.option('--type <type>', 'Specify item type when ambiguous: change|spec')
.option('--strict', 'Enable strict validation mode')
.option('--json', 'Output validation results as JSON')
@@ -518,7 +519,7 @@ program
.option('--no-interactive', 'Disable interactive prompts')
.option('--store <id>', STORE_OPTION_DESCRIPTION)
.addOption(hiddenStorePathOption())
.action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; archived?: boolean; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string; store?: string; storePath?: string }) => {
.action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; archived?: boolean; report?: string; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string; store?: string; storePath?: string }) => {
try {
const validateCommand = new ValidateCommand();
await validateCommand.execute(itemName, options);
+6 -5
View File
@@ -545,11 +545,12 @@ export class ChangeCommand {
console.log(`Change "${changeName}" is valid`);
} else {
console.error(`Change "${changeName}" has issues`);
report.issues.forEach(issue => {
const label = issue.level === 'ERROR' ? 'ERROR' : 'WARNING';
const prefix = issue.level === 'ERROR' ? '✗' : '⚠';
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
});
}
report.issues.forEach(issue => {
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
console.error(`${prefix} [${issue.level}] ${issue.path}: ${issue.message}`);
});
if (!report.valid) {
// Next steps footer to guide fixing issues
this.printNextSteps(report.issues);
if (!options?.json) {
+115 -17
View File
@@ -24,6 +24,7 @@ interface ExecuteOptions {
changes?: boolean;
specs?: boolean;
archived?: boolean;
report?: string;
type?: string;
strict?: boolean;
json?: boolean;
@@ -42,9 +43,65 @@ interface BulkItemResult {
durationMs: number;
}
type BulkScope = 'all' | 'changes' | 'specs' | 'archived';
interface BulkValidationResult<T extends BulkItemResult = BulkItemResult> {
items: T[];
summary: {
totals: { items: number; passed: number; failed: number };
byType: Partial<Record<ItemType, { items: number; passed: number; failed: number }>>;
};
root: ReturnType<typeof toRootOutput>;
}
/** Findings are a distinct report, not a partial full-v1 items collection. */
export function projectValidationFindings<T extends BulkItemResult>(full: BulkValidationResult<T>, scope: BulkScope) {
const itemFindings = full.items.filter(item => item.issues.length > 0);
return {
report: {
kind: 'validation-findings' as const,
version: '1.0' as const,
scope,
returnedItems: itemFindings.length,
totalItems: full.summary.totals.items,
},
itemFindings,
summary: full.summary,
root: full.root,
};
}
export class ValidateCommand {
async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise<void> {
const bulk = options.all || options.changes || options.specs;
let findingsScope: BulkScope | undefined;
if (options.report !== undefined) {
const message = options.report !== 'full' && options.report !== 'findings'
? `Unknown validation report '${options.report}'.`
: itemName !== undefined
? 'A validation report cannot be combined with an item name.'
: options.archived && bulk
? 'A validation report cannot combine archived and active scopes.'
: !options.archived && !bulk
? 'A validation report requires an explicit bulk scope.'
: undefined;
if (message) {
const fix = 'Use --report full|findings with --all, --changes, --specs, or --archived, without an item name. Do not combine archived and active scopes.';
if (options.json) {
console.log(JSON.stringify({ status: [{ severity: 'error', code: 'invalid_validation_report_request', message, fix }] }, null, 2));
} else {
console.error(`Error: ${message}`);
console.error(`Fix: ${fix}`);
}
process.exitCode = 1;
return;
}
if (options.report === 'findings') {
findingsScope = options.archived ? 'archived'
: options.all || (options.changes && options.specs) ? 'all'
: options.changes ? 'changes' : 'specs';
}
}
const root = await resolveRootForCommand(options, {
json: options.json,
...(bulk ? { allowImplicitRoot: false } : {}),
@@ -63,6 +120,7 @@ export class ValidateCommand {
await this.runArchivedTaskValidation(root, {
json: !!options.json,
noInteractive: resolveNoInteractive(options),
findingsScope,
});
return;
}
@@ -72,7 +130,7 @@ export class ValidateCommand {
await this.runBulkValidation(root, {
changes: !!options.all || !!options.changes,
specs: !!options.all || !!options.specs,
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) });
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options), findingsScope });
return;
}
@@ -245,11 +303,12 @@ export class ValidateCommand {
console.log(`${type === 'change' ? 'Change' : 'Specification'} '${id}' is valid`);
} else {
console.error(`${type === 'change' ? 'Change' : 'Specification'} '${id}' has issues`);
for (const issue of report.issues) {
const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
}
}
for (const issue of report.issues) {
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
console.error(`${prefix} [${issue.level}] ${issue.path}: ${issue.message}`);
}
if (!report.valid) {
this.printNextSteps(type, id, root, report.issues);
}
}
@@ -285,7 +344,38 @@ export class ValidateCommand {
bullets.forEach(b => console.error(` ${b}`));
}
private async runBulkValidation(root: ResolvedOpenSpecRoot, scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise<void> {
private printFindingsReport(full: BulkValidationResult, scope: BulkScope, json: boolean, root: ResolvedOpenSpecRoot): void {
const findings = projectValidationFindings(full, scope);
if (json) {
console.log(JSON.stringify(findings, null, 2));
return;
}
console.log(`Scope: ${scope} (${findings.report.totalItems} items)`);
if (findings.itemFindings.length === 0) {
console.log('No item findings.');
}
for (const item of findings.itemFindings) {
console.error(`${item.type}/${item.id}`);
for (const issue of item.issues) {
console.error(` [${issue.level}] ${issue.path}: ${issue.message}`);
}
}
const totals = findings.summary.totals;
console.log(`Totals: ${totals.passed} passed, ${totals.failed} failed (${totals.items} items)`);
if (scope !== 'archived') this.printBulkDetails(full.items, root);
}
private printBulkDetails(results: BulkItemResult[], root: ResolvedOpenSpecRoot): void {
const firstFailure = results.find((res) => !res.valid);
if (firstFailure) {
const storeFlag = isStoreSelectedRoot(root) ? ` --store ${root.storeId}` : '';
console.log(
`Details: openspec validate ${firstFailure.id} --type ${firstFailure.type}${storeFlag}`
);
}
}
private async runBulkValidation(root: ResolvedOpenSpecRoot, scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean; findingsScope?: BulkScope }): Promise<void> {
const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
const [changeIds, specIds] = await Promise.all([
scope.changes ? this.listChangeIds(root) : Promise.resolve<string[]>([]),
@@ -331,7 +421,9 @@ export class ValidateCommand {
},
} as const;
if (opts.json) {
if (opts.findingsScope) {
this.printFindingsReport({ items: [], summary, root: toRootOutput(root) }, opts.findingsScope, opts.json, root);
} else if (opts.json) {
const out = { items: [] as BulkItemResult[], summary, version: '1.0', root: toRootOutput(root) };
console.log(JSON.stringify(out, null, 2));
} else {
@@ -387,22 +479,22 @@ export class ValidateCommand {
},
} as const;
if (opts.json) {
if (opts.findingsScope) {
this.printFindingsReport({ items: results, summary, root: toRootOutput(root) }, opts.findingsScope, opts.json, root);
} else if (opts.json) {
const out = { items: results, summary, version: '1.0', root: toRootOutput(root) };
console.log(JSON.stringify(out, null, 2));
} else {
for (const res of results) {
if (res.valid) console.log(`✓ ${res.type}/${res.id}`);
else console.error(`✗ ${res.type}/${res.id}`);
for (const issue of res.issues) {
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
console.error(` ${prefix} [${issue.level}] ${issue.path}: ${issue.message}`);
}
}
console.log(`Totals: ${summary.totals.passed} passed, ${summary.totals.failed} failed (${summary.totals.items} items)`);
const firstFailure = results.find((res) => !res.valid);
if (firstFailure) {
const storeFlag = isStoreSelectedRoot(root) ? ` --store ${root.storeId}` : '';
console.log(
`Details: openspec validate ${firstFailure.id} --type ${firstFailure.type}${storeFlag}`
);
}
this.printBulkDetails(results, root);
}
process.exitCode = failed > 0 ? 1 : 0;
@@ -443,7 +535,7 @@ export class ValidateCommand {
*/
private async runArchivedTaskValidation(
root: ResolvedOpenSpecRoot,
opts: { json: boolean; noInteractive?: boolean }
opts: { json: boolean; noInteractive?: boolean; findingsScope?: BulkScope }
): Promise<void> {
// List first (may throw on a real archive-read failure), then start the
// spinner so a thrown error never leaves a spinner spinning.
@@ -502,6 +594,12 @@ export class ValidateCommand {
byType: { change: summarizeType(results, 'change') },
} as const;
if (opts.findingsScope) {
this.printFindingsReport({ items: results, summary, root: toRootOutput(root) }, opts.findingsScope, opts.json, root);
process.exitCode = failed > 0 ? 1 : 0;
return;
}
if (opts.json) {
const out = { items: results, summary, version: '1.0', root: toRootOutput(root) };
console.log(JSON.stringify(out, null, 2));
@@ -0,0 +1,33 @@
/**
* SourceCraft Code Assistant Command Adapter
*
* Formats commands for the SourceCraft Code Assistant VS Code extension.
*
* @see https://sourcecraft.dev/portal/docs/en/code-assistant/operations/agent/slash-commands
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { escapeYamlValue } from '../yaml.js';
/**
* SourceCraft Code Assistant adapter for command generation.
* File path: .codeassistant/commands/opsx-<id>.md
* Format: YAML frontmatter with description
*/
export const codeassistantAdapter: ToolCommandAdapter = {
toolId: 'codeassistant',
getFilePath(commandId: string): string {
return path.join('.codeassistant', 'commands', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
description: ${escapeYamlValue(content.description)}
---
${content.body}
`;
},
};
@@ -27,6 +27,7 @@ export { kiroAdapter } from './kiro.js';
export { ohMyPiAdapter } from './oh-my-pi.js';
export { opencodeAdapter } from './opencode.js';
export { piAdapter } from './pi.js';
export { codeassistantAdapter } from './codeassistant.js';
export { qoderAdapter } from './qoder.js';
export { lingmaAdapter } from './lingma.js';
export { qwenAdapter } from './qwen.js';
+2
View File
@@ -29,6 +29,7 @@ import { kiroAdapter } from './adapters/kiro.js';
import { ohMyPiAdapter } from './adapters/oh-my-pi.js';
import { opencodeAdapter } from './adapters/opencode.js';
import { piAdapter } from './adapters/pi.js';
import { codeassistantAdapter } from './adapters/codeassistant.js';
import { qoderAdapter } from './adapters/qoder.js';
import { lingmaAdapter } from './adapters/lingma.js';
import { qwenAdapter } from './adapters/qwen.js';
@@ -67,6 +68,7 @@ export class CommandAdapterRegistry {
CommandAdapterRegistry.register(ohMyPiAdapter);
CommandAdapterRegistry.register(opencodeAdapter);
CommandAdapterRegistry.register(piAdapter);
CommandAdapterRegistry.register(codeassistantAdapter);
CommandAdapterRegistry.register(qoderAdapter);
CommandAdapterRegistry.register(lingmaAdapter);
CommandAdapterRegistry.register(qwenAdapter);
+6
View File
@@ -107,6 +107,12 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
name: 'archived',
description: 'Validate that archived changes have all tasks completed (for pre-commit linting)',
},
{
name: 'report',
description: 'Select bulk report content',
takesValue: true,
values: ['full', 'findings'],
},
COMMON_FLAGS.type,
COMMON_FLAGS.strict,
COMMON_FLAGS.jsonValidation,
+1
View File
@@ -74,6 +74,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Oh My Pi', value: 'oh-my-pi', available: true, successLabel: 'Oh My Pi', skillsDir: '.omp' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },
{ name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' },
{ name: 'SourceCraft Code Assistant', value: 'codeassistant', available: true, successLabel: 'SourceCraft Code Assistant', skillsDir: '.codeassistant' },
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder', requiresIdeRestart: true },
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },
{ name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] },
+30 -49
View File
@@ -18,6 +18,7 @@ import {
storePointerProblem,
} from './project-config.js';
import { findRepoPlanningRootSync } from './planning-home.js';
import { ANCHORED_OPENSPEC_DIRS, ensureDirectoryAnchor } from './openspec-root.js';
import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
import {
AI_TOOLS,
@@ -55,6 +56,7 @@ import {
resolveToolSkillsDir,
toolSupportsSkills,
type ToolSkillStatus,
formatIdeRestart,
} from './shared/index.js';
import { getGlobalConfig, type Delivery, type Profile } from './global-config.js';
import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js';
@@ -148,7 +150,6 @@ type ValidatedInitTool = {
skillsRoot: string;
isGlobalSkillTarget: boolean;
wasConfigured: boolean;
requiresIdeRestart?: boolean;
writesSkills: boolean;
};
@@ -843,7 +844,6 @@ export class InitCommand {
skillsRoot: isGlobalSkillTarget ? skillsPath : projectPath,
isGlobalSkillTarget,
wasConfigured: preState?.configured ?? false,
requiresIdeRestart: tool.requiresIdeRestart,
writesSkills: !tool.skillsDir || skillWriters.has(tool.value),
});
}
@@ -856,24 +856,6 @@ export class InitCommand {
// ═══════════════════════════════════════════════════════════
private async createDirectoryStructure(openspecPath: string, extendMode: boolean): Promise<void> {
if (extendMode) {
// In extend mode, just ensure directories exist without spinner
const directories = [
openspecPath,
path.join(openspecPath, 'specs'),
path.join(openspecPath, 'changes'),
path.join(openspecPath, 'changes', 'archive'),
];
for (const dir of directories) {
FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
await FileSystemUtils.createDirectory(dir);
}
return;
}
const spinner = this.startSpinner('Creating OpenSpec structure...');
const directories = [
openspecPath,
path.join(openspecPath, 'specs'),
@@ -881,17 +863,37 @@ export class InitCommand {
path.join(openspecPath, 'changes', 'archive'),
];
if (extendMode) {
// In extend mode, just ensure directories exist without spinner
for (const dir of directories) {
FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
await FileSystemUtils.createDirectory(dir);
}
await this.writeGitkeepFiles(openspecPath);
return;
}
const spinner = this.startSpinner('Creating OpenSpec structure...');
for (const dir of directories) {
FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
await FileSystemUtils.createDirectory(dir);
}
await this.writeGitkeepFiles(openspecPath);
spinner.stopAndPersist({
symbol: PALETTE.white('▌'),
text: PALETTE.white('OpenSpec structure created'),
});
}
private async writeGitkeepFiles(openspecPath: string): Promise<void> {
for (const relativeDir of ANCHORED_OPENSPEC_DIRS) {
await ensureDirectoryAnchor(path.dirname(openspecPath), relativeDir);
}
}
// ═══════════════════════════════════════════════════════════
// SKILL & COMMAND GENERATION
// ═══════════════════════════════════════════════════════════
@@ -1402,37 +1404,16 @@ export class InitCommand {
console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);
// Restart instruction only when at least one IDE/editor-resident tool
// actually received a generated surface. Two conditions, coupled to the SAME
// tool: (1) its commands/skills are loaded by a long-running editor process
// (CLI tools pick the files up immediately, so a restart line would be wrong
// for them — see #1067), and (2) a surface was actually generated for it
// under the active delivery (an IDE tool that generated nothing has nothing a
// restart would pick up, even if a co-configured CLI tool did generate).
// Wording follows what the IDE tool itself generated, not the global
// aggregate: it must not say "commands" when the IDE tool only got skills
// while a co-configured CLI tool got commands. Not "slash commands" either:
// Amazon Q's generated files are prompt-library entries invoked with @, so a
// restart line promising slash commands would be wrong for it.
const restartCommandsGenerated = successfulTools.some(
(tool) =>
tool.requiresIdeRestart &&
shouldGenerateCommandsForTool(tool.value, activeDelivery)
// Restart instruction for successfully configured IDE/editor-resident tools
// with a supported surface under the active delivery. The rule and wording live in
// formatIdeRestart so `update` says the same thing for the same event.
const restartHint = formatIdeRestart(
successfulTools.map((tool) => tool.value),
activeDelivery
);
const restartSkillsGenerated = successfulTools.some(
(tool) =>
tool.requiresIdeRestart &&
shouldGenerateSkillsForTool(tool.value, activeDelivery)
);
if (restartCommandsGenerated || restartSkillsGenerated) {
if (restartHint) {
console.log();
console.log(
chalk.white(
restartCommandsGenerated
? 'Restart your IDE for the new commands to take effect.'
: 'Restart your IDE for the new skills to take effect.'
)
);
console.log(chalk.white(restartHint));
}
console.log();
+9 -3
View File
@@ -271,17 +271,23 @@ async function ensureDefaultConfig(
});
}
async function ensureDirectoryAnchor(
export async function ensureDirectoryAnchor(
storeRoot: string,
relativeDir: string,
ledger: CreatedPathLedgerEntry[]
ledger: CreatedPathLedgerEntry[] = []
): Promise<void> {
const directory = path.join(storeRoot, relativeDir);
if ((await fs.readdir(directory)).length > 0) return;
const relativePath = `${relativeDir}/${DIRECTORY_ANCHOR_FILE_NAME}`;
const absolutePath = path.join(directory, DIRECTORY_ANCHOR_FILE_NAME);
await fs.writeFile(absolutePath, '', 'utf-8');
try {
// A file or symlink may appear after readdir. Never replace or follow it.
await fs.writeFile(absolutePath, '', { encoding: 'utf-8', flag: 'wx' });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') return;
throw error;
}
ledger.push({
relativePath: relativeArtifact(relativePath, 'file'),
absolutePath,
+63
View File
@@ -0,0 +1,63 @@
/**
* IDE restart hint
*
* Shared restart guidance for tools successfully configured by init or update.
* The wording covers additions, updates, and removals, including an empty
* workflow selection that removes every generated file.
*/
import { AI_TOOLS } from '../config.js';
import {
shouldGenerateCommandsForTool,
shouldGenerateSkillsForTool,
} from '../command-surface.js';
import type { Delivery } from '../global-config.js';
/** The surface a restart hint names. Absent when no hint is due. */
export type IdeRestartSurface = 'commands' | 'skills';
function isIdeResident(toolId: string): boolean {
return Boolean(
AI_TOOLS.find((tool) => tool.value === toolId)?.requiresIdeRestart
);
}
/**
* Both conditions stay coupled to the SAME tool: its surfaces are loaded by a
* long-running editor process (a CLI picks them up immediately, so a restart
* line would be wrong for it — see #1067), and it supports a generated surface
* under the active delivery. A CLI tool's commands must not determine the hint
* for an IDE tool that only supports skills. Commands take precedence when
* both surfaces are supported under the active delivery.
*/
export function resolveIdeRestartSurface(
toolIds: readonly string[],
delivery: Delivery
): IdeRestartSurface | null {
const ideTools = [...new Set(toolIds)].filter(isIdeResident);
if (ideTools.some((toolId) => shouldGenerateCommandsForTool(toolId, delivery))) {
return 'commands';
}
if (ideTools.some((toolId) => shouldGenerateSkillsForTool(toolId, delivery))) {
return 'skills';
}
return null;
}
/**
* The restart line to print, or null when no restart is needed. Deliberately
* not "slash commands": Amazon Q's generated files are prompt-library entries
* invoked with `@`, so promising slash commands would be wrong for it.
*/
export function formatIdeRestart(
toolIds: readonly string[],
delivery: Delivery
): string | null {
const surface = resolveIdeRestartSurface(toolIds, delivery);
return surface
? `Restart your IDE to refresh ${surface}.`
: null;
}
+6
View File
@@ -36,3 +36,9 @@ export {
hasGlobalSkillTarget,
resolveToolSkillsDir,
} from './skill-paths.js';
export {
type IdeRestartSurface,
resolveIdeRestartSurface,
formatIdeRestart,
} from './ide-restart.js';
+5 -1
View File
@@ -324,7 +324,11 @@ export async function buildUpdatedSpec(
);
}
}
} catch {
} catch (error) {
// An unreadable target is not a new spec. Preserve the filesystem error
// for callers, rather than synthesizing a baseline or a missing-target finding.
const code = (error as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error;
// Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
// REMOVED will be ignored with a warning since there's nothing to remove
if (plan.modified.length > 0 || plan.renamed.length > 0) {
+30
View File
@@ -7,6 +7,28 @@
import type { SkillTemplate, CommandTemplate } from '../types.js';
import { STORE_SELECTION_GUIDANCE } from './store-selection.js';
const PLANNING_GUIDANCE = `## Planning a Change
When the user is planning a change, guide them toward shared understanding with focused discovery questions. For open-ended discussion, follow the conversation without imposing an interview or a required output.
Before asking a factual question, follow the context discovery below and inspect relevant OpenSpec artifacts, source, tests, docs, and configuration. Do not ask the user to repeat facts you can verify. Summarize relevant findings without reproducing private context or rules. If evidence is missing, conflicting, or inaccessible, state that limitation and ask only for the clarification needed to proceed.
- **Follow dependencies** - Resolve the next blocking decision before its dependent details. For example, clarify the user's outcome and scope before choosing an API or data model. Revisit downstream assumptions when an earlier answer changes. Skip branches that do not matter to this goal.
- **Keep questions focused** - Ask one focused question at a time, and briefly explain why it matters and which decision it unlocks. Batch questions only if the user asks for a batch; keep them small and group related decisions.
- **Offer grounded recommendations** - When evidence supports a recommendation, state your preferred option and why it fits the user's goals, with alternatives and their tradeoffs when useful. Do not invent intent, priorities, or external constraints: ask the user when only they can answer. Avoid a fixed question format.
- **Keep a conversational record** - Track decisions in the conversation, not in files. Separate confirmed decisions from proposed defaults and unresolved questions. Silence is not acceptance. Accepting an answer or a batch of recommendations is not permission to write. Keep file-write confirmation separate from discovery questions and follow the guardrails below.
Stop asking when the user has enough clarity. Let them pause, pivot, or defer a decision; do not exhaust every branch or force a proposal.
For example, after inspecting the relevant code:
\`\`\`text
The CLI already uses SQLite and has no remote service. Is sharing state
across devices in scope? That determines whether local storage is enough.
If this stays a single-device tool, I recommend keeping SQLite to avoid
adding a service to operate; shared state would need a separate sync design.
\`\`\``;
export function getExploreSkillTemplate(): SkillTemplate {
return {
name: 'openspec-explore',
@@ -32,6 +54,10 @@ ${STORE_SELECTION_GUIDANCE}
---
${PLANNING_GUIDANCE}
---
## What You Might Do
Depending on what the user brings, you might:
@@ -350,6 +376,10 @@ ${STORE_SELECTION_GUIDANCE}
---
${PLANNING_GUIDANCE}
---
## What You Might Do
Depending on what the user brings, you might:
@@ -63,6 +63,10 @@ ${STORE_SELECTION_GUIDANCE}
- \`resolvedOutputPath\`: Resolved path or pattern to write the artifact
- \`dependencies\`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- **Inspect the relevant project before drafting**: Read \`context\` and \`rules\` first, then inspect relevant implementation, nearby tests, configuration, and documentation outside \`openspec/\`. Keep inspection read-only and proportional to the change; reuse findings for later artifacts and inspect more only as needed.
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
- Otherwise create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose the concrete file path
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
@@ -176,6 +180,10 @@ ${STORE_SELECTION_GUIDANCE}
- \`resolvedOutputPath\`: Resolved path or pattern to write the artifact
- \`dependencies\`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- **Inspect the relevant project before drafting**: Read \`context\` and \`rules\` first, then inspect relevant implementation, nearby tests, configuration, and documentation outside \`openspec/\`. Keep inspection read-only and proportional to the change; reuse findings for later artifacts and inspect more only as needed.
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
- Otherwise create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose the concrete file path
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
+8
View File
@@ -98,6 +98,10 @@ ${STORE_SELECTION_GUIDANCE}
- \`resolvedOutputPath\`: Resolved path or pattern to write the artifact
- \`dependencies\`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- **Inspect the relevant project before drafting**: Read \`context\` and \`rules\` first, then inspect relevant implementation, nearby tests, configuration, and documentation outside \`openspec/\`. Keep inspection read-only and proportional to the change; reuse findings for later artifacts and inspect more only as needed.
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
- Otherwise create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose the concrete file path
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
@@ -247,6 +251,10 @@ ${STORE_SELECTION_GUIDANCE}
- \`resolvedOutputPath\`: Resolved path or pattern to write the artifact
- \`dependencies\`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- **Inspect the relevant project before drafting**: Read \`context\` and \`rules\` first, then inspect relevant implementation, nearby tests, configuration, and documentation outside \`openspec/\`. Keep inspection read-only and proportional to the change; reuse findings for later artifacts and inspect more only as needed.
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
- If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\`
- Otherwise create the artifact file using \`template\` as the structure and write it to \`resolvedOutputPath\`. If \`resolvedOutputPath\` is a glob, follow \`instruction\` to choose the concrete file path
- Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file
+4 -12
View File
@@ -27,6 +27,7 @@ import {
resolveToolSkillsDir,
toolSupportsSkills,
type ToolVersionStatus,
formatIdeRestart,
} from './shared/index.js';
import {
detectLegacyArtifacts,
@@ -501,18 +502,9 @@ export class UpdateCommand {
console.log();
const affectedToolIds = [...new Set([...newlyConfiguredTools, ...updatedToolIds])];
const shouldRestartIde = affectedToolIds.some((toolId) => {
const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
return Boolean(
tool?.requiresIdeRestart &&
(
shouldGenerateCommandsForTool(toolId, delivery) ||
shouldGenerateSkillsForTool(toolId, delivery)
)
);
});
if (shouldRestartIde) {
console.log(chalk.dim('Restart your IDE for changes to take effect.'));
const restartHint = formatIdeRestart(affectedToolIds, delivery);
if (restartHint) {
console.log(chalk.dim(restartHint));
}
if (failedTools.length > 0) {
throw new Error(`OpenSpec update failed for: ${failedTools.map((tool) => tool.name).join(', ')}`);
+86 -3
View File
@@ -5,6 +5,7 @@ import { SpecSchema, ChangeSchema, Spec, Change } from '../schemas/index.js';
import { MarkdownParser } from '../parsers/markdown-parser.js';
import { ChangeParser } from '../parsers/change-parser.js';
import { ValidationReport, ValidationIssue, ValidationLevel } from './types.js';
import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
import {
MIN_PURPOSE_LENGTH,
MAX_REQUIREMENT_TEXT_LENGTH,
@@ -151,9 +152,10 @@ export class Validator {
* - No duplicates within sections; no cross-section conflicts per spec
*
* When `options.mainSpecsDir` is given, MODIFIED blocks are also checked
* against the current main specs for the scenario loss archive refuses to
* apply (#1477). When `options.projectRoot` is given, the schema's tracked
* task files are checked for ambiguous numbering (#1520). Omitting either
* against the current main specs for scenario loss (#1477), and merge
* conflicts are reported as INFO without changing the verdict (#1112).
* When `options.projectRoot` is given, the schema's tracked task files are
* checked for ambiguous numbering (#1520). Omitting either
* option keeps existing library and archive callers behaving as before.
*/
async validateChangeDeltaSpecs(
@@ -395,6 +397,23 @@ export class Validator {
}
}
}
// Reuse archive's merge builder to report conflicts with the main specs.
// Keep structural errors and scenario loss in their existing diagnostics.
if (options.mainSpecsDir) {
issues.push(
...(await this.findArchiveBlockers(changeDir, options.mainSpecsDir, [
...issues.filter((issue) => issue.level === 'ERROR').map((issue) => issue.path),
// Collected in the loop above but not turned into issues until
// after this try block, so they are invisible to the filter. A
// delta with no parsed sections has nothing for the merge to
// apply, which it reports as a failure of its own - on top of the
// error that actually names the mistake.
...missingHeaderSpecs,
...emptySectionSpecs.map((spec) => spec.path),
]))
);
}
} catch (error) {
// A missing specs dir (or a stray `specs` file) means no deltas;
// anything else (EACCES, EIO) must stay loud — discoverSpecFiles
@@ -779,6 +798,70 @@ export class Validator {
return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;
}
/**
* Dry-run archive's merge builder without writing its result. Reusing the
* builder preserves its already-synced delta rules instead of duplicating them.
* INFO leaves the verdict unchanged: a missing target can be a typo or a
* requirement introduced by a sibling change that has not archived yet.
* This does not run archive's later merged-spec validation or retirement checks.
*/
private async findArchiveBlockers(
changeDir: string,
mainSpecsDir: string,
alreadyReportedPaths: string[]
): Promise<ValidationIssue[]> {
const alreadyReported = new Set(alreadyReportedPaths);
// Only ever reaches a generated skeleton's placeholder Purpose, which this
// dry run discards.
const changeName = path.basename(changeDir);
const issues: ValidationIssue[] = [];
let updates: Awaited<ReturnType<typeof findSpecUpdates>>;
try {
updates = await findSpecUpdates(changeDir, mainSpecsDir);
} catch (error) {
// An incomplete advisory check must not discard the validation report.
// Source discovery already ran above; archive retains its own path guards.
return [{
level: 'INFO',
path: 'specs',
message: `Could not check archive merge conflicts: ${
error instanceof Error ? error.message : String(error)
}`,
}];
}
for (const update of updates) {
// discoverSpecFiles builds both this id and the entryPath the checks
// above report under, from the same walk.
const entryPath = FileSystemUtils.toPosixPath(`${update.id}/spec.md`);
// A delta those checks already rejected would be reported twice, the
// second time in archive's wording rather than the wording that names
// the actual mistake.
if (alreadyReported.has(entryPath)) continue;
try {
await buildUpdatedSpec(update, changeName, { silent: true });
} catch (error) {
// Only the thrown preconditions, which carry no errno. A filesystem
// error says nothing about whether the delta applies, and `validate
// --all` reads six changes at once, so a transient EMFILE would report
// a collision that is not there - the same reason the scenario-loss
// check above reads only the codes that mean the file is unusable.
if ((error as NodeJS.ErrnoException)?.code !== undefined) continue;
issues.push({
level: 'INFO',
path: entryPath,
message: `Archive would refuse this delta: ${
error instanceof Error ? error.message : String(error)
}`,
});
}
}
return issues;
}
private createReport(issues: ValidationIssue[]): ValidationReport {
const errors = issues.filter(i => i.level === 'ERROR').length;
const warnings = issues.filter(i => i.level === 'WARNING').length;
+7 -8
View File
@@ -78,15 +78,14 @@ const SKILL_INVOCATION_PREFIX: Record<string, string> = {
};
/**
* Tools that have no slash-command surface at all: skills are matched
* automatically or invoked by natural-language prompts, never by typing a
* `/<name>` command. Rovo Dev CLI is such a tool — `/skills` only manages
* skills, and any `/openspec-*` form would be a dead command (see
* docs/supported-tools.md). References for these tools are spelled as prose
* ("the openspec-propose skill") so generated content never tells the user to
* type a command their CLI does not register.
* Tools with no documented slash invocation for skills: use automatic
* matching or natural-language prompts instead. SourceCraft Code Assistant
* supports separate command files, but its skills use description matching.
* Rovo Dev's `/skills` only manages skills (see docs/supported-tools.md).
* Skill references for these tools are spelled as prose ("the openspec-propose
* skill") so skills-only delivery does not advertise unregistered commands.
*/
const NATURAL_LANGUAGE_SKILL_TOOLS = new Set<string>(['rovodev']);
const NATURAL_LANGUAGE_SKILL_TOOLS = new Set<string>(['rovodev', 'codeassistant']);
/**
* Whether a tool references skills by natural language rather than a slash
+67
View File
@@ -2,7 +2,9 @@ import { afterAll, describe, it, expect } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'node:child_process';
import { runCLI, cliProjectRoot } from '../helpers/run-cli.js';
import { isolatedGitEnv } from '../helpers/store-git.js';
import { AI_TOOLS } from '../../src/core/config.js';
import { getGlobalDataDir, registerStore } from '../../src/core/index.js';
import { createOpenSpecRoot } from '../helpers/openspec-fixtures.js';
@@ -39,6 +41,37 @@ afterAll(async () => {
});
describe('openspec CLI e2e basics', () => {
it('preserves initialized directories through a Git clone without listing anchors as work', async () => {
const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-init-clone-'));
tempRoots.push(base);
const projectDir = path.join(base, 'project');
const cloneDir = path.join(base, 'clone');
await fs.mkdir(projectDir);
const env = {
...isolatedGitEnv(base),
XDG_CONFIG_HOME: path.join(base, 'config'),
XDG_DATA_HOME: path.join(base, 'data'),
};
const initialized = await runCLI(['init', '--tools', 'none'], { cwd: projectDir, env });
expect(initialized.exitCode).toBe(0);
const gitOptions = { cwd: projectDir, env: { ...process.env, ...env }, stdio: 'pipe' as const };
execFileSync('git', ['init'], gitOptions);
execFileSync('git', ['add', 'openspec'], gitOptions);
execFileSync('git', ['commit', '-m', 'Initialize OpenSpec'], gitOptions);
execFileSync('git', ['clone', '--no-local', projectDir, cloneDir], gitOptions);
expect(await fs.readdir(path.join(cloneDir, 'openspec', 'specs'))).toEqual(['.gitkeep']);
expect(await fs.readdir(path.join(cloneDir, 'openspec', 'changes'))).toEqual(['archive']);
expect(await fs.readdir(path.join(cloneDir, 'openspec', 'changes', 'archive'))).toEqual(['.gitkeep']);
const changes = await runCLI(['list', '--json'], { cwd: cloneDir, env });
expectJsonOnlyOutput(changes);
expect(JSON.parse(changes.stdout).changes).toEqual([]);
const specs = await runCLI(['list', '--specs'], { cwd: cloneDir, env });
expect(specs.exitCode).toBe(0);
expect(specs.stdout).toContain('No specs found.');
});
it('shows help output', async () => {
const result = await runCLI(['--help']);
expect(result.exitCode).toBe(0);
@@ -86,6 +119,40 @@ describe('openspec CLI e2e basics', () => {
expectJsonOnlyOutput(result);
});
describe('legacy change list compatibility', () => {
it.each([
{ args: [], output: 'c1\n' },
{ args: ['--long'], output: 'c1: Test Change [deltas 1]\n' },
])('preserves text output with $args and warns on stderr', async ({ args, output }) => {
const projectDir = await prepareFixture('tmp-init');
const result = await runCLI(['change', 'list', ...args], { cwd: projectDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe(output);
expect(result.stderr).toContain('Warning: "openspec change list" is deprecated. Use "openspec list".');
});
it('preserves JSON output and warns on stderr', async () => {
const projectDir = await prepareFixture('tmp-init');
const result = await runCLI(['change', 'list', '--json'], { cwd: projectDir });
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout)).toEqual([
{ id: 'c1', title: 'Test Change', deltaCount: 1, taskStatus: { total: 0, completed: 0 } },
]);
expect(result.stderr).toContain('Warning: "openspec change list" is deprecated. Use "openspec list".');
});
it('rejects the unsupported --all option', async () => {
const projectDir = await prepareFixture('tmp-init');
const result = await runCLI(['change', 'list', '--all'], { cwd: projectDir });
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe('');
expect(result.stderr).toContain("error: unknown option '--all'");
});
});
it('keeps schemas --json free of spinner output', async () => {
const projectDir = await prepareFixture('tmp-init');
const result = await runCLI(['schemas', '--json'], { cwd: projectDir });
+112
View File
@@ -0,0 +1,112 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { promises as fs, realpathSync } from 'fs';
import path from 'path';
import { tmpdir } from 'os';
import { getGlobalDataDir, registerStore } from '../../src/core/index.js';
import { runCLI } from '../helpers/run-cli.js';
describe('validation report CLI contract', () => {
let projectDir: string;
beforeAll(async () => {
projectDir = await fs.mkdtemp(path.join(tmpdir(), 'openspec-findings-e2e-'));
await fs.mkdir(path.join(projectDir, 'openspec', 'specs'), { recursive: true });
await fs.writeFile(path.join(projectDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n');
for (const [id, checkbox] of [['done', 'x'], ['unfinished', ' ']]) {
const dir = path.join(projectDir, 'openspec', 'changes', 'archive', id);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'tasks.md'), `# Tasks\n\n- [${checkbox}] 1.1 Work\n`);
}
});
afterAll(async () => {
await fs.rm(projectDir, { recursive: true, force: true });
});
it('selects findings through the real CLI while retaining full totals and failure status', async () => {
const full = await runCLI(['validate', '--archived', '--json'], { cwd: projectDir });
const compact = await runCLI(['validate', '--archived', '--json', '--report', 'findings'], { cwd: projectDir });
expect(compact.exitCode).toBe(full.exitCode);
expect(compact.exitCode).toBe(1);
expect(compact.stderr).toBe('');
const fullDoc = JSON.parse(full.stdout);
const compactDoc = JSON.parse(compact.stdout);
expect(compactDoc.report).toEqual({
kind: 'validation-findings', version: '1.0', scope: 'archived', returnedItems: 1, totalItems: 2,
});
expect(compactDoc.itemFindings).toEqual([
{ ...fullDoc.items.find((item: { id: string }) => item.id === 'unfinished'), durationMs: expect.any(Number) },
]);
expect(compactDoc.summary).toEqual(fullDoc.summary);
expect(compactDoc.root).toEqual(fullDoc.root);
expect(compactDoc).not.toHaveProperty('items');
expect(compactDoc).not.toHaveProperty('version');
});
it.each([
['--all', '--report', 'unknown'],
['--all', '--report=FINDINGS'],
['--report', 'findings'],
['some-item', '--all', '--report', 'full'],
['--all', '--archived', '--report', 'findings'],
])('returns semantic errors as JSON before resolving a nonexistent store: %j', async (...args) => {
const result = await runCLI(['validate', ...args, '--json', '--store', 'missing-store'], { cwd: projectDir });
expect(result.exitCode).toBe(1);
expect(result.stderr).toBe('');
expect(JSON.parse(result.stdout)).toEqual({ status: [{
severity: 'error', code: 'invalid_validation_report_request',
message: expect.any(String), fix: expect.any(String),
}] });
});
it('keeps a missing report argument as a parser syntax error', async () => {
const result = await runCLI(['validate', '--all', '--json', '--report'], { cwd: projectDir });
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe('');
expect(result.stderr).toContain("option '--report <full|findings>' argument missing");
});
it('uses a real registered store and preserves its report root', async () => {
const env = {
XDG_CONFIG_HOME: path.join(projectDir, 'config'),
XDG_DATA_HOME: path.join(projectDir, 'data'),
};
await registerStore({ id: 'report-store', localPath: projectDir, globalDataDir: getGlobalDataDir({ env }) });
const result = await runCLI(['validate', '--archived', '--report', 'findings', '--json', '--store', 'report-store'], { cwd: projectDir, env });
expect(result.exitCode).toBe(1);
expect(result.stderr).toBe('');
const output = JSON.parse(result.stdout);
expect(output, JSON.stringify(output)).toHaveProperty('root');
expect(realpathSync.native(output.root.path)).toBe(realpathSync.native(projectDir));
expect(output.root.source).toBe('store');
expect(output.root.store_id).toBe('report-store');
expect(output.report).toMatchObject({ scope: 'archived', totalItems: 2, returnedItems: 1 });
expect(output.itemFindings[0].id).toBe('unfinished');
});
it('retains root failure diagnostics instead of fabricating an empty report', async () => {
const result = await runCLI(['validate', '--all', '--report', 'findings', '--json', '--store', 'missing-store'], { cwd: projectDir });
expect(result.exitCode).toBe(1);
expect(result.stderr).toBe('');
const output = JSON.parse(result.stdout);
expect(output.status).toHaveLength(1);
expect(output.status[0].severity).toBe('error');
expect(output.status[0].code).not.toBe('invalid_validation_report_request');
expect(output).not.toHaveProperty('report');
});
it('retains fatal archive-discovery diagnostics', async () => {
const malformedDir = await fs.mkdtemp(path.join(tmpdir(), 'openspec-findings-malformed-'));
try {
await fs.mkdir(path.join(malformedDir, 'openspec', 'changes'), { recursive: true });
await fs.writeFile(path.join(malformedDir, 'openspec', 'changes', 'archive'), 'not a directory');
const result = await runCLI(['validate', '--archived', '--report', 'findings', '--json'], { cwd: malformedDir });
expect(result.exitCode).toBe(1);
expect(result.stderr).toBe('');
expect(JSON.parse(result.stdout)).toMatchObject({ status: [{ code: 'validate_error' }] });
expect(JSON.parse(result.stdout)).not.toHaveProperty('report');
} finally {
await fs.rm(malformedDir, { recursive: true, force: true });
}
});
});
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { execFileSync } from 'child_process';
import { runCLI } from '../helpers/run-cli.js';
describe('validate command enriched human output', () => {
const projectRoot = process.cwd();
@@ -18,6 +19,143 @@ describe('validate command enriched human output', () => {
await fs.rm(testDir, { recursive: true, force: true });
});
const writeArchiveBlocker = async () => {
const mainDir = path.join(testDir, 'openspec', 'specs', 'widgets');
const changeDir = path.join(changesDir, 'c-archive');
const deltaDir = path.join(changeDir, 'specs', 'widgets');
await fs.mkdir(mainDir, { recursive: true });
await fs.mkdir(deltaDir, { recursive: true });
await fs.writeFile(path.join(mainDir, 'spec.md'), `# Widgets Specification
## Purpose
Define how widgets report their existing state consistently to all callers.
## Requirements
### Requirement: Existing state
The system SHALL report the existing state.
#### Scenario: Query state
- **WHEN** queried
- **THEN** the state is reported
`);
await fs.writeFile(
path.join(changeDir, 'proposal.md'),
'# Widget update\n\n## Why\nUpdate widgets.\n\n## What Changes\n- Update state reporting\n'
);
await fs.writeFile(path.join(deltaDir, 'spec.md'), `## MODIFIED Requirements
### Requirement: Future state
The system SHALL report the future state.
#### Scenario: Query state
- **WHEN** queried
- **THEN** the state is reported
`);
};
const entryPoints = [
['validate', 'c-archive'],
['change', 'validate', 'c-archive'],
['validate', '--changes'],
['validate', '--all'],
];
for (const strict of [false, true]) {
for (const args of entryPoints) {
const invocation = [...args, ...(strict ? ['--strict'] : [])];
it(`shows non-blocking archive advice for ${invocation.join(' ')}`, async () => {
await writeArchiveBlocker();
const result = await runCLI([...invocation, '--no-interactive'], { cwd: testDir });
expect(result.exitCode).toBe(0);
expect(result.stderr).toContain('ℹ [INFO] widgets/spec.md: Archive would refuse this delta:');
expect(result.stderr).toContain('Future state');
expect(result.stderr).not.toContain('Next steps:');
expect(result.stdout).toMatch(/is valid|0 failed/);
});
it(`keeps archive advice structured and non-blocking for ${invocation.join(' ')} --json`, async () => {
await writeArchiveBlocker();
const result = await runCLI([...invocation, '--json', '--no-interactive'], { cwd: testDir });
expect(result.exitCode).toBe(0);
const output = JSON.parse(result.stdout);
const report = args[0] === 'change'
? output
: output.items.find((item: { id: string }) => item.id === 'c-archive');
expect(report.valid).toBe(true);
expect(report.issues).toContainEqual(expect.objectContaining({
level: 'INFO',
path: 'widgets/spec.md',
message: expect.stringContaining('Archive would refuse this delta:'),
}));
expect(result.stderr).not.toContain('Archive would refuse this delta:');
if (args[0] !== 'change') expect(output.summary.totals.failed).toBe(0);
});
}
}
for (const args of [['validate', 'c-archive'], ['validate', '--changes']]) {
for (const json of [false, true]) {
it.skipIf(process.platform === 'win32')(
`reports an incomplete archive check without failing ${args.join(' ')}${json ? ' --json' : ''}`,
async () => {
await writeArchiveBlocker();
const deltaFile = path.join(changesDir, 'c-archive', 'specs', 'widgets', 'spec.md');
const delta = await fs.readFile(deltaFile, 'utf-8');
await fs.writeFile(deltaFile, delta.replace('## MODIFIED Requirements', '## ADDED Requirements'));
const mainFile = path.join(testDir, 'openspec', 'specs', 'widgets', 'spec.md');
const missingFile = path.join(testDir, 'missing-spec.md');
await fs.unlink(mainFile);
await fs.symlink(missingFile, mainFile);
const result = await runCLI(
[...args, '--strict', '--no-interactive', ...(json ? ['--json'] : [])],
{ cwd: testDir }
);
if (json) {
const output = JSON.parse(result.stdout);
expect(output.items).toHaveLength(1);
expect(output.items[0].valid).toBe(true);
expect(output.items[0].issues).toContainEqual(expect.objectContaining({
level: 'INFO',
path: 'specs',
message: expect.stringContaining('Could not check archive merge conflicts:'),
}));
expect(output.summary.totals).toEqual({ items: 1, passed: 1, failed: 0 });
} else {
expect(result.stdout).toMatch(/is valid|0 failed/);
expect(result.stderr).toContain('ℹ [INFO] specs: Could not check archive merge conflicts:');
expect(result.stderr).not.toContain('Next steps:');
}
expect(result.exitCode).toBe(0);
}
);
}
}
it('preserves INFO severity in the deprecated command when another delta is invalid', async () => {
await writeArchiveBlocker();
const invalidDir = path.join(changesDir, 'c-archive', 'specs', 'broken');
await fs.mkdir(invalidDir, { recursive: true });
await fs.writeFile(
path.join(invalidDir, 'spec.md'),
'## ADDED Requirements\n\n### Requirement: Missing scenario\nThe system SHALL do something.\n'
);
const result = await runCLI(['change', 'validate', 'c-archive', '--no-interactive'], { cwd: testDir });
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('ℹ [INFO] widgets/spec.md: Archive would refuse this delta:');
expect(result.stderr).toContain('[ERROR]');
expect(result.stderr).toContain('Next steps:');
});
it('prints Next steps footer and guidance on invalid change', async () => {
const changeContent = `# Test Change\n\n## Why\nThis is a sufficiently long explanation to pass the why length requirement for validation purposes.\n\n## What Changes\nThere are changes proposed, but no delta specs provided yet.`;
const changeId = 'c-next-steps';
+274
View File
@@ -0,0 +1,274 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import ora from 'ora';
import { ValidateCommand, projectValidationFindings } from '../../src/commands/validate.js';
import { resolveRootForCommand, toRootOutput, type ResolvedOpenSpecRoot } from '../../src/core/root-selection.js';
import { Validator } from '../../src/core/validation/validator.js';
vi.mock('../../src/core/root-selection.js', async (importOriginal) => ({
...await importOriginal<typeof import('../../src/core/root-selection.js')>(),
resolveRootForCommand: vi.fn(),
}));
vi.mock('ora', () => ({ default: vi.fn() }));
type Options = NonNullable<Parameters<ValidateCommand['execute']>[1]>;
describe('validate findings reports', () => {
let directory: string;
let root: ResolvedOpenSpecRoot;
let previousExitCode: typeof process.exitCode;
let stdout: string[];
let stderr: string[];
async function write(relative: string, contents: string): Promise<void> {
const filename = path.join(directory, relative);
await fs.mkdir(path.dirname(filename), { recursive: true });
await fs.writeFile(filename, contents);
}
function delta(body = 'The feature SHALL return its documented result.'): string {
return `## ADDED Requirements\n### Requirement: Example behavior\n${body}\n\n#### Scenario: Normal request\n- **WHEN** requested\n- **THEN** the documented result is returned\n`;
}
async function seed(): Promise<void> {
await write('openspec/changes/a-clean/specs/example/spec.md', delta());
await write('openspec/changes/b-warning/specs/example/spec.md', delta('The feature returns its documented result.'));
await write('openspec/changes/c-info/specs/example/spec.md', `${delta()}\n### Notes\nNon-requirement notes.\n`);
await fs.mkdir(path.join(root.changesDir, 'd-error'));
await write('openspec/specs/clean/spec.md', `## Purpose\nThis specification defines a deterministic example for testing validation output contracts.\n\n## Requirements\n${delta().replace('## ADDED Requirements\n', '')}`);
await write('openspec/specs/error/spec.md', '# Invalid specification\n');
await write('openspec/changes/archive/a-clean/tasks.md', '- [x] 1.1 Done\n');
await write('openspec/changes/archive/b-error/tasks.md', '- [ ] 1.1 Pending\n');
}
async function run(options: Options, item?: string) {
stdout = [];
stderr = [];
process.exitCode = undefined;
await new ValidateCommand().execute(item, { noInteractive: true, ...options });
return { stdout: [...stdout], stderr: [...stderr], exitCode: process.exitCode ?? 0 };
}
async function json(options: Options) {
const result = await run({ ...options, json: true });
expect(result.stdout).toHaveLength(1);
expect(result.stderr).toEqual([]);
return { ...result, document: JSON.parse(result.stdout[0]) };
}
beforeEach(async () => {
previousExitCode = process.exitCode;
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-findings-'));
root = {
path: directory,
changesDir: path.join(directory, 'openspec', 'changes'),
specsDir: path.join(directory, 'openspec', 'specs'),
archiveDir: path.join(directory, 'openspec', 'changes', 'archive'),
defaultSchema: 'spec-driven',
source: 'nearest',
};
await fs.mkdir(root.changesDir, { recursive: true });
await fs.mkdir(root.specsDir, { recursive: true });
vi.mocked(resolveRootForCommand).mockReset().mockResolvedValue(root);
vi.mocked(ora).mockClear();
vi.spyOn(console, 'log').mockImplementation((...args) => stdout.push(args.join(' ')));
vi.spyOn(console, 'error').mockImplementation((...args) => stderr.push(args.join(' ')));
// Timing is not part of report compatibility; fix it for byte-for-byte checks.
vi.spyOn(Date, 'now').mockReturnValue(1_000);
});
afterEach(async () => {
vi.restoreAllMocks();
process.exitCode = previousExitCode;
await fs.rm(directory, { recursive: true, force: true });
});
const scopes: Array<[string, Options]> = [
['changes', { changes: true }],
['specs', { specs: true }],
['all', { all: true }],
['all', { changes: true, specs: true }],
['all', { all: true, changes: true }],
['all', { all: true, specs: true }],
['archived', { archived: true }],
];
it.each(scopes)('preserves full output and projects the complete %s scope (%j)', async (scope, options) => {
await seed();
const full = await json(options);
expect(await json({ ...options, report: 'full' })).toEqual(full);
expect(await run({ ...options, report: 'full' })).toEqual(await run(options));
const findings = await json({ ...options, report: 'findings' });
const selected = full.document.items.filter((item: { issues: unknown[] }) => item.issues.length > 0);
expect(findings.document).toEqual({
report: { kind: 'validation-findings', version: '1.0', scope, returnedItems: selected.length, totalItems: full.document.summary.totals.items },
itemFindings: selected,
summary: full.document.summary,
root: full.document.root,
});
expect(findings.document).not.toHaveProperty('items');
expect(findings.document).not.toHaveProperty('version');
expect(typeof findings.document.report.version).toBe('string');
expect(findings.exitCode).toBe(full.exitCode);
});
it('preserves legacy mixed-flag precedence when report is omitted', async () => {
await seed();
for (const json of [false, true]) {
expect(await run({ archived: true, all: true, json })).toEqual(await run({ archived: true, json }));
expect(await run({ all: true, json }, 'ignored-item')).toEqual(await run({ all: true, json }));
}
});
it.each([false, true])('retains warning and INFO records with full-mode verdicts (strict=%s)', async (strict) => {
await seed();
const full = await json({ changes: true, strict });
const findings = await json({ changes: true, strict, report: 'findings' });
const warning = findings.document.itemFindings.find((item: { id: string }) => item.id === 'b-warning');
const info = findings.document.itemFindings.find((item: { id: string }) => item.id === 'c-info');
expect(warning.issues.map((issue: { level: string }) => issue.level)).toEqual(['WARNING']);
expect(warning.valid).toBe(!strict);
expect(info.issues.map((issue: { level: string }) => issue.level)).toEqual(['INFO']);
expect(info.valid).toBe(true);
expect(findings.document.summary).toEqual(full.document.summary);
expect(findings.exitCode).toBe(full.exitCode);
});
it.each([false, true])('preserves warning-only exit status without errors (strict=%s)', async (strict) => {
await write('openspec/changes/warning/specs/example/spec.md', delta('The feature returns its documented result.'));
const full = await json({ changes: true, strict });
const findings = await json({ changes: true, strict, report: 'findings' });
expect(findings.document.itemFindings).toHaveLength(1);
expect(findings.exitCode).toBe(strict ? 1 : 0);
expect(findings.exitCode).toBe(full.exitCode);
});
it('keeps an INFO-only strict run successful while displaying its finding', async () => {
await write('openspec/changes/info/specs/example/spec.md', `${delta()}\n### Notes\nNon-requirement notes.\n`);
const findings = await json({ changes: true, strict: true, report: 'findings' });
expect(findings.document.itemFindings).toHaveLength(1);
expect(findings.document.itemFindings[0].issues[0].level).toBe('INFO');
expect(findings.exitCode).toBe(0);
});
it('orders human streams independently and emits every issue without clean rows', async () => {
await seed();
const findings = await json({ all: true, report: 'findings' });
const human = await run({ all: true, report: 'findings' });
expect(human.stdout[0]).toMatch(/^Scope:/);
expect(human.stdout[1]).toMatch(/^Totals:/);
expect(human.stdout[2]).toMatch(/^Details: openspec validate d-error --type change/);
expect(human.stdout).toHaveLength(3);
const errorText = human.stderr.join('\n');
expect(errorText).not.toContain('change/a-clean');
expect(errorText).not.toContain('spec/clean');
let offset = -1;
for (const item of findings.document.itemFindings) {
const heading = `${item.type}/${item.id}`;
const headingOffset = errorText.indexOf(heading, offset + 1);
expect(headingOffset).toBeGreaterThan(offset);
expect(errorText.split(heading)).toHaveLength(2);
offset = headingOffset;
for (const issue of item.issues) {
const issueOffset = errorText.indexOf(`[${issue.level}] ${issue.path}: ${issue.message}`, offset + 1);
expect(issueOffset).toBeGreaterThan(offset);
offset = issueOffset;
}
}
const archived = await run({ archived: true, report: 'findings' });
expect(archived.stdout).toHaveLength(2);
expect(archived.stdout.join('\n')).not.toContain('Details:');
});
it.each(scopes)('keeps empty %s scopes explicit and successful (%j)', async (scope, options) => {
const full = await json(options);
expect(await json({ ...options, report: 'full' })).toEqual(full);
expect(await run({ ...options, report: 'full' })).toEqual(await run(options));
const findings = await json({ ...options, report: 'findings' });
expect(findings.document.report).toEqual({ kind: 'validation-findings', version: '1.0', scope, returnedItems: 0, totalItems: 0 });
expect(findings.document.itemFindings).toEqual([]);
expect(findings.document.summary).toEqual(full.document.summary);
expect(findings.document.root).toEqual(toRootOutput(root));
expect(findings.exitCode).toBe(0);
const human = await run({ ...options, report: 'findings' });
expect(human.stdout).toEqual([expect.stringMatching(/^Scope:/), 'No item findings.', 'Totals: 0 passed, 0 failed (0 items)']);
expect(human.stderr).toEqual([]);
expect(human.exitCode).toBe(0);
});
it('distinguishes a clean non-empty scope from an empty scope', async () => {
await write('openspec/changes/clean/specs/example/spec.md', delta());
const result = await json({ changes: true, report: 'findings' });
expect(result.document.report).toMatchObject({ returnedItems: 0, totalItems: 1, scope: 'changes' });
expect(result.document.itemFindings).toEqual([]);
expect(result.document.summary.totals).toEqual({ items: 1, passed: 1, failed: 0 });
const human = await run({ changes: true, report: 'findings' });
expect(human.stdout).toEqual([expect.stringMatching(/^Scope:/), 'No item findings.', 'Totals: 1 passed, 0 failed (1 items)']);
expect(human.stderr).toEqual([]);
expect(human.exitCode).toBe(0);
});
it.each(['full', 'findings'])('rejects invalid %s requests before root resolution, progress, or validation', async (report) => {
const validateSpec = vi.spyOn(Validator.prototype, 'validateSpec');
const validateChange = vi.spyOn(Validator.prototype, 'validateChangeDeltaSpecs');
const invalid: Array<[Options, string?]> = [
[{ report }],
[{ report }, 'named-item'],
[{ report, all: true }, 'named-item'],
...[{ all: true }, { changes: true }, { specs: true }].map((scope): [Options] => [{ ...scope, archived: true, report }]),
[{ report: 'unsupported', all: true }],
[{ report: '', all: true }],
];
for (const [options, item] of invalid) {
const human = await run({ ...options, noInteractive: false }, item);
expect(human.stdout).toEqual([]);
expect(human.stderr.join('\n')).toMatch(/report/i);
expect(human.exitCode).toBe(1);
const result = await run({ ...options, json: true, noInteractive: false }, item);
expect(result.stderr).toEqual([]);
expect(result.stdout).toHaveLength(1);
expect(JSON.parse(result.stdout[0])).toEqual({ status: [{ severity: 'error', code: 'invalid_validation_report_request', message: expect.any(String), fix: expect.any(String) }] });
expect(result.exitCode).toBe(1);
}
expect(resolveRootForCommand).not.toHaveBeenCalled();
expect(ora).not.toHaveBeenCalled();
expect(validateSpec).not.toHaveBeenCalled();
expect(validateChange).not.toHaveBeenCalled();
});
it.each([{ all: true }, { archived: true }])('preserves selected-store records, root, verdict, and full output (%j)', async (scope) => {
await seed();
root.source = 'store';
root.storeId = 'team';
const options = { ...scope, store: 'team' };
const full = await json(options);
expect(await json({ ...options, report: 'full' })).toEqual(full);
expect(await run({ ...options, report: 'full' })).toEqual(await run(options));
const result = await json({ ...options, report: 'findings' });
expect(resolveRootForCommand).toHaveBeenLastCalledWith(expect.objectContaining({ store: 'team' }), expect.any(Object));
expect(result.document.root).toEqual(toRootOutput(root));
expect(result.document.itemFindings).toEqual(full.document.items.filter((item: { issues: unknown[] }) => item.issues.length));
expect(result.exitCode).toBe(full.exitCode);
if ('all' in scope) {
const human = await run({ ...options, report: 'findings' });
expect(human.stdout.at(-1)).toContain('--store team');
}
});
it('projects whole records in input order without modifying the full report', () => {
const issue = { level: 'INFO' as const, path: path.join('nested', 'spec.md'), message: 'Informational', line: 7 };
const clean = { id: 'clean', type: 'change' as const, valid: true, issues: [], durationMs: 1 };
const first = { ...clean, id: 'z-first', issues: [issue], futureField: { preserved: true } };
const second = { ...first, id: 'a-second', valid: false };
const full = { items: [first, clean, second], summary: { totals: { items: 3, passed: 2, failed: 1 }, byType: { change: { items: 3, passed: 2, failed: 1 } } }, version: '1.0' as const, root: toRootOutput(root) };
const original = structuredClone(full);
const result = projectValidationFindings(full, 'changes');
expect(result.itemFindings).toEqual([first, second]);
expect(result.itemFindings[0]).toBe(first);
expect(result.itemFindings[1]).toBe(second);
expect(result.report).toMatchObject({ returnedItems: 2, totalItems: 3 });
expect(full).toEqual(original);
});
});
+12
View File
@@ -532,5 +532,17 @@ describe('available-tools', () => {
expect(ohMyPiTool?.name).toBe('Oh My Pi');
expect(ohMyPiTool?.skillsDir).toBe('.omp');
});
it('should detect SourceCraft Code Assistant when .codeassistant directory exists', async () => {
await fs.mkdir(path.join(testDir, '.codeassistant'), { recursive: true });
const tools = getAvailableTools(testDir);
const toolValues = tools.map((t) => t.value);
expect(toolValues).toContain('codeassistant');
const codeassistantTool = tools.find((t) => t.value === 'codeassistant');
expect(codeassistantTool?.name).toBe('SourceCraft Code Assistant');
expect(codeassistantTool?.skillsDir).toBe('.codeassistant');
});
});
});
@@ -28,6 +28,7 @@ import { qoderAdapter } from '../../../src/core/command-generation/adapters/qode
import { qwenAdapter } from '../../../src/core/command-generation/adapters/qwen.js';
import { roocodeAdapter } from '../../../src/core/command-generation/adapters/roocode.js';
import { traeAdapter } from '../../../src/core/command-generation/adapters/trae.js';
import { codeassistantAdapter } from '../../../src/core/command-generation/adapters/codeassistant.js';
import { zcodeAdapter } from '../../../src/core/command-generation/adapters/zcode.js';
import type {
CommandContent,
@@ -1147,6 +1148,47 @@ describe('command-generation/adapters', () => {
});
});
describe('codeassistantAdapter', () => {
it('should have correct toolId', () => {
expect(codeassistantAdapter.toolId).toBe('codeassistant');
});
it('should generate correct file path', () => {
const filePath = codeassistantAdapter.getFilePath('explore');
expect(filePath).toBe(path.join('.codeassistant', 'commands', 'opsx-explore.md'));
});
it('should generate correct file path for different command IDs', () => {
expect(codeassistantAdapter.getFilePath('new')).toBe(path.join('.codeassistant', 'commands', 'opsx-new.md'));
expect(codeassistantAdapter.getFilePath('bulk-archive')).toBe(path.join('.codeassistant', 'commands', 'opsx-bulk-archive.md'));
});
it('should format file with correct YAML frontmatter', () => {
const output = codeassistantAdapter.formatFile(sampleContent);
const frontmatter = output.match(/^---\n([\s\S]*?)\n---\n\n/);
expect(frontmatter).not.toBeNull();
expect(parseYaml(frontmatter![1])).toEqual({ description: sampleContent.description });
expect(output.slice(frontmatter![0].length)).toBe(`${sampleContent.body}\n`);
});
it('generates registered commands with hyphenated workflow references', () => {
const content: CommandContent = {
...sampleContent,
body: 'Use /opsx:propose, /opsx:update, and /opsx:bulk-archive. Keep /opsx:unknown.',
};
const adapter = CommandAdapterRegistry.get('codeassistant');
expect(adapter).toBe(codeassistantAdapter);
const generated = generateCommand(content, adapter!);
expect(generated.path).toBe(path.join('.codeassistant', 'commands', 'opsx-explore.md'));
expect(generated.fileContent).toContain(
'Use /opsx-propose, /opsx-update, and /opsx-bulk-archive. Keep /opsx:unknown.'
);
expect(content.body).toContain('/opsx:propose');
});
});
describe('YAML frontmatter escaping across adapters', () => {
// Derived from the registry, not hand-listed: a newly registered adapter
// must be covered by default. Adding one that emits no YAML frontmatter is
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { COMMAND_REGISTRY } from '../../../src/core/completions/command-registry.js';
import { CompletionFactory } from '../../../src/core/completions/factory.js';
describe('validation report completions', () => {
const validate = COMMAND_REGISTRY.find((command) => command.name === 'validate')!;
it('registers the report flag and both supported values', () => {
expect(validate.flags.find((flag) => flag.name === 'report')).toMatchObject({
takesValue: true,
values: ['full', 'findings'],
});
});
it.each(['zsh', 'bash', 'fish', 'powershell'] as const)(
'includes the report flag in %s completions',
(shell) => {
const script = CompletionFactory.createGenerator(shell).generate([validate]);
expect(script).toContain(shell === 'fish' ? '-l report' : '--report');
},
);
it('offers both report values in zsh', () => {
const script = CompletionFactory.createGenerator('zsh').generate([validate]);
const reportLine = script.split('\n').find((line) => line.includes("'--report["));
expect(reportLine).toContain('(full findings)');
});
it('offers both report values in fish', () => {
const script = CompletionFactory.createGenerator('fish').generate([validate]);
for (const value of ['full', 'findings']) {
expect(script).toContain(`-l report -r -f -a '${value}'`);
}
});
});
+116 -3
View File
@@ -68,6 +68,81 @@ describe('InitCommand', () => {
expect(await directoryExists(path.join(openspecPath, 'changes', 'archive'))).toBe(true);
});
it('should create .gitkeep files in empty directories', async () => {
const initCommand = new InitCommand({ tools: 'claude', force: true });
await initCommand.execute(testDir);
const openspecPath = path.join(testDir, 'openspec');
expect(await fileExists(path.join(openspecPath, 'specs', '.gitkeep'))).toBe(true);
// The archive anchor also keeps its parent changes/ directory in Git.
expect(await fileExists(path.join(openspecPath, 'changes', '.gitkeep'))).toBe(false);
expect(await fileExists(path.join(openspecPath, 'changes', 'archive', '.gitkeep'))).toBe(true);
});
it('should restore missing directories and anchors in extend mode', async () => {
const initCommand1 = new InitCommand({ tools: 'claude', force: true });
await initCommand1.execute(testDir);
const openspecPath = path.join(testDir, 'openspec');
// Older projects may lose these empty directories when cloned.
await fs.rm(path.join(openspecPath, 'specs'), { recursive: true });
await fs.rm(path.join(openspecPath, 'changes'), { recursive: true });
// Re-run init (triggers extend mode since openspec dir already exists)
const initCommand2 = new InitCommand({ tools: 'claude', force: true });
await initCommand2.execute(testDir);
expect(await fileExists(path.join(openspecPath, 'specs', '.gitkeep'))).toBe(true);
expect(await fileExists(path.join(openspecPath, 'changes', '.gitkeep'))).toBe(false);
expect(await fileExists(path.join(openspecPath, 'changes', 'archive', '.gitkeep'))).toBe(true);
});
it('should preserve existing directory anchor contents when re-running init', async () => {
const marker = path.join(testDir, 'openspec', 'specs', '.gitkeep');
await fs.mkdir(path.dirname(marker), { recursive: true });
await fs.writeFile(marker, 'Keep this directory in Git.\n');
await new InitCommand({ tools: 'none', force: true }).execute(testDir);
expect(await fs.readFile(marker, 'utf-8')).toBe('Keep this directory in Git.\n');
});
it('should not add anchors to populated directories', async () => {
const specsPath = path.join(testDir, 'openspec', 'specs');
const archivePath = path.join(testDir, 'openspec', 'changes', 'archive');
await fs.mkdir(specsPath, { recursive: true });
await fs.mkdir(archivePath, { recursive: true });
await fs.writeFile(path.join(specsPath, '.custom'), 'keep me');
await fs.mkdir(path.join(archivePath, '2026-08-27-example'));
await new InitCommand({ tools: 'none', force: true }).execute(testDir);
expect(await fs.readdir(specsPath)).toEqual(['.custom']);
expect(await fs.readdir(archivePath)).toEqual(['2026-08-27-example']);
});
it.skipIf(process.platform === 'win32').each([false, true])(
'should leave anchor symlinks untouched (dangling: %s)',
async (dangling) => {
const target = path.join(configTempDir, 'outside-target');
if (!dangling) await fs.writeFile(target, 'do not overwrite');
const marker = path.join(testDir, 'openspec', 'specs', '.gitkeep');
await fs.mkdir(path.dirname(marker), { recursive: true });
await fs.symlink(target, marker);
await new InitCommand({ tools: 'none', force: true }).execute(testDir);
expect(await fs.readlink(marker)).toBe(target);
if (dangling) {
expect(await fileExists(target)).toBe(false);
} else {
expect(await fs.readFile(target, 'utf-8')).toBe('do not overwrite');
}
},
);
it('should create config.yaml with default schema', async () => {
const initCommand = new InitCommand({ tools: 'claude', force: true });
@@ -558,6 +633,44 @@ describe('InitCommand', () => {
expect(await directoryExists(path.join(testDir, '.agents'))).toBe(false);
});
it.each(['both', 'skills', 'commands'] as const)(
'should initialize SourceCraft Code Assistant with delivery=%s and working invocation hints',
async (delivery) => {
if (delivery !== 'both') {
saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery });
}
await new InitCommand({ tools: 'codeassistant', force: true }).execute(testDir);
const skillFile = path.join(testDir, '.codeassistant', 'skills', 'openspec-apply-change', 'SKILL.md');
const commandFile = path.join(testDir, '.codeassistant', 'commands', 'opsx-apply.md');
expect(await fileExists(skillFile)).toBe(delivery !== 'commands');
expect(await fileExists(commandFile)).toBe(delivery !== 'skills');
if (delivery !== 'commands') {
const skillContent = await fs.readFile(skillFile, 'utf-8');
expect(skillContent).toContain(delivery === 'skills' ? 'the openspec-archive-change skill' : '/opsx-archive');
expect(skillContent).not.toContain('/opsx:');
if (delivery === 'skills') {
expect(skillContent).not.toContain('/openspec-');
expect(skillContent).not.toContain('/opsx-');
}
}
if (delivery !== 'skills') {
const commandContent = await fs.readFile(commandFile, 'utf-8');
expect(commandContent).toMatch(/^---\ndescription: /);
expect(commandContent).toContain('/opsx-archive');
expect(commandContent).not.toContain('/opsx:');
}
const logCalls = vi.mocked(console.log).mock.calls.flat().map(String);
const startHint = logCalls.find((entry) => entry.includes('Start your first change'));
expect(startHint).toContain(delivery === 'skills'
? 'ask SourceCraft Code Assistant to use the openspec-propose skill with "your idea"'
: '/opsx-propose');
}
);
it('should support the shared agents target as an adapterless skills-only tool', async () => {
saveGlobalConfig({
featureFlags: {},
@@ -1067,7 +1180,7 @@ describe('InitCommand', () => {
await initCommand.execute(testDir);
expect(getConsoleOutput()).toContain('Restart your IDE for the new commands to take effect.');
expect(getConsoleOutput()).toContain('Restart your IDE to refresh commands.');
});
it('should word the restart hint for skills when an IDE tool gets only a skill surface', async () => {
@@ -1078,7 +1191,7 @@ describe('InitCommand', () => {
await initCommand.execute(testDir);
expect(getConsoleOutput()).toContain('Restart your IDE for the new skills to take effect.');
expect(getConsoleOutput()).toContain('Restart your IDE to refresh skills.');
});
it('should create skills for multiple tools at once', async () => {
@@ -2099,7 +2212,7 @@ describe('InitCommand - profile and detection features', () => {
// Commands were generated, but they are not slash commands.
const restartHint = logCalls.find((entry) => entry.includes('Restart your IDE'));
expect(restartHint).toContain('Restart your IDE for the new commands to take effect.');
expect(restartHint).toContain('Restart your IDE to refresh commands.');
expect(restartHint).not.toContain('slash commands');
});
+60 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as fsPromises from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
@@ -10,6 +11,10 @@ import {
rollbackCreatedPaths,
} from '../../src/core/index.js';
vi.mock('node:fs/promises', async (importOriginal) => ({
...await importOriginal<typeof import('node:fs/promises')>(),
}));
describe('OpenSpec root helper', () => {
let tempDir: string;
@@ -18,6 +23,7 @@ describe('OpenSpec root helper', () => {
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tempDir, { recursive: true, force: true });
});
@@ -135,6 +141,59 @@ describe('OpenSpec root helper', () => {
);
});
it('records only new anchors and includes them in rollback', async () => {
const root = path.join(tempDir, 'store');
createHealthyRoot(root);
const result = await ensureOpenSpecRoot(root, { anchorEmptyDirectories: true });
expect(result.createdArtifacts).toEqual([
'openspec/specs/.gitkeep',
'openspec/changes/archive/.gitkeep',
]);
expect((await ensureOpenSpecRoot(root, { anchorEmptyDirectories: true })).createdPaths).toEqual([]);
await rollbackCreatedPaths(result.createdPaths);
expect(fs.readdirSync(path.join(root, 'openspec', 'specs'))).toEqual([]);
expect(fs.readdirSync(path.join(root, 'openspec', 'changes', 'archive'))).toEqual([]);
});
it.each(['file', 'directory', 'symlink'] as const)(
'preserves a competing %s created after checking an empty directory',
async (kind) => {
const root = path.join(tempDir, 'store');
createHealthyRoot(root);
const marker = path.join(root, 'openspec', 'specs', '.gitkeep');
const target = path.join(tempDir, 'outside-target');
fs.mkdirSync(target);
fs.writeFileSync(path.join(target, 'user.txt'), 'keep me');
vi.spyOn(fsPromises, 'readdir').mockImplementationOnce(async () => {
if (kind === 'file') fs.writeFileSync(marker, 'keep me');
if (kind === 'directory') fs.mkdirSync(marker);
if (kind === 'symlink') fs.symlinkSync(target, marker, process.platform === 'win32' ? 'junction' : 'dir');
return [];
});
const result = await ensureOpenSpecRoot(root, { anchorEmptyDirectories: true });
expect(result.createdArtifacts).toEqual(['openspec/changes/archive/.gitkeep']);
if (kind === 'file') expect(fs.readFileSync(marker, 'utf-8')).toBe('keep me');
if (kind === 'directory') expect(fs.lstatSync(marker).isDirectory()).toBe(true);
if (kind === 'symlink') expect(fs.lstatSync(marker).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(path.join(target, 'user.txt'), 'utf-8')).toBe('keep me');
},
);
it('propagates anchor write failures other than an existing path', async () => {
const root = path.join(tempDir, 'store');
createHealthyRoot(root);
const error = Object.assign(new Error('permission denied'), { code: 'EACCES' });
vi.spyOn(fsPromises, 'writeFile').mockRejectedValueOnce(error);
await expect(ensureOpenSpecRoot(root, { anchorEmptyDirectories: true })).rejects.toBe(error);
});
it('rolls back only ledger-created files and empty directories', async () => {
const root = path.join(tempDir, 'store');
const result = await ensureOpenSpecRoot(root);
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CommandAdapterRegistry } from '../../src/core/command-generation/index.js';
import {
formatIdeRestart,
resolveIdeRestartSurface,
} from '../../src/core/shared/ide-restart.js';
describe('resolveIdeRestartSurface', () => {
afterEach(() => vi.restoreAllMocks());
it('names commands when an IDE-resident tool received command files', () => {
expect(resolveIdeRestartSurface(['cursor'], 'both')).toBe('commands');
expect(resolveIdeRestartSurface(['cursor'], 'commands')).toBe('commands');
});
it('names skills when the IDE-resident tool only received skills', () => {
expect(resolveIdeRestartSurface(['cursor'], 'skills')).toBe('skills');
});
it('stays silent for CLI-resident tools, which pick files up immediately', () => {
expect(resolveIdeRestartSurface(['claude'], 'both')).toBeNull();
expect(resolveIdeRestartSurface(['codex'], 'skills')).toBeNull();
});
it.each([
['commands', null],
['both', 'skills'],
] as const)('does not borrow CLI commands when delivery is %s', (delivery, expected) => {
// Model an IDE tool without an adapter: it receives no files with commands
// delivery, and only skills with both. Claude still receives commands.
const hasAdapter = CommandAdapterRegistry.has.bind(CommandAdapterRegistry);
vi.spyOn(CommandAdapterRegistry, 'has').mockImplementation(
(toolId) => toolId !== 'cursor' && hasAdapter(toolId)
);
expect(resolveIdeRestartSurface(['claude', 'cursor'], delivery)).toBe(expected);
});
it('handles duplicates and empty input', () => {
expect(resolveIdeRestartSurface(['cursor', 'cursor', 'claude'], 'commands')).toBe(
'commands'
);
expect(resolveIdeRestartSurface([], 'both')).toBeNull();
});
});
describe('formatIdeRestart', () => {
it('produces the same sentence init and update both print', () => {
expect(formatIdeRestart(['cursor'], 'both')).toBe(
'Restart your IDE to refresh commands.'
);
expect(formatIdeRestart(['cursor'], 'skills')).toBe(
'Restart your IDE to refresh skills.'
);
});
it('returns null when no restart is needed', () => {
expect(formatIdeRestart(['claude'], 'both')).toBeNull();
});
});
+61
View File
@@ -49,6 +49,67 @@ function fencedBlockLines(body: string): Array<[number, string]> {
}
describe('explore templates', () => {
it('guides planning without forcing an interview on open-ended exploration (#1017)', () => {
for (const [label, body] of bodies) {
expect(body, label).toContain('When the user is planning a change');
expect(body, label).toContain('For open-ended discussion, follow the conversation');
expect(body, label).toContain('Stop asking when the user has enough clarity');
expect(body, label).toContain('Let them pause, pivot, or defer a decision');
expect(body, label).not.toContain('Relentless Interview Mode');
}
});
it('investigates repository facts before asking while acknowledging missing evidence (#1017)', () => {
for (const [label, body] of bodies) {
expect(body, label).toContain('Before asking a factual question, follow the context discovery below');
expect(body, label).toContain('relevant OpenSpec artifacts, source, tests, docs, and configuration');
expect(body, label).toContain('Do not ask the user to repeat facts you can verify');
expect(body, label).toContain('If evidence is missing, conflicting, or inaccessible');
expect(body, label).toContain('ask only for the clarification needed to proceed');
}
});
it('resolves blocking decisions first and revisits dependent assumptions (#1017)', () => {
for (const [label, body] of bodies) {
expect(body, label).toContain('Resolve the next blocking decision before its dependent details');
expect(body, label).toContain('Revisit downstream assumptions when an earlier answer changes');
expect(body, label).toContain('Skip branches that do not matter to this goal');
}
});
it('asks one focused question and recommends only when evidence supports a choice (#1017)', () => {
for (const [label, body] of bodies) {
expect(body, label).toContain('Ask one focused question at a time');
expect(body, label).toContain('Batch questions only if the user asks for a batch');
expect(body, label).toContain('explain why it matters and which decision it unlocks');
expect(body, label).toContain('When evidence supports a recommendation');
expect(body, label).toContain('Do not invent intent, priorities, or external constraints');
}
});
it('keeps decisions in the conversation without accepting defaults or authorizing writes (#1017)', () => {
for (const [label, body] of bodies) {
expect(body, label).toContain('Track decisions in the conversation');
expect(body, label).toContain('Separate confirmed decisions from proposed defaults and unresolved questions');
expect(body, label).toContain('Silence is not acceptance');
expect(body, label).toContain('Accepting an answer or a batch of recommendations is not permission to write');
expect(body, label).toContain('Keep file-write confirmation separate from discovery questions');
}
});
it('delivers the same planning guidance exactly once in both templates (#1017)', () => {
const sections = bodies.map(([label, body]) => {
const heading = '## Planning a Change';
expect(occurrenceCount(body, heading), label).toBe(1);
const start = body.indexOf(heading);
const end = body.indexOf('\n---', start);
expect(end, label).toBeGreaterThan(start);
return body.slice(start, end);
});
expect(sections[0]).toBe(sections[1]);
});
// Regression for #696: explore never loaded the project's declared
// context, so it reasoned without the tech stack, conventions, and
// rules every artifact-creating workflow already receives.
+47
View File
@@ -89,6 +89,53 @@ describe('default task guidance', () => {
});
});
describe('planning code inspection (#339)', () => {
it('inspects the project after loading instructions and dependencies, before creating or delegating artifacts', () => {
for (const [label, body] of loopBodies) {
const instructions = body.indexOf('openspec instructions <artifact-id>');
const dependencies = body.indexOf('Read any completed dependency files');
const inspection = body.indexOf('**Inspect the relevant project before drafting**');
const delegation = body.indexOf('If the `instruction` field delegates creation');
expect(instructions, label).toBeGreaterThanOrEqual(0);
expect(dependencies, label).toBeGreaterThan(instructions);
expect(inspection, label).toBeGreaterThan(dependencies);
expect(delegation, label).toBeGreaterThan(inspection);
const guidance = body.slice(inspection, delegation);
expect(guidance, label).toContain('Read `context` and `rules` first');
expect(guidance, label).toContain('relevant implementation, nearby tests, configuration, and documentation outside `openspec/`');
expect(guidance, label).toContain('Keep inspection read-only and proportional to the change');
expect(guidance, label).toContain('reuse findings for later artifacts');
expect(guidance, label).toContain('Do this discovery now');
}
});
it('handles separate stores, missing code, and uncertain findings without inventing facts', () => {
for (const [label, body] of loopBodies) {
expect(body, label).toContain('the planning home may be separate from the code');
expect(body, label).toContain('If the target is unclear, ask');
expect(body, label).toContain('For greenfield or non-code changes, inspect the available structure and relevant documents');
expect(body, label).toContain('If source is unavailable, state the limitation');
expect(body, label).toContain('Distinguish observed behavior from assumptions and proposed additions');
expect(body, label).toContain('surface conflicts with existing specs instead of silently deciding which is correct');
}
});
it('preserves inspection guidance through every command adapter', () => {
for (const command of getCommandContents(['propose', 'ff'])) {
for (const adapter of CommandAdapterRegistry.getAll()) {
const generated = generateCommand(command, adapter).fileContent;
const inspection = generated.indexOf('**Inspect the relevant project before drafting**');
const delegation = generated.indexOf('If the `instruction` field delegates creation');
const label = `${adapter.toolId} ${command.id}`;
expect(inspection, label).toBeGreaterThanOrEqual(0);
expect(delegation, label).toBeGreaterThan(inspection);
expect(generated, label).toContain('Keep inspection read-only and proportional to the change');
}
}
});
});
describe('propose implementation boundary', () => {
it('makes the planning-only boundary prominent (#232, #258, #262)', () => {
for (const [label, body] of proposeBodies) {
@@ -38,18 +38,18 @@ import {
import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js';
const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
getExploreSkillTemplate: 'ecaa0bea4c1cd14eee9dbfcfe4b5808fff4ff808cba0a46789b37c1df3048d9a',
getExploreSkillTemplate: '6315fcc5c2eb848963bc8bca4c23e657412a99608e610daee59fb4e58cd21fd4',
getNewChangeSkillTemplate: 'eabd1e895c5881dcb17dcbaa3fb26098dd59e8eacb318e400820b4dc811ef781',
getContinueChangeSkillTemplate: '012136f6411a99c8fa228e2f9444cb64b0a89e0f56fdeac2fe03b2f5bee0c5d7',
getApplyChangeSkillTemplate: 'd1e7d5ceb85193c0964057dbb88e9651526754bd33f84020e2440ff0621d5dbb',
getFfChangeSkillTemplate: '5501740e7ec36ab23ab8c3a0d6dd0655a5e2f35433c7b90e82904fef5e7a326a',
getFfChangeSkillTemplate: 'efa6a70c111b18b61a7720250b9622afa9a212fb64edf609cf80e2182a9bdf8c',
getSyncSpecsSkillTemplate: 'b099e2ff31859c9b10d928066e662524f9aad9ecf2be12fceacb732d718c4146',
getOnboardSkillTemplate: '3a836faae463d88c289a1c129cb7ee556a563b7e53e1a52a4711ff152a3b51f7',
getOpsxExploreCommandTemplate: '1460fcb4fbdf22244e9e76608102e611db598cd4cca8c5dbd001292854bcba6e',
getOpsxExploreCommandTemplate: 'b4706a5b8fd280f7929eea610ecc9d41676b2d2dd6653d259cbbc2bfe01813d9',
getOpsxNewCommandTemplate: 'f2d30e569798a4c92ba932859d6ba4e0ad10e18feccbade1cfee0957597b3463',
getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559',
getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0',
getOpsxFfCommandTemplate: 'e603bc0996604e6c17a3140943ea642a32d0fc65565e25424bf956e124c55772',
getOpsxFfCommandTemplate: '21132fc9c6d3b3ab2d2295d6bbd72d1e0052eb35ea1be0258c8b1ab3e200c4db',
getArchiveChangeSkillTemplate: '56bfada1a5f35a127791b70de9d428a75b5aedd1584d6c9803a1ecb1fd1b4a23',
getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d',
getOpsxSyncCommandTemplate: '0d2427efb79986e8fff3f96bd075a739c80d45eb29159fae717e950030da8202',
@@ -58,25 +58,25 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
getOpsxOnboardCommandTemplate: 'ee99aa99252c602720fbb8c63fb3ac438a5bd4e952fd961ddf1ae956cbfc2c8f',
getOpsxBulkArchiveCommandTemplate: '9fa8cdebe2f5667ebfc37bdc023396762c59d5b038c771dac2d8fd2c19e2627b',
getOpsxVerifyCommandTemplate: '1efcf7eff0671f48e9d9420f50865c563dd3079ee60f8c380bb7a90dd0102696',
getOpsxProposeSkillTemplate: '24623c066f97e34b957d448d1f9a9e8b8a13da3dfce45d45671f6226a2534848',
getOpsxProposeCommandTemplate: 'e67ba591efb0fecacb2229d06dfa84af18b825fab8a7b01377279e4f09a06ce4',
getOpsxProposeSkillTemplate: '9c0fbf0137151bd03ec30c45180f83daec96e8976ceaf517c63147f84b803446',
getOpsxProposeCommandTemplate: 'b3c145f541dcc13d9859eae8f7bedbe4553371477ed2c5ac07a4a80f82c46f52',
getFeedbackSkillTemplate: 'dabeb5e825b9349abc8156c3e7b8608f27987912a6d9bf47ef29addde6138133',
getUpdateChangeSkillTemplate: '7dc8abc6f64c58bf34d7581ed4ab095a3b7a53cb372349bee2d840db58622819',
getOpsxUpdateCommandTemplate: 'e2388521b22f92f74561df9a0c2f98e1fa4d265af93b5ba26f42fb47a6c5bfed',
};
const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = {
'openspec-explore': '886680e71f2900378bd12bb9ff25c888a41a8f851e0bb3ec056affcc18d07ca8',
'openspec-explore': 'dd84af68d3c93b40659dcdd8d383423b25b443cacdc4b514cd70614ae10c5cac',
'openspec-new-change': 'ec4529beef978e34634a6f7286fab55d68fad8fb374dceb45691d52caab33fbb',
'openspec-continue-change': 'bb6194a16c54891cdb253678e8f70ce53b2af86735243980f366ce551d37e42e',
'openspec-apply-change': '81ea96d9fa6ec8536cd23c1fe561ed28e1cc1cad0a8ceb700588e08974cc0e49',
'openspec-ff-change': '217c78da2b6e8358f609ac57dcd02266aaec3354ce26dc6ec2fc9c2174673ab4',
'openspec-ff-change': '31355250514bce51b16ff37ee2b833bc9d475cd0dbd4b1f68fe2041694575623',
'openspec-sync-specs': 'd933d8856584d6c1253de91e652e7aee9e85c77ad4d3531f6476f79d84e6e5e8',
'openspec-archive-change': '7c65053d674ba4e1e20e2bf73ba7e5a7f94baef2eaa9b33cee48d4cadea51b7a',
'openspec-bulk-archive-change': '2039b9ecf6e64339dffe0e16272507a386d9fe326f419ff758315aa736fdd96c',
'openspec-verify-change': 'af9be013dcbe8c6d8f6d9ab10c893fbd03f4c62933c384d82f63894dd0ceb84f',
'openspec-onboard': 'f6f59476acaf5e4d65dbb180da4cef62432612f3cecf207d471a951295e2003a',
'openspec-propose': '25d08ed4f031770cea219604167d76bca9f3e89fe0c2f545263674482c6f13f0',
'openspec-propose': 'e358b45102a88082cf20f5c4441cba02533724ad6eef8ed15ba174e3496cb6ed',
'openspec-update-change': '586547406aca94422dfeb3ffedce6c01049429b743f57ce829baa79ebc714d51',
};
+129 -3
View File
@@ -1260,6 +1260,40 @@ metadata:
expect(skillContent).not.toContain('/opsx-');
});
it.each(['both', 'commands'] as const)(
'should discover and refresh SourceCraft Code Assistant commands with delivery=%s',
async (delivery) => {
setMockConfig({ featureFlags: {}, profile: 'core', delivery });
const commandsDir = path.join(testDir, '.codeassistant', 'commands');
await fs.mkdir(commandsDir, { recursive: true });
await fs.writeFile(path.join(commandsDir, 'opsx-apply.md'), 'old command content');
const skillFile = path.join(testDir, '.codeassistant', 'skills', 'openspec-apply-change', 'SKILL.md');
if (delivery === 'both') {
await fs.mkdir(path.dirname(skillFile), { recursive: true });
await fs.writeFile(skillFile, 'old skill content');
}
await updateCommand.execute(testDir);
const commandContent = await fs.readFile(path.join(commandsDir, 'opsx-apply.md'), 'utf-8');
expect(commandContent).toMatch(/^---\ndescription: /);
expect(commandContent).toContain('/opsx-archive');
expect(commandContent).not.toContain('/opsx:');
expect(await FileSystemUtils.fileExists(path.join(commandsDir, 'opsx-propose.md'))).toBe(true);
expect(await FileSystemUtils.fileExists(skillFile)).toBe(delivery === 'both');
if (delivery === 'both') {
const skillContent = await fs.readFile(skillFile, 'utf-8');
expect(skillContent).toContain('/opsx-archive');
expect(skillContent).not.toContain('/opsx:');
}
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
expect(consoleSpy.mock.calls.flat().map(String).some((entry) => entry.includes('up to date'))).toBe(true);
}
);
it('should update command files when tool is configured via commands-only delivery without skills', async () => {
setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' });
const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx');
@@ -1947,7 +1981,12 @@ metadata:
consoleSpy.mockRestore();
});
it('should suggest an IDE restart for IDE-resident tools', async () => {
it.each([
['both', 'commands'],
['commands', 'commands'],
['skills', 'skills'],
] as const)('should name the generated IDE surface with %s delivery', async (delivery, surface) => {
setMockConfig({ featureFlags: {}, profile: 'core', delivery });
const skillsDir = path.join(testDir, '.cursor', 'skills');
await fs.mkdir(path.join(skillsDir, 'openspec-explore'), {
recursive: true,
@@ -1962,11 +2001,64 @@ metadata:
await updateCommand.execute(testDir);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Restart your IDE')
expect.stringContaining(`Restart your IDE to refresh ${surface}.`)
);
expect(await FileSystemUtils.fileExists(
path.join(testDir, '.cursor', 'commands', 'opsx-explore.md')
)).toBe(delivery !== 'skills');
expect(await FileSystemUtils.fileExists(
path.join(skillsDir, 'openspec-explore', 'SKILL.md')
)).toBe(delivery !== 'commands');
consoleSpy.mockClear();
await updateCommand.execute(testDir);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('up to date'));
expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Restart your IDE'));
consoleSpy.mockRestore();
});
it.each(['both', 'commands', 'skills'] as const)(
'should describe removal-only IDE updates with %s delivery',
async (delivery) => {
setMockConfig({ featureFlags: {}, profile: 'core', delivery });
await new InitCommand({ tools: 'cursor', force: true }).execute(testDir);
setMockConfig({ featureFlags: {}, profile: 'custom', workflows: [], delivery });
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
expect(await FileSystemUtils.fileExists(
path.join(testDir, '.cursor', 'commands', 'opsx-explore.md')
)).toBe(false);
expect(await FileSystemUtils.fileExists(
path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md')
)).toBe(false);
const surface = delivery === 'skills' ? 'skills' : 'commands';
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(`Restart your IDE to refresh ${surface}.`)
);
expect(consoleSpy).not.toHaveBeenCalledWith(
expect.stringContaining('Restart your IDE for the new')
);
}
);
it('should not suggest an IDE restart when only a CLI tool needs updating', async () => {
await new InitCommand({ tools: 'claude,cursor', force: true }).execute(testDir);
await fs.writeFile(
path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'),
'old'
);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated: Claude Code'));
expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Updated: Cursor'));
expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Restart your IDE'));
});
});
describe('smart update detection', () => {
@@ -2570,7 +2662,13 @@ ${OPENSPEC_MARKERS.end}
expect(menuLines).toHaveLength(1);
expect(menuLines[0]).toContain('/opsx-propose');
expect(logCalls.some((entry) => entry.includes('/opsx:propose'))).toBe(false);
expect(logCalls.some((entry) => entry.includes('Restart your IDE'))).toBe(true);
// The hint names what was generated, the same sentence init prints, rather
// than update's older generic "changes".
expect(
logCalls.some((entry) =>
entry.includes('Restart your IDE to refresh commands.')
)
).toBe(true);
});
it('should preserve legacy Codex prompts when a configured Codex tool lacks the replacement workflow', async () => {
@@ -3366,6 +3464,34 @@ More user content after markers.
expect(updateSkillContent).toContain('/openspec-');
});
it.each(['skills', 'commands'] as const)(
'should switch SourceCraft Code Assistant to delivery=%s without deleting custom files',
async (delivery) => {
await new InitCommand({ tools: 'codeassistant', force: true }).execute(testDir);
const toolDir = path.join(testDir, '.codeassistant');
const customCommand = path.join(toolDir, 'commands', 'opsx-custom.md');
const customSkill = path.join(toolDir, 'skills', 'custom-review', 'SKILL.md');
await fs.mkdir(path.dirname(customSkill), { recursive: true });
await fs.writeFile(customCommand, 'custom command');
await fs.writeFile(customSkill, 'custom skill');
setMockConfig({ featureFlags: {}, profile: 'core', delivery });
await updateCommand.execute(testDir);
expect(await FileSystemUtils.fileExists(path.join(toolDir, 'commands', 'opsx-apply.md'))).toBe(delivery === 'commands');
const skillFile = path.join(toolDir, 'skills', 'openspec-apply-change', 'SKILL.md');
expect(await FileSystemUtils.fileExists(skillFile)).toBe(delivery === 'skills');
if (delivery === 'skills') {
const skillContent = await fs.readFile(skillFile, 'utf-8');
expect(skillContent).toContain('the openspec-archive-change skill');
expect(skillContent).not.toContain('/openspec-');
expect(skillContent).not.toContain('/opsx-');
}
expect(await fs.readFile(customCommand, 'utf-8')).toBe('custom command');
expect(await fs.readFile(customSkill, 'utf-8')).toBe('custom skill');
}
);
it('should respect commands-only delivery setting', async () => {
setMockConfig({
featureFlags: {},
@@ -0,0 +1,383 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs, realpathSync } from 'fs';
import os from 'os';
import path from 'path';
import { Validator } from '../../src/core/validation/validator.js';
import { buildUpdatedSpec, findSpecUpdates } from '../../src/core/specs-apply.js';
/**
* validate reports the deltas archive would refuse to apply (#1112).
*
* Compare findings against archive's merge builder, not its later validation
* and retirement checks. A delta the builder accepts must produce no finding.
* Reporting a change that merges cleanly
* would send an author to rewrite working work, which is worse than the gap
* this closes.
*/
describe('validate: deltas archive would refuse (#1112)', () => {
let testDir: string;
let changesDir: string;
let mainSpecsDir: string;
const REQUIREMENT = `### Requirement: Widget state\nThe system SHALL report the widget state.\n\n#### Scenario: Existing scenario\n- **WHEN** queried\n- **THEN** the state is reported`;
const mainSpec = (body: string) =>
`# widgets Specification\n\n## Purpose\nDefine widget behavior for these tests.\n\n## Requirements\n\n${body}\n`;
const writeMainSpec = async (id: string, body: string) => {
const file = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, mainSpec(body));
};
const writeChange = async (changeName: string, specId: string, delta: string) => {
const changeDir = path.join(changesDir, changeName);
const specDir = path.join(changeDir, 'specs', ...specId.split('/'));
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(path.join(specDir, 'spec.md'), delta);
return changeDir;
};
const validate = (changeDir: string, strict = false) =>
new Validator(strict).validateChangeDeltaSpecs(changeDir, { mainSpecsDir });
/** The preflight finding, so assertions cannot pass on an unrelated issue. */
const blocker = (report: { issues: Array<{ level: string; message: string }> }) =>
report.issues.find((i) => i.message.startsWith('Archive would refuse this delta:'));
/** What archive's merge builder does: null when the delta applies cleanly. */
const archiveError = async (changeDir: string): Promise<string | null> => {
for (const update of await findSpecUpdates(changeDir, mainSpecsDir)) {
try {
await buildUpdatedSpec(update, path.basename(changeDir), { silent: true });
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
return null;
};
beforeEach(async () => {
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-preflight-'));
changesDir = path.join(testDir, 'openspec', 'changes');
mainSpecsDir = path.join(testDir, 'openspec', 'specs');
await fs.mkdir(changesDir, { recursive: true });
await fs.mkdir(mainSpecsDir, { recursive: true });
});
afterEach(async () => {
vi.restoreAllMocks();
await fs.rm(testDir, { recursive: true, force: true });
});
it('keeps strict validation valid when advisory discovery encounters a filesystem error', async () => {
const changeDir = await writeChange('c1', 'widgets', `## ADDED Requirements\n\n${REQUIREMENT}\n`);
const specsDir = realpathSync.native(path.join(changeDir, 'specs'));
const readdir = fs.readdir;
let discoveries = 0;
vi.spyOn(fs, 'readdir').mockImplementation(async (dir, ...rest) => {
if (realpathSync.native(String(dir)) === specsDir && ++discoveries === 2) {
throw Object.assign(new Error('EIO: cannot discover archive inputs'), { code: 'EIO' });
}
return readdir(dir, ...(rest as []));
});
const report = await validate(changeDir, true);
expect(report.valid).toBe(true);
expect(report.issues).toContainEqual({
level: 'INFO',
path: 'specs',
message: 'Could not check archive merge conflicts: EIO: cannot discover archive inputs',
});
expect(blocker(report)).toBeUndefined();
});
it.skipIf(process.platform === 'win32').each([
['outside', true],
['outside', false],
['dangling', true],
['dangling', false],
] as const)('preserves the validation report for a %s target link (valid delta: %s)', async (link, validDelta) => {
const body = validDelta ? REQUIREMENT : '### Requirement: Widget state\nThe system SHALL report the widget state.';
const changeDir = await writeChange('c1', 'widgets', `## ADDED Requirements\n\n${body}\n`);
const target = path.join(mainSpecsDir, 'widgets', 'spec.md');
const outside = path.join(testDir, 'outside.md');
if (link === 'outside') await fs.writeFile(outside, mainSpec(REQUIREMENT));
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.symlink(outside, target);
// Advisory discovery must not weaken the merge path's security checks.
await expect(findSpecUpdates(changeDir, mainSpecsDir)).rejects.toThrow();
for (const strict of [false, true]) {
const report = await validate(changeDir, strict);
expect(report.valid).toBe(validDelta);
expect(report.issues).toContainEqual(expect.objectContaining({
level: 'INFO',
path: 'specs',
message: expect.stringContaining('Could not check archive merge conflicts:'),
}));
if (!validDelta) {
expect(report.issues).toContainEqual(expect.objectContaining({
level: 'ERROR', path: 'widgets/spec.md', message: expect.stringContaining('must include at least one scenario'),
}));
}
}
if (link === 'outside') expect(await fs.readFile(outside, 'utf8')).toBe(mainSpec(REQUIREMENT));
else await expect(fs.stat(outside)).rejects.toMatchObject({ code: 'ENOENT' });
});
it.skipIf(process.platform === 'win32')('still refuses an unsafe delta source before the advisory check', async () => {
const changeDir = await writeChange('c1', 'widgets', `## ADDED Requirements\n\n${REQUIREMENT}\n`);
const delta = path.join(changeDir, 'specs', 'widgets', 'spec.md');
const outside = path.join(testDir, 'outside-delta.md');
await fs.rename(delta, outside);
await fs.symlink(outside, delta);
await expect(validate(changeDir)).rejects.toThrow('Path is outside the allowed directory');
});
it.each(['EMFILE', 'EIO', 'EACCES'])(
'does not misreport a target read failure (%s) as a missing requirement',
async (code) => {
await writeMainSpec('widgets', REQUIREMENT);
const changeDir = await writeChange('c1', 'widgets', `## MODIFIED Requirements\n\n${REQUIREMENT}\n`);
const [update] = await findSpecUpdates(changeDir, mainSpecsDir);
const target = realpathSync.native(update.target);
const readFile = fs.readFile;
const failure = Object.assign(new Error(`${code}: cannot read target`), { code });
const spy = vi.spyOn(fs, 'readFile').mockImplementation(async (file, ...rest) => {
if (realpathSync.native(String(file)) === target) throw failure;
return readFile(file, ...(rest as []));
});
const report = await validate(changeDir);
expect(spy.mock.calls.some(([file]) => realpathSync.native(String(file)) === target)).toBe(true);
expect(blocker(report)).toBeUndefined();
await expect(buildUpdatedSpec(update, 'c1', { silent: true })).rejects.toBe(failure);
}
);
it.each([
['already-synced addition', `## ADDED Requirements\n\n${REQUIREMENT}\n`],
['already-synced removal', '## REMOVED Requirements\n\n### Requirement: Gone\n'],
])('stays silent on an %s', async (_name, delta) => {
await writeMainSpec('widgets', REQUIREMENT);
const changeDir = await writeChange('c1', 'widgets', delta);
expect(blocker(await validate(changeDir))).toBeUndefined();
expect(await archiveError(changeDir)).toBeNull();
});
it('reports a rename target collision', async () => {
await writeMainSpec('widgets', `${REQUIREMENT}\n\n${REQUIREMENT.replace('Widget state', 'Gadget state')}`);
const changeDir = await writeChange('c1', 'widgets', '## RENAMED Requirements\n\n- FROM: `### Requirement: Widget state`\n- TO: `### Requirement: Gadget state`\n');
const error = await archiveError(changeDir);
expect(error).toContain('already exists');
expect(blocker(await validate(changeDir))?.message).toBe(`Archive would refuse this delta: ${error}`);
});
it.each([
['MODIFIED', `## MODIFIED Requirements\n\n${REQUIREMENT}\n`],
['RENAMED', '## RENAMED Requirements\n\n- FROM: `### Requirement: Widget state`\n- TO: `### Requirement: Gadget state`\n'],
])('reports %s against a capability that does not exist', async (_operation, delta) => {
const changeDir = await writeChange('c1', 'new-capability', delta);
const error = await archiveError(changeDir);
expect(error).toContain('target spec does not exist');
expect(blocker(await validate(changeDir))?.message).toBe(`Archive would refuse this delta: ${error}`);
});
it('keeps library validation unchanged when mainSpecsDir is omitted', async () => {
const changeDir = await writeChange('c1', 'widgets', `## MODIFIED Requirements\n\n${REQUIREMENT}\n`);
const report = await new Validator(true).validateChangeDeltaSpecs(changeDir);
expect(report.valid).toBe(true);
expect(blocker(report)).toBeUndefined();
});
it('does not synthesize a new baseline for ADDED when the existing spec cannot be read through an alias or canonical path', async () => {
await writeMainSpec('widgets', REQUIREMENT);
await fs.symlink(
path.join(mainSpecsDir, 'widgets'),
path.join(mainSpecsDir, 'widgets-alias'),
process.platform === 'win32' ? 'junction' : 'dir'
);
const changeDir = await writeChange('c1', 'widgets-alias', `## ADDED Requirements\n\n${REQUIREMENT.replace('Widget state', 'Gadget state')}\n`);
const [update] = await findSpecUpdates(changeDir, mainSpecsDir);
const target = realpathSync.native(update.target);
// Keep distinct path spellings so this exercises both reads of the same file.
expect(update.target).not.toBe(target);
const readFile = fs.readFile;
const failure = Object.assign(new Error('EIO: cannot read target'), { code: 'EIO' });
vi.spyOn(fs, 'readFile').mockImplementation(async (file, ...rest) => {
if (realpathSync.native(String(file)) === target) throw failure;
return readFile(file, ...(rest as []));
});
await expect(buildUpdatedSpec(update, 'c1', { silent: true })).rejects.toBe(failure);
await expect(buildUpdatedSpec({ ...update, target }, 'c1', { silent: true })).rejects.toBe(failure);
});
it('reports a nested capability without suppressing findings for other files', async () => {
await writeMainSpec('area/widgets', REQUIREMENT);
const changeDir = await writeChange('c1', 'area/widgets', `## MODIFIED Requirements\n\n${REQUIREMENT.replace('Widget state', 'Missing')}\n`);
await writeChange('c1', 'invalid', '## ADDED Requirements\n\nNo entries.\n');
const report = await validate(changeDir);
expect(report.issues.filter((issue) => issue.level === 'INFO')).toEqual([
expect.objectContaining({ path: 'area/widgets/spec.md', message: `Archive would refuse this delta: ${await archiveError(changeDir)}` }),
]);
});
it('does not create or rewrite spec files or print merge warnings', async () => {
await writeMainSpec('widgets', REQUIREMENT);
const changeDir = await writeChange('c1', 'widgets', `## ADDED Requirements\n\n${REQUIREMENT}\n`);
await writeChange('c1', 'new-capability', `## ADDED Requirements\n\n${REQUIREMENT}\n`);
const mainFile = path.join(mainSpecsDir, 'widgets', 'spec.md');
const deltaFile = path.join(changeDir, 'specs', 'widgets', 'spec.md');
const before = await Promise.all([fs.readFile(mainFile, 'utf8'), fs.readFile(deltaFile, 'utf8')]);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await validate(changeDir);
expect(await Promise.all([fs.readFile(mainFile, 'utf8'), fs.readFile(deltaFile, 'utf8')])).toEqual(before);
await expect(fs.stat(path.join(mainSpecsDir, 'new-capability'))).rejects.toMatchObject({ code: 'ENOENT' });
expect(log).not.toHaveBeenCalled();
});
it('reports a MODIFIED naming a requirement the main spec does not have', async () => {
await writeMainSpec('widgets', REQUIREMENT);
const changeDir = await writeChange(
'c1',
'widgets',
`## MODIFIED Requirements\n\n### Requirement: Gadget state\nThe system SHALL report the gadget state.\n\n#### Scenario: Queried\n- **WHEN** queried\n- **THEN** reported\n`
);
const issue = blocker(await validate(changeDir));
expect(issue?.message).toContain('MODIFIED failed for header "### Requirement: Gadget state"');
expect(await archiveError(changeDir)).not.toBeNull();
});
it('reports an ADDED whose requirement already exists in the main spec', async () => {
await writeMainSpec('widgets', REQUIREMENT);
const changeDir = await writeChange(
'c1',
'widgets',
`## ADDED Requirements\n\n### Requirement: Widget state\nThe system SHALL report the widget state twice.\n\n#### Scenario: Queried\n- **WHEN** queried\n- **THEN** reported\n`
);
expect(blocker(await validate(changeDir))?.message).toContain('already exists');
expect(await archiveError(changeDir)).not.toBeNull();
});
it('reports a RENAMED whose source is not in the main spec', async () => {
await writeMainSpec('widgets', REQUIREMENT);
const changeDir = await writeChange(
'c1',
'widgets',
`## RENAMED Requirements\n\n- FROM: \`### Requirement: Gadget state\`\n- TO: \`### Requirement: Doodad state\`\n`
);
expect(blocker(await validate(changeDir))?.message).toContain('source not found');
expect(await archiveError(changeDir)).not.toBeNull();
});
it('stays silent on a delta that applies cleanly', async () => {
await writeMainSpec('widgets', REQUIREMENT);
const changeDir = await writeChange(
'c1',
'widgets',
`## ADDED Requirements\n\n### Requirement: Gadget state\nThe system SHALL report the gadget state.\n\n#### Scenario: Queried\n- **WHEN** queried\n- **THEN** reported\n`
);
expect(blocker(await validate(changeDir))).toBeUndefined();
expect(await archiveError(changeDir)).toBeNull();
});
it('stays silent on a rename the baseline already absorbed', async () => {
// Source gone, target present: specs-apply reads this as an early-synced
// rename and applies it as a no-op. A preflight with its own copy of the
// rules would call it a missing source and fail a change that archives.
await writeMainSpec('widgets', REQUIREMENT.replace('Widget state', 'Doodad state'));
const changeDir = await writeChange(
'c1',
'widgets',
`## RENAMED Requirements\n\n- FROM: \`### Requirement: Widget state\`\n- TO: \`### Requirement: Doodad state\`\n`
);
expect(blocker(await validate(changeDir))).toBeUndefined();
expect(await archiveError(changeDir)).toBeNull();
});
it('stays silent when the capability is new, so there is nothing to apply against', async () => {
const changeDir = await writeChange(
'c1',
'gizmos',
`## ADDED Requirements\n\n### Requirement: Gizmo state\nThe system SHALL report the gizmo state.\n\n#### Scenario: Queried\n- **WHEN** queried\n- **THEN** reported\n`
);
expect(blocker(await validate(changeDir))).toBeUndefined();
expect(await archiveError(changeDir)).toBeNull();
});
it('reports without changing the verdict, in strict mode too', async () => {
await writeMainSpec('widgets', REQUIREMENT);
const changeDir = await writeChange(
'c1',
'widgets',
`## MODIFIED Requirements\n\n### Requirement: Gadget state\nThe system SHALL report the gadget state.\n\n#### Scenario: Queried\n- **WHEN** queried\n- **THEN** reported\n`
);
// The same shape is a typo'd header and a change modifying a sibling's
// unarchived requirement, and validate stays valid for the second one
// today. Telling the two apart needs the opt-in marker #1112 asks for, so
// this reports the collision and leaves the verdict where it was.
for (const strict of [false, true]) {
const report = await validate(changeDir, strict);
expect(report.valid).toBe(true);
expect(blocker(report)?.level).toBe('INFO');
}
});
it('does not restate a delta with no parsed sections, reported after the loop', async () => {
// missingHeaderSpecs / emptySectionSpecs are collected inside the loop but
// their errors are pushed after it, so a preflight keyed on issues raised
// so far would not see them and would add a second finding for a file the
// validator is about to name properly.
await writeMainSpec('widgets', REQUIREMENT);
const changeDir = await writeChange('c1', 'widgets', '# notes\n\nNo delta headers here.\n');
const report = await validate(changeDir);
expect(report.issues.some((i) => i.message.startsWith('No delta sections found'))).toBe(true);
expect(blocker(report)).toBeUndefined();
});
it.skipIf(process.platform === 'win32')('uses the same display path when suppressing malformed deltas with a literal backslash', async () => {
const changeDir = await writeChange('c1', 'area\\widgets', '# Notes\n\nNo delta headers.\n');
const report = await validate(changeDir);
expect(report.issues).toContainEqual(expect.objectContaining({
level: 'ERROR', path: 'area/widgets/spec.md', message: expect.stringContaining('No delta sections found'),
}));
expect(blocker(report)).toBeUndefined();
});
it('does not restate a section that parsed no requirement entries', async () => {
await writeMainSpec('widgets', REQUIREMENT);
const changeDir = await writeChange('c1', 'widgets', '## ADDED Requirements\n\nNothing here.\n');
const report = await validate(changeDir);
expect(report.issues.some((i) => i.message.includes('no requirement entries parsed'))).toBe(true);
expect(blocker(report)).toBeUndefined();
});
it('does not restate a failure the delta checks already named', async () => {
// The scenario-loss check reports this one in wording that names the
// dropped scenario; buildUpdatedSpec throws on it too, a few steps later.
await writeMainSpec(
'widgets',
`${REQUIREMENT}\n\n#### Scenario: Second scenario\n- **WHEN** idle\n- **THEN** idle is reported`
);
const changeDir = await writeChange(
'c1',
'widgets',
`## MODIFIED Requirements\n\n### Requirement: Widget state\nThe system SHALL report the widget state.\n\n#### Scenario: Existing scenario\n- **WHEN** queried\n- **THEN** the state is reported\n`
);
const report = await validate(changeDir);
expect(report.issues.some((i) => i.level === 'ERROR')).toBe(true);
expect(blocker(report)).toBeUndefined();
expect(await archiveError(changeDir)).not.toBeNull();
});
});
+118 -2
View File
@@ -1,7 +1,12 @@
import { describe, it, expect } from 'vitest';
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as os from 'node:os';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
import spawn from 'cross-spawn';
import { createFakeTool, envWithFakeTools } from './helpers/fake-tool.js';
import { isolatedGitEnv } from './helpers/store-git.js';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -23,3 +28,114 @@ describe('published package install scripts', () => {
}
);
});
describe('npm source installation', () => {
let tempDir: string;
let sourceDir: string;
let env: NodeJS.ProcessEnv;
let pnpmLog: string;
const compilerDir = path.dirname(createRequire(import.meta.url).resolve('typescript/package.json'));
function run(command: string, args: string[], cwd = sourceDir) {
return spawn.sync(command, args, { cwd, env, encoding: 'utf-8', timeout: 30_000 });
}
function succeed(command: string, args: string[], cwd = sourceDir) {
const result = run(command, args, cwd);
expect(result.status, `${result.error ?? ''}\n${result.stdout}\n${result.stderr}`).toBe(0);
return result.stdout;
}
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-npm-source-'));
sourceDir = path.join(tempDir, 'source with spaces');
fs.mkdirSync(path.join(sourceDir, 'src'), { recursive: true });
const pnpm = createFakeTool(tempDir, 'pnpm', { exitCode: 99 });
pnpmLog = pnpm.logPath;
const npmConfig = path.join(tempDir, 'npmrc');
fs.writeFileSync(npmConfig, '');
env = envWithFakeTools({
...process.env,
...isolatedGitEnv(tempDir),
npm_config_cache: path.join(tempDir, 'npm-cache'),
npm_config_userconfig: npmConfig,
npm_config_offline: 'true',
npm_config_audit: 'false',
npm_config_fund: 'false',
npm_config_ignore_scripts: 'false',
}, [pnpm]);
const { scripts } = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf-8'));
// Exercise the real lifecycle hooks and compiler without registry access or
// copying the full application into every fixture.
fs.writeFileSync(path.join(sourceDir, 'package.json'), JSON.stringify({
name: 'openspec-source-fixture',
version: '1.0.0',
type: 'module',
files: ['dist'],
scripts: { prepare: scripts.prepare, prepack: scripts.prepack, build: scripts.build },
devDependencies: { typescript: pathToFileURL(compilerDir).href },
}));
fs.copyFileSync(path.join(repoRoot, 'build.js'), path.join(sourceDir, 'build.js'));
fs.writeFileSync(path.join(sourceDir, 'tsconfig.json'), JSON.stringify({
compilerOptions: { rootDir: 'src', outDir: 'dist', declaration: true, types: [] },
include: ['src'],
}));
fs.writeFileSync(path.join(sourceDir, 'src', 'index.ts'), 'console.log("source install works");\n');
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
function installAndRun(spec: string) {
const consumerDir = path.join(tempDir, 'consumer');
fs.mkdirSync(consumerDir);
fs.writeFileSync(path.join(consumerDir, 'package.json'), '{"private":true}');
succeed('npm', ['install', '--omit=dev', spec], consumerDir);
const installed = path.join(consumerDir, 'node_modules', 'openspec-source-fixture');
expect(succeed(process.execPath, [path.join(installed, 'dist', 'index.js')], consumerDir).trim())
.toBe('source install works');
expect(fs.existsSync(path.join(installed, 'dist', 'index.d.ts'))).toBe(true);
expect(fs.existsSync(path.join(installed, 'build.js'))).toBe(false);
expect(fs.existsSync(path.join(consumerDir, 'node_modules', 'typescript'))).toBe(false);
expect(fs.existsSync(pnpmLog)).toBe(false);
}
it('builds a Git dependency without pnpm, even when the consumer omits dev dependencies', () => {
succeed('git', ['init']);
succeed('git', ['add', '.']);
succeed('git', ['-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=', 'commit', '-m', 'fixture']);
installAndRun(`git+${pathToFileURL(sourceDir).href}`);
}, 60_000);
it('packs freshly compiled artifacts without pnpm', () => {
succeed('npm', ['install', '--ignore-scripts']);
fs.mkdirSync(path.join(sourceDir, 'dist'));
fs.writeFileSync(path.join(sourceDir, 'dist', 'stale.js'), 'stale');
succeed('npm', ['pack']);
expect(fs.existsSync(path.join(sourceDir, 'dist', 'stale.js'))).toBe(false);
installAndRun(path.join(sourceDir, 'openspec-source-fixture-1.0.0.tgz'));
}, 60_000);
it.each([false, true])('refuses to pack without build dependencies (stale artifacts: %s)', (stale) => {
if (stale) {
fs.mkdirSync(path.join(sourceDir, 'dist'));
fs.writeFileSync(path.join(sourceDir, 'dist', 'index.js'), 'stale');
}
const result = run('npm', ['pack', '--json']);
expect(result.status).not.toBe(0);
expect(`${result.stdout}\n${result.stderr}`).toContain('Build failed');
expect(fs.readdirSync(sourceDir).some((name) => name.endsWith('.tgz'))).toBe(false);
});
it('refuses to pack when TypeScript compilation fails', () => {
succeed('npm', ['install', '--ignore-scripts']);
fs.writeFileSync(path.join(sourceDir, 'src', 'index.ts'), 'const invalid: string = 123;\n');
const result = run('npm', ['pack', '--json']);
expect(result.status).not.toBe(0);
expect(`${result.stdout}\n${result.stderr}`).toContain('Build failed');
expect(fs.readdirSync(sourceDir).some((name) => name.endsWith('.tgz'))).toBe(false);
});
});
+2 -2
View File
@@ -234,8 +234,8 @@ describe('getSkillReferenceTransformer', () => {
expect(transformer('/opsx:unknown-command')).toBe('/opsx:unknown-command');
});
it('uses natural-language references for Rovo Dev, which has no slash surface', () => {
const transformer = getSkillReferenceTransformer('rovodev');
it.each(['rovodev', 'codeassistant'])('uses natural-language skill references for %s', (toolId) => {
const transformer = getSkillReferenceTransformer(toolId);
expect(transformer('/opsx:propose')).toBe('the openspec-propose skill');
expect(transformer('Run `/opsx:apply` then /opsx:archive')).toBe(
'Run `the openspec-apply-change skill` then the openspec-archive-change skill'
+2 -1
View File
@@ -82,6 +82,7 @@ function isDocsRoute(pathname) {
pathname === '/llms-full.txt' ||
pathname === '/llms.mdx/docs' ||
pathname.startsWith('/llms.mdx/docs/') ||
pathname === '/icon.svg'
pathname === '/icon.svg' ||
pathname === '/openspec-pixel.svg'
);
}
+2 -1
View File
@@ -11,6 +11,7 @@
{ "pattern": "openspec.dev/og/docs/*", "zone_name": "openspec.dev" },
{ "pattern": "openspec.dev/llms*", "zone_name": "openspec.dev" },
{ "pattern": "openspec.dev/llms.mdx/docs/*", "zone_name": "openspec.dev" },
{ "pattern": "openspec.dev/icon.svg*", "zone_name": "openspec.dev" }
{ "pattern": "openspec.dev/icon.svg*", "zone_name": "openspec.dev" },
{ "pattern": "openspec.dev/openspec-pixel.svg*", "zone_name": "openspec.dev" }
]
}
+8 -8
View File
@@ -13,11 +13,11 @@
},
"dependencies": {
"beautiful-mermaid": "^1.1.3",
"fumadocs-core": "^16.14.5",
"fumadocs-mdx": "^15.2.2",
"fumadocs-ui": "^16.14.5",
"lucide-react": "^1.28.0",
"next": "16.3.1",
"fumadocs-core": "^16.15.2",
"fumadocs-mdx": "^15.3.1",
"fumadocs-ui": "^16.15.2",
"lucide-react": "^1.34.0",
"next": "16.3.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"zod": "^4.4.3"
@@ -25,9 +25,9 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.3.1",
"@types/mdx": "^2.0.14",
"@types/node": "^26.2.0",
"@types/node": "^26.3.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/react-dom": "^19.2.5",
"postcss": "^8.5.26",
"serve": "^14.2.6",
"tailwindcss": "^4.3.1",
@@ -41,7 +41,7 @@
"postcss": "^8.5.26",
"sharp": "^0.35.3",
"brace-expansion@<=5.0.8": ">=5.0.9 <6",
"fast-uri@<3.1.5": "^3.1.5",
"fast-uri@<3.1.6": "^3.1.6",
"nanoid@<3.3.17": ">=3.3.17 <4"
}
}
+504 -504
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -2,13 +2,13 @@ packages:
- '.'
allowBuilds:
esbuild@0.28.1: true
esbuild@0.28.2: true
overrides:
postcss: ^8.5.26
sharp: ^0.35.3
brace-expansion@<=5.0.8: '>=5.0.9 <6'
fast-uri@<3.1.5: ^3.1.5
fast-uri@<3.1.6: ^3.1.6
# GHSA-2v37-7h3g-55p8 / CVE-2026-67213 — nanoid infinite loop on size=0. Build-time
# only (transitive via postcss); this is a statically exported site with no server
# runtime. Remove once transitive nanoid is >=3.3.17 (check: pnpm why nanoid).